diff --git a/tests/unit/web/pm-wizard-state.test.ts b/tests/unit/web/pm-wizard-state.test.ts index 42ba6101..b9c6e4db 100644 --- a/tests/unit/web/pm-wizard-state.test.ts +++ b/tests/unit/web/pm-wizard-state.test.ts @@ -325,6 +325,58 @@ describe('wizardReducer', () => { expect(next.trelloBoardDetails?.labels[2]).toEqual(newLabel); }); + it('ADD_TRELLO_BOARD_CUSTOM_FIELD appends a custom field to trelloBoardDetails.customFields', () => { + const state = { + ...initialState(), + trelloBoardDetails: { + lists: [], + labels: [], + customFields: [{ id: 'cf-existing', name: 'Existing', type: 'text' }], + }, + }; + const newCustomField = { id: 'cf-cost', name: 'Cost', type: 'number' }; + const next = dispatch(state, { + type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', + customField: newCustomField, + }); + expect(next.trelloBoardDetails?.customFields).toHaveLength(2); + expect(next.trelloBoardDetails?.customFields[1]).toEqual(newCustomField); + }); + + it('ADD_TRELLO_BOARD_CUSTOM_FIELD is a no-op when trelloBoardDetails is null', () => { + const state = initialState(); + const next = dispatch(state, { + type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', + customField: { id: 'cf-1', name: 'test', type: 'number' }, + }); + expect(next.trelloBoardDetails).toBeNull(); + expect(next).toBe(state); + }); + + it('ADD_TRELLO_BOARD_CUSTOM_FIELD preserves existing custom fields', () => { + const existingFields = [ + { id: 'cf-1', name: 'Budget', type: 'number' }, + { id: 'cf-2', name: 'Tags', type: 'list' }, + ]; + const state = { + ...initialState(), + trelloBoardDetails: { + lists: [], + labels: [], + customFields: existingFields, + }, + }; + const newCustomField = { id: 'cf-3', name: 'Cost', type: 'number' }; + const next = dispatch(state, { + type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', + customField: newCustomField, + }); + expect(next.trelloBoardDetails?.customFields).toHaveLength(3); + expect(next.trelloBoardDetails?.customFields[0]).toEqual(existingFields[0]); + expect(next.trelloBoardDetails?.customFields[1]).toEqual(existingFields[1]); + expect(next.trelloBoardDetails?.customFields[2]).toEqual(newCustomField); + }); + it('unknown action returns state unchanged', () => { const state = initialState(); // @ts-expect-error testing unknown action diff --git a/web/src/components/projects/pm-wizard-hooks.ts b/web/src/components/projects/pm-wizard-hooks.ts index 3910d9b5..2d29ffe6 100644 --- a/web/src/components/projects/pm-wizard-hooks.ts +++ b/web/src/components/projects/pm-wizard-hooks.ts @@ -399,6 +399,51 @@ export function useTrelloLabelCreation(state: WizardState, dispatch: React.Dispa return { createLabelMutation, createMissingLabelsMutation }; } +// ============================================================================ +// Trello Custom Field Creation +// ============================================================================ + +export function useTrelloCustomFieldCreation( + state: WizardState, + dispatch: React.Dispatch, +) { + const createCustomFieldMutation = useMutation({ + mutationFn: () => { + if ( + !state.trelloApiKeyCredentialId || + !state.trelloTokenCredentialId || + !state.trelloBoardId + ) { + throw new Error('Missing credentials or board selection'); + } + return trpcClient.integrationsDiscovery.createTrelloCustomField.mutate({ + apiKeyCredentialId: state.trelloApiKeyCredentialId, + tokenCredentialId: state.trelloTokenCredentialId, + boardId: state.trelloBoardId, + name: 'Cost', + type: 'number', + }); + }, + onSuccess: (customField) => { + dispatch({ type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD', customField }); + dispatch({ type: 'SET_TRELLO_COST_FIELD', id: customField.id }); + }, + onError: (error) => { + console.error('Failed to create custom field:', error); + const message = error instanceof Error ? error.message : String(error); + if (message.includes('403')) { + alert( + 'Failed to create custom field: The Trello Custom Fields power-up is required. Please enable it on your Trello board and try again.', + ); + } else { + alert(`Failed to create custom field: ${message}`); + } + }, + }); + + return { createCustomFieldMutation }; +} + // ============================================================================ // Save Mutation // ============================================================================ diff --git a/web/src/components/projects/pm-wizard-state.ts b/web/src/components/projects/pm-wizard-state.ts index 344d5a63..a9dd7065 100644 --- a/web/src/components/projects/pm-wizard-state.ts +++ b/web/src/components/projects/pm-wizard-state.ts @@ -90,7 +90,11 @@ export type WizardAction = | { type: 'SET_JIRA_LABEL'; key: string; value: string } | { type: 'SET_JIRA_COST_FIELD'; id: string } | { type: 'INIT_EDIT'; state: Partial } - | { type: 'ADD_TRELLO_BOARD_LABEL'; label: { id: string; name: string; color: string } }; + | { type: 'ADD_TRELLO_BOARD_LABEL'; label: { id: string; name: string; color: string } } + | { + type: 'ADD_TRELLO_BOARD_CUSTOM_FIELD'; + customField: { id: string; name: string; type: string }; + }; // ============================================================================ // Initial state and constants @@ -240,6 +244,15 @@ export const wizardReducer: Reducer = (state, action) labels: [...state.trelloBoardDetails.labels, action.label], }, }; + case 'ADD_TRELLO_BOARD_CUSTOM_FIELD': + if (!state.trelloBoardDetails) return state; + return { + ...state, + trelloBoardDetails: { + ...state.trelloBoardDetails, + customFields: [...state.trelloBoardDetails.customFields, action.customField], + }, + }; default: return state; } diff --git a/web/src/components/projects/pm-wizard-trello-steps.tsx b/web/src/components/projects/pm-wizard-trello-steps.tsx index 835bfaab..aca1facd 100644 --- a/web/src/components/projects/pm-wizard-trello-steps.tsx +++ b/web/src/components/projects/pm-wizard-trello-steps.tsx @@ -156,13 +156,17 @@ export function TrelloFieldMappingStep({ dispatch, onCreateLabel, onCreateAllMissingLabels, + onCreateCostField, creatingSlot, + creatingCostField, }: { state: WizardState; dispatch: React.Dispatch; onCreateLabel?: (slot: string) => void; onCreateAllMissingLabels?: () => void; + onCreateCostField?: () => void; creatingSlot?: string | null; + creatingCostField?: boolean; }) { const existingLabelNames = new Set( (state.trelloBoardDetails?.labels ?? []).map((l) => l.name.toLowerCase()), @@ -298,7 +302,36 @@ export function TrelloFieldMappingStep({ {/* Cost custom field */}
- +
+ + {(() => { + const existingCostField = state.trelloBoardDetails?.customFields.some( + (f) => f.type === 'number' && f.name.toLowerCase() === 'cost', + ); + const showCreateCostButton = + state.trelloBoardDetails && + onCreateCostField && + !state.trelloCostFieldId && + !existingCostField; + return showCreateCostButton ? ( + + ) : null; + })()} +
{state.trelloBoardDetails ? ( >(new Set([1])); const [creatingSlot, setCreatingSlot] = useState(null); + const [creatingCostField, setCreatingCostField] = useState(false); // ---- Step navigation helpers ---- @@ -120,6 +122,7 @@ export function PMWizard({ state, dispatch, ); + const { createCustomFieldMutation } = useTrelloCustomFieldCreation(state, dispatch); const webhookManagement = useWebhookManagement(projectId, state); const { saveMutation } = useSaveMutation(projectId, state); @@ -137,6 +140,13 @@ export function PMWizard({ ); }; + const handleCreateCostField = () => { + setCreatingCostField(true); + createCustomFieldMutation.mutate(undefined, { + onSettled: () => setCreatingCostField(false), + }); + }; + const handleCreateAllMissingLabels = () => { const existingLabelNames = new Set( (state.trelloBoardDetails?.labels ?? []).map((l) => l.name.toLowerCase()), @@ -305,7 +315,9 @@ export function PMWizard({ dispatch={dispatch} onCreateLabel={handleCreateLabel} onCreateAllMissingLabels={handleCreateAllMissingLabels} + onCreateCostField={handleCreateCostField} creatingSlot={creatingSlot} + creatingCostField={creatingCostField} /> ) : (