diff --git a/src/app/stores/flow-editor.spec.ts b/src/app/stores/flow-editor.spec.ts index c6bd67f..1b6f797 100644 --- a/src/app/stores/flow-editor.spec.ts +++ b/src/app/stores/flow-editor.spec.ts @@ -235,6 +235,76 @@ describe('EditorStateHolder', () => { expect(service.structureNavigationRequest()).toBe(previousRequest + 1); }); + it('marks the node inside a container that carries the error, once the subflow is open', async () => { + // The error is reported against the container, with the node that actually carries it in + // relatedNodeIds: at the root that marks the container, and inside the subflow it has to mark + // the block - opening it to a canvas with nothing marked leaves nowhere to look. + const validation: GroupedFlowValidation = { + flowLevel: [], + byContainer: { + 'container-1': { + body: [{ + message: "Global input 'document' is referenced but not declared in flow.globalInputs", + code: 'GLOBAL_INPUT_NOT_DECLARED', + entity: 'container', + id: 'container-1', + field: 'specificConfiguration.subFlow', + relatedNodeIds: ['inner-block-1'] + }], + guard: [] + } + } + }; + flowsServiceSpy.getGroupedFlowValidation.mockReturnValue(of(validation)); + const flow = makeFlow({ + data: { + blocks: [], + containers: [{ + id: 'container-1', + name: 'Loop', + typeName: 'LoopContainer', + nodeFamily: 'container', + inputs: [], + outputs: [], + specificConfiguration: { + subFlow: { blocks: [], containers: [], connections: [], dependencies: [] } + } + }], + connections: [], + dependencies: [] + } + }); + + await service.openDocument(flow); + // At the root the container is what carries a subflow error, and nothing else would mark it. + expect(service.isValidationNodeHighlighted('container-1')).toBe(true); + + expect(service.openSubflow([{ containerId: 'container-1', configurationPath: 'subFlow' }])).toBe(true); + expect(service.isValidationNodeHighlighted('inner-block-1')).toBe(true); + }); + + it('focuses one error on request, and goes back to all of them when the focus is dropped', async () => { + const validation: GroupedFlowValidation = { + flowLevel: [ + { message: 'First', code: 'A', relatedNodeIds: ['block-1'] }, + { message: 'Second', code: 'B', relatedNodeIds: ['block-2'] } + ], + byContainer: {} + }; + flowsServiceSpy.getGroupedFlowValidation.mockReturnValue(of(validation)); + await service.openDocument(makeFlow()); + + expect(service.isValidationNodeHighlighted('block-1')).toBe(true); + expect(service.isValidationNodeHighlighted('block-2')).toBe(true); + + service.setHighlightedValidationNodes(['block-2']); + expect(service.isValidationNodeHighlighted('block-1')).toBe(false); + expect(service.isValidationNodeHighlighted('block-2')).toBe(true); + + service.setHighlightedValidationNodes([]); + expect(service.isValidationNodeHighlighted('block-1')).toBe(true); + }); + it('shows only the active subflow validation errors', async () => { const validation: GroupedFlowValidation = { flowLevel: [{ message: 'Root flow error', code: 'ROOT' }], diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts index 9600aa9..23e0a3b 100644 --- a/src/app/stores/flow-editor.ts +++ b/src/app/stores/flow-editor.ts @@ -48,7 +48,33 @@ export class EditorStateHolder { ? containerErrors.guard : containerErrors.body; }); - readonly highlightedValidationNodeIds = signal([]); + /** + * What the panel has focused, or null to mark whatever the current view's errors point at. The + * default matters most inside a container: an error there is reported against the container, with + * the node that actually carries it in relatedNodeIds, so without this the subflow opened to a + * canvas where nothing was marked and the error named a container the view no longer showed. + */ + private readonly focusedValidationNodeIds = signal(null); + + readonly highlightedValidationNodeIds = computed(() => { + const focused = this.focusedValidationNodeIds(); + if (focused) return focused; + return uniqueNodeIds([ + ...this.flowValidationErrors().flatMap((error) => error.relatedNodeIds ?? []), + ...this.containerIdsWithSubflowErrors() + ]); + }); + + /** + * Containers whose errors all live inside their subflow. Those are grouped away from the flow + * level, so at the root nothing else would mark the container that holds them. + */ + private readonly containerIdsWithSubflowErrors = computed(() => { + if (this.activeSubflow()) return []; + return Object.entries(this.groupedFlowValidation().byContainer) + .filter(([, errors]) => errors.body.length > 0 || errors.guard.length > 0) + .map(([containerId]) => containerId); + }); readonly validationRequiresSave = signal(false); readonly activeSubflow = signal(null); readonly structureNavigationRequest = signal(0); @@ -306,9 +332,10 @@ export class EditorStateHolder { ) } + /** Focuses one error's nodes; an empty list goes back to marking everything the view reports. */ setHighlightedValidationNodes(nodeIds: string[]) { - const unique = Array.from(new Set((nodeIds ?? []).filter((id) => typeof id === 'string' && id.length > 0))); - this.highlightedValidationNodeIds.set(unique); + const unique = uniqueNodeIds(nodeIds ?? []); + this.focusedValidationNodeIds.set(unique.length > 0 ? unique : null); } isValidationNodeHighlighted(blockId: string | null | undefined): boolean { @@ -327,10 +354,9 @@ export class EditorStateHolder { private refreshValidationPresentation(flow?: Flow | null) { this.localFlowValidationErrors.set(flow ? this.deriveGlobalInputReferenceErrors(flow) : []); - const errors = this.flowValidationErrors(); - this.highlightedValidationNodeIds.set(Array.from(new Set( - errors.flatMap((error) => Array.isArray(error.relatedNodeIds) ? error.relatedNodeIds : []) - ))); + // A fresh result is about the whole flow again, so whatever one error the panel had focused is + // no longer what the person is looking at. + this.focusedValidationNodeIds.set(null); } private extractValidationErrors(error: unknown): FlowValidationError[] { @@ -464,3 +490,9 @@ function emptyGroupedFlowValidation(): GroupedFlowValidation { function isGuardSubflow(configurationPath: string): boolean { return configurationPath.split('.').at(-1)?.toLowerCase().includes('guard') ?? false; } + +function uniqueNodeIds(nodeIds: (string | null | undefined)[]): string[] { + return Array.from(new Set( + nodeIds.filter((id): id is string => typeof id === 'string' && id.length > 0) + )); +}