diff --git a/tests/unit/web/pm-wizard-state.test.ts b/tests/unit/web/pm-wizard-state.test.ts new file mode 100644 index 00000000..fcdba300 --- /dev/null +++ b/tests/unit/web/pm-wizard-state.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it } from 'vitest'; + +import { + INITIAL_JIRA_LABELS, + areCredentialsReady, + buildEditState, + createInitialState, + isStep1Complete, + isStep2Complete, + isStep3Complete, + isStep4Complete, + wizardReducer, +} from '../../../web/src/components/projects/pm-wizard-state.js'; +import type { + WizardAction, + WizardState, +} from '../../../web/src/components/projects/pm-wizard-state.js'; + +// ============================================================================ +// createInitialState +// ============================================================================ + +describe('createInitialState', () => { + it('returns a valid initial state with trello as default provider', () => { + const state = createInitialState(); + expect(state.provider).toBe('trello'); + expect(state.trelloApiKeyCredentialId).toBeNull(); + expect(state.trelloTokenCredentialId).toBeNull(); + expect(state.jiraEmailCredentialId).toBeNull(); + expect(state.jiraApiTokenCredentialId).toBeNull(); + expect(state.jiraBaseUrl).toBe(''); + expect(state.verificationResult).toBeNull(); + expect(state.verifyError).toBeNull(); + expect(state.trelloBoardId).toBe(''); + expect(state.trelloBoards).toEqual([]); + expect(state.jiraProjectKey).toBe(''); + expect(state.jiraProjects).toEqual([]); + expect(state.trelloBoardDetails).toBeNull(); + expect(state.jiraProjectDetails).toBeNull(); + expect(state.trelloListMappings).toEqual({}); + expect(state.trelloLabelMappings).toEqual({}); + expect(state.trelloCostFieldId).toBe(''); + expect(state.jiraStatusMappings).toEqual({}); + expect(state.jiraIssueTypes).toEqual({}); + expect(state.jiraLabels).toEqual(INITIAL_JIRA_LABELS); + expect(state.jiraCostFieldId).toBe(''); + expect(state.isEditing).toBe(false); + }); +}); + +// ============================================================================ +// wizardReducer +// ============================================================================ + +describe('wizardReducer', () => { + function initialState(): WizardState { + return createInitialState(); + } + + function dispatch(state: WizardState, action: WizardAction): WizardState { + return wizardReducer(state, action); + } + + it('SET_PROVIDER resets to initial state with new provider', () => { + const state = { + ...initialState(), + trelloApiKeyCredentialId: 5, + trelloBoardId: 'board-1', + }; + const next = dispatch(state, { type: 'SET_PROVIDER', provider: 'jira' }); + expect(next.provider).toBe('jira'); + // Should have been reset + expect(next.trelloApiKeyCredentialId).toBeNull(); + expect(next.trelloBoardId).toBe(''); + }); + + it('SET_TRELLO_API_KEY_CRED clears verification', () => { + const state = { + ...initialState(), + verificationResult: { provider: 'trello' as const, display: 'Test User' }, + verifyError: 'previous error', + }; + const next = dispatch(state, { type: 'SET_TRELLO_API_KEY_CRED', id: 42 }); + expect(next.trelloApiKeyCredentialId).toBe(42); + expect(next.verificationResult).toBeNull(); + expect(next.verifyError).toBeNull(); + }); + + it('SET_TRELLO_TOKEN_CRED clears verification', () => { + const state = { + ...initialState(), + verificationResult: { provider: 'trello' as const, display: 'Test User' }, + }; + const next = dispatch(state, { type: 'SET_TRELLO_TOKEN_CRED', id: 7 }); + expect(next.trelloTokenCredentialId).toBe(7); + expect(next.verificationResult).toBeNull(); + }); + + it('SET_JIRA_EMAIL_CRED clears verification', () => { + const state = { + ...initialState(), + verificationResult: { provider: 'jira' as const, display: 'JIRA User' }, + }; + const next = dispatch(state, { type: 'SET_JIRA_EMAIL_CRED', id: 3 }); + expect(next.jiraEmailCredentialId).toBe(3); + expect(next.verificationResult).toBeNull(); + }); + + it('SET_JIRA_API_TOKEN_CRED clears verification', () => { + const state = { ...initialState() }; + const next = dispatch(state, { type: 'SET_JIRA_API_TOKEN_CRED', id: 9 }); + expect(next.jiraApiTokenCredentialId).toBe(9); + }); + + it('SET_JIRA_BASE_URL clears verification', () => { + const state = { + ...initialState(), + verificationResult: { provider: 'jira' as const, display: 'JIRA User' }, + verifyError: 'old error', + }; + const next = dispatch(state, { type: 'SET_JIRA_BASE_URL', url: 'https://myorg.atlassian.net' }); + expect(next.jiraBaseUrl).toBe('https://myorg.atlassian.net'); + expect(next.verificationResult).toBeNull(); + expect(next.verifyError).toBeNull(); + }); + + it('SET_VERIFICATION stores result and clears error', () => { + const state = { ...initialState(), verifyError: 'old error' }; + const next = dispatch(state, { + type: 'SET_VERIFICATION', + result: { provider: 'trello', display: '@user (John Doe)' }, + }); + expect(next.verificationResult).toEqual({ provider: 'trello', display: '@user (John Doe)' }); + expect(next.verifyError).toBeNull(); + }); + + it('SET_VERIFICATION with error stores error and null result', () => { + const state = { + ...initialState(), + verificationResult: { provider: 'trello' as const, display: '@user' }, + }; + const next = dispatch(state, { + type: 'SET_VERIFICATION', + result: null, + error: 'auth failed', + }); + expect(next.verificationResult).toBeNull(); + expect(next.verifyError).toBe('auth failed'); + }); + + it('SET_TRELLO_BOARDS sets boards', () => { + const state = initialState(); + const boards = [{ id: 'b1', name: 'My Board', url: 'https://trello.com/b/abc' }]; + const next = dispatch(state, { type: 'SET_TRELLO_BOARDS', boards }); + expect(next.trelloBoards).toEqual(boards); + }); + + it('SET_TRELLO_BOARD_ID clears details and mappings', () => { + const state = { + ...initialState(), + trelloBoardDetails: { + lists: [{ id: 'l1', name: 'Todo' }], + labels: [], + customFields: [], + }, + trelloListMappings: { todo: 'l1' }, + trelloLabelMappings: { processing: 'label-1' }, + trelloCostFieldId: 'cf-1', + }; + const next = dispatch(state, { type: 'SET_TRELLO_BOARD_ID', id: 'new-board' }); + expect(next.trelloBoardId).toBe('new-board'); + expect(next.trelloBoardDetails).toBeNull(); + expect(next.trelloListMappings).toEqual({}); + expect(next.trelloLabelMappings).toEqual({}); + expect(next.trelloCostFieldId).toBe(''); + }); + + it('SET_JIRA_PROJECTS sets projects', () => { + const state = initialState(); + const projects = [{ key: 'PROJ', name: 'My Project' }]; + const next = dispatch(state, { type: 'SET_JIRA_PROJECTS', projects }); + expect(next.jiraProjects).toEqual(projects); + }); + + it('SET_JIRA_PROJECT_KEY clears details and mappings', () => { + const state = { + ...initialState(), + jiraProjectDetails: { + statuses: [{ name: 'In Progress', id: 'ip' }], + issueTypes: [], + fields: [], + }, + jiraStatusMappings: { todo: 'Todo' }, + jiraIssueTypes: { task: 'Task' }, + jiraCostFieldId: 'cf-1', + }; + const next = dispatch(state, { type: 'SET_JIRA_PROJECT_KEY', key: 'NEW' }); + expect(next.jiraProjectKey).toBe('NEW'); + expect(next.jiraProjectDetails).toBeNull(); + expect(next.jiraStatusMappings).toEqual({}); + expect(next.jiraIssueTypes).toEqual({}); + expect(next.jiraCostFieldId).toBe(''); + }); + + it('SET_TRELLO_LIST_MAPPING merges into existing mappings', () => { + const state = { + ...initialState(), + trelloListMappings: { backlog: 'list-1' }, + }; + const next = dispatch(state, { + type: 'SET_TRELLO_LIST_MAPPING', + key: 'todo', + value: 'list-2', + }); + expect(next.trelloListMappings).toEqual({ backlog: 'list-1', todo: 'list-2' }); + }); + + it('SET_TRELLO_LABEL_MAPPING merges into existing mappings', () => { + const state = { ...initialState() }; + const next = dispatch(state, { + type: 'SET_TRELLO_LABEL_MAPPING', + key: 'processing', + value: 'label-abc', + }); + expect(next.trelloLabelMappings.processing).toBe('label-abc'); + }); + + it('SET_TRELLO_COST_FIELD sets the field ID', () => { + const state = initialState(); + const next = dispatch(state, { type: 'SET_TRELLO_COST_FIELD', id: 'cf-cost' }); + expect(next.trelloCostFieldId).toBe('cf-cost'); + }); + + it('SET_JIRA_STATUS_MAPPING merges into existing mappings', () => { + const state = { + ...initialState(), + jiraStatusMappings: { backlog: 'Backlog' }, + }; + const next = dispatch(state, { + type: 'SET_JIRA_STATUS_MAPPING', + key: 'todo', + value: 'To Do', + }); + expect(next.jiraStatusMappings).toEqual({ backlog: 'Backlog', todo: 'To Do' }); + }); + + it('SET_JIRA_ISSUE_TYPE merges into existing issue types', () => { + const state = { ...initialState() }; + const next = dispatch(state, { type: 'SET_JIRA_ISSUE_TYPE', key: 'task', value: 'Task' }); + expect(next.jiraIssueTypes.task).toBe('Task'); + }); + + it('SET_JIRA_LABEL merges into existing labels', () => { + const state = { ...initialState() }; + const next = dispatch(state, { + type: 'SET_JIRA_LABEL', + key: 'processing', + value: 'my-processing', + }); + expect(next.jiraLabels.processing).toBe('my-processing'); + // Other defaults preserved + expect(next.jiraLabels.error).toBe(INITIAL_JIRA_LABELS.error); + }); + + it('SET_JIRA_COST_FIELD sets the field ID', () => { + const state = initialState(); + const next = dispatch(state, { type: 'SET_JIRA_COST_FIELD', id: 'customfield_10042' }); + expect(next.jiraCostFieldId).toBe('customfield_10042'); + }); + + it('INIT_EDIT merges partial state and sets isEditing', () => { + const state = initialState(); + const next = dispatch(state, { + type: 'INIT_EDIT', + state: { provider: 'jira', jiraBaseUrl: 'https://example.atlassian.net' }, + }); + expect(next.isEditing).toBe(true); + expect(next.provider).toBe('jira'); + expect(next.jiraBaseUrl).toBe('https://example.atlassian.net'); + }); + + it('unknown action returns state unchanged', () => { + const state = initialState(); + // @ts-expect-error testing unknown action + const next = dispatch(state, { type: 'UNKNOWN_ACTION' }); + expect(next).toEqual(state); + }); +}); + +// ============================================================================ +// Step-completion helpers +// ============================================================================ + +describe('isStep1Complete', () => { + it('returns true when provider is set', () => { + expect(isStep1Complete({ ...createInitialState(), provider: 'trello' })).toBe(true); + expect(isStep1Complete({ ...createInitialState(), provider: 'jira' })).toBe(true); + }); +}); + +describe('isStep2Complete', () => { + it('returns false when trello credentials missing', () => { + const state = { + ...createInitialState(), + provider: 'trello' as const, + verificationResult: { provider: 'trello' as const, display: '@user' }, + }; + expect(isStep2Complete(state)).toBe(false); + }); + + it('returns false when trello creds present but no verification', () => { + const state = { + ...createInitialState(), + provider: 'trello' as const, + trelloApiKeyCredentialId: 1, + trelloTokenCredentialId: 2, + }; + expect(isStep2Complete(state)).toBe(false); + }); + + it('returns true when trello creds present and verified', () => { + const state = { + ...createInitialState(), + provider: 'trello' as const, + trelloApiKeyCredentialId: 1, + trelloTokenCredentialId: 2, + verificationResult: { provider: 'trello' as const, display: '@user (User)' }, + }; + expect(isStep2Complete(state)).toBe(true); + }); + + it('returns false when jira baseUrl missing even with creds', () => { + const state = { + ...createInitialState(), + provider: 'jira' as const, + jiraEmailCredentialId: 1, + jiraApiTokenCredentialId: 2, + jiraBaseUrl: '', + verificationResult: { provider: 'jira' as const, display: 'User' }, + }; + expect(isStep2Complete(state)).toBe(false); + }); + + it('returns true when jira creds and baseUrl present and verified', () => { + const state = { + ...createInitialState(), + provider: 'jira' as const, + jiraEmailCredentialId: 1, + jiraApiTokenCredentialId: 2, + jiraBaseUrl: 'https://myorg.atlassian.net', + verificationResult: { provider: 'jira' as const, display: 'User (user@example.com)' }, + }; + expect(isStep2Complete(state)).toBe(true); + }); +}); + +describe('isStep3Complete', () => { + it('returns true for trello when boardId set', () => { + const state = { ...createInitialState(), provider: 'trello' as const, trelloBoardId: 'b1' }; + expect(isStep3Complete(state)).toBe(true); + }); + + it('returns false for trello when boardId empty', () => { + const state = { ...createInitialState(), provider: 'trello' as const }; + expect(isStep3Complete(state)).toBe(false); + }); + + it('returns true for jira when projectKey set', () => { + const state = { ...createInitialState(), provider: 'jira' as const, jiraProjectKey: 'PROJ' }; + expect(isStep3Complete(state)).toBe(true); + }); + + it('returns false for jira when projectKey empty', () => { + const state = { ...createInitialState(), provider: 'jira' as const }; + expect(isStep3Complete(state)).toBe(false); + }); +}); + +describe('isStep4Complete', () => { + it('returns true for trello when any list mapping set', () => { + const state = { + ...createInitialState(), + provider: 'trello' as const, + trelloListMappings: { todo: 'list-1' }, + }; + expect(isStep4Complete(state)).toBe(true); + }); + + it('returns false for trello when no list mappings', () => { + const state = { ...createInitialState(), provider: 'trello' as const }; + expect(isStep4Complete(state)).toBe(false); + }); + + it('returns true for jira when any status mapping set', () => { + const state = { + ...createInitialState(), + provider: 'jira' as const, + jiraStatusMappings: { todo: 'To Do' }, + }; + expect(isStep4Complete(state)).toBe(true); + }); + + it('returns false for jira when no status mappings', () => { + const state = { ...createInitialState(), provider: 'jira' as const }; + expect(isStep4Complete(state)).toBe(false); + }); +}); + +describe('areCredentialsReady', () => { + it('returns true for trello when both credentials set', () => { + const state = { + ...createInitialState(), + provider: 'trello' as const, + trelloApiKeyCredentialId: 1, + trelloTokenCredentialId: 2, + }; + expect(areCredentialsReady(state)).toBe(true); + }); + + it('returns false for trello when one credential missing', () => { + const state = { + ...createInitialState(), + provider: 'trello' as const, + trelloApiKeyCredentialId: 1, + }; + expect(areCredentialsReady(state)).toBe(false); + }); + + it('returns true for jira when email, api token, and baseUrl set', () => { + const state = { + ...createInitialState(), + provider: 'jira' as const, + jiraEmailCredentialId: 1, + jiraApiTokenCredentialId: 2, + jiraBaseUrl: 'https://myorg.atlassian.net', + }; + expect(areCredentialsReady(state)).toBe(true); + }); + + it('returns false for jira when baseUrl missing', () => { + const state = { + ...createInitialState(), + provider: 'jira' as const, + jiraEmailCredentialId: 1, + jiraApiTokenCredentialId: 2, + jiraBaseUrl: '', + }; + expect(areCredentialsReady(state)).toBe(false); + }); +}); + +// ============================================================================ +// buildEditState +// ============================================================================ + +describe('buildEditState', () => { + it('builds trello edit state from config and credentials', () => { + const config = { + boardId: 'board-abc', + lists: { todo: 'list-1', done: 'list-2' }, + labels: { processing: 'label-x' }, + customFields: { cost: 'cf-cost-1' }, + }; + const credentials = new Map([ + ['api_key', 10], + ['token', 20], + ]); + const result = buildEditState('trello', config, credentials); + expect(result.provider).toBe('trello'); + expect(result.trelloApiKeyCredentialId).toBe(10); + expect(result.trelloTokenCredentialId).toBe(20); + expect(result.trelloBoardId).toBe('board-abc'); + expect(result.trelloListMappings).toEqual({ todo: 'list-1', done: 'list-2' }); + expect(result.trelloLabelMappings).toEqual({ processing: 'label-x' }); + expect(result.trelloCostFieldId).toBe('cf-cost-1'); + }); + + it('builds jira edit state from config and credentials', () => { + const config = { + baseUrl: 'https://example.atlassian.net', + projectKey: 'PROJ', + statuses: { todo: 'To Do', done: 'Done' }, + issueTypes: { task: 'Task', subtask: 'Subtask' }, + labels: { processing: 'cascade-processing' }, + customFields: { cost: 'customfield_10042' }, + }; + const credentials = new Map([ + ['email', 5], + ['api_token', 6], + ]); + const result = buildEditState('jira', config, credentials); + expect(result.provider).toBe('jira'); + expect(result.jiraEmailCredentialId).toBe(5); + expect(result.jiraApiTokenCredentialId).toBe(6); + expect(result.jiraBaseUrl).toBe('https://example.atlassian.net'); + expect(result.jiraProjectKey).toBe('PROJ'); + expect(result.jiraStatusMappings).toEqual({ todo: 'To Do', done: 'Done' }); + expect(result.jiraIssueTypes).toEqual({ task: 'Task', subtask: 'Subtask' }); + expect(result.jiraLabels).toEqual({ processing: 'cascade-processing' }); + expect(result.jiraCostFieldId).toBe('customfield_10042'); + }); + + it('handles missing optional config fields gracefully', () => { + const config = { boardId: 'board-1' }; + const credentials = new Map(); + const result = buildEditState('trello', config, credentials); + expect(result.trelloBoardId).toBe('board-1'); + expect(result.trelloListMappings).toBeUndefined(); + expect(result.trelloCostFieldId).toBe(''); + expect(result.trelloApiKeyCredentialId).toBeNull(); + }); + + it('returns only provider for unknown provider', () => { + const result = buildEditState('unknown', {}, new Map()); + expect(result.provider).toBe('unknown'); + expect(Object.keys(result).length).toBe(1); + }); +}); diff --git a/web/src/components/projects/pm-wizard-common-steps.tsx b/web/src/components/projects/pm-wizard-common-steps.tsx new file mode 100644 index 00000000..e9ac410a --- /dev/null +++ b/web/src/components/projects/pm-wizard-common-steps.tsx @@ -0,0 +1,330 @@ +/** + * Provider-agnostic step renderer components for PMWizard: + * WebhookStep and SaveStep. + */ +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import type { UseMutationResult } from '@tanstack/react-query'; +import { + AlertCircle, + AlertTriangle, + ChevronDown, + ChevronRight, + ExternalLink, + KeyRound, + Loader2, + RefreshCw, + Trash2, +} from 'lucide-react'; +import type { WizardState } from './pm-wizard-state.js'; + +// ============================================================================ +// WebhookStep +// ============================================================================ + +interface ActiveWebhook { + id: string; + url: string; + active: boolean; +} + +interface WebhooksQueryProps { + isLoading: boolean; + data?: { + errors?: Record; + }; + refetch: () => void; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: webhook step UI with provider-specific admin credential fields +export function WebhookStep({ + state, + webhooksQuery, + activeWebhooks, + callbackBaseUrl, + adminTokensOpen, + setAdminTokensOpen, + oneTimeTrelloApiKey, + setOneTimeTrelloApiKey, + oneTimeTrelloToken, + setOneTimeTrelloToken, + oneTimeJiraEmail, + setOneTimeJiraEmail, + oneTimeJiraApiToken, + setOneTimeJiraApiToken, + createWebhookMutation, + deleteWebhookMutation, +}: { + state: WizardState; + webhooksQuery: WebhooksQueryProps; + activeWebhooks: ActiveWebhook[]; + callbackBaseUrl: string; + adminTokensOpen: boolean; + setAdminTokensOpen: (open: boolean | ((prev: boolean) => boolean)) => void; + oneTimeTrelloApiKey: string; + setOneTimeTrelloApiKey: (v: string) => void; + oneTimeTrelloToken: string; + setOneTimeTrelloToken: (v: string) => void; + oneTimeJiraEmail: string; + setOneTimeJiraEmail: (v: string) => void; + oneTimeJiraApiToken: string; + setOneTimeJiraApiToken: (v: string) => void; + createWebhookMutation: UseMutationResult; + deleteWebhookMutation: UseMutationResult; +}) { + return ( +
+ {/* Per-provider errors */} + {webhooksQuery.data?.errors && + Object.entries(webhooksQuery.data.errors) + .filter(([provider, err]) => err != null && provider !== 'github') + .map(([provider, err]) => ( +
+ +
+ + {provider} + + : {String(err)} +
+ +
+ ))} + + {webhooksQuery.isLoading ? ( +
+ Loading webhooks... +
+ ) : activeWebhooks.length > 0 ? ( +
+ + {activeWebhooks.map((w) => ( +
+
+ + {w.url} +
+ +
+ ))} +
+ ) : ( +
+ + No {state.provider === 'trello' ? 'Trello' : 'JIRA'} webhooks configured for this project. +
+ )} + +
+
+ +
+ {createWebhookMutation.isError && ( +

+ {(createWebhookMutation.error as Error).message} +

+ )} + {createWebhookMutation.isSuccess && ( +

+ {webhooksQuery.data?.errors && + Object.entries(webhooksQuery.data.errors) + .filter(([provider]) => provider !== 'github') + .some(([, e]) => e != null) + ? 'Webhook created, but some providers failed to load — see warnings above.' + : 'Webhook created successfully.'} +

+ )} +
+ + {/* One-time admin credentials */} +
+ + {adminTokensOpen && ( +
+

+ Provide tokens with elevated permissions for webhook management. These are used once + and never saved. +

+ {/* PM-provider-specific fields */} + {state.provider === 'trello' ? ( + <> +
+ + setOneTimeTrelloApiKey(e.target.value)} + placeholder="One-time API key" + type="password" + className="h-8 text-sm" + /> +
+
+ + setOneTimeTrelloToken(e.target.value)} + placeholder="One-time token" + type="password" + className="h-8 text-sm" + /> +
+ + ) : ( + <> +
+ + setOneTimeJiraEmail(e.target.value)} + placeholder="user@example.com" + className="h-8 text-sm" + /> +
+
+ + setOneTimeJiraApiToken(e.target.value)} + placeholder="One-time API token" + type="password" + className="h-8 text-sm" + /> +
+ + )} +
+ )} +
+
+ ); +} + +// ============================================================================ +// SaveStep +// ============================================================================ + +export function SaveStep({ + state, + saveMutation, +}: { + state: WizardState; + saveMutation: UseMutationResult; +}) { + return ( +
+ {/* Summary */} +
+
+ Provider + {state.provider === 'trello' ? 'Trello' : 'JIRA'} +
+ {state.verificationResult && ( +
+ Identity + {state.verificationResult.display} +
+ )} +
+ + {state.provider === 'trello' ? 'Board' : 'Project'} + + + {state.provider === 'trello' + ? state.trelloBoards.find((b) => b.id === state.trelloBoardId)?.name || + state.trelloBoardId + : state.jiraProjects.find((p) => p.key === state.jiraProjectKey)?.name || + state.jiraProjectKey} + +
+
+ + {state.provider === 'trello' ? 'Lists mapped' : 'Statuses mapped'} + + + {state.provider === 'trello' + ? Object.keys(state.trelloListMappings).filter((k) => state.trelloListMappings[k]) + .length + : Object.keys(state.jiraStatusMappings).filter((k) => state.jiraStatusMappings[k]) + .length} + +
+
+ +

+ Trigger configuration is managed separately in the Agent Configs tab. +

+ +
+ + {saveMutation.isSuccess && ( + + Integration saved successfully. + + )} + {saveMutation.isError && ( + {(saveMutation.error as Error).message} + )} +
+
+ ); +} diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts new file mode 100644 index 00000000..fd2f4263 --- /dev/null +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -0,0 +1,408 @@ +/** + * Custom hooks for PM Wizard mutations and side-effects. + * Each hook encapsulates one concern to keep the main orchestrator thin. + */ +import { API_URL } from '@/lib/api.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import type { WizardAction, WizardState } from './pm-wizard-state.js'; + +// ============================================================================ +// Trello Discovery +// ============================================================================ + +export function useTrelloDiscovery( + state: WizardState, + dispatch: React.Dispatch, + advanceToStep: (step: number) => void, +) { + const boardsMutation = useMutation({ + mutationFn: () => { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before fetching boards'); + } + return trpcClient.integrationsDiscovery.trelloBoards.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + }); + }, + onSuccess: (boards) => dispatch({ type: 'SET_TRELLO_BOARDS', boards }), + }); + + const boardDetailsMutation = useMutation({ + mutationFn: (boardId: string) => { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before fetching board details'); + } + return trpcClient.integrationsDiscovery.trelloBoardDetails.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + boardId, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_TRELLO_BOARD_DETAILS', details }); + advanceToStep(4); + }, + }); + + const handleBoardSelect = (boardId: string) => { + dispatch({ type: 'SET_TRELLO_BOARD_ID', id: boardId }); + if (boardId) { + boardDetailsMutation.mutate(boardId); + } + }; + + // Auto-fetch boards when verification result changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'trello') return; + if (state.trelloBoards.length === 0 && !boardsMutation.isPending) { + boardsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch board list and details + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on edit mode state changes + useEffect(() => { + if (!state.isEditing || state.provider !== 'trello') return; + + if ( + state.trelloApiKeyCredentialId && + state.trelloTokenCredentialId && + state.trelloBoards.length === 0 && + !boardsMutation.isPending + ) { + boardsMutation.mutate(); + } + if ( + state.trelloBoardId && + !state.trelloBoardDetails && + state.trelloApiKeyCredentialId && + state.trelloTokenCredentialId && + !boardDetailsMutation.isPending + ) { + boardDetailsMutation.mutate(state.trelloBoardId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.trelloBoardId]); + + return { boardsMutation, boardDetailsMutation, handleBoardSelect }; +} + +// ============================================================================ +// JIRA Discovery +// ============================================================================ + +export function useJiraDiscovery( + state: WizardState, + dispatch: React.Dispatch, + advanceToStep: (step: number) => void, +) { + const jiraProjectsMutation = useMutation({ + mutationFn: () => { + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before fetching projects'); + } + return trpcClient.integrationsDiscovery.jiraProjects.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + }); + }, + onSuccess: (projects) => dispatch({ type: 'SET_JIRA_PROJECTS', projects }), + }); + + const jiraDetailsMutation = useMutation({ + mutationFn: (projectKey: string) => { + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before fetching project details'); + } + return trpcClient.integrationsDiscovery.jiraProjectDetails.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + projectKey, + }); + }, + onSuccess: (details) => { + dispatch({ type: 'SET_JIRA_PROJECT_DETAILS', details }); + advanceToStep(4); + }, + }); + + const handleProjectSelect = (key: string) => { + dispatch({ type: 'SET_JIRA_PROJECT_KEY', key }); + if (key) { + jiraDetailsMutation.mutate(key); + } + }; + + // Auto-fetch projects when verification result changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change + useEffect(() => { + if (!state.verificationResult || state.provider !== 'jira') return; + if (state.jiraProjects.length === 0 && !jiraProjectsMutation.isPending) { + jiraProjectsMutation.mutate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.verificationResult]); + + // In edit mode, auto-fetch project list and details + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on edit mode state changes + useEffect(() => { + if (!state.isEditing || state.provider !== 'jira') return; + + if ( + state.jiraEmailCredentialId && + state.jiraApiTokenCredentialId && + state.jiraProjects.length === 0 && + !jiraProjectsMutation.isPending + ) { + jiraProjectsMutation.mutate(); + } + if ( + state.jiraProjectKey && + !state.jiraProjectDetails && + state.jiraEmailCredentialId && + state.jiraApiTokenCredentialId && + !jiraDetailsMutation.isPending + ) { + jiraDetailsMutation.mutate(state.jiraProjectKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.isEditing, state.jiraProjectKey]); + + return { jiraProjectsMutation, jiraDetailsMutation, handleProjectSelect }; +} + +// ============================================================================ +// Verification +// ============================================================================ + +export function useVerification( + state: WizardState, + dispatch: React.Dispatch, + advanceToStep: (step: number) => void, +) { + const verifyMutation = useMutation({ + mutationFn: async () => { + const provider = state.provider; + if (provider === 'trello') { + if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { + throw new Error('Select both credentials before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyTrello.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + }); + return { provider: 'trello' as const, result }; + } + if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { + throw new Error('Select both credentials before verifying'); + } + const result = await trpcClient.integrationsDiscovery.verifyJira.mutate({ + emailCredentialId: state.jiraEmailCredentialId, + apiTokenCredentialId: state.jiraApiTokenCredentialId, + baseUrl: state.jiraBaseUrl, + }); + return { provider: 'jira' as const, result }; + }, + onSuccess: ({ provider, result }) => { + // Ignore if provider changed while we were verifying + if (provider !== state.provider) return; + if (provider === 'trello') { + const r = result as { username: string; fullName: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'trello', display: `@${r.username} (${r.fullName})` }, + }); + } else { + const r = result as { displayName: string; emailAddress: string }; + dispatch({ + type: 'SET_VERIFICATION', + result: { provider: 'jira', display: `${r.displayName} (${r.emailAddress})` }, + }); + } + advanceToStep(3); + }, + onError: (err) => { + dispatch({ + type: 'SET_VERIFICATION', + result: null, + error: err instanceof Error ? err.message : String(err), + }); + }, + }); + + return { verifyMutation }; +} + +// ============================================================================ +// Webhook Management +// ============================================================================ + +export function useWebhookManagement(projectId: string, state: WizardState) { + const queryClient = useQueryClient(); + const callbackBaseUrl = + API_URL || + (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); + + const [adminTokensOpen, setAdminTokensOpen] = useState(false); + const [oneTimeTrelloApiKey, setOneTimeTrelloApiKey] = useState(''); + const [oneTimeTrelloToken, setOneTimeTrelloToken] = useState(''); + const [oneTimeJiraEmail, setOneTimeJiraEmail] = useState(''); + const [oneTimeJiraApiToken, setOneTimeJiraApiToken] = useState(''); + + const buildOneTimeTokens = () => { + const tokens: Record = {}; + if (oneTimeTrelloApiKey) tokens.trelloApiKey = oneTimeTrelloApiKey; + if (oneTimeTrelloToken) tokens.trelloToken = oneTimeTrelloToken; + if (oneTimeJiraEmail) tokens.jiraEmail = oneTimeJiraEmail; + if (oneTimeJiraApiToken) tokens.jiraApiToken = oneTimeJiraApiToken; + return Object.keys(tokens).length > 0 ? tokens : undefined; + }; + + const clearOneTimeTokens = () => { + setOneTimeTrelloApiKey(''); + setOneTimeTrelloToken(''); + setOneTimeJiraEmail(''); + setOneTimeJiraApiToken(''); + }; + + const createWebhookMutation = useMutation({ + mutationFn: () => + trpcClient.webhooks.create.mutate({ + projectId, + callbackBaseUrl, + trelloOnly: state.provider === 'trello' ? true : undefined, + jiraOnly: state.provider === 'jira' ? true : undefined, + oneTimeTokens: buildOneTimeTokens(), + }), + onSuccess: () => { + clearOneTimeTokens(); + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + const deleteWebhookMutation = useMutation({ + mutationFn: (deleteCallbackBaseUrl: string) => + trpcClient.webhooks.delete.mutate({ + projectId, + callbackBaseUrl: deleteCallbackBaseUrl, + trelloOnly: state.provider === 'trello' ? true : undefined, + jiraOnly: state.provider === 'jira' ? true : undefined, + oneTimeTokens: buildOneTimeTokens(), + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, + }); + }, + }); + + return { + callbackBaseUrl, + adminTokensOpen, + setAdminTokensOpen, + oneTimeTrelloApiKey, + setOneTimeTrelloApiKey, + oneTimeTrelloToken, + setOneTimeTrelloToken, + oneTimeJiraEmail, + setOneTimeJiraEmail, + oneTimeJiraApiToken, + setOneTimeJiraApiToken, + createWebhookMutation, + deleteWebhookMutation, + }; +} + +// ============================================================================ +// Save Mutation +// ============================================================================ + +export function useSaveMutation(projectId: string, state: WizardState) { + const queryClient = useQueryClient(); + + const saveMutation = useMutation({ + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles two provider types + credential linking + 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 { + 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 result = await trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'pm', + provider: state.provider, + config, + }); + + // Set credentials + const credPairs: Array<{ role: string; credentialId: number }> = + state.provider === 'trello' + ? [ + ...(state.trelloApiKeyCredentialId + ? [{ role: 'api_key', credentialId: state.trelloApiKeyCredentialId }] + : []), + ...(state.trelloTokenCredentialId + ? [{ role: 'token', credentialId: state.trelloTokenCredentialId }] + : []), + ] + : [ + ...(state.jiraEmailCredentialId + ? [{ role: 'email', credentialId: state.jiraEmailCredentialId }] + : []), + ...(state.jiraApiTokenCredentialId + ? [{ role: 'api_token', credentialId: state.jiraApiTokenCredentialId }] + : []), + ]; + + for (const { role, credentialId } of credPairs) { + await trpcClient.projects.integrationCredentials.set.mutate({ + projectId, + category: 'pm', + role, + credentialId, + }); + } + + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrationCredentials.list.queryOptions({ + projectId, + category: 'pm', + }).queryKey, + }); + }, + }); + + return { saveMutation }; +} diff --git a/web/src/components/projects/pm-wizard-jira-steps.tsx b/web/src/components/projects/pm-wizard-jira-steps.tsx new file mode 100644 index 00000000..fce94af9 --- /dev/null +++ b/web/src/components/projects/pm-wizard-jira-steps.tsx @@ -0,0 +1,298 @@ +/** + * JIRA-specific step renderer components for PMWizard. + */ +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import type { UseMutationResult } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import type { WizardAction, WizardState } from './pm-wizard-state.js'; +import { FieldMappingRow, InlineCredentialCreator, SearchableSelect } from './wizard-shared.js'; +import type { CredentialOption } from './wizard-shared.js'; + +// ============================================================================ +// Slot definitions +// ============================================================================ + +const JIRA_STATUS_SLOTS = [ + 'backlog', + 'splitting', + 'stories', + 'planning', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', +]; + +const JIRA_LABEL_SLOTS = ['processing', 'processed', 'error', 'readyToProcess']; + +// ============================================================================ +// JiraCredentialsStep +// ============================================================================ + +export function JiraCredentialsStep({ + state, + dispatch, + orgCredentials, +}: { + state: WizardState; + dispatch: React.Dispatch; + orgCredentials: CredentialOption[]; +}) { + return ( +
+
+ + dispatch({ type: 'SET_JIRA_BASE_URL', url: e.target.value })} + placeholder="https://your-instance.atlassian.net" + /> +
+
+ + + dispatch({ type: 'SET_JIRA_EMAIL_CRED', id })} + /> +
+
+ + + dispatch({ type: 'SET_JIRA_API_TOKEN_CRED', id })} + /> +
+
+ ); +} + +// ============================================================================ +// JiraProjectStep +// ============================================================================ + +export function JiraProjectStep({ + state, + onProjectSelect, + jiraProjectsMutation, + jiraDetailsMutation, +}: { + state: WizardState; + onProjectSelect: (key: string) => void; + jiraProjectsMutation: UseMutationResult; + jiraDetailsMutation: UseMutationResult; +}) { + return ( +
+ + ({ + label: p.name, + value: p.key, + detail: p.key, + }))} + value={state.jiraProjectKey} + onChange={onProjectSelect} + placeholder="Select a JIRA project..." + isLoading={jiraProjectsMutation.isPending} + error={jiraProjectsMutation.isError ? (jiraProjectsMutation.error as Error).message : null} + onRetry={() => + (jiraProjectsMutation as UseMutationResult).mutate() + } + /> + {state.jiraProjectKey && jiraDetailsMutation.isPending && ( +
+ Loading project details... +
+ )} +
+ ); +} + +// ============================================================================ +// JiraFieldMappingStep +// ============================================================================ + +export function JiraFieldMappingStep({ + state, + dispatch, +}: { + state: WizardState; + dispatch: React.Dispatch; +}) { + return ( +
+ {/* Status mappings */} +
+ +

+ Map each CASCADE status to a JIRA status in the project. +

+ {state.jiraProjectDetails ? ( + JIRA_STATUS_SLOTS.map((slot) => ( + ({ + label: s.name, + value: s.name, + })) ?? [] + } + value={state.jiraStatusMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_STATUS_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a project first to populate status options. +

+ )} +
+ + {/* Issue types */} +
+ +

+ Map CASCADE issue types. Typically "task" for the main type and + "subtask" for sub-tasks. +

+ {state.jiraProjectDetails ? ( + <> + !t.subtask) + .map((t) => ({ + label: t.name, + value: t.name, + }))} + value={state.jiraIssueTypes.task ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_ISSUE_TYPE', + key: 'task', + value: v, + }) + } + manualFallback + /> + t.subtask) + .map((t) => ({ + label: t.name, + value: t.name, + }))} + value={state.jiraIssueTypes.subtask ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_JIRA_ISSUE_TYPE', + key: 'subtask', + value: v, + }) + } + manualFallback + /> + + ) : ( +

Select a project first.

+ )} +
+ + {/* Labels */} +
+ +

+ CASCADE label names used in JIRA. These are created automatically by CASCADE. +

+ {JIRA_LABEL_SLOTS.map((slot) => ( +
+ {slot} + + dispatch({ + type: 'SET_JIRA_LABEL', + key: slot, + value: e.target.value, + }) + } + placeholder={`JIRA label for ${slot}`} + className="flex-1" + /> +
+ ))} +
+ + {/* Cost custom field */} +
+ + {state.jiraProjectDetails ? ( + ({ + label: `${f.name} (${f.id})`, + value: f.id, + }))} + value={state.jiraCostFieldId} + onChange={(v) => dispatch({ type: 'SET_JIRA_COST_FIELD', id: v })} + manualFallback + /> + ) : ( + + dispatch({ + type: 'SET_JIRA_COST_FIELD', + id: e.target.value, + }) + } + placeholder="e.g., customfield_10042" + /> + )} +
+
+ ); +} diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts new file mode 100644 index 00000000..781132fa --- /dev/null +++ b/web/src/components/projects/pm-wizard-state.ts @@ -0,0 +1,320 @@ +/** + * PM Wizard state management: types, initial state, reducer, and step-completion helpers. + * Has zero imports from other pm-wizard files to avoid circular dependencies. + */ +import type { Reducer } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface TrelloBoardOption { + id: string; + name: string; + url: string; +} + +export interface TrelloBoardDetails { + lists: Array<{ id: string; name: string }>; + labels: Array<{ id: string; name: string; color: string }>; + customFields: Array<{ id: string; name: string; type: string }>; +} + +export interface JiraProjectOption { + key: string; + name: string; +} + +export interface JiraProjectDetails { + statuses: Array<{ name: string; id: string }>; + issueTypes: Array<{ name: string; subtask: boolean }>; + fields: Array<{ id: string; name: string; custom: boolean }>; +} + +export type Provider = 'trello' | 'jira'; + +export interface WizardState { + provider: Provider; + // Step 2: Credentials + trelloApiKeyCredentialId: number | null; + trelloTokenCredentialId: number | null; + jiraEmailCredentialId: number | null; + jiraApiTokenCredentialId: number | null; + jiraBaseUrl: string; + verificationResult: { provider: Provider; display: string } | null; + verifyError: string | null; + // Step 3: Board/Project + trelloBoardId: string; + trelloBoards: TrelloBoardOption[]; + jiraProjectKey: string; + jiraProjects: JiraProjectOption[]; + // Step 4: Field mapping + trelloBoardDetails: TrelloBoardDetails | null; + jiraProjectDetails: JiraProjectDetails | null; + // Trello mappings + trelloListMappings: Record; + trelloLabelMappings: Record; + trelloCostFieldId: string; + // JIRA mappings + jiraStatusMappings: Record; + jiraIssueTypes: Record; + jiraLabels: Record; + jiraCostFieldId: string; + // Editing mode + isEditing: boolean; +} + +export type WizardAction = + | { type: 'SET_PROVIDER'; provider: Provider } + | { type: 'SET_TRELLO_API_KEY_CRED'; id: number | null } + | { type: 'SET_TRELLO_TOKEN_CRED'; id: number | null } + | { type: 'SET_JIRA_EMAIL_CRED'; id: number | null } + | { type: 'SET_JIRA_API_TOKEN_CRED'; id: number | null } + | { type: 'SET_JIRA_BASE_URL'; url: string } + | { + type: 'SET_VERIFICATION'; + result: { provider: Provider; display: string } | null; + error?: string | null; + } + | { type: 'SET_TRELLO_BOARDS'; boards: TrelloBoardOption[] } + | { type: 'SET_TRELLO_BOARD_ID'; id: string } + | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } + | { type: 'SET_JIRA_PROJECT_KEY'; key: string } + | { type: 'SET_TRELLO_BOARD_DETAILS'; details: TrelloBoardDetails | null } + | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } + | { type: 'SET_TRELLO_LIST_MAPPING'; key: string; value: string } + | { type: 'SET_TRELLO_LABEL_MAPPING'; key: string; value: string } + | { type: 'SET_TRELLO_COST_FIELD'; id: string } + | { type: 'SET_JIRA_STATUS_MAPPING'; key: string; value: string } + | { type: 'SET_JIRA_ISSUE_TYPE'; key: string; value: string } + | { type: 'SET_JIRA_LABEL'; key: string; value: string } + | { type: 'SET_JIRA_COST_FIELD'; id: string } + | { type: 'INIT_EDIT'; state: Partial }; + +// ============================================================================ +// Initial state and constants +// ============================================================================ + +export const INITIAL_JIRA_LABELS: Record = { + processing: 'cascade-processing', + processed: 'cascade-processed', + error: 'cascade-error', + readyToProcess: 'cascade-ready', +}; + +export function createInitialState(): WizardState { + return { + provider: 'trello', + trelloApiKeyCredentialId: null, + trelloTokenCredentialId: null, + jiraEmailCredentialId: null, + jiraApiTokenCredentialId: null, + jiraBaseUrl: '', + verificationResult: null, + verifyError: null, + trelloBoardId: '', + trelloBoards: [], + jiraProjectKey: '', + jiraProjects: [], + trelloBoardDetails: null, + jiraProjectDetails: null, + trelloListMappings: {}, + trelloLabelMappings: {}, + trelloCostFieldId: '', + jiraStatusMappings: {}, + jiraIssueTypes: {}, + jiraLabels: { ...INITIAL_JIRA_LABELS }, + jiraCostFieldId: '', + isEditing: false, + }; +} + +// ============================================================================ +// Reducer +// ============================================================================ + +export const wizardReducer: Reducer = (state, action) => { + switch (action.type) { + case 'SET_PROVIDER': + return { + ...createInitialState(), + provider: action.provider, + }; + case 'SET_TRELLO_API_KEY_CRED': + return { + ...state, + trelloApiKeyCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_TRELLO_TOKEN_CRED': + return { + ...state, + trelloTokenCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_EMAIL_CRED': + return { + ...state, + jiraEmailCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_API_TOKEN_CRED': + return { + ...state, + jiraApiTokenCredentialId: action.id, + verificationResult: null, + verifyError: null, + }; + case 'SET_JIRA_BASE_URL': + return { ...state, jiraBaseUrl: action.url, verificationResult: null, verifyError: null }; + case 'SET_VERIFICATION': + return { ...state, verificationResult: action.result, verifyError: action.error ?? null }; + case 'SET_TRELLO_BOARDS': + return { ...state, trelloBoards: action.boards }; + case 'SET_TRELLO_BOARD_ID': + return { + ...state, + trelloBoardId: action.id, + trelloBoardDetails: null, + trelloListMappings: {}, + trelloLabelMappings: {}, + trelloCostFieldId: '', + }; + case 'SET_JIRA_PROJECTS': + return { ...state, jiraProjects: action.projects }; + case 'SET_JIRA_PROJECT_KEY': + return { + ...state, + jiraProjectKey: action.key, + jiraProjectDetails: null, + jiraStatusMappings: {}, + jiraIssueTypes: {}, + jiraCostFieldId: '', + }; + case 'SET_TRELLO_BOARD_DETAILS': + return { ...state, trelloBoardDetails: action.details }; + case 'SET_JIRA_PROJECT_DETAILS': + return { ...state, jiraProjectDetails: action.details }; + case 'SET_TRELLO_LIST_MAPPING': + return { + ...state, + trelloListMappings: { ...state.trelloListMappings, [action.key]: action.value }, + }; + case 'SET_TRELLO_LABEL_MAPPING': + return { + ...state, + trelloLabelMappings: { ...state.trelloLabelMappings, [action.key]: action.value }, + }; + case 'SET_TRELLO_COST_FIELD': + return { ...state, trelloCostFieldId: action.id }; + case 'SET_JIRA_STATUS_MAPPING': + return { + ...state, + jiraStatusMappings: { ...state.jiraStatusMappings, [action.key]: action.value }, + }; + case 'SET_JIRA_ISSUE_TYPE': + return { + ...state, + jiraIssueTypes: { ...state.jiraIssueTypes, [action.key]: action.value }, + }; + case 'SET_JIRA_LABEL': + return { + ...state, + jiraLabels: { ...state.jiraLabels, [action.key]: action.value }, + }; + case 'SET_JIRA_COST_FIELD': + return { ...state, jiraCostFieldId: action.id }; + case 'INIT_EDIT': + return { ...state, ...action.state, isEditing: true }; + default: + return state; + } +}; + +// ============================================================================ +// Edit-mode state builder +// ============================================================================ + +/** + * Build a partial WizardState from an existing integration's config and credentials. + * Called when editing an existing PM integration. + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: restoring state from two provider config shapes +export function buildEditState( + provider: string, + initialConfig: Record, + initialCredentials: Map, +): Partial { + const editState: Partial = { + provider: provider as Provider, + }; + + if (provider === 'trello') { + editState.trelloApiKeyCredentialId = initialCredentials.get('api_key') ?? null; + editState.trelloTokenCredentialId = initialCredentials.get('token') ?? null; + editState.trelloBoardId = (initialConfig.boardId as string) ?? ''; + + const lists = initialConfig.lists as Record | undefined; + if (lists) editState.trelloListMappings = lists; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.trelloLabelMappings = labels; + + const cf = initialConfig.customFields as Record | undefined; + editState.trelloCostFieldId = cf?.cost ?? ''; + } else if (provider === 'jira') { + editState.jiraEmailCredentialId = initialCredentials.get('email') ?? null; + editState.jiraApiTokenCredentialId = initialCredentials.get('api_token') ?? null; + editState.jiraBaseUrl = (initialConfig.baseUrl as string) ?? ''; + editState.jiraProjectKey = (initialConfig.projectKey as string) ?? ''; + + const statuses = initialConfig.statuses as Record | undefined; + if (statuses) editState.jiraStatusMappings = statuses; + + const issueTypes = initialConfig.issueTypes as Record | undefined; + if (issueTypes) editState.jiraIssueTypes = issueTypes; + + const labels = initialConfig.labels as Record | undefined; + if (labels) editState.jiraLabels = labels; + + const cf = initialConfig.customFields as Record | undefined; + editState.jiraCostFieldId = cf?.cost ?? ''; + } + + return editState; +} + +// ============================================================================ +// Step-completion helpers (pure functions) +// ============================================================================ + +export function isStep1Complete(state: WizardState): boolean { + return !!state.provider; +} + +export function isStep2Complete(state: WizardState): boolean { + const credsReady = + state.provider === 'trello' + ? !!(state.trelloApiKeyCredentialId && state.trelloTokenCredentialId) + : !!(state.jiraEmailCredentialId && state.jiraApiTokenCredentialId && state.jiraBaseUrl); + return credsReady && !!state.verificationResult; +} + +export function isStep3Complete(state: WizardState): boolean { + return state.provider === 'trello' ? !!state.trelloBoardId : !!state.jiraProjectKey; +} + +export function isStep4Complete(state: WizardState): boolean { + return state.provider === 'trello' + ? Object.keys(state.trelloListMappings).length > 0 + : Object.keys(state.jiraStatusMappings).length > 0; +} + +export function areCredentialsReady(state: WizardState): boolean { + return state.provider === 'trello' + ? !!(state.trelloApiKeyCredentialId && state.trelloTokenCredentialId) + : !!(state.jiraEmailCredentialId && state.jiraApiTokenCredentialId && state.jiraBaseUrl); +} diff --git a/web/src/components/projects/pm-wizard-trello-steps.tsx b/web/src/components/projects/pm-wizard-trello-steps.tsx new file mode 100644 index 00000000..7fe93622 --- /dev/null +++ b/web/src/components/projects/pm-wizard-trello-steps.tsx @@ -0,0 +1,258 @@ +/** + * Trello-specific step renderer components for PMWizard. + */ +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import type { UseMutationResult } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import type { WizardAction, WizardState } from './pm-wizard-state.js'; +import { FieldMappingRow, InlineCredentialCreator, SearchableSelect } from './wizard-shared.js'; +import type { CredentialOption } from './wizard-shared.js'; + +// ============================================================================ +// Slot definitions +// ============================================================================ + +const TRELLO_LIST_SLOTS = [ + 'backlog', + 'splitting', + 'stories', + 'planning', + 'todo', + 'inProgress', + 'inReview', + 'done', + 'merged', + 'debug', +]; + +const TRELLO_LABEL_SLOTS = ['readyToProcess', 'processing', 'processed', 'error']; + +// ============================================================================ +// TrelloCredentialsStep +// ============================================================================ + +export function TrelloCredentialsStep({ + state, + dispatch, + orgCredentials, +}: { + state: WizardState; + dispatch: React.Dispatch; + orgCredentials: CredentialOption[]; +}) { + return ( +
+
+ +
+ +
+ dispatch({ type: 'SET_TRELLO_API_KEY_CRED', id })} + /> +
+
+ +
+ +
+ dispatch({ type: 'SET_TRELLO_TOKEN_CRED', id })} + /> +
+
+ ); +} + +// ============================================================================ +// TrelloBoardStep +// ============================================================================ + +export function TrelloBoardStep({ + state, + onBoardSelect, + boardsMutation, + boardDetailsMutation, +}: { + state: WizardState; + onBoardSelect: (boardId: string) => void; + boardsMutation: UseMutationResult; + boardDetailsMutation: UseMutationResult; +}) { + return ( +
+ + ({ + label: b.name, + value: b.id, + detail: b.url.split('/').pop(), + }))} + value={state.trelloBoardId} + onChange={onBoardSelect} + placeholder="Select a Trello board..." + isLoading={boardsMutation.isPending} + error={boardsMutation.isError ? (boardsMutation.error as Error).message : null} + onRetry={() => + (boardsMutation as UseMutationResult).mutate() + } + /> + {state.trelloBoardId && boardDetailsMutation.isPending && ( +
+ Loading board details... +
+ )} +
+ ); +} + +// ============================================================================ +// TrelloFieldMappingStep +// ============================================================================ + +export function TrelloFieldMappingStep({ + state, + dispatch, +}: { + state: WizardState; + dispatch: React.Dispatch; +}) { + return ( +
+ {/* List mappings */} +
+ +

+ Map each CASCADE stage to a Trello list on the board. +

+ {state.trelloBoardDetails ? ( + TRELLO_LIST_SLOTS.map((slot) => ( + ({ + label: l.name, + value: l.id, + })) ?? [] + } + value={state.trelloListMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_TRELLO_LIST_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a board first to populate list options. +

+ )} +
+ + {/* Label mappings */} +
+ +

+ Map each CASCADE label to a Trello label on the board. +

+ {state.trelloBoardDetails ? ( + TRELLO_LABEL_SLOTS.map((slot) => ( + l.name) + .map((l) => ({ + label: `${l.name} (${l.color})`, + value: l.id, + })) ?? [] + } + value={state.trelloLabelMappings[slot] ?? ''} + onChange={(v) => + dispatch({ + type: 'SET_TRELLO_LABEL_MAPPING', + key: slot, + value: v, + }) + } + manualFallback + /> + )) + ) : ( +

+ Select a board first to populate label options. +

+ )} +
+ + {/* Cost custom field */} +
+ + {state.trelloBoardDetails ? ( + f.type === 'number') + .map((f) => ({ + label: f.name, + value: f.id, + }))} + value={state.trelloCostFieldId} + onChange={(v) => dispatch({ type: 'SET_TRELLO_COST_FIELD', id: v })} + manualFallback + /> + ) : ( + + dispatch({ + type: 'SET_TRELLO_COST_FIELD', + id: e.target.value, + }) + } + placeholder="Custom field ID for cost tracking" + /> + )} +
+
+ ); +} diff --git a/web/src/components/projects/pm-wizard.tsx b/web/src/components/projects/pm-wizard.tsx index 610e8725..28db4c4c 100644 --- a/web/src/components/projects/pm-wizard.tsx +++ b/web/src/components/projects/pm-wizard.tsx @@ -1,254 +1,41 @@ -import { Input } from '@/components/ui/input.js'; import { Label } from '@/components/ui/label.js'; -import { API_URL } from '@/lib/api.js'; -import { trpc, trpcClient } from '@/lib/trpc.js'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { CheckCircle, Globe, Loader2, XCircle } from 'lucide-react'; +import { useEffect, useReducer, useState } from 'react'; +import { SaveStep, WebhookStep } from './pm-wizard-common-steps.js'; import { - AlertCircle, - AlertTriangle, - CheckCircle, - ChevronDown, - ChevronRight, - ExternalLink, - Globe, - KeyRound, - Loader2, - RefreshCw, - Trash2, - XCircle, -} from 'lucide-react'; -import { type Reducer, useEffect, useReducer, useState } from 'react'; -import { InlineCredentialCreator, WizardStep } from './wizard-shared.js'; + useJiraDiscovery, + useSaveMutation, + useTrelloDiscovery, + useVerification, + useWebhookManagement, +} from './pm-wizard-hooks.js'; +import { + JiraCredentialsStep, + JiraFieldMappingStep, + JiraProjectStep, +} from './pm-wizard-jira-steps.js'; +import { + areCredentialsReady, + buildEditState, + createInitialState, + isStep1Complete, + isStep2Complete, + isStep3Complete, + isStep4Complete, + wizardReducer, +} from './pm-wizard-state.js'; +import { + TrelloBoardStep, + TrelloCredentialsStep, + TrelloFieldMappingStep, +} from './pm-wizard-trello-steps.js'; +import { WizardStep } from './wizard-shared.js'; import type { CredentialOption } from './wizard-shared.js'; // ============================================================================ -// Types -// ============================================================================ - -interface TrelloBoardOption { - id: string; - name: string; - url: string; -} - -interface TrelloBoardDetails { - lists: Array<{ id: string; name: string }>; - labels: Array<{ id: string; name: string; color: string }>; - customFields: Array<{ id: string; name: string; type: string }>; -} - -interface JiraProjectOption { - key: string; - name: string; -} - -interface JiraProjectDetails { - statuses: Array<{ name: string; id: string }>; - issueTypes: Array<{ name: string; subtask: boolean }>; - fields: Array<{ id: string; name: string; custom: boolean }>; -} - -// ============================================================================ -// Wizard State -// ============================================================================ - -type Provider = 'trello' | 'jira'; - -interface WizardState { - provider: Provider; - // Step 2: Credentials - trelloApiKeyCredentialId: number | null; - trelloTokenCredentialId: number | null; - jiraEmailCredentialId: number | null; - jiraApiTokenCredentialId: number | null; - jiraBaseUrl: string; - verificationResult: { provider: Provider; display: string } | null; - verifyError: string | null; - // Step 3: Board/Project - trelloBoardId: string; - trelloBoards: TrelloBoardOption[]; - jiraProjectKey: string; - jiraProjects: JiraProjectOption[]; - // Step 4: Field mapping - trelloBoardDetails: TrelloBoardDetails | null; - jiraProjectDetails: JiraProjectDetails | null; - // Trello mappings - trelloListMappings: Record; - trelloLabelMappings: Record; - trelloCostFieldId: string; - // JIRA mappings - jiraStatusMappings: Record; - jiraIssueTypes: Record; - jiraLabels: Record; - jiraCostFieldId: string; - // Editing mode - isEditing: boolean; -} - -type WizardAction = - | { type: 'SET_PROVIDER'; provider: Provider } - | { type: 'SET_TRELLO_API_KEY_CRED'; id: number | null } - | { type: 'SET_TRELLO_TOKEN_CRED'; id: number | null } - | { type: 'SET_JIRA_EMAIL_CRED'; id: number | null } - | { type: 'SET_JIRA_API_TOKEN_CRED'; id: number | null } - | { type: 'SET_JIRA_BASE_URL'; url: string } - | { - type: 'SET_VERIFICATION'; - result: { provider: Provider; display: string } | null; - error?: string | null; - } - | { type: 'SET_TRELLO_BOARDS'; boards: TrelloBoardOption[] } - | { type: 'SET_TRELLO_BOARD_ID'; id: string } - | { type: 'SET_JIRA_PROJECTS'; projects: JiraProjectOption[] } - | { type: 'SET_JIRA_PROJECT_KEY'; key: string } - | { type: 'SET_TRELLO_BOARD_DETAILS'; details: TrelloBoardDetails | null } - | { type: 'SET_JIRA_PROJECT_DETAILS'; details: JiraProjectDetails | null } - | { type: 'SET_TRELLO_LIST_MAPPING'; key: string; value: string } - | { type: 'SET_TRELLO_LABEL_MAPPING'; key: string; value: string } - | { type: 'SET_TRELLO_COST_FIELD'; id: string } - | { type: 'SET_JIRA_STATUS_MAPPING'; key: string; value: string } - | { type: 'SET_JIRA_ISSUE_TYPE'; key: string; value: string } - | { type: 'SET_JIRA_LABEL'; key: string; value: string } - | { type: 'SET_JIRA_COST_FIELD'; id: string } - | { type: 'INIT_EDIT'; state: Partial }; - -const INITIAL_JIRA_LABELS: Record = { - processing: 'cascade-processing', - processed: 'cascade-processed', - error: 'cascade-error', - readyToProcess: 'cascade-ready', -}; - -function createInitialState(): WizardState { - return { - provider: 'trello', - trelloApiKeyCredentialId: null, - trelloTokenCredentialId: null, - jiraEmailCredentialId: null, - jiraApiTokenCredentialId: null, - jiraBaseUrl: '', - verificationResult: null, - verifyError: null, - trelloBoardId: '', - trelloBoards: [], - jiraProjectKey: '', - jiraProjects: [], - trelloBoardDetails: null, - jiraProjectDetails: null, - trelloListMappings: {}, - trelloLabelMappings: {}, - trelloCostFieldId: '', - jiraStatusMappings: {}, - jiraIssueTypes: {}, - jiraLabels: { ...INITIAL_JIRA_LABELS }, - jiraCostFieldId: '', - isEditing: false, - }; -} - -const wizardReducer: Reducer = (state, action) => { - switch (action.type) { - case 'SET_PROVIDER': - return { - ...createInitialState(), - provider: action.provider, - }; - case 'SET_TRELLO_API_KEY_CRED': - return { - ...state, - trelloApiKeyCredentialId: action.id, - verificationResult: null, - verifyError: null, - }; - case 'SET_TRELLO_TOKEN_CRED': - return { - ...state, - trelloTokenCredentialId: action.id, - verificationResult: null, - verifyError: null, - }; - case 'SET_JIRA_EMAIL_CRED': - return { - ...state, - jiraEmailCredentialId: action.id, - verificationResult: null, - verifyError: null, - }; - case 'SET_JIRA_API_TOKEN_CRED': - return { - ...state, - jiraApiTokenCredentialId: action.id, - verificationResult: null, - verifyError: null, - }; - case 'SET_JIRA_BASE_URL': - return { ...state, jiraBaseUrl: action.url, verificationResult: null, verifyError: null }; - case 'SET_VERIFICATION': - return { ...state, verificationResult: action.result, verifyError: action.error ?? null }; - case 'SET_TRELLO_BOARDS': - return { ...state, trelloBoards: action.boards }; - case 'SET_TRELLO_BOARD_ID': - return { - ...state, - trelloBoardId: action.id, - trelloBoardDetails: null, - trelloListMappings: {}, - trelloLabelMappings: {}, - trelloCostFieldId: '', - }; - case 'SET_JIRA_PROJECTS': - return { ...state, jiraProjects: action.projects }; - case 'SET_JIRA_PROJECT_KEY': - return { - ...state, - jiraProjectKey: action.key, - jiraProjectDetails: null, - jiraStatusMappings: {}, - jiraIssueTypes: {}, - jiraCostFieldId: '', - }; - case 'SET_TRELLO_BOARD_DETAILS': - return { ...state, trelloBoardDetails: action.details }; - case 'SET_JIRA_PROJECT_DETAILS': - return { ...state, jiraProjectDetails: action.details }; - case 'SET_TRELLO_LIST_MAPPING': - return { - ...state, - trelloListMappings: { ...state.trelloListMappings, [action.key]: action.value }, - }; - case 'SET_TRELLO_LABEL_MAPPING': - return { - ...state, - trelloLabelMappings: { ...state.trelloLabelMappings, [action.key]: action.value }, - }; - case 'SET_TRELLO_COST_FIELD': - return { ...state, trelloCostFieldId: action.id }; - case 'SET_JIRA_STATUS_MAPPING': - return { - ...state, - jiraStatusMappings: { ...state.jiraStatusMappings, [action.key]: action.value }, - }; - case 'SET_JIRA_ISSUE_TYPE': - return { - ...state, - jiraIssueTypes: { ...state.jiraIssueTypes, [action.key]: action.value }, - }; - case 'SET_JIRA_LABEL': - return { - ...state, - jiraLabels: { ...state.jiraLabels, [action.key]: action.value }, - }; - case 'SET_JIRA_COST_FIELD': - return { ...state, jiraCostFieldId: action.id }; - case 'INIT_EDIT': - return { ...state, ...action.state, isEditing: true }; - default: - return state; - } -}; - -// ============================================================================ -// Wizard Step Shell +// Constants // ============================================================================ const STEP_TITLES = [ @@ -260,204 +47,10 @@ const STEP_TITLES = [ 'Save', ] as const; -// ============================================================================ -// Searchable Select -// ============================================================================ - -function SearchableSelect({ - options, - value, - onChange, - placeholder, - isLoading, - error, - onRetry, -}: { - options: T[]; - value: string; - onChange: (value: string) => void; - placeholder: string; - isLoading?: boolean; - error?: string | null; - onRetry?: () => void; -}) { - const [search, setSearch] = useState(''); - - const filtered = search - ? options.filter( - (o) => - o.value === value || - o.label.toLowerCase().includes(search.toLowerCase()) || - o.value.toLowerCase().includes(search.toLowerCase()) || - o.detail?.toLowerCase().includes(search.toLowerCase()), - ) - : options; - - if (isLoading) { - return ( -
- Loading... -
- ); - } - - if (error) { - return ( -
-
- {error} -
- {onRetry && ( - - )} -
- ); - } - - return ( -
- {options.length > 5 && ( - setSearch(e.target.value)} - placeholder="Filter..." - className="h-8 text-sm" - /> - )} - -
- ); -} - -// ============================================================================ -// Field Mapping Row -// ============================================================================ - -function FieldMappingRow({ - slotLabel, - options, - value, - onChange, - manualFallback, -}: { - slotLabel: string; - options: Array<{ label: string; value: string }>; - value: string; - onChange: (value: string) => void; - manualFallback?: boolean; -}) { - const [isManual, setIsManual] = useState(false); - - // If the value doesn't match any option, show manual mode - const hasMatch = !value || options.some((o) => o.value === value); - const showManual = isManual || (value && !hasMatch && manualFallback); - - return ( -
- {slotLabel} - {showManual ? ( -
- onChange(e.target.value)} - placeholder="Enter ID manually" - className="flex-1" - /> - {manualFallback && ( - - )} -
- ) : ( -
- - {manualFallback && ( - - )} -
- )} -
- ); -} - -// ============================================================================ -// CASCADE slot key definitions -// ============================================================================ - -const TRELLO_LIST_SLOTS = [ - 'backlog', - 'splitting', - 'stories', - 'planning', - 'todo', - 'inProgress', - 'inReview', - 'done', - 'merged', - 'debug', -]; - -const TRELLO_LABEL_SLOTS = ['readyToProcess', 'processing', 'processed', 'error']; - -const JIRA_STATUS_SLOTS = [ - 'backlog', - 'splitting', - 'stories', - 'planning', - 'todo', - 'inProgress', - 'inReview', - 'done', - 'merged', -]; - -const JIRA_LABEL_SLOTS = ['processing', 'processed', 'error', 'readyToProcess']; - // ============================================================================ // Main PMWizard Component // ============================================================================ -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: wizard component with provider-specific branching across 6 steps export function PMWizard({ projectId, initialProvider, @@ -469,7 +62,6 @@ export function PMWizard({ initialConfig?: Record; initialCredentials: Map; }) { - const queryClient = useQueryClient(); const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId })); @@ -477,54 +69,8 @@ export function PMWizard({ const [state, dispatch] = useReducer(wizardReducer, undefined, createInitialState); const [openSteps, setOpenSteps] = useState>(new Set([1])); - // Initialize from existing integration - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: restoring state from two provider config shapes - useEffect(() => { - if (!initialConfig || !initialProvider) return; - - const editState: Partial = { - provider: initialProvider as Provider, - }; - - // Restore credential selections - if (initialProvider === 'trello') { - editState.trelloApiKeyCredentialId = initialCredentials.get('api_key') ?? null; - editState.trelloTokenCredentialId = initialCredentials.get('token') ?? null; - editState.trelloBoardId = (initialConfig.boardId as string) ?? ''; - - const lists = initialConfig.lists as Record | undefined; - if (lists) editState.trelloListMappings = lists; - - const labels = initialConfig.labels as Record | undefined; - if (labels) editState.trelloLabelMappings = labels; - - const cf = initialConfig.customFields as Record | undefined; - editState.trelloCostFieldId = cf?.cost ?? ''; - } else if (initialProvider === 'jira') { - editState.jiraEmailCredentialId = initialCredentials.get('email') ?? null; - editState.jiraApiTokenCredentialId = initialCredentials.get('api_token') ?? null; - editState.jiraBaseUrl = (initialConfig.baseUrl as string) ?? ''; - editState.jiraProjectKey = (initialConfig.projectKey as string) ?? ''; - - const statuses = initialConfig.statuses as Record | undefined; - if (statuses) editState.jiraStatusMappings = statuses; + // ---- Step navigation helpers ---- - const issueTypes = initialConfig.issueTypes as Record | undefined; - if (issueTypes) editState.jiraIssueTypes = issueTypes; - - const labels = initialConfig.labels as Record | undefined; - if (labels) editState.jiraLabels = labels; - - const cf = initialConfig.customFields as Record | undefined; - editState.jiraCostFieldId = cf?.cost ?? ''; - } - - dispatch({ type: 'INIT_EDIT', state: editState }); - // In edit mode, open all steps - setOpenSteps(new Set([1, 2, 3, 4, 5, 6])); - }, [initialConfig, initialProvider, initialCredentials]); - - // Toggle step open/closed const toggleStep = (step: number) => { setOpenSteps((prev) => { const next = new Set(prev); @@ -545,26 +91,34 @@ export function PMWizard({ }); }; - // ---- Step status calculations ---- + // ---- Initialize from existing integration ---- - const step1Complete = !!state.provider; + useEffect(() => { + if (!initialConfig || !initialProvider) return; + const editState = buildEditState(initialProvider, initialConfig, initialCredentials); + dispatch({ type: 'INIT_EDIT', state: editState }); + setOpenSteps(new Set([1, 2, 3, 4, 5, 6])); + }, [initialConfig, initialProvider, initialCredentials]); - const credsReady = - state.provider === 'trello' - ? !!(state.trelloApiKeyCredentialId && state.trelloTokenCredentialId) - : !!(state.jiraEmailCredentialId && state.jiraApiTokenCredentialId && state.jiraBaseUrl); - const step2Complete = credsReady && !!state.verificationResult; + // ---- Custom hooks ---- - const step3Complete = - state.provider === 'trello' ? !!state.trelloBoardId : !!state.jiraProjectKey; + const { verifyMutation } = useVerification(state, dispatch, advanceToStep); + const { boardsMutation, boardDetailsMutation, handleBoardSelect } = useTrelloDiscovery( + state, + dispatch, + advanceToStep, + ); + const { jiraProjectsMutation, jiraDetailsMutation, handleProjectSelect } = useJiraDiscovery( + state, + dispatch, + advanceToStep, + ); + const webhookManagement = useWebhookManagement(projectId, state); + const { saveMutation } = useSaveMutation(projectId, state); - const step4Complete = - state.provider === 'trello' - ? Object.keys(state.trelloListMappings).length > 0 - : Object.keys(state.jiraStatusMappings).length > 0; + // ---- Step status ---- - // Step 5 (webhooks) is optional, always "complete" - const step5Complete = true; + const credsReady = areCredentialsReady(state); function getStatus( stepNum: number, @@ -575,338 +129,6 @@ export function PMWizard({ return 'pending'; } - // ---- Mutations ---- - - const verifyMutation = useMutation({ - mutationFn: async () => { - const provider = state.provider; - if (provider === 'trello') { - if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { - throw new Error('Select both credentials before verifying'); - } - const result = await trpcClient.integrationsDiscovery.verifyTrello.mutate({ - apiKeyCredentialId: state.trelloApiKeyCredentialId, - tokenCredentialId: state.trelloTokenCredentialId, - }); - return { provider: 'trello' as const, result }; - } - if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { - throw new Error('Select both credentials before verifying'); - } - const result = await trpcClient.integrationsDiscovery.verifyJira.mutate({ - emailCredentialId: state.jiraEmailCredentialId, - apiTokenCredentialId: state.jiraApiTokenCredentialId, - baseUrl: state.jiraBaseUrl, - }); - return { provider: 'jira' as const, result }; - }, - onSuccess: ({ provider, result }) => { - // Ignore if provider changed while we were verifying - if (provider !== state.provider) return; - if (provider === 'trello') { - const r = result as { username: string; fullName: string }; - dispatch({ - type: 'SET_VERIFICATION', - result: { provider: 'trello', display: `@${r.username} (${r.fullName})` }, - }); - } else { - const r = result as { displayName: string; emailAddress: string }; - dispatch({ - type: 'SET_VERIFICATION', - result: { provider: 'jira', display: `${r.displayName} (${r.emailAddress})` }, - }); - } - advanceToStep(3); - }, - onError: (err) => { - dispatch({ - type: 'SET_VERIFICATION', - result: null, - error: err instanceof Error ? err.message : String(err), - }); - }, - }); - - const boardsMutation = useMutation({ - mutationFn: () => { - if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { - throw new Error('Select both credentials before fetching boards'); - } - return trpcClient.integrationsDiscovery.trelloBoards.mutate({ - apiKeyCredentialId: state.trelloApiKeyCredentialId, - tokenCredentialId: state.trelloTokenCredentialId, - }); - }, - onSuccess: (boards) => dispatch({ type: 'SET_TRELLO_BOARDS', boards }), - }); - - const boardDetailsMutation = useMutation({ - mutationFn: (boardId: string) => { - if (!state.trelloApiKeyCredentialId || !state.trelloTokenCredentialId) { - throw new Error('Select both credentials before fetching board details'); - } - return trpcClient.integrationsDiscovery.trelloBoardDetails.mutate({ - apiKeyCredentialId: state.trelloApiKeyCredentialId, - tokenCredentialId: state.trelloTokenCredentialId, - boardId, - }); - }, - onSuccess: (details) => { - dispatch({ type: 'SET_TRELLO_BOARD_DETAILS', details }); - advanceToStep(4); - }, - }); - - const jiraProjectsMutation = useMutation({ - mutationFn: () => { - if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { - throw new Error('Select both credentials before fetching projects'); - } - return trpcClient.integrationsDiscovery.jiraProjects.mutate({ - emailCredentialId: state.jiraEmailCredentialId, - apiTokenCredentialId: state.jiraApiTokenCredentialId, - baseUrl: state.jiraBaseUrl, - }); - }, - onSuccess: (projects) => dispatch({ type: 'SET_JIRA_PROJECTS', projects }), - }); - - const jiraDetailsMutation = useMutation({ - mutationFn: (projectKey: string) => { - if (!state.jiraEmailCredentialId || !state.jiraApiTokenCredentialId) { - throw new Error('Select both credentials before fetching project details'); - } - return trpcClient.integrationsDiscovery.jiraProjectDetails.mutate({ - emailCredentialId: state.jiraEmailCredentialId, - apiTokenCredentialId: state.jiraApiTokenCredentialId, - baseUrl: state.jiraBaseUrl, - projectKey, - }); - }, - onSuccess: (details) => { - dispatch({ type: 'SET_JIRA_PROJECT_DETAILS', details }); - advanceToStep(4); - }, - }); - - // Fetch boards/projects when step 3 opens and credentials are verified - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on verification result change - useEffect(() => { - if (!state.verificationResult) return; - if ( - state.provider === 'trello' && - state.trelloBoards.length === 0 && - !boardsMutation.isPending - ) { - boardsMutation.mutate(); - } else if ( - state.provider === 'jira' && - state.jiraProjects.length === 0 && - !jiraProjectsMutation.isPending - ) { - jiraProjectsMutation.mutate(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.verificationResult]); - - // In edit mode, auto-fetch boards/projects list and details when credentials are present - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally trigger only on edit mode state changes - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: two-provider branching with guard conditions - useEffect(() => { - if (!state.isEditing) return; - - if (state.provider === 'trello') { - if ( - state.trelloApiKeyCredentialId && - state.trelloTokenCredentialId && - state.trelloBoards.length === 0 && - !boardsMutation.isPending - ) { - boardsMutation.mutate(); - } - if ( - state.trelloBoardId && - !state.trelloBoardDetails && - state.trelloApiKeyCredentialId && - state.trelloTokenCredentialId && - !boardDetailsMutation.isPending - ) { - boardDetailsMutation.mutate(state.trelloBoardId); - } - } else if (state.provider === 'jira') { - if ( - state.jiraEmailCredentialId && - state.jiraApiTokenCredentialId && - state.jiraProjects.length === 0 && - !jiraProjectsMutation.isPending - ) { - jiraProjectsMutation.mutate(); - } - if ( - state.jiraProjectKey && - !state.jiraProjectDetails && - state.jiraEmailCredentialId && - state.jiraApiTokenCredentialId && - !jiraDetailsMutation.isPending - ) { - jiraDetailsMutation.mutate(state.jiraProjectKey); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.isEditing, state.trelloBoardId, state.jiraProjectKey]); - - // Fetch board/project details when selection changes - const handleBoardSelect = (boardId: string) => { - dispatch({ type: 'SET_TRELLO_BOARD_ID', id: boardId }); - if (boardId) { - boardDetailsMutation.mutate(boardId); - } - }; - - const handleProjectSelect = (key: string) => { - dispatch({ type: 'SET_JIRA_PROJECT_KEY', key }); - if (key) { - jiraDetailsMutation.mutate(key); - } - }; - - // ---- Webhook management ---- - const callbackBaseUrl = - API_URL || - (typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : ''); - - const [adminTokensOpen, setAdminTokensOpen] = useState(false); - const [oneTimeTrelloApiKey, setOneTimeTrelloApiKey] = useState(''); - const [oneTimeTrelloToken, setOneTimeTrelloToken] = useState(''); - const [oneTimeJiraEmail, setOneTimeJiraEmail] = useState(''); - const [oneTimeJiraApiToken, setOneTimeJiraApiToken] = useState(''); - - const buildOneTimeTokens = () => { - const tokens: Record = {}; - if (oneTimeTrelloApiKey) tokens.trelloApiKey = oneTimeTrelloApiKey; - if (oneTimeTrelloToken) tokens.trelloToken = oneTimeTrelloToken; - if (oneTimeJiraEmail) tokens.jiraEmail = oneTimeJiraEmail; - if (oneTimeJiraApiToken) tokens.jiraApiToken = oneTimeJiraApiToken; - return Object.keys(tokens).length > 0 ? tokens : undefined; - }; - - const clearOneTimeTokens = () => { - setOneTimeTrelloApiKey(''); - setOneTimeTrelloToken(''); - setOneTimeJiraEmail(''); - setOneTimeJiraApiToken(''); - }; - - const createWebhookMutation = useMutation({ - mutationFn: () => - trpcClient.webhooks.create.mutate({ - projectId, - callbackBaseUrl, - trelloOnly: state.provider === 'trello' ? true : undefined, - jiraOnly: state.provider === 'jira' ? true : undefined, - oneTimeTokens: buildOneTimeTokens(), - }), - onSuccess: () => { - clearOneTimeTokens(); - queryClient.invalidateQueries({ - queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - const deleteWebhookMutation = useMutation({ - mutationFn: (deleteCallbackBaseUrl: string) => - trpcClient.webhooks.delete.mutate({ - projectId, - callbackBaseUrl: deleteCallbackBaseUrl, - trelloOnly: state.provider === 'trello' ? true : undefined, - jiraOnly: state.provider === 'jira' ? true : undefined, - oneTimeTokens: buildOneTimeTokens(), - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey, - }); - }, - }); - - // ---- Save ---- - - const saveMutation = useMutation({ - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: handles two provider types + credential linking - 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 { - 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 result = await trpcClient.projects.integrations.upsert.mutate({ - projectId, - category: 'pm', - provider: state.provider, - config, - }); - - // Set credentials - const credPairs: Array<{ role: string; credentialId: number }> = - state.provider === 'trello' - ? [ - ...(state.trelloApiKeyCredentialId - ? [{ role: 'api_key', credentialId: state.trelloApiKeyCredentialId }] - : []), - ...(state.trelloTokenCredentialId - ? [{ role: 'token', credentialId: state.trelloTokenCredentialId }] - : []), - ] - : [ - ...(state.jiraEmailCredentialId - ? [{ role: 'email', credentialId: state.jiraEmailCredentialId }] - : []), - ...(state.jiraApiTokenCredentialId - ? [{ role: 'api_token', credentialId: state.jiraApiTokenCredentialId }] - : []), - ]; - - for (const { role, credentialId } of credPairs) { - await trpcClient.projects.integrationCredentials.set.mutate({ - projectId, - category: 'pm', - role, - credentialId, - }); - } - - return result; - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: trpc.projects.integrationCredentials.list.queryOptions({ - projectId, - category: 'pm', - }).queryKey, - }); - }, - }); - // ---- Active webhooks for this provider ---- const activeWebhooks = state.provider === 'trello' @@ -929,7 +151,7 @@ export function PMWizard({ toggleStep(1)} > @@ -962,120 +184,18 @@ export function PMWizard({ toggleStep(2)} > {state.provider === 'trello' ? ( -
-
- -
- -
- dispatch({ type: 'SET_TRELLO_API_KEY_CRED', id })} - /> -
-
- -
- -
- dispatch({ type: 'SET_TRELLO_TOKEN_CRED', id })} - /> -
-
+ ) : ( -
-
- - dispatch({ type: 'SET_JIRA_BASE_URL', url: e.target.value })} - placeholder="https://your-instance.atlassian.net" - /> -
-
- - - dispatch({ type: 'SET_JIRA_EMAIL_CRED', id })} - /> -
-
- - - dispatch({ type: 'SET_JIRA_API_TOKEN_CRED', id })} - /> -
-
+ )}
@@ -1111,54 +231,24 @@ export function PMWizard({ toggleStep(3)} > {state.provider === 'trello' ? ( -
- - ({ - label: b.name, - value: b.id, - detail: b.url.split('/').pop(), - }))} - value={state.trelloBoardId} - onChange={handleBoardSelect} - placeholder="Select a Trello board..." - isLoading={boardsMutation.isPending} - error={boardsMutation.isError ? boardsMutation.error.message : null} - onRetry={() => boardsMutation.mutate()} - /> - {state.trelloBoardId && boardDetailsMutation.isPending && ( -
- Loading board details... -
- )} -
+ ) : ( -
- - ({ - label: p.name, - value: p.key, - detail: p.key, - }))} - value={state.jiraProjectKey} - onChange={handleProjectSelect} - placeholder="Select a JIRA project..." - isLoading={jiraProjectsMutation.isPending} - error={jiraProjectsMutation.isError ? jiraProjectsMutation.error.message : null} - onRetry={() => jiraProjectsMutation.mutate()} - /> - {state.jiraProjectKey && jiraDetailsMutation.isPending && ( -
- Loading project details... -
- )} -
+ )}
@@ -1166,255 +256,14 @@ export function PMWizard({ toggleStep(4)} > {state.provider === 'trello' ? ( -
- {/* List mappings */} -
- -

- Map each CASCADE stage to a Trello list on the board. -

- {state.trelloBoardDetails ? ( - TRELLO_LIST_SLOTS.map((slot) => ( - ({ - label: l.name, - value: l.id, - })) ?? [] - } - value={state.trelloListMappings[slot] ?? ''} - onChange={(v) => - dispatch({ - type: 'SET_TRELLO_LIST_MAPPING', - key: slot, - value: v, - }) - } - manualFallback - /> - )) - ) : ( -

- Select a board first to populate list options. -

- )} -
- - {/* Label mappings */} -
- -

- Map each CASCADE label to a Trello label on the board. -

- {state.trelloBoardDetails ? ( - TRELLO_LABEL_SLOTS.map((slot) => ( - l.name) - .map((l) => ({ - label: `${l.name} (${l.color})`, - value: l.id, - })) ?? [] - } - value={state.trelloLabelMappings[slot] ?? ''} - onChange={(v) => - dispatch({ - type: 'SET_TRELLO_LABEL_MAPPING', - key: slot, - value: v, - }) - } - manualFallback - /> - )) - ) : ( -

- Select a board first to populate label options. -

- )} -
- - {/* Cost custom field */} -
- - {state.trelloBoardDetails ? ( - f.type === 'number') - .map((f) => ({ - label: f.name, - value: f.id, - }))} - value={state.trelloCostFieldId} - onChange={(v) => dispatch({ type: 'SET_TRELLO_COST_FIELD', id: v })} - manualFallback - /> - ) : ( - - dispatch({ - type: 'SET_TRELLO_COST_FIELD', - id: e.target.value, - }) - } - placeholder="Custom field ID for cost tracking" - /> - )} -
-
+ ) : ( -
- {/* Status mappings */} -
- -

- Map each CASCADE status to a JIRA status in the project. -

- {state.jiraProjectDetails ? ( - JIRA_STATUS_SLOTS.map((slot) => ( - ({ - label: s.name, - value: s.name, - })) ?? [] - } - value={state.jiraStatusMappings[slot] ?? ''} - onChange={(v) => - dispatch({ - type: 'SET_JIRA_STATUS_MAPPING', - key: slot, - value: v, - }) - } - manualFallback - /> - )) - ) : ( -

- Select a project first to populate status options. -

- )} -
- - {/* Issue types */} -
- -

- Map CASCADE issue types. Typically "task" for the main type and - "subtask" for sub-tasks. -

- {state.jiraProjectDetails ? ( - <> - !t.subtask) - .map((t) => ({ - label: t.name, - value: t.name, - }))} - value={state.jiraIssueTypes.task ?? ''} - onChange={(v) => - dispatch({ - type: 'SET_JIRA_ISSUE_TYPE', - key: 'task', - value: v, - }) - } - manualFallback - /> - t.subtask) - .map((t) => ({ - label: t.name, - value: t.name, - }))} - value={state.jiraIssueTypes.subtask ?? ''} - onChange={(v) => - dispatch({ - type: 'SET_JIRA_ISSUE_TYPE', - key: 'subtask', - value: v, - }) - } - manualFallback - /> - - ) : ( -

Select a project first.

- )} -
- - {/* Labels */} -
- -

- CASCADE label names used in JIRA. These are created automatically by CASCADE. -

- {JIRA_LABEL_SLOTS.map((slot) => ( -
- {slot} - - dispatch({ - type: 'SET_JIRA_LABEL', - key: slot, - value: e.target.value, - }) - } - placeholder={`JIRA label for ${slot}`} - className="flex-1" - /> -
- ))} -
- - {/* Cost custom field */} -
- - {state.jiraProjectDetails ? ( - ({ - label: `${f.name} (${f.id})`, - value: f.id, - }))} - value={state.jiraCostFieldId} - onChange={(v) => dispatch({ type: 'SET_JIRA_COST_FIELD', id: v })} - manualFallback - /> - ) : ( - - dispatch({ - type: 'SET_JIRA_COST_FIELD', - id: e.target.value, - }) - } - placeholder="e.g., customfield_10042" - /> - )} -
-
+ )}
@@ -1422,181 +271,16 @@ export function PMWizard({ toggleStep(5)} > -
- {/* Per-provider errors */} - {webhooksQuery.data?.errors && - Object.entries(webhooksQuery.data.errors) - .filter(([provider, err]) => err != null && provider !== 'github') - .map(([provider, err]) => ( -
- -
- - {provider} - - : {String(err)} -
- -
- ))} - - {webhooksQuery.isLoading ? ( -
- Loading webhooks... -
- ) : activeWebhooks.length > 0 ? ( -
- - {activeWebhooks.map((w) => ( -
-
- - {w.url} -
- -
- ))} -
- ) : ( -
- - No {state.provider === 'trello' ? 'Trello' : 'JIRA'} webhooks configured for this - project. -
- )} - -
-
- -
- {createWebhookMutation.isError && ( -

{createWebhookMutation.error.message}

- )} - {createWebhookMutation.isSuccess && ( -

- {webhooksQuery.data?.errors && - Object.entries(webhooksQuery.data.errors) - .filter(([provider]) => provider !== 'github') - .some(([, e]) => e != null) - ? 'Webhook created, but some providers failed to load — see warnings above.' - : 'Webhook created successfully.'} -

- )} -
- - {/* One-time admin credentials */} -
- - {adminTokensOpen && ( -
-

- Provide tokens with elevated permissions for webhook management. These are used - once and never saved. -

- {/* PM-provider-specific fields */} - {state.provider === 'trello' ? ( - <> -
- - setOneTimeTrelloApiKey(e.target.value)} - placeholder="One-time API key" - type="password" - className="h-8 text-sm" - /> -
-
- - setOneTimeTrelloToken(e.target.value)} - placeholder="One-time token" - type="password" - className="h-8 text-sm" - /> -
- - ) : ( - <> -
- - setOneTimeJiraEmail(e.target.value)} - placeholder="user@example.com" - className="h-8 text-sm" - /> -
-
- - setOneTimeJiraApiToken(e.target.value)} - placeholder="One-time API token" - type="password" - className="h-8 text-sm" - /> -
- - )} -
- )} -
-
+
{/* Step 6: Save */} @@ -1607,72 +291,7 @@ export function PMWizard({ isOpen={openSteps.has(6)} onToggle={() => toggleStep(6)} > -
- {/* Summary */} -
-
- Provider - {state.provider === 'trello' ? 'Trello' : 'JIRA'} -
- {state.verificationResult && ( -
- Identity - {state.verificationResult.display} -
- )} -
- - {state.provider === 'trello' ? 'Board' : 'Project'} - - - {state.provider === 'trello' - ? state.trelloBoards.find((b) => b.id === state.trelloBoardId)?.name || - state.trelloBoardId - : state.jiraProjects.find((p) => p.key === state.jiraProjectKey)?.name || - state.jiraProjectKey} - -
-
- - {state.provider === 'trello' ? 'Lists mapped' : 'Statuses mapped'} - - - {state.provider === 'trello' - ? Object.keys(state.trelloListMappings).filter((k) => state.trelloListMappings[k]) - .length - : Object.keys(state.jiraStatusMappings).filter((k) => state.jiraStatusMappings[k]) - .length} - -
-
- -

- Trigger configuration is managed separately in the Agent Configs tab. -

- -
- - {saveMutation.isSuccess && ( - - Integration saved successfully. - - )} - {saveMutation.isError && ( - {saveMutation.error.message} - )} -
-
+
); diff --git a/web/src/components/projects/wizard-shared.tsx b/web/src/components/projects/wizard-shared.tsx index 2c6f61ba..9a598203 100644 --- a/web/src/components/projects/wizard-shared.tsx +++ b/web/src/components/projects/wizard-shared.tsx @@ -5,7 +5,15 @@ import { Input } from '@/components/ui/input.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Check, ChevronDown, ChevronRight, Loader2, Plus } from 'lucide-react'; +import { + AlertCircle, + Check, + ChevronDown, + ChevronRight, + Loader2, + Plus, + RefreshCw, +} from 'lucide-react'; import { useState } from 'react'; // ============================================================================ @@ -78,6 +86,173 @@ export function WizardStep({ ); } +// ============================================================================ +// Searchable Select +// ============================================================================ + +/** + * A filterable select dropdown with loading/error states. + * Shows a search input when there are more than 5 options. + */ +export function SearchableSelect({ + options, + value, + onChange, + placeholder, + isLoading, + error, + onRetry, +}: { + options: T[]; + value: string; + onChange: (value: string) => void; + placeholder: string; + isLoading?: boolean; + error?: string | null; + onRetry?: () => void; +}) { + const [search, setSearch] = useState(''); + + const filtered = search + ? options.filter( + (o) => + o.value === value || + o.label.toLowerCase().includes(search.toLowerCase()) || + o.value.toLowerCase().includes(search.toLowerCase()) || + o.detail?.toLowerCase().includes(search.toLowerCase()), + ) + : options; + + if (isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+ {onRetry && ( + + )} +
+ ); + } + + return ( +
+ {options.length > 5 && ( + setSearch(e.target.value)} + placeholder="Filter..." + className="h-8 text-sm" + /> + )} + +
+ ); +} + +// ============================================================================ +// Field Mapping Row +// ============================================================================ + +/** + * A single field mapping row with a label, dropdown, and optional manual text input fallback. + */ +export function FieldMappingRow({ + slotLabel, + options, + value, + onChange, + manualFallback, +}: { + slotLabel: string; + options: Array<{ label: string; value: string }>; + value: string; + onChange: (value: string) => void; + manualFallback?: boolean; +}) { + const [isManual, setIsManual] = useState(false); + + // If the value doesn't match any option, show manual mode + const hasMatch = !value || options.some((o) => o.value === value); + const showManual = isManual || (value && !hasMatch && manualFallback); + + return ( +
+ {slotLabel} + {showManual ? ( +
+ onChange(e.target.value)} + placeholder="Enter ID manually" + className="flex-1" + /> + {manualFallback && ( + + )} +
+ ) : ( +
+ + {manualFallback && ( + + )} +
+ )} +
+ ); +} + // ============================================================================ // Inline Credential Creator // ============================================================================