diff --git a/.vscode/mcp.json b/.vscode/mcp.json
index c5b03255..fea2e440 100644
--- a/.vscode/mcp.json
+++ b/.vscode/mcp.json
@@ -4,6 +4,16 @@
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp@latest"]
+ },
+ "chrome-devtools": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "chrome-devtools-mcp@latest"]
+ },
+ "playwright": {
+ "type": "stdio",
+ "command": "npx",
+ "args": ["-y", "@playwright/mcp@latest"]
}
},
"inputs": []
diff --git a/migration/bold-workflow/insert-bold-workflowsteps.sql b/migration/bold-workflow/insert-bold-workflowsteps.sql
index 4b0c8388..447fd575 100644
--- a/migration/bold-workflow/insert-bold-workflowsteps.sql
+++ b/migration/bold-workflow/insert-bold-workflowsteps.sql
@@ -21,8 +21,8 @@ SELECT
'bold',
'Record',
1,
- '{"tool": "record"}',
- '{}',
+ '{"tool": "record"}'::jsonb,
+ '{}'::jsonb,
(now() AT TIME ZONE 'utc'),
(now() AT TIME ZONE 'utc'),
(SELECT u.id FROM users u ORDER BY u.id ASC LIMIT 1)
@@ -35,10 +35,12 @@ SELECT
'bold',
'Careful speech',
2,
- '{"tool": "phraseBackTranslate", "settings": {"artifactTypeId": "'
+ (
+ '{"tool": "phraseBackTranslate", "settings": "{\"artifactTypeId\": \"'
|| (SELECT CAST(id AS TEXT) FROM artifacttypes WHERE typename = 'carefulspeech' ORDER BY id LIMIT 1)
- || '", "namedRegion": "CarefulSpeech"}}',
- '{}',
+ || '\", \"namedRegion\": \"CarefulSpeech\"}"}'
+ )::jsonb,
+ '{}'::jsonb,
(now() AT TIME ZONE 'utc'),
(now() AT TIME ZONE 'utc'),
(SELECT u.id FROM users u ORDER BY u.id ASC LIMIT 1)
@@ -52,10 +54,12 @@ SELECT
'bold',
'Lwc translation',
3,
- '{"tool": "phraseBackTranslate", "settings": {"artifactTypeId": "'
+ (
+ '{"tool": "phraseBackTranslate", "settings": "{\"artifactTypeId\": \"'
|| (SELECT CAST(id AS TEXT) FROM artifacttypes WHERE typename = 'backtranslation' ORDER BY id LIMIT 1)
- || '", "namedRegion": "BT"}}',
- '{}',
+ || '\", \"namedRegion\": \"BT\"}"}'
+ )::jsonb,
+ '{}'::jsonb,
(now() AT TIME ZONE 'utc'),
(now() AT TIME ZONE 'utc'),
(SELECT u.id FROM users u ORDER BY u.id ASC LIMIT 1)
@@ -69,10 +73,12 @@ SELECT
'bold',
'Careful transcription',
4,
- '{"tool": "transcribe", "settings": {"artifactTypeId": "'
+ (
+ '{"tool": "transcribe", "settings": "{\"artifactTypeId\": \"'
|| (SELECT CAST(id AS TEXT) FROM artifacttypes WHERE typename = 'carefulspeech' ORDER BY id LIMIT 1)
- || '"}}',
- '{}',
+ || '\"}"}'
+ )::jsonb,
+ '{}'::jsonb,
(now() AT TIME ZONE 'utc'),
(now() AT TIME ZONE 'utc'),
(SELECT u.id FROM users u ORDER BY u.id ASC LIMIT 1)
@@ -86,10 +92,12 @@ SELECT
'bold',
'Lwc transcription',
5,
- '{"tool": "transcribe", "settings": {"artifactTypeId": "'
+ (
+ '{"tool": "transcribe", "settings": "{\"artifactTypeId\": \"'
|| (SELECT CAST(id AS TEXT) FROM artifacttypes WHERE typename = 'backtranslation' ORDER BY id LIMIT 1)
- || '"}}',
- '{}',
+ || '\"}"}'
+ )::jsonb,
+ '{}'::jsonb,
(now() AT TIME ZONE 'utc'),
(now() AT TIME ZONE 'utc'),
(SELECT u.id FROM users u ORDER BY u.id ASC LIMIT 1)
@@ -103,8 +111,8 @@ SELECT
'bold',
'Free translation',
6,
- '{"tool": "wholeBackTranslate"}',
- '{}',
+ '{"tool": "wholeBackTranslate"}'::jsonb,
+ '{}'::jsonb,
(now() AT TIME ZONE 'utc'),
(now() AT TIME ZONE 'utc'),
(SELECT u.id FROM users u ORDER BY u.id ASC LIMIT 1)
@@ -117,10 +125,12 @@ SELECT
'bold',
'Free transcription',
7,
- '{"tool": "transcribe", "settings": {"artifactTypeId": "'
+ (
+ '{"tool": "transcribe", "settings": "{\"artifactTypeId\": \"'
|| (SELECT CAST(id AS TEXT) FROM artifacttypes WHERE typename = 'wholebacktranslation' ORDER BY id LIMIT 1)
- || '"}}',
- '{}',
+ || '\"}"}'
+ )::jsonb,
+ '{}'::jsonb,
(now() AT TIME ZONE 'utc'),
(now() AT TIME ZONE 'utc'),
(SELECT u.id FROM users u ORDER BY u.id ASC LIMIT 1)
diff --git a/src/renderer/src/components/App/OrgHead.tsx b/src/renderer/src/components/App/OrgHead.tsx
index a18b111f..dbc921df 100644
--- a/src/renderer/src/components/App/OrgHead.tsx
+++ b/src/renderer/src/components/App/OrgHead.tsx
@@ -18,7 +18,7 @@ import BigDialog from '../../hoc/BigDialog';
import { BigDialogBp } from '../../hoc/BigDialogBp';
import GroupTabs from '../GroupTabs';
import { StepEditor } from '../StepEditor';
-import { defaultWorkflow } from '../../crud';
+import { defaultWorkflow, useTeamWorkflowProcess } from '../../crud';
import { useRole } from '../../crud/useRole';
import { useOrbitData } from '../../hoc/useOrbitData';
import { ProjectSort } from '../Team/ProjectDialog/ProjectSort';
@@ -89,6 +89,9 @@ export const OrgHead = () => {
return organizations.find((o) => o.id === orgId);
}, [orgId, organizations]);
+ const headerWorkflowProcess = useTeamWorkflowProcess(orgId ?? undefined);
+ const stepEditorProcess = headerWorkflowProcess ?? defaultWorkflow;
+
const isAdmin = useMemo(
() => userIsOrgAdmin(orgId ?? ''),
[orgId, userIsOrgAdmin]
@@ -267,7 +270,7 @@ export const OrgHead = () => {
bp={isMobile ? BigDialogBp.mobile : BigDialogBp.md}
>
diff --git a/src/renderer/src/components/StepEditor/StepEditor.cy.tsx b/src/renderer/src/components/StepEditor/StepEditor.cy.tsx
index 190d7715..388b36a6 100644
--- a/src/renderer/src/components/StepEditor/StepEditor.cy.tsx
+++ b/src/renderer/src/components/StepEditor/StepEditor.cy.tsx
@@ -195,6 +195,112 @@ const mountStepEditor = (memory: Memory) => {
);
};
+const BOLD_STEPS: MockOrgWfAttrs[] = [
+ {
+ id: 'bold-1',
+ name: 'Record',
+ sequencenum: 1,
+ process: 'bold',
+ tool: '{"tool": "record"}',
+ },
+ {
+ id: 'bold-2',
+ name: 'Careful speech',
+ sequencenum: 2,
+ process: 'bold',
+ tool: '{"tool": "phraseBackTranslate", "settings": "{\\"artifactTypeId\\": \\"art-cs\\", \\"namedRegion\\": \\"CarefulSpeech\\"}"}',
+ },
+ {
+ id: 'bold-3',
+ name: 'Lwc translation',
+ sequencenum: 3,
+ process: 'bold',
+ tool: '{"tool": "phraseBackTranslate", "settings": "{\\"artifactTypeId\\": \\"art-pbt\\", \\"namedRegion\\": \\"BT\\"}"}',
+ },
+ {
+ id: 'bold-4',
+ name: 'Careful transcription',
+ sequencenum: 4,
+ process: 'bold',
+ tool: '{"tool": "transcribe", "settings": "{\\"artifactTypeId\\": \\"art-cs\\"}"}',
+ },
+ {
+ id: 'bold-5',
+ name: 'Lwc transcription',
+ sequencenum: 5,
+ process: 'bold',
+ tool: '{"tool": "transcribe", "settings": "{\\"artifactTypeId\\": \\"art-pbt\\"}"}',
+ },
+ {
+ id: 'bold-6',
+ name: 'Free translation',
+ sequencenum: 6,
+ process: 'bold',
+ tool: '{"tool": "wholeBackTranslate"}',
+ },
+ {
+ id: 'bold-7',
+ name: 'Free transcription',
+ sequencenum: 7,
+ process: 'bold',
+ tool: '{"tool": "transcribe", "settings": "{\\"artifactTypeId\\": \\"art-wbt\\"}"}',
+ },
+];
+
+describe('StepEditor — Bold workflow', () => {
+ it('loads all 7 Bold workflow steps with correct names', () => {
+ const memory = createWorkflowStepMemory(TEST_ORG_ID, BOLD_STEPS);
+ mountStepEditor(memory);
+ cy.get('.MuiDialogContent-root input#stepName').should('have.length', 7);
+ const names = [
+ 'Record',
+ 'Careful speech',
+ 'LWC translation',
+ 'Careful transcription',
+ 'LWC transcription',
+ 'Free translation',
+ 'Free transcription',
+ ];
+ names.forEach((name, i) => {
+ cy.get('.MuiDialogContent-root input#stepName')
+ .eq(i)
+ .should('have.value', name);
+ });
+ });
+
+ it('handles settings stored as an embedded object (pre-fix format) without crashing', () => {
+ // Regression guard: getToolSettings must normalize object settings to a JSON string
+ // so that prettySettings (JSON.parse) does not receive "[object Object]" and throw.
+ const stepsWithEmbeddedObj: MockOrgWfAttrs[] = [
+ {
+ id: 'bold-obj-1',
+ name: 'Record',
+ sequencenum: 1,
+ process: 'bold',
+ tool: '{"tool": "record"}',
+ },
+ {
+ id: 'bold-obj-2',
+ name: 'Careful speech',
+ sequencenum: 2,
+ process: 'bold',
+ // settings as a nested object (old DB format) instead of a JSON string
+ tool: JSON.stringify({
+ tool: 'phraseBackTranslate',
+ settings: { artifactTypeId: 'art-cs', namedRegion: 'CarefulSpeech' },
+ }),
+ },
+ ];
+ const memory = createWorkflowStepMemory(TEST_ORG_ID, stepsWithEmbeddedObj);
+ mountStepEditor(memory);
+ // Both steps must render — if getToolSettings crashes, only step 1 would appear
+ cy.get('.MuiDialogContent-root input#stepName').should('have.length', 2);
+ cy.get('.MuiDialogContent-root input#stepName')
+ .eq(1)
+ .should('have.value', 'Careful speech');
+ });
+});
+
describe('StepEditor (Edit Workflow)', () => {
it('loads org workflow steps and shows the top Add control', () => {
const memory = createWorkflowStepMemory(TEST_ORG_ID, [
diff --git a/src/renderer/src/components/StepEditor/StepEditor.tsx b/src/renderer/src/components/StepEditor/StepEditor.tsx
index 39798938..110a6926 100644
--- a/src/renderer/src/components/StepEditor/StepEditor.tsx
+++ b/src/renderer/src/components/StepEditor/StepEditor.tsx
@@ -84,7 +84,11 @@ export const StepEditor = ({ process, org }: IProps) => {
clearRequested,
clearCompleted,
} = useContext(UnsavedContext).state;
- const { GetOrgWorkflowSteps, localizedWorkStep } = useOrgWorkflowSteps();
+ const {
+ GetOrgWorkflowSteps,
+ getProcessTemplateSteps,
+ resolveOrgWorkflowStepPresentation,
+ } = useOrgWorkflowSteps();
const { showMessage } = useSnackBar();
const saving = useRef(false);
const toolId = 'stepEditor';
@@ -397,28 +401,64 @@ export const StepEditor = ({ process, org }: IProps) => {
}, [toolsChanged]);
useEffect(() => {
- GetOrgWorkflowSteps({ process: 'ANY', org, showAll: true }).then(
- (orgSteps) => {
- const newRows = Array();
- orgSteps.forEach((s) => {
- const tool = getTool(s.attributes?.tool);
- const settings = getToolSettings(s.attributes?.tool);
- newRows.push({
- id: s.id,
- seq: s.attributes?.sequencenum,
- name: localizedWorkStep(s.attributes?.name),
- pos: 0,
- tool: toCamel(tool),
- settings: settings,
- prettySettings: prettySettings(tool, settings),
- rIdx: newRows.length,
- });
- });
- setRows(newRows.sort((i, j) => i.seq - j.seq));
+ // Scope to the team's workflow when `process` is set (e.g. bold). Using
+ // 'ANY' merged every process and sorted only by sequencenum, so Edit Workflow
+ // could disagree with the steps CreateOrgWorkflowSteps just added.
+ GetOrgWorkflowSteps({
+ process: process ?? 'ANY',
+ org,
+ showAll: true,
+ }).then((orgSteps) => {
+ const newRows = Array();
+ const rawCounts = new Map();
+ for (const s of orgSteps) {
+ const n = s.attributes?.name ?? '';
+ rawCounts.set(n, (rawCounts.get(n) ?? 0) + 1);
}
- );
+ const proc = process ?? 'ANY';
+ const sortedOrg = [...orgSteps].sort((a, b) => {
+ const d = a.attributes.sequencenum - b.attributes.sequencenum;
+ if (d !== 0) return d;
+ return String(a.id).localeCompare(String(b.id));
+ });
+ const templates = proc !== 'ANY' ? getProcessTemplateSteps(proc) : [];
+ const uniqueSeqs = new Set(
+ sortedOrg.map((s) => s.attributes?.sequencenum ?? 0)
+ ).size;
+ const useTemplateIndex =
+ proc !== 'ANY' &&
+ templates.length === sortedOrg.length &&
+ sortedOrg.length > 0 &&
+ (uniqueSeqs < sortedOrg.length || uniqueSeqs <= 1);
+
+ sortedOrg.forEach((s, idx) => {
+ const rawName = s.attributes?.name ?? '';
+ const dup = (rawCounts.get(rawName) ?? 0) > 1;
+ const pres = resolveOrgWorkflowStepPresentation(
+ s,
+ proc,
+ dup,
+ useTemplateIndex
+ ? { index: idx, orgCount: sortedOrg.length }
+ : undefined
+ );
+ const tool = getTool(pres.toolAttr);
+ const settings = getToolSettings(pres.toolAttr);
+ newRows.push({
+ id: s.id,
+ seq: pres.sequencenum ?? s.attributes?.sequencenum,
+ name: pres.name,
+ pos: 0,
+ tool: toCamel(tool),
+ settings: settings,
+ prettySettings: prettySettings(tool, settings),
+ rIdx: newRows.length,
+ });
+ });
+ setRows(newRows.sort((i, j) => i.seq - j.seq));
+ });
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [org]);
+ }, [org, process]);
useEffect(() => {
setSortKey((sortKey) => sortKey + 1);
diff --git a/src/renderer/src/crud/useOrgWorkflowSteps.tsx b/src/renderer/src/crud/useOrgWorkflowSteps.tsx
index 7c59b426..f0e8f9e2 100644
--- a/src/renderer/src/crud/useOrgWorkflowSteps.tsx
+++ b/src/renderer/src/crud/useOrgWorkflowSteps.tsx
@@ -54,7 +54,88 @@ export const useOrgWorkflowSteps = () => {
}
};
- const AddOrgWFToOps = async (
+ /** Same template list as {@link CreateOrgWorkflowSteps} (remote, then offline fallback). */
+ const getProcessTemplateSteps = (process: string): WorkflowStepD[] => {
+ const offlineOnly = getGlobal('offlineOnly');
+ const bySeq = (a: WorkflowStepD, b: WorkflowStepD) =>
+ a.attributes.sequencenum - b.attributes.sequencenum;
+ let processSteps = workflowsteps
+ .filter(
+ (s) =>
+ s.attributes.process === process &&
+ Boolean(s?.keys?.remoteId) !== offlineOnly
+ )
+ .sort(bySeq);
+ if (processSteps.length === 0 && !offlineOnly) {
+ processSteps = workflowsteps
+ .filter((s) => s.attributes.process === process && !s?.keys?.remoteId)
+ .sort(bySeq);
+ }
+ return processSteps;
+ };
+
+ /**
+ * Row label, tool JSON, and optional corrected `sequencenum` for Edit Workflow.
+ *
+ * - **Index alignment**: when `indexAlign` is passed and template count matches
+ * org count, use the Nth template row (fixes API data where every org step
+ * reused the same `sequencenum`, so lookup by sequence always hit "Record").
+ * - **Duplicate-name repair**: when raw `name` repeats in the batch, align
+ * by `sequencenum` when that still distinguishes rows.
+ */
+ const resolveOrgWorkflowStepPresentation = (
+ orgStep: OrgWorkflowStepD,
+ processFilter: string,
+ duplicateRawNameInBatch: boolean,
+ indexAlign?: { index: number; orgCount: number }
+ ): { name: string; toolAttr: string | undefined; sequencenum?: number } => {
+ const orgName = orgStep.attributes?.name ?? '';
+ const orgTool = orgStep.attributes?.tool;
+ if (!processFilter || processFilter === 'ANY') {
+ return {
+ name: localizedWorkStep(orgName),
+ toolAttr: orgTool,
+ };
+ }
+ const templates = getProcessTemplateSteps(processFilter);
+ if (
+ indexAlign &&
+ templates.length === indexAlign.orgCount &&
+ templates[indexAlign.index]
+ ) {
+ const tmpl = templates[indexAlign.index];
+ const sn = tmpl.attributes.sequencenum;
+ const sequencenum =
+ typeof sn === 'number' && sn < 0 ? sn : indexAlign.index + 1;
+ return {
+ name: localizedWorkStep(tmpl.attributes.name),
+ toolAttr: tmpl.attributes.tool ?? orgTool,
+ sequencenum,
+ };
+ }
+ if (!duplicateRawNameInBatch) {
+ return {
+ name: localizedWorkStep(orgName),
+ toolAttr: orgTool,
+ };
+ }
+ const tmpl = templates.find(
+ (w) => w.attributes.sequencenum === orgStep.attributes.sequencenum
+ );
+ if (tmpl?.attributes?.name && orgName !== tmpl.attributes.name) {
+ return {
+ name: localizedWorkStep(tmpl.attributes.name),
+ toolAttr: tmpl.attributes.tool ?? orgTool,
+ sequencenum: tmpl.attributes.sequencenum,
+ };
+ }
+ return {
+ name: localizedWorkStep(orgName),
+ toolAttr: orgTool,
+ };
+ };
+
+ const AddOrgWFToOps = (
tb: RecordTransformBuilder,
wf: WorkflowStepD,
org: string,
@@ -68,10 +149,8 @@ export const useOrgWorkflowSteps = () => {
// }
const wfs = {
type: 'orgworkflowstep',
- attributes: {
- ...wf.attributes,
- },
- } as OrgWorkflowStep;
+ attributes: { ...wf.attributes },
+ } as OrgWorkflowStepD;
ops.push(...AddRecord(tb, wfs, user, memory));
ops.push(
...ReplaceRelatedRecord(
@@ -104,7 +183,11 @@ export const useOrgWorkflowSteps = () => {
related(s, 'organization') === org &&
Boolean(s.keys?.remoteId) !== getGlobal('offlineOnly')
)
- .sort((i, j) => i.attributes.sequencenum - j.attributes.sequencenum);
+ .sort((i, j) => {
+ const d = i.attributes.sequencenum - j.attributes.sequencenum;
+ if (d !== 0) return d;
+ return String(i.id).localeCompare(String(j.id));
+ });
};
const CreateOrgWorkflowSteps = (
@@ -112,17 +195,23 @@ export const useOrgWorkflowSteps = () => {
process: string,
org: string
) => {
- const offlineOnly = getGlobal('offlineOnly');
- const processSteps = workflowsteps
- .filter(
- (s) =>
- s.attributes.process === process &&
- Boolean(s?.keys?.remoteId) !== offlineOnly
- )
- .sort((a, b) => a.attributes.sequencenum - b.attributes.sequencenum);
+ const processSteps = getProcessTemplateSteps(process);
const opArray: RecordOperation[] = [];
- for (let stepIndex = 0; stepIndex < processSteps.length; stepIndex++)
- AddOrgWFToOps(tb, processSteps[stepIndex] as WorkflowStepD, org, opArray);
+ let visibleOrdinal = 0;
+ for (let stepIndex = 0; stepIndex < processSteps.length; stepIndex++) {
+ const wf = processSteps[stepIndex] as WorkflowStepD;
+ const sn = wf.attributes.sequencenum;
+ const sequencenum =
+ typeof sn === 'number' && sn < 0 ? sn : ++visibleOrdinal;
+ const normalized = {
+ ...wf,
+ attributes: {
+ ...wf.attributes,
+ sequencenum,
+ },
+ } as WorkflowStepD;
+ AddOrgWFToOps(tb, normalized, org, opArray);
+ }
return opArray;
};
@@ -168,7 +257,9 @@ export const useOrgWorkflowSteps = () => {
return {
GetOrgWorkflowSteps,
CreateOrgWorkflowSteps,
+ getProcessTemplateSteps,
localizedWorkStepFromId,
localizedWorkStep,
+ resolveOrgWorkflowStepPresentation,
};
};
diff --git a/src/renderer/src/crud/useStepTool.ts b/src/renderer/src/crud/useStepTool.ts
index 52191eb7..a62db261 100644
--- a/src/renderer/src/crud/useStepTool.ts
+++ b/src/renderer/src/crud/useStepTool.ts
@@ -1,36 +1,42 @@
-import { useMemo } from 'react';
import { useGlobal } from '../context/useGlobal';
import { OrgWorkflowStep } from '../model';
import { findRecord } from './tryFindRecord';
export const getTool = (jsonTool?: string) => {
- if (jsonTool) {
- const tool = JSON.parse(jsonTool);
- return tool.tool || '';
+ try {
+ if (jsonTool) {
+ const tool = JSON.parse(jsonTool);
+ return tool.tool || '';
+ }
+ } catch (error) {
+ console.error('[getTool] error', error); // workflowsteps record not well formed
}
return '';
};
export const getToolSettings = (jsonTool?: string) => {
- if (jsonTool) {
- const tool = JSON.parse(jsonTool);
- return tool.settings || '';
+ try {
+ if (jsonTool) {
+ const tool = JSON.parse(jsonTool);
+ const settings = tool.settings;
+ if (!settings) return '';
+ return typeof settings === 'string' ? settings : JSON.stringify(settings);
+ }
+ } catch (error) {
+ console.error('[getToolSettings] error', error); // workflowsteps record not well formed
}
return '';
};
export const useStepTool = (stepId: string) => {
const [memory] = useGlobal('memory');
- return useMemo(() => {
- if (!stepId) return { tool: '', settings: '' };
- const workflowstep = findRecord(
- memory,
- 'orgworkflowstep',
- stepId
- ) as OrgWorkflowStep;
- return {
- tool: getTool(workflowstep?.attributes?.tool),
- settings: getToolSettings(workflowstep?.attributes?.tool),
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [stepId, memory?.cache]);
+ if (!stepId) return { tool: '', settings: '' };
+ const workflowstep = findRecord(
+ memory,
+ 'orgworkflowstep',
+ stepId
+ ) as OrgWorkflowStep;
+ return {
+ tool: getTool(workflowstep?.attributes?.tool),
+ settings: getToolSettings(workflowstep?.attributes?.tool),
+ };
};