From d9ccb668a4c0cbca0e20024575b9f28ea5bf7310 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 13 Mar 2026 19:05:43 +0000 Subject: [PATCH 1/2] feat(pm-wizard): add Trello custom field creation UI with Create button - Add ADD_TRELLO_BOARD_CUSTOM_FIELD action to WizardAction type - Handle ADD_TRELLO_BOARD_CUSTOM_FIELD in wizardReducer to append custom field - Create useTrelloCustomFieldCreation hook following useTrelloLabelCreation pattern - Hook calls createTrelloCustomField tRPC endpoint and dispatches actions on success - Add Create button next to cost field in TrelloFieldMappingStep - Button shows Loader2 spinner during creation and is disabled while creating - New field auto-selects in dropdown on successful creation - Display clear 403 error about Custom Fields power-up requirement on failure - Add comprehensive test cases for ADD_TRELLO_BOARD_CUSTOM_FIELD action - All tests passing (4618 tests) Closes #309 --- tests/unit/web/pm-wizard-state.test.ts | 52 +++++++++++++++++++ .../components/projects/pm-wizard-hooks.ts | 45 ++++++++++++++++ .../components/projects/pm-wizard-state.ts | 15 +++++- .../projects/pm-wizard-trello-steps.tsx | 25 ++++++++- web/src/components/projects/pm-wizard.tsx | 12 +++++ 5 files changed, 147 insertions(+), 2 deletions(-) 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..23c17d8d 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,26 @@ export function TrelloFieldMappingStep({ {/* Cost custom field */}
- +
+ + {state.trelloBoardDetails && onCreateCostField && ( + + )} +
{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} /> ) : ( From 171451da19f5a07bb0aee4d6b689ce622a731d86 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Fri, 13 Mar 2026 19:16:13 +0000 Subject: [PATCH 2/2] fix(pm-wizard): hide cost field Create button when field already selected or exists Guard the Create button with checks for an already-selected cost field (trelloCostFieldId) and an already-existing Cost number custom field in customFields, matching the label creation pattern. Co-Authored-By: Claude Opus 4.6 --- .../projects/pm-wizard-trello-steps.tsx | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/web/src/components/projects/pm-wizard-trello-steps.tsx b/web/src/components/projects/pm-wizard-trello-steps.tsx index 23c17d8d..aca1facd 100644 --- a/web/src/components/projects/pm-wizard-trello-steps.tsx +++ b/web/src/components/projects/pm-wizard-trello-steps.tsx @@ -304,23 +304,33 @@ export function TrelloFieldMappingStep({
- {state.trelloBoardDetails && onCreateCostField && ( - - )} + {(() => { + 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 ? (