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), + }; };