From e046294898883cdd909943e57eb4532d8300ff28 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Thu, 23 Apr 2026 19:55:26 +0000 Subject: [PATCH] fix(wizard): gate optional PM wizard steps on required steps being complete --- tests/unit/web/jira-wizard-isComplete.test.ts | 140 ++++++++++++++++++ .../unit/web/linear-wizard-isComplete.test.ts | 122 +++++++++++++++ .../unit/web/trello-wizard-isComplete.test.ts | 122 +++++++++++++++ .../projects/pm-providers/jira/wizard.ts | 27 +++- .../projects/pm-providers/linear/wizard.ts | 25 +++- .../projects/pm-providers/trello/wizard.ts | 25 +++- 6 files changed, 451 insertions(+), 10 deletions(-) create mode 100644 tests/unit/web/jira-wizard-isComplete.test.ts create mode 100644 tests/unit/web/linear-wizard-isComplete.test.ts create mode 100644 tests/unit/web/trello-wizard-isComplete.test.ts diff --git a/tests/unit/web/jira-wizard-isComplete.test.ts b/tests/unit/web/jira-wizard-isComplete.test.ts new file mode 100644 index 00000000..01aecb06 --- /dev/null +++ b/tests/unit/web/jira-wizard-isComplete.test.ts @@ -0,0 +1,140 @@ +/** + * JIRA wizard — isComplete predicates for optional steps. + * + * Guards that optional steps (labels, custom-fields, issue-types, webhook) + * only show green check marks after the required steps (credentials + + * project + status mapping) are all complete. Prevents the UI bug where a + * brand-new unconfigured integration showed every step as green. + */ + +import { describe, expect, it } from 'vitest'; +import { jiraProviderWizard } from '../../../web/src/components/projects/pm-providers/jira/wizard.js'; +import { createInitialState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +const getStep = (id: string) => { + const step = jiraProviderWizard.steps.find((s) => s.id === id); + if (!step) throw new Error(`Step ${id} not found`); + return step; +}; + +describe('JIRA optional steps — isComplete gating', () => { + describe('fresh state (createInitialState)', () => { + const state = createInitialState(); + + it('jira-labels is NOT complete on fresh state', () => { + expect(getStep('jira-labels').isComplete(state)).toBe(false); + }); + + it('jira-custom-fields is NOT complete on fresh state', () => { + expect(getStep('jira-custom-fields').isComplete(state)).toBe(false); + }); + + it('jira-issue-types is NOT complete on fresh state', () => { + expect(getStep('jira-issue-types').isComplete(state)).toBe(false); + }); + + it('jira-webhook is NOT complete on fresh state', () => { + expect(getStep('jira-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('partially configured (credentials only, no project)', () => { + const state = { + ...createInitialState(), + jiraEmail: 'user@example.com', + jiraApiToken: 'token123', + jiraBaseUrl: 'https://example.atlassian.net', + verificationResult: { provider: 'jira' as const, display: 'user@example.com' }, + }; + + it('jira-labels is NOT complete when project not selected', () => { + expect(getStep('jira-labels').isComplete(state)).toBe(false); + }); + + it('jira-custom-fields is NOT complete when project not selected', () => { + expect(getStep('jira-custom-fields').isComplete(state)).toBe(false); + }); + + it('jira-issue-types is NOT complete when project not selected', () => { + expect(getStep('jira-issue-types').isComplete(state)).toBe(false); + }); + + it('jira-webhook is NOT complete when project not selected', () => { + expect(getStep('jira-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('credentials + project, but no status mapping', () => { + const state = { + ...createInitialState(), + jiraEmail: 'user@example.com', + jiraApiToken: 'token123', + jiraBaseUrl: 'https://example.atlassian.net', + verificationResult: { provider: 'jira' as const, display: 'user@example.com' }, + jiraProjectKey: 'PROJ', + jiraStatusMappings: {}, + }; + + it('jira-labels is NOT complete without status mapping', () => { + expect(getStep('jira-labels').isComplete(state)).toBe(false); + }); + + it('jira-webhook is NOT complete without status mapping', () => { + expect(getStep('jira-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('fully configured (credentials + project + status mapping)', () => { + const state = { + ...createInitialState(), + jiraEmail: 'user@example.com', + jiraApiToken: 'token123', + jiraBaseUrl: 'https://example.atlassian.net', + verificationResult: { provider: 'jira' as const, display: 'user@example.com' }, + jiraProjectKey: 'PROJ', + jiraStatusMappings: { todo: 'To Do', inProgress: 'In Progress' }, + }; + + it('jira-labels is complete when all required steps done', () => { + expect(getStep('jira-labels').isComplete(state)).toBe(true); + }); + + it('jira-custom-fields is complete when all required steps done', () => { + expect(getStep('jira-custom-fields').isComplete(state)).toBe(true); + }); + + it('jira-issue-types is complete when all required steps done', () => { + expect(getStep('jira-issue-types').isComplete(state)).toBe(true); + }); + + it('jira-webhook is complete when all required steps done', () => { + expect(getStep('jira-webhook').isComplete(state)).toBe(true); + }); + }); + + describe('edit mode with stored credentials (isEditing + hasStoredCredentials)', () => { + const state = { + ...createInitialState(), + isEditing: true, + hasStoredCredentials: true, + jiraProjectKey: 'PROJ', + jiraStatusMappings: { todo: 'To Do' }, + }; + + it('jira-labels is complete in edit mode with stored credentials', () => { + expect(getStep('jira-labels').isComplete(state)).toBe(true); + }); + + it('jira-custom-fields is complete in edit mode with stored credentials', () => { + expect(getStep('jira-custom-fields').isComplete(state)).toBe(true); + }); + + it('jira-issue-types is complete in edit mode with stored credentials', () => { + expect(getStep('jira-issue-types').isComplete(state)).toBe(true); + }); + + it('jira-webhook is complete in edit mode with stored credentials', () => { + expect(getStep('jira-webhook').isComplete(state)).toBe(true); + }); + }); +}); diff --git a/tests/unit/web/linear-wizard-isComplete.test.ts b/tests/unit/web/linear-wizard-isComplete.test.ts new file mode 100644 index 00000000..f480a9be --- /dev/null +++ b/tests/unit/web/linear-wizard-isComplete.test.ts @@ -0,0 +1,122 @@ +/** + * Linear wizard — isComplete predicates for optional steps. + * + * Guards that optional steps (labels, project-scope, webhook) only show + * green check marks after the required steps (credentials + team + status + * mapping) are all complete. Prevents the UI bug where a brand-new + * unconfigured integration showed every step as green. + */ + +import { describe, expect, it } from 'vitest'; +import { linearProviderWizard } from '../../../web/src/components/projects/pm-providers/linear/wizard.js'; +import { createInitialState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +const getStep = (id: string) => { + const step = linearProviderWizard.steps.find((s) => s.id === id); + if (!step) throw new Error(`Step ${id} not found`); + return step; +}; + +describe('Linear optional steps — isComplete gating', () => { + describe('fresh state (createInitialState)', () => { + const state = createInitialState(); + + it('linear-labels is NOT complete on fresh state', () => { + expect(getStep('linear-labels').isComplete(state)).toBe(false); + }); + + it('linear-project-scope is NOT complete on fresh state', () => { + expect(getStep('linear-project-scope').isComplete(state)).toBe(false); + }); + + it('linear-webhook is NOT complete on fresh state', () => { + expect(getStep('linear-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('partially configured (credentials only, no team)', () => { + const state = { + ...createInitialState(), + linearApiKey: 'lin_api_123', + verificationResult: { provider: 'linear' as const, display: 'user@example.com' }, + }; + + it('linear-labels is NOT complete when team not selected', () => { + expect(getStep('linear-labels').isComplete(state)).toBe(false); + }); + + it('linear-project-scope is NOT complete when team not selected', () => { + expect(getStep('linear-project-scope').isComplete(state)).toBe(false); + }); + + it('linear-webhook is NOT complete when team not selected', () => { + expect(getStep('linear-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('credentials + team, but no status mapping', () => { + const state = { + ...createInitialState(), + linearApiKey: 'lin_api_123', + verificationResult: { provider: 'linear' as const, display: 'user@example.com' }, + linearTeamId: 'team-1', + linearStatusMappings: {}, + }; + + it('linear-labels is NOT complete without status mapping', () => { + expect(getStep('linear-labels').isComplete(state)).toBe(false); + }); + + it('linear-project-scope is NOT complete without status mapping', () => { + expect(getStep('linear-project-scope').isComplete(state)).toBe(false); + }); + + it('linear-webhook is NOT complete without status mapping', () => { + expect(getStep('linear-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('fully configured (credentials + team + status mapping)', () => { + const state = { + ...createInitialState(), + linearApiKey: 'lin_api_123', + verificationResult: { provider: 'linear' as const, display: 'user@example.com' }, + linearTeamId: 'team-1', + linearStatusMappings: { todo: 'state-uuid-1', inProgress: 'state-uuid-2' }, + }; + + it('linear-labels is complete when all required steps done', () => { + expect(getStep('linear-labels').isComplete(state)).toBe(true); + }); + + it('linear-project-scope is complete when all required steps done', () => { + expect(getStep('linear-project-scope').isComplete(state)).toBe(true); + }); + + it('linear-webhook is complete when all required steps done', () => { + expect(getStep('linear-webhook').isComplete(state)).toBe(true); + }); + }); + + describe('edit mode with stored credentials (isEditing + hasStoredCredentials)', () => { + const state = { + ...createInitialState(), + isEditing: true, + hasStoredCredentials: true, + linearTeamId: 'team-1', + linearStatusMappings: { todo: 'state-uuid-1' }, + }; + + it('linear-labels is complete in edit mode with stored credentials', () => { + expect(getStep('linear-labels').isComplete(state)).toBe(true); + }); + + it('linear-project-scope is complete in edit mode with stored credentials', () => { + expect(getStep('linear-project-scope').isComplete(state)).toBe(true); + }); + + it('linear-webhook is complete in edit mode with stored credentials', () => { + expect(getStep('linear-webhook').isComplete(state)).toBe(true); + }); + }); +}); diff --git a/tests/unit/web/trello-wizard-isComplete.test.ts b/tests/unit/web/trello-wizard-isComplete.test.ts new file mode 100644 index 00000000..bc97cbd9 --- /dev/null +++ b/tests/unit/web/trello-wizard-isComplete.test.ts @@ -0,0 +1,122 @@ +/** + * Trello wizard — isComplete predicates for optional steps. + * + * Guards that optional steps (labels, custom-fields, webhook) only show + * green check marks after the required steps (credentials + board + status + * mapping) are all complete. Prevents the UI bug where a brand-new + * unconfigured integration showed every step as green. + */ + +import { describe, expect, it } from 'vitest'; +import { trelloProviderWizard } from '../../../web/src/components/projects/pm-providers/trello/wizard.js'; +import { createInitialState } from '../../../web/src/components/projects/pm-wizard-state.js'; + +// Grab the optional steps by id +const getStep = (id: string) => { + const step = trelloProviderWizard.steps.find((s) => s.id === id); + if (!step) throw new Error(`Step ${id} not found`); + return step; +}; + +describe('Trello optional steps — isComplete gating', () => { + describe('fresh state (createInitialState)', () => { + const state = createInitialState(); + + it('trello-labels is NOT complete on fresh state', () => { + expect(getStep('trello-labels').isComplete(state)).toBe(false); + }); + + it('trello-custom-fields is NOT complete on fresh state', () => { + expect(getStep('trello-custom-fields').isComplete(state)).toBe(false); + }); + + it('trello-webhook is NOT complete on fresh state', () => { + expect(getStep('trello-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('partially configured (credentials only, no board)', () => { + const state = { + ...createInitialState(), + trelloApiKey: 'key123', + trelloToken: 'token123', + verificationResult: { provider: 'trello' as const, display: 'user@example.com' }, + }; + + it('trello-labels is NOT complete when board not selected', () => { + expect(getStep('trello-labels').isComplete(state)).toBe(false); + }); + + it('trello-custom-fields is NOT complete when board not selected', () => { + expect(getStep('trello-custom-fields').isComplete(state)).toBe(false); + }); + + it('trello-webhook is NOT complete when board not selected', () => { + expect(getStep('trello-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('credentials + board, but no status mapping', () => { + const state = { + ...createInitialState(), + trelloApiKey: 'key123', + trelloToken: 'token123', + verificationResult: { provider: 'trello' as const, display: 'user@example.com' }, + trelloBoardId: 'board-1', + trelloListMappings: {}, + }; + + it('trello-labels is NOT complete without status mapping', () => { + expect(getStep('trello-labels').isComplete(state)).toBe(false); + }); + + it('trello-webhook is NOT complete without status mapping', () => { + expect(getStep('trello-webhook').isComplete(state)).toBe(false); + }); + }); + + describe('fully configured (credentials + board + status mapping)', () => { + const state = { + ...createInitialState(), + trelloApiKey: 'key123', + trelloToken: 'token123', + verificationResult: { provider: 'trello' as const, display: 'user@example.com' }, + trelloBoardId: 'board-1', + trelloListMappings: { todo: 'list-1', inProgress: 'list-2' }, + }; + + it('trello-labels is complete when all required steps done', () => { + expect(getStep('trello-labels').isComplete(state)).toBe(true); + }); + + it('trello-custom-fields is complete when all required steps done', () => { + expect(getStep('trello-custom-fields').isComplete(state)).toBe(true); + }); + + it('trello-webhook is complete when all required steps done', () => { + expect(getStep('trello-webhook').isComplete(state)).toBe(true); + }); + }); + + describe('edit mode with stored credentials (isEditing + hasStoredCredentials)', () => { + const state = { + ...createInitialState(), + isEditing: true, + hasStoredCredentials: true, + trelloBoardId: 'board-1', + trelloListMappings: { todo: 'list-1' }, + }; + + it('trello-labels is complete in edit mode with stored credentials', () => { + expect(getStep('trello-labels').isComplete(state)).toBe(true); + }); + + it('trello-custom-fields is complete in edit mode with stored credentials', () => { + expect(getStep('trello-custom-fields').isComplete(state)).toBe(true); + }); + + it('trello-webhook is complete in edit mode with stored credentials', () => { + expect(getStep('trello-webhook').isComplete(state)).toBe(true); + }); + }); +}); diff --git a/web/src/components/projects/pm-providers/jira/wizard.ts b/web/src/components/projects/pm-providers/jira/wizard.ts index 62693db8..ae40e874 100644 --- a/web/src/components/projects/pm-providers/jira/wizard.ts +++ b/web/src/components/projects/pm-providers/jira/wizard.ts @@ -80,6 +80,25 @@ function isCredentialsComplete(state: { ); } +/** + * Returns true when all required JIRA steps are done: + * credentials + project selected + at least one status mapping. + * Used to gate optional step `isComplete` predicates so they only show + * green after the integration is actually configured. + */ +function areJiraRequiredStepsDone( + state: Parameters[0] & { + jiraProjectKey: string; + jiraStatusMappings: Record; + }, +): boolean { + return ( + isCredentialsComplete(state) && + Boolean(state.jiraProjectKey) && + Object.keys(state.jiraStatusMappings).length > 0 + ); +} + interface JiraProviderHooks { readonly projectOptions: ReadonlyArray<{ readonly id: string; readonly name: string }>; readonly projectsLoading: boolean; @@ -250,25 +269,25 @@ export const jiraProviderWizard: ProviderWizardDefinition = { id: 'jira-labels', title: 'Labels', Component: JiraLabelMappingAdapter, - isComplete: () => true, // labels are optional + isComplete: (state) => areJiraRequiredStepsDone(state), // optional, but only green after required steps }, { id: 'jira-custom-fields', title: 'Custom fields', Component: JiraCustomFieldMappingAdapter, - isComplete: () => true, // cost field optional + isComplete: (state) => areJiraRequiredStepsDone(state), // optional, but only green after required steps }, { id: 'jira-issue-types', title: 'Issue types', Component: JiraIssueTypeAdapter, - isComplete: () => true, // issue-type mapping optional + isComplete: (state) => areJiraRequiredStepsDone(state), // optional, but only green after required steps }, { id: 'jira-webhook', title: 'Webhook', Component: JiraWebhookAdapter, - isComplete: () => true, + isComplete: (state) => areJiraRequiredStepsDone(state), }, ], diff --git a/web/src/components/projects/pm-providers/linear/wizard.ts b/web/src/components/projects/pm-providers/linear/wizard.ts index 2d42755c..fc0369fe 100644 --- a/web/src/components/projects/pm-providers/linear/wizard.ts +++ b/web/src/components/projects/pm-providers/linear/wizard.ts @@ -83,6 +83,25 @@ function isCredentialsComplete(state: { return Boolean(state.linearApiKey && state.verificationResult); } +/** + * Returns true when all required Linear steps are done: + * credentials + team selected + at least one status mapping. + * Used to gate optional step `isComplete` predicates so they only show + * green after the integration is actually configured. + */ +function areLinearRequiredStepsDone( + state: Parameters[0] & { + linearTeamId: string; + linearStatusMappings: Record; + }, +): boolean { + return ( + isCredentialsComplete(state) && + Boolean(state.linearTeamId) && + Object.keys(state.linearStatusMappings).length > 0 + ); +} + interface LinearProviderHooks { readonly teamOptions: ReadonlyArray<{ readonly id: string; @@ -232,19 +251,19 @@ export const linearProviderWizard: ProviderWizardDefinition = { id: 'linear-labels', title: 'Labels', Component: LinearLabelMappingAdapter, - isComplete: () => true, // labels optional + isComplete: (state) => areLinearRequiredStepsDone(state), // optional, but only green after required steps }, { id: 'linear-project-scope', title: 'Project scope', Component: LinearProjectScopeAdapter, - isComplete: () => true, // optional narrowing + isComplete: (state) => areLinearRequiredStepsDone(state), // optional, but only green after required steps }, { id: 'linear-webhook', title: 'Webhook', Component: LinearWebhookAdapter, - isComplete: () => true, + isComplete: (state) => areLinearRequiredStepsDone(state), }, ], diff --git a/web/src/components/projects/pm-providers/trello/wizard.ts b/web/src/components/projects/pm-providers/trello/wizard.ts index 7c34f795..487aec7d 100644 --- a/web/src/components/projects/pm-providers/trello/wizard.ts +++ b/web/src/components/projects/pm-providers/trello/wizard.ts @@ -84,6 +84,25 @@ function isCredentialsComplete(state: { return Boolean(state.trelloApiKey && state.trelloToken && state.verificationResult); } +/** + * Returns true when all required Trello steps are done: + * credentials + board selected + at least one list mapping. + * Used to gate optional step `isComplete` predicates so they only show + * green after the integration is actually configured. + */ +function areTrelloRequiredStepsDone( + state: Parameters[0] & { + trelloBoardId: string; + trelloListMappings: Record; + }, +): boolean { + return ( + isCredentialsComplete(state) && + Boolean(state.trelloBoardId) && + Object.keys(state.trelloListMappings).length > 0 + ); +} + /** * The shape returned by `useProviderHooks`. Each step adapter pulls the * slice it needs from this record. Ports all the mutations + memoized @@ -249,19 +268,19 @@ export const trelloProviderWizard: ProviderWizardDefinition = { id: 'trello-labels', title: 'Label mapping', Component: TrelloLabelMappingAdapter, - isComplete: () => true, // labels are optional + isComplete: (state) => areTrelloRequiredStepsDone(state), // optional, but only green after required steps }, { id: 'trello-custom-fields', title: 'Custom fields', Component: TrelloCustomFieldMappingAdapter, - isComplete: () => true, // cost field is optional + isComplete: (state) => areTrelloRequiredStepsDone(state), // optional, but only green after required steps }, { id: 'trello-webhook', title: 'Webhook', Component: TrelloWebhookAdapter, - isComplete: () => true, + isComplete: (state) => areTrelloRequiredStepsDone(state), }, ],