diff --git a/tests/unit/web/pm-wizard-hooks.test.ts b/tests/unit/web/pm-wizard-hooks.test.ts new file mode 100644 index 00000000..81f1927c --- /dev/null +++ b/tests/unit/web/pm-wizard-hooks.test.ts @@ -0,0 +1,403 @@ +/** + * Unit tests for pure functions extracted in the pm-wizard-hooks refactor: + * - buildProviderAuthArg (generic auth-arg builder for all three providers) + * - runPerLabelCreations (batch label creator with per-item error handling) + * - buildTrelloIntegrationConfig / buildJiraIntegrationConfig (pure config builders) + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildProviderAuthArg, + runPerLabelCreations, +} from '../../../web/src/components/projects/pm-wizard-hooks.js'; +import type { WizardState } from '../../../web/src/components/projects/pm-wizard-state.js'; +import { + buildJiraIntegrationConfig, + buildLinearIntegrationConfig, + buildTrelloIntegrationConfig, + createInitialState, +} from '../../../web/src/components/projects/pm-wizard-state.js'; + +// ============================================================================ +// buildProviderAuthArg +// ============================================================================ + +describe('buildProviderAuthArg', () => { + function trelloState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'trello', ...overrides }; + } + function jiraState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'jira', ...overrides }; + } + function linearState(overrides: Partial = {}): WizardState { + return { ...createInitialState(), provider: 'linear', ...overrides }; + } + + // ── Edit mode — stored credentials path ────────────────────────────── + it('trello: returns { projectId } in edit mode when stored creds and no raw key', () => { + const state = trelloState({ + isEditing: true, + hasStoredCredentials: true, + trelloApiKey: '', + trelloToken: '', + }); + expect(buildProviderAuthArg(state, 'proj-1')).toEqual({ projectId: 'proj-1' }); + }); + + it('jira: returns { projectId } in edit mode when stored creds and no raw token', () => { + const state = jiraState({ + isEditing: true, + hasStoredCredentials: true, + jiraApiToken: '', + jiraEmail: '', + }); + expect(buildProviderAuthArg(state, 'proj-jira')).toEqual({ projectId: 'proj-jira' }); + }); + + it('linear: returns { projectId } in edit mode when stored creds and no raw key', () => { + const state = linearState({ + isEditing: true, + hasStoredCredentials: true, + linearApiKey: '', + }); + expect(buildProviderAuthArg(state, 'proj-lin')).toEqual({ projectId: 'proj-lin' }); + }); + + // ── Fresh setup — credentials path ────────────────────────────────── + it('trello: returns credentials when api_key and token present (fresh setup)', () => { + const state = trelloState({ trelloApiKey: 'key-abc', trelloToken: 'tok-xyz' }); + expect(buildProviderAuthArg(state, 'proj-1')).toEqual({ + credentials: { api_key: 'key-abc', token: 'tok-xyz' }, + }); + }); + + it('jira: returns credentials when email + api_token + base_url present (fresh setup)', () => { + const state = jiraState({ + jiraEmail: 'user@example.com', + jiraApiToken: 'jira-tok', + jiraBaseUrl: 'https://example.atlassian.net', + }); + expect(buildProviderAuthArg(state, 'proj-j')).toEqual({ + credentials: { + email: 'user@example.com', + api_token: 'jira-tok', + base_url: 'https://example.atlassian.net', + }, + }); + }); + + it('linear: returns credentials when api_key present (fresh setup)', () => { + const state = linearState({ linearApiKey: 'lin_abc' }); + expect(buildProviderAuthArg(state, 'proj-l')).toEqual({ + credentials: { api_key: 'lin_abc' }, + }); + }); + + // ── Edit mode — user re-typed key → use fresh credentials ─────────── + it('trello: uses fresh credentials when user re-typed api_key in edit mode', () => { + const state = trelloState({ + isEditing: true, + hasStoredCredentials: true, + trelloApiKey: 'new-key', + trelloToken: 'new-tok', + }); + expect(buildProviderAuthArg(state, 'proj-1')).toEqual({ + credentials: { api_key: 'new-key', token: 'new-tok' }, + }); + }); + + it('linear: uses fresh credentials when user re-typed api_key in edit mode', () => { + const state = linearState({ + isEditing: true, + hasStoredCredentials: true, + linearApiKey: 'lin_fresh', + }); + expect(buildProviderAuthArg(state, 'proj-l')).toEqual({ + credentials: { api_key: 'lin_fresh' }, + }); + }); + + // ── Error cases ────────────────────────────────────────────────────── + it('trello: throws when no api_key in fresh mode', () => { + const state = trelloState({ trelloToken: 'tok' }); + expect(() => buildProviderAuthArg(state, 'proj-1')).toThrow( + 'Enter both credentials before verifying', + ); + }); + + it('trello: throws when no token in fresh mode', () => { + const state = trelloState({ trelloApiKey: 'key' }); + expect(() => buildProviderAuthArg(state, 'proj-1')).toThrow( + 'Enter both credentials before verifying', + ); + }); + + it('jira: throws when no email in fresh mode', () => { + const state = jiraState({ jiraApiToken: 'tok', jiraBaseUrl: 'https://x.atlassian.net' }); + expect(() => buildProviderAuthArg(state, 'proj-j')).toThrow( + 'Enter both credentials before verifying', + ); + }); + + it('jira: throws when no api_token in fresh mode', () => { + const state = jiraState({ jiraEmail: 'u@x.com', jiraBaseUrl: 'https://x.atlassian.net' }); + expect(() => buildProviderAuthArg(state, 'proj-j')).toThrow( + 'Enter both credentials before verifying', + ); + }); + + it('linear: throws when no api_key in fresh mode', () => { + const state = linearState({ linearApiKey: '' }); + expect(() => buildProviderAuthArg(state, 'proj-l')).toThrow( + 'Enter your API key before verifying', + ); + }); +}); + +// ============================================================================ +// buildTrelloIntegrationConfig +// ============================================================================ + +describe('buildTrelloIntegrationConfig', () => { + function seed(overrides: Partial = {}): WizardState { + return { + ...createInitialState(), + provider: 'trello', + trelloBoardId: 'board-abc', + trelloListMappings: { todo: 'list-1', done: 'list-2' }, + trelloLabelMappings: { processing: 'label-x' }, + ...overrides, + }; + } + + it('produces the expected config shape', () => { + const config = buildTrelloIntegrationConfig(seed()); + expect(config).toEqual({ + boardId: 'board-abc', + lists: { todo: 'list-1', done: 'list-2' }, + labels: { processing: 'label-x' }, + }); + }); + + it('includes customFields when trelloCostFieldId is set', () => { + const config = buildTrelloIntegrationConfig(seed({ trelloCostFieldId: 'cf-cost' })); + expect(config.customFields).toEqual({ cost: 'cf-cost' }); + }); + + it('omits customFields when trelloCostFieldId is empty', () => { + const config = buildTrelloIntegrationConfig(seed({ trelloCostFieldId: '' })); + expect(config).not.toHaveProperty('customFields'); + }); + + it('passes through empty mappings', () => { + const config = buildTrelloIntegrationConfig( + seed({ trelloListMappings: {}, trelloLabelMappings: {} }), + ); + expect(config.lists).toEqual({}); + expect(config.labels).toEqual({}); + }); +}); + +// ============================================================================ +// buildJiraIntegrationConfig +// ============================================================================ + +describe('buildJiraIntegrationConfig', () => { + function seed(overrides: Partial = {}): WizardState { + return { + ...createInitialState(), + provider: 'jira', + jiraProjectKey: 'PROJ', + jiraBaseUrl: 'https://example.atlassian.net', + jiraStatusMappings: { todo: 'To Do', done: 'Done' }, + jiraLabels: { processing: 'cascade-processing' }, + ...overrides, + }; + } + + it('produces the expected config shape', () => { + const config = buildJiraIntegrationConfig(seed()); + expect(config).toEqual({ + projectKey: 'PROJ', + baseUrl: 'https://example.atlassian.net', + statuses: { todo: 'To Do', done: 'Done' }, + labels: { processing: 'cascade-processing' }, + }); + }); + + it('includes issueTypes when jiraIssueTypes non-empty', () => { + const config = buildJiraIntegrationConfig( + seed({ jiraIssueTypes: { task: 'Task', subtask: 'Sub-task' } }), + ); + expect(config.issueTypes).toEqual({ task: 'Task', subtask: 'Sub-task' }); + }); + + it('omits issueTypes when jiraIssueTypes is empty', () => { + const config = buildJiraIntegrationConfig(seed({ jiraIssueTypes: {} })); + expect(config).not.toHaveProperty('issueTypes'); + }); + + it('omits labels when jiraLabels is empty', () => { + const config = buildJiraIntegrationConfig(seed({ jiraLabels: {} })); + expect(config).not.toHaveProperty('labels'); + }); + + it('includes customFields when jiraCostFieldId set', () => { + const config = buildJiraIntegrationConfig(seed({ jiraCostFieldId: 'customfield_10042' })); + expect(config.customFields).toEqual({ cost: 'customfield_10042' }); + }); + + it('omits customFields when jiraCostFieldId is empty', () => { + const config = buildJiraIntegrationConfig(seed({ jiraCostFieldId: '' })); + expect(config).not.toHaveProperty('customFields'); + }); +}); + +// ============================================================================ +// buildLinearIntegrationConfig (already tested in pm-wizard-state.test.ts; +// added here for cross-reference completeness) +// ============================================================================ + +describe('buildLinearIntegrationConfig', () => { + function seed(overrides: Partial = {}): WizardState { + return { + ...createInitialState(), + provider: 'linear', + linearTeamId: 'T1', + linearStatusMappings: { todo: 'S-TD' }, + linearLabels: {}, + ...overrides, + }; + } + + it('produces the expected config shape', () => { + const config = buildLinearIntegrationConfig(seed()); + expect(config).toEqual({ teamId: 'T1', statuses: { todo: 'S-TD' } }); + }); + + it('includes projectId when linearProjectId is set', () => { + const config = buildLinearIntegrationConfig(seed({ linearProjectId: 'P1' })); + expect(config.projectId).toBe('P1'); + }); + + it('omits projectId when linearProjectId is empty', () => { + const config = buildLinearIntegrationConfig(seed({ linearProjectId: '' })); + expect(config).not.toHaveProperty('projectId'); + }); +}); + +// ============================================================================ +// runPerLabelCreations +// ============================================================================ + +const { mockCreateLabel } = vi.hoisted(() => ({ + mockCreateLabel: vi.fn(), +})); + +vi.mock('../../../web/src/lib/trpc.js', () => ({ + trpcClient: { + pm: { + discovery: { + createLabel: { mutate: mockCreateLabel }, + }, + }, + }, + trpc: {}, +})); + +describe('runPerLabelCreations', () => { + beforeEach(() => { + mockCreateLabel.mockReset(); + }); + + it('returns successes when all labels created', async () => { + mockCreateLabel + .mockResolvedValueOnce({ id: 'lbl-1', name: 'cascade-ready', color: 'sky' }) + .mockResolvedValueOnce({ id: 'lbl-2', name: 'cascade-processing', color: 'blue' }); + + const result = await runPerLabelCreations({ + labelsToCreate: [ + { slot: 'readyToProcess', name: 'cascade-ready', color: 'sky' }, + { slot: 'processing', name: 'cascade-processing', color: 'blue' }, + ], + providerId: 'trello', + containerId: 'board-1', + authArg: { credentials: { api_key: 'k', token: 't' } }, + }); + + expect(result.successes).toHaveLength(2); + expect(result.errors).toHaveLength(0); + expect(result.successes[0]).toEqual({ id: 'lbl-1', name: 'cascade-ready', color: 'sky' }); + expect(result.successes[1]).toEqual({ + id: 'lbl-2', + name: 'cascade-processing', + color: 'blue', + }); + }); + + it('collects per-item errors without aborting remaining items', async () => { + mockCreateLabel + .mockRejectedValueOnce(new Error('rate limit')) + .mockResolvedValueOnce({ id: 'lbl-2', name: 'cascade-processing', color: 'blue' }); + + const result = await runPerLabelCreations({ + labelsToCreate: [ + { slot: 'readyToProcess', name: 'cascade-ready', color: 'sky' }, + { slot: 'processing', name: 'cascade-processing', color: 'blue' }, + ], + providerId: 'trello', + containerId: 'board-1', + authArg: { projectId: 'proj-1' }, + }); + + expect(result.successes).toHaveLength(1); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toEqual({ name: 'cascade-ready', error: 'rate limit' }); + expect(result.successes[0].name).toBe('cascade-processing'); + }); + + it('returns empty arrays when labelsToCreate is empty', async () => { + const result = await runPerLabelCreations({ + labelsToCreate: [], + providerId: 'linear', + containerId: 'team-1', + authArg: { credentials: { api_key: 'lin_key' } }, + }); + + expect(result.successes).toHaveLength(0); + expect(result.errors).toHaveLength(0); + expect(mockCreateLabel).not.toHaveBeenCalled(); + }); + + it('passes the correct arguments to the tRPC mutation', async () => { + mockCreateLabel.mockResolvedValueOnce({ id: 'lbl-1', name: 'my-label', color: 'green' }); + + await runPerLabelCreations({ + labelsToCreate: [{ slot: 'processed', name: 'my-label', color: 'green' }], + providerId: 'linear', + containerId: 'team-abc', + authArg: { credentials: { api_key: 'lin_key' } }, + }); + + expect(mockCreateLabel).toHaveBeenCalledWith({ + providerId: 'linear', + containerId: 'team-abc', + name: 'my-label', + color: 'green', + credentials: { api_key: 'lin_key' }, + }); + }); + + it('converts non-Error rejections to string errors', async () => { + mockCreateLabel.mockRejectedValueOnce('some string error'); + + const result = await runPerLabelCreations({ + labelsToCreate: [{ slot: 'auto', name: 'cascade-auto', color: 'purple' }], + providerId: 'trello', + containerId: 'board-1', + authArg: { projectId: 'proj-1' }, + }); + + expect(result.errors[0].error).toBe('some string error'); + }); +}); diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 00a5c061..c34f3ef2 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -1,6 +1,15 @@ /** * Custom hooks for PM Wizard mutations and side-effects. * Each hook encapsulates one concern to keep the main orchestrator thin. + * + * Generic hooks introduced in spec 013 refactor: + * - buildProviderAuthArg — single auth-arg builder for all three providers + * - useProviderLabelCreation— parameterized label-creation hook (replaces 2 copies) + * - useProviderCustomFieldCreation — parameterized CF hook (replaces 2 copies) + * - useSaveMutation — data-driven, no provider branching + * + * Per-provider thin wrappers (useTrelloDiscovery, etc.) remain exported for + * backward-compatibility with existing wizard.ts imports. */ import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -11,10 +20,239 @@ import type { LinearProjectOption, LinearTeamDetails, LinearTeamOption, + Provider, WizardAction, WizardState, } from './pm-wizard-state.js'; -import { buildLinearIntegrationConfig, shouldUseStoredCredentials } from './pm-wizard-state.js'; +import { + buildJiraIntegrationConfig, + buildLinearIntegrationConfig, + buildTrelloIntegrationConfig, + shouldUseStoredCredentials, +} from './pm-wizard-state.js'; + +// ============================================================================ +// Auth-arg builder — shared across all mutations +// ============================================================================ + +/** + * Build the `{ projectId }` or `{ credentials: ... }` portion of a tRPC + * request for any provider. Returns the stored-creds path when the user is + * editing an existing integration without re-typing their key. + * + * Extracted so every per-provider mutation stays below the cognitive- + * complexity threshold and a single place enforces the invariant. + */ +export function buildProviderAuthArg( + state: WizardState, + projectId: string, +): { projectId: string } | { credentials: Record } { + if (shouldUseStoredCredentials(state)) { + return { projectId }; + } + if (state.provider === 'trello') { + if (!state.trelloApiKey || !state.trelloToken) { + throw new Error('Enter both credentials before verifying'); + } + return { credentials: { api_key: state.trelloApiKey, token: state.trelloToken } }; + } + if (state.provider === 'linear') { + if (!state.linearApiKey) { + throw new Error('Enter your API key before verifying'); + } + return { credentials: { api_key: state.linearApiKey } }; + } + // jira + if (!state.jiraEmail || !state.jiraApiToken) { + throw new Error('Enter both credentials before verifying'); + } + return { + credentials: { + email: state.jiraEmail, + api_token: state.jiraApiToken, + base_url: state.jiraBaseUrl, + }, + }; +} + +// ============================================================================ +// Label creation utilities +// ============================================================================ + +/** + * Iterate `labelsToCreate` through `pm.discovery.createLabel`, collecting + * successes + per-name errors. Factored out so the two + * `createMissingLabelsMutation` bodies (Trello + Linear) stay below the + * biome cognitive-complexity threshold. + */ +export async function runPerLabelCreations(opts: { + labelsToCreate: Array<{ slot: string; name: string; color?: string }>; + providerId: 'trello' | 'linear'; + containerId: string; + authArg: { projectId: string } | { credentials: Record }; +}): Promise<{ + successes: Array<{ id: string; name: string; color: string }>; + errors: Array<{ name: string; error: string }>; +}> { + const successes: Array<{ id: string; name: string; color: string }> = []; + const errors: Array<{ name: string; error: string }> = []; + for (const { name, color } of opts.labelsToCreate) { + try { + const label = await trpcClient.pm.discovery.createLabel.mutate({ + providerId: opts.providerId, + containerId: opts.containerId, + name, + color, + ...opts.authArg, + }); + successes.push(label); + } catch (err) { + errors.push({ name, error: err instanceof Error ? err.message : String(err) }); + } + } + return { successes, errors }; +} + +// ============================================================================ +// Generic label-creation hook +// ============================================================================ + +interface LabelCreationConfig { + providerId: 'trello' | 'linear'; + /** Returns the container ID (board / team) from state */ + getContainerId: (state: WizardState) => string; + /** Error when container not yet selected */ + containerError: string; + /** Dispatch to add a newly created label to the local list */ + addLabel: (label: { id: string; name: string; color: string }) => WizardAction; + /** Dispatch to map a slot to the newly created label ID */ + setLabelMapping: (slot: string, id: string) => WizardAction; +} + +function useProviderLabelCreation( + config: LabelCreationConfig, + state: WizardState, + dispatch: React.Dispatch, + projectId: string, +) { + const createLabelMutation = useMutation({ + mutationFn: (vars: { name: string; color?: string; slot: string }) => { + const containerId = config.getContainerId(state); + if (!containerId) throw new Error(config.containerError); + const authArg = buildProviderAuthArg(state, projectId); + return trpcClient.pm.discovery.createLabel.mutate({ + providerId: config.providerId, + containerId, + name: vars.name, + color: vars.color, + ...authArg, + }); + }, + onSuccess: (label, vars) => { + dispatch(config.addLabel(label)); + dispatch(config.setLabelMapping(vars.slot, label.id)); + }, + onError: (error) => { + console.error('Failed to create label:', error); + alert(`Failed to create label: ${error instanceof Error ? error.message : String(error)}`); + }, + }); + + const createMissingLabelsMutation = useMutation({ + mutationFn: async (labelsToCreate: Array<{ slot: string; name: string; color?: string }>) => { + const containerId = config.getContainerId(state); + if (!containerId) throw new Error(config.containerError); + const authArg = buildProviderAuthArg(state, projectId); + return runPerLabelCreations({ + labelsToCreate, + providerId: config.providerId, + containerId, + authArg, + }); + }, + onSuccess: (result, labelsToCreate) => { + for (const label of result.successes) { + const slot = labelsToCreate.find((l) => l.name === label.name)?.slot; + if (slot) { + dispatch(config.addLabel(label)); + dispatch(config.setLabelMapping(slot, label.id)); + } + } + if (result.errors.length > 0) { + const errorMsg = result.errors.map((e) => `${e.name}: ${e.error}`).join('\n'); + alert( + `Some labels failed to create:\n${errorMsg}\n\n${result.successes.length} label(s) created successfully.`, + ); + } + }, + onError: (error) => { + console.error('Failed to create labels:', error); + alert(`Failed to create labels: ${error instanceof Error ? error.message : String(error)}`); + }, + }); + + return { createLabelMutation, createMissingLabelsMutation }; +} + +// ============================================================================ +// Generic custom-field-creation hook +// ============================================================================ + +interface CustomFieldCreationConfig { + providerId: 'trello' | 'jira'; + /** Returns the container ID from state (boardId / projectKey) */ + getContainerId: (state: WizardState) => string; + /** Error thrown when container not yet selected (required for Trello; omit for global providers like JIRA) */ + containerError?: string; + /** Dispatch to add a new custom field to the local list */ + addCustomField: (field: { id: string; name: string; type: string }) => WizardAction; + /** Dispatch to set the cost field ID */ + setCostField: (id: string) => WizardAction; + /** Optional override for error handling (default: generic alert) */ + onError?: (error: unknown) => void; +} + +function useProviderCustomFieldCreation( + config: CustomFieldCreationConfig, + state: WizardState, + dispatch: React.Dispatch, + projectId: string, +) { + const createCustomFieldMutation = useMutation({ + mutationFn: ({ name }: { name: string }) => { + const containerId = config.getContainerId(state); + if (!containerId && config.containerError) throw new Error(config.containerError); + const authArg = buildProviderAuthArg(state, projectId); + return trpcClient.pm.discovery.createCustomField.mutate({ + providerId: config.providerId, + containerId: containerId || 'global', + name, + ...authArg, + }); + }, + onSuccess: (customField) => { + dispatch( + config.addCustomField({ + id: customField.id, + name: customField.name, + type: customField.type, + }), + ); + dispatch(config.setCostField(customField.id)); + }, + onError: (error) => { + if (config.onError) { + config.onError(error); + return; + } + console.error('Failed to create custom field:', error); + const message = error instanceof Error ? error.message : String(error); + alert(`Failed to create custom field: ${message}`); + }, + }); + + return { createCustomFieldMutation }; +} // ============================================================================ // Trello Discovery @@ -367,43 +605,6 @@ export function useLinearDiscovery( // Verification // ============================================================================ -/** - * Build the `{ projectId }` or `{ credentials: ... }` portion of a tRPC - * request, picking the stored-creds path when the user is editing an - * existing integration and hasn't re-typed the key. Extracted so the - * `verifyMutation` body stays below the cognitive-complexity threshold. - */ -function buildVerifyAuthArg( - state: WizardState, - projectId: string, -): { projectId: string } | { credentials: Record } { - if (shouldUseStoredCredentials(state)) { - return { projectId }; - } - if (state.provider === 'trello') { - if (!state.trelloApiKey || !state.trelloToken) { - throw new Error('Enter both credentials before verifying'); - } - return { credentials: { api_key: state.trelloApiKey, token: state.trelloToken } }; - } - if (state.provider === 'linear') { - if (!state.linearApiKey) { - throw new Error('Enter your API key before verifying'); - } - return { credentials: { api_key: state.linearApiKey } }; - } - if (!state.jiraEmail || !state.jiraApiToken) { - throw new Error('Enter both credentials before verifying'); - } - return { - credentials: { - email: state.jiraEmail, - api_token: state.jiraApiToken, - base_url: state.jiraBaseUrl, - }, - }; -} - export function useVerification( state: WizardState, dispatch: React.Dispatch, @@ -416,12 +617,12 @@ export function useVerification( // Calls the `currentUser` discovery capability; every provider // maps its native `getMe()` response to `{ id, name, displayName? }`. // - // Edit-mode fallback: `buildVerifyAuthArg` returns `{ projectId }` + // Edit-mode fallback: `buildProviderAuthArg` returns `{ projectId }` // when the user is editing with stored credentials but an empty // API-key field, so the backend resolves the stored secret via // `resolvePMCredentials` instead of requiring re-entry. const provider = state.provider; - const authArg = buildVerifyAuthArg(state, projectId); + const authArg = buildProviderAuthArg(state, projectId); const me = (await trpcClient.pm.discovery.discover.mutate({ providerId: provider, capability: 'currentUser', @@ -468,40 +669,6 @@ export function useVerification( // (`webhooks.list/create/delete` + `callbackBaseUrl` formula) — // see `./pm-providers/{trello,jira,linear}/wizard.ts`. -/** - * Iterate `labelsToCreate` through `pm.discovery.createLabel`, collecting - * successes + per-name errors. Factored out so the two - * `createMissingLabelsMutation` bodies (Trello + Linear) stay below the - * biome cognitive-complexity threshold. - */ -async function runPerLabelCreations(opts: { - labelsToCreate: Array<{ slot: string; name: string; color?: string }>; - providerId: 'trello' | 'linear'; - containerId: string; - authArg: { projectId: string } | { credentials: Record }; -}): Promise<{ - successes: Array<{ id: string; name: string; color: string }>; - errors: Array<{ name: string; error: string }>; -}> { - const successes: Array<{ id: string; name: string; color: string }> = []; - const errors: Array<{ name: string; error: string }> = []; - for (const { name, color } of opts.labelsToCreate) { - try { - const label = await trpcClient.pm.discovery.createLabel.mutate({ - providerId: opts.providerId, - containerId: opts.containerId, - name, - color, - ...opts.authArg, - }); - successes.push(label); - } catch (err) { - errors.push({ name, error: err instanceof Error ? err.message : String(err) }); - } - } - return { successes, errors }; -} - // ============================================================================ // Trello Label Creation // ============================================================================ @@ -511,84 +678,18 @@ export function useTrelloLabelCreation( dispatch: React.Dispatch, projectId: string, ) { - const createLabelMutation = useMutation({ - mutationFn: (vars: { name: string; color?: string; slot: string }) => { - if (!state.trelloBoardId) { - throw new Error('Board must be selected before creating a label'); - } - const useStored = shouldUseStoredCredentials(state); - if (!useStored && (!state.trelloApiKey || !state.trelloToken)) { - throw new Error('Missing credentials — enter them on the credentials step'); - } - // Plan 010/1: routes through generic pm.discovery.createLabel. - // Edit mode with stored creds → projectId path (see - // `shouldUseStoredCredentials` in pm-wizard-state.ts). - return trpcClient.pm.discovery.createLabel.mutate({ - providerId: 'trello', - containerId: state.trelloBoardId, - name: vars.name, - color: vars.color, - ...(useStored - ? { projectId } - : { credentials: { api_key: state.trelloApiKey, token: state.trelloToken } }), - }); - }, - onSuccess: (label, vars) => { - dispatch({ type: 'ADD_TRELLO_BOARD_LABEL', label }); - dispatch({ type: 'SET_TRELLO_LABEL_MAPPING', key: vars.slot, value: label.id }); - }, - onError: (error) => { - console.error('Failed to create label:', error); - alert(`Failed to create label: ${error instanceof Error ? error.message : String(error)}`); - }, - }); - - const createMissingLabelsMutation = useMutation({ - mutationFn: async (labelsToCreate: Array<{ slot: string; name: string; color?: string }>) => { - if (!state.trelloBoardId) { - throw new Error('Board must be selected before creating labels'); - } - const useStored = shouldUseStoredCredentials(state); - if (!useStored && (!state.trelloApiKey || !state.trelloToken)) { - throw new Error('Missing credentials — enter them on the credentials step'); - } - const authArg = useStored - ? { projectId } - : { credentials: { api_key: state.trelloApiKey, token: state.trelloToken } }; - return runPerLabelCreations({ - labelsToCreate, - providerId: 'trello', - containerId: state.trelloBoardId, - authArg, - }); - }, - onSuccess: (result, labelsToCreate) => { - // Handle successful label creations - for (let i = 0; i < result.successes.length; i++) { - const label = result.successes[i]; - // Find the slot for this label by matching the name - const slot = labelsToCreate.find((l) => l.name === label.name)?.slot; - if (slot) { - dispatch({ type: 'ADD_TRELLO_BOARD_LABEL', label }); - dispatch({ type: 'SET_TRELLO_LABEL_MAPPING', key: slot, value: label.id }); - } - } - - // Show error feedback if any labels failed - if (result.errors.length > 0) { - const errorMsg = result.errors.map((e) => `${e.name}: ${e.error}`).join('\n'); - alert( - `Some labels failed to create:\n${errorMsg}\n\n${result.successes.length} label(s) created successfully.`, - ); - } - }, - onError: (error) => { - console.error('Failed to create labels:', error); - alert(`Failed to create labels: ${error instanceof Error ? error.message : String(error)}`); - }, - }); - - return { createLabelMutation, createMissingLabelsMutation }; + return useProviderLabelCreation( + { + providerId: 'trello', + getContainerId: (s) => s.trelloBoardId, + containerError: 'Board must be selected before creating a label', + addLabel: (label) => ({ type: 'ADD_TRELLO_BOARD_LABEL', label }), + setLabelMapping: (slot, id) => ({ type: 'SET_TRELLO_LABEL_MAPPING', key: slot, value: id }), + }, + state, + dispatch, + projectId, + ); } // ============================================================================ @@ -600,51 +701,29 @@ export function useTrelloCustomFieldCreation( dispatch: React.Dispatch, projectId: string, ) { - const createCustomFieldMutation = useMutation({ - // Plan 011/2: the shared custom-field-mapping step lets operators type - // a name. `mutate({ name })` — callers without a preference pass - // `{ name: 'Cost' }` to preserve the legacy default. - mutationFn: ({ name }: { name: string }) => { - if (!state.trelloBoardId) { - throw new Error('Board must be selected before creating a custom field'); - } - const useStored = shouldUseStoredCredentials(state); - if (!useStored && (!state.trelloApiKey || !state.trelloToken)) { - throw new Error('Missing credentials — enter them on the credentials step'); - } - // Plan 010/1 (leftover caller): routes through pm.discovery.createCustomField. - return trpcClient.pm.discovery.createCustomField.mutate({ - providerId: 'trello', - containerId: state.trelloBoardId, - name, - ...(useStored - ? { projectId } - : { - credentials: { - api_key: state.trelloApiKey, - token: state.trelloToken, - }, - }), - }); - }, - onSuccess: (customField) => { - dispatch({ type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', customField }); - dispatch({ type: 'SET_TRELLO_COST_FIELD', id: customField.id }); - }, - onError: (error) => { - console.error('Failed to create custom field:', error); - const message = error instanceof Error ? error.message : String(error); - if (message.includes('403')) { - alert( - 'Failed to create custom field: The Trello Custom Fields power-up is required. Please enable it on your Trello board and try again.', - ); - } else { - alert(`Failed to create custom field: ${message}`); - } + return useProviderCustomFieldCreation( + { + providerId: 'trello', + getContainerId: (s) => s.trelloBoardId, + containerError: 'Board must be selected before creating a custom field', + addCustomField: (f) => ({ type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', customField: f }), + setCostField: (id) => ({ type: 'SET_TRELLO_COST_FIELD', id }), + onError: (error) => { + console.error('Failed to create custom field:', error); + const message = error instanceof Error ? error.message : String(error); + if (message.includes('403')) { + alert( + 'Failed to create custom field: The Trello Custom Fields power-up is required. Please enable it on your Trello board and try again.', + ); + } else { + alert(`Failed to create custom field: ${message}`); + } + }, }, - }); - - return { createCustomFieldMutation }; + state, + dispatch, + projectId, + ); } // ============================================================================ @@ -656,84 +735,78 @@ export function useJiraCustomFieldCreation( dispatch: React.Dispatch, projectId: string, ) { - const createJiraCustomFieldMutation = useMutation({ - // Plan 011/3: the shared custom-field-mapping step lets operators type - // a name; callers without a preference pass `{ name: 'Cost' }`. - mutationFn: ({ name }: { name: string }) => { - const useStored = shouldUseStoredCredentials(state); - if (!useStored && (!state.jiraEmail || !state.jiraApiToken || !state.jiraBaseUrl)) { - throw new Error('Missing JIRA credentials or base URL'); - } - // Plan 010/1: routes through generic pm.discovery.createCustomField. - // JIRA's project key isn't needed for the mutation (fields are global) - // but we pass the configured projectKey as containerId for uniform shape. - return trpcClient.pm.discovery.createCustomField.mutate({ - providerId: 'jira', - containerId: state.jiraProjectKey || 'global', - name, - ...(useStored - ? { projectId } - : { - credentials: { - email: state.jiraEmail, - api_token: state.jiraApiToken, - base_url: state.jiraBaseUrl, - }, - }), - }); - }, - onSuccess: (field) => { - dispatch({ type: 'ADD_JIRA_PROJECT_CUSTOM_FIELD', field: { ...field, custom: true } }); - dispatch({ type: 'SET_JIRA_COST_FIELD', id: field.id }); - }, - onError: (error) => { - console.error('Failed to create JIRA custom field:', error); - const message = error instanceof Error ? error.message : String(error); - if (message.includes('403') || message.toLowerCase().includes('admin')) { - alert( - 'Failed to create custom field: JIRA admin permissions are required to create global custom fields. Please contact your JIRA administrator.', - ); - } else { - alert(`Failed to create JIRA custom field: ${message}`); - } - }, - }); - - return { createJiraCustomFieldMutation }; + const inner = useProviderCustomFieldCreation( + { + providerId: 'jira', + // JIRA fields are global; containerId is sent as-is for uniform shape + getContainerId: (s) => s.jiraProjectKey || 'global', + addCustomField: (f) => ({ + type: 'ADD_JIRA_PROJECT_CUSTOM_FIELD', + field: { ...f, custom: true }, + }), + setCostField: (id) => ({ type: 'SET_JIRA_COST_FIELD', id }), + onError: (error) => { + console.error('Failed to create JIRA custom field:', error); + const message = error instanceof Error ? error.message : String(error); + if (message.includes('403') || message.toLowerCase().includes('admin')) { + alert( + 'Failed to create custom field: JIRA admin permissions are required to create global custom fields. Please contact your JIRA administrator.', + ); + } else { + alert(`Failed to create JIRA custom field: ${message}`); + } + }, + }, + state, + dispatch, + projectId, + ); + // Preserve the legacy export name for JIRA callers + return { createJiraCustomFieldMutation: inner.createCustomFieldMutation }; } // ============================================================================ -// Save Mutation +// Save Mutation — data-driven, no per-provider branching // ============================================================================ +type CredentialEntry = { envVarKey: string; stateField: keyof WizardState; label: string }; + +const SAVE_CONFIGS: Record< + Provider, + { + buildConfig: (state: WizardState) => Record; + credentials: CredentialEntry[]; + } +> = { + trello: { + buildConfig: buildTrelloIntegrationConfig, + credentials: [ + { envVarKey: 'TRELLO_API_KEY', stateField: 'trelloApiKey', label: 'Trello API Key' }, + { envVarKey: 'TRELLO_TOKEN', stateField: 'trelloToken', label: 'Trello Token' }, + ], + }, + jira: { + buildConfig: buildJiraIntegrationConfig, + credentials: [ + { envVarKey: 'JIRA_EMAIL', stateField: 'jiraEmail', label: 'JIRA Email' }, + { envVarKey: 'JIRA_API_TOKEN', stateField: 'jiraApiToken', label: 'JIRA API Token' }, + ], + }, + linear: { + buildConfig: buildLinearIntegrationConfig, + credentials: [ + { envVarKey: 'LINEAR_API_KEY', stateField: 'linearApiKey', label: 'Linear API Key' }, + ], + }, +}; + export function useSaveMutation(projectId: string, state: WizardState) { const queryClient = useQueryClient(); const saveMutation = useMutation({ - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles three provider types + credential persisting mutationFn: async () => { - let config: Record; - if (state.provider === 'trello') { - config = { - boardId: state.trelloBoardId, - lists: state.trelloListMappings, - labels: state.trelloLabelMappings, - ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), - }; - } else if (state.provider === 'linear') { - config = buildLinearIntegrationConfig(state); - } else { - config = { - projectKey: state.jiraProjectKey, - baseUrl: state.jiraBaseUrl, - statuses: state.jiraStatusMappings, - ...(Object.keys(state.jiraIssueTypes).length > 0 - ? { issueTypes: state.jiraIssueTypes } - : {}), - ...(Object.keys(state.jiraLabels).length > 0 ? { labels: state.jiraLabels } : {}), - ...(state.jiraCostFieldId ? { customFields: { cost: state.jiraCostFieldId } } : {}), - }; - } + const providerCfg = SAVE_CONFIGS[state.provider]; + const config = providerCfg.buildConfig(state); const result = await trpcClient.projects.integrations.upsert.mutate({ projectId, @@ -743,47 +816,14 @@ export function useSaveMutation(projectId: string, state: WizardState) { }); // Persist credentials to project_credentials table - if (state.provider === 'trello') { - if (state.trelloApiKey) { - await trpcClient.projects.credentials.set.mutate({ - projectId, - envVarKey: 'TRELLO_API_KEY', - value: state.trelloApiKey, - name: 'Trello API Key', - }); - } - if (state.trelloToken) { - await trpcClient.projects.credentials.set.mutate({ - projectId, - envVarKey: 'TRELLO_TOKEN', - value: state.trelloToken, - name: 'Trello Token', - }); - } - } else if (state.provider === 'linear') { - if (state.linearApiKey) { - await trpcClient.projects.credentials.set.mutate({ - projectId, - envVarKey: 'LINEAR_API_KEY', - value: state.linearApiKey, - name: 'Linear API Key', - }); - } - } else { - if (state.jiraEmail) { - await trpcClient.projects.credentials.set.mutate({ - projectId, - envVarKey: 'JIRA_EMAIL', - value: state.jiraEmail, - name: 'JIRA Email', - }); - } - if (state.jiraApiToken) { + for (const cred of providerCfg.credentials) { + const value = state[cred.stateField] as string; + if (value) { await trpcClient.projects.credentials.set.mutate({ projectId, - envVarKey: 'JIRA_API_TOKEN', - value: state.jiraApiToken, - name: 'JIRA API Token', + envVarKey: cred.envVarKey, + value, + name: cred.label, }); } } @@ -837,71 +877,20 @@ export function useLinearLabelCreation( dispatch: React.Dispatch, projectId: string, ) { - const createLabelMutation = useMutation({ - mutationFn: (vars: { name: string; color?: string; slot: string }) => { - if (!state.linearTeamId) { - throw new Error('Team must be selected before creating a label'); - } - const useStored = shouldUseStoredCredentials(state); - if (!useStored && !state.linearApiKey) { - throw new Error('Missing credentials — enter them on the credentials step'); - } - // Plan 010/1: routes through generic pm.discovery.createLabel. - return trpcClient.pm.discovery.createLabel.mutate({ - providerId: 'linear', - containerId: state.linearTeamId, - name: vars.name, - color: vars.color, - ...(useStored ? { projectId } : { credentials: { api_key: state.linearApiKey } }), - }); - }, - onSuccess: (label, vars) => { - dispatch({ type: 'ADD_LINEAR_TEAM_LABEL', label }); - dispatch({ type: 'SET_LINEAR_LABEL', key: vars.slot, value: label.id }); - }, - onError: (error) => { - console.error('Failed to create Linear label:', error); - alert(`Failed to create label: ${error instanceof Error ? error.message : String(error)}`); - }, - }); - - const createMissingLabelsMutation = useMutation({ - mutationFn: async (labelsToCreate: Array<{ slot: string; name: string; color?: string }>) => { - if (!state.linearTeamId) { - throw new Error('Team must be selected before creating labels'); - } - const useStored = shouldUseStoredCredentials(state); - if (!useStored && !state.linearApiKey) { - throw new Error('Missing credentials — enter them on the credentials step'); - } - const authArg = useStored ? { projectId } : { credentials: { api_key: state.linearApiKey } }; - return runPerLabelCreations({ - labelsToCreate, - providerId: 'linear', - containerId: state.linearTeamId, - authArg, - }); - }, - onSuccess: (result, labelsToCreate) => { - for (const label of result.successes) { - const slot = labelsToCreate.find((l) => l.name === label.name)?.slot; - if (slot) { - dispatch({ type: 'ADD_LINEAR_TEAM_LABEL', label }); - dispatch({ type: 'SET_LINEAR_LABEL', key: slot, value: label.id }); - } - } - if (result.errors.length > 0) { - const errorMsg = result.errors.map((e) => `${e.name}: ${e.error}`).join('\n'); - alert( - `Some labels failed to create:\n${errorMsg}\n\n${result.successes.length} label(s) created successfully.`, - ); - } - }, - onError: (error) => { - console.error('Failed to create Linear labels:', error); - alert(`Failed to create labels: ${error instanceof Error ? error.message : String(error)}`); - }, - }); - - return { createLabelMutation, createMissingLabelsMutation }; + return useProviderLabelCreation( + { + providerId: 'linear', + getContainerId: (s) => s.linearTeamId, + containerError: 'Team must be selected before creating a label', + addLabel: (label) => ({ type: 'ADD_LINEAR_TEAM_LABEL', label }), + setLabelMapping: (slot, id) => ({ type: 'SET_LINEAR_LABEL', key: slot, value: id }), + }, + state, + dispatch, + projectId, + ); } + +export type { CustomFieldCreationConfig, LabelCreationConfig }; +// Re-export the generic utilities for direct use in tests / advanced consumers +export { useProviderCustomFieldCreation, useProviderLabelCreation }; diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 0b8e931f..af21a618 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -510,6 +510,34 @@ export function shouldUseStoredCredentials(state: WizardState): boolean { return !state.linearApiKey; } +/** + * Build the Trello integration config payload from wizard state. + * Pure function so it can be unit-tested without the React runtime. + */ +export function buildTrelloIntegrationConfig(state: WizardState): Record { + return { + boardId: state.trelloBoardId, + lists: state.trelloListMappings, + labels: state.trelloLabelMappings, + ...(state.trelloCostFieldId ? { customFields: { cost: state.trelloCostFieldId } } : {}), + }; +} + +/** + * Build the JIRA integration config payload from wizard state. + * Pure function so it can be unit-tested without the React runtime. + */ +export function buildJiraIntegrationConfig(state: WizardState): Record { + return { + projectKey: state.jiraProjectKey, + baseUrl: state.jiraBaseUrl, + statuses: state.jiraStatusMappings, + ...(Object.keys(state.jiraIssueTypes).length > 0 ? { issueTypes: state.jiraIssueTypes } : {}), + ...(Object.keys(state.jiraLabels).length > 0 ? { labels: state.jiraLabels } : {}), + ...(state.jiraCostFieldId ? { customFields: { cost: state.jiraCostFieldId } } : {}), + }; +} + /** * Build the Linear integration config payload from wizard state. * Pure function so it can be unit-tested without the React runtime.