Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions tests/unit/web/pm-wizard-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions web/src/components/projects/pm-wizard-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WizardAction>,
) {
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
// ============================================================================
Expand Down
15 changes: 14 additions & 1 deletion web/src/components/projects/pm-wizard-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WizardState> }
| { 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
Expand Down Expand Up @@ -240,6 +244,15 @@ export const wizardReducer: Reducer<WizardState, WizardAction> = (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;
}
Expand Down
35 changes: 34 additions & 1 deletion web/src/components/projects/pm-wizard-trello-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,17 @@ export function TrelloFieldMappingStep({
dispatch,
onCreateLabel,
onCreateAllMissingLabels,
onCreateCostField,
creatingSlot,
creatingCostField,
}: {
state: WizardState;
dispatch: React.Dispatch<WizardAction>;
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()),
Expand Down Expand Up @@ -298,7 +302,36 @@ export function TrelloFieldMappingStep({

{/* Cost custom field */}
<div className="space-y-2">
<Label>Custom Field: Cost</Label>
<div className="flex items-center justify-between">
<Label>Custom Field: Cost</Label>
{(() => {
const existingCostField = state.trelloBoardDetails?.customFields.some(
(f) => f.type === 'number' && f.name.toLowerCase() === 'cost',
);
const showCreateCostButton =
state.trelloBoardDetails &&
onCreateCostField &&
!state.trelloCostFieldId &&
!existingCostField;
return showCreateCostButton ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={onCreateCostField}
disabled={creatingCostField}
className="h-7 text-xs"
>
{creatingCostField ? (
<Loader2 className="h-3 w-3 animate-spin mr-1" />
) : (
<Plus className="h-3 w-3 mr-1" />
)}
Create
</Button>
) : null;
})()}
</div>
{state.trelloBoardDetails ? (
<FieldMappingRow
slotLabel="cost"
Expand Down
12 changes: 12 additions & 0 deletions web/src/components/projects/pm-wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SaveStep, WebhookStep } from './pm-wizard-common-steps.js';
import {
useJiraDiscovery,
useSaveMutation,
useTrelloCustomFieldCreation,
useTrelloDiscovery,
useTrelloLabelCreation,
useVerification,
Expand Down Expand Up @@ -71,6 +72,7 @@ export function PMWizard({
const [state, dispatch] = useReducer(wizardReducer, undefined, createInitialState);
const [openSteps, setOpenSteps] = useState<Set<number>>(new Set([1]));
const [creatingSlot, setCreatingSlot] = useState<string | null>(null);
const [creatingCostField, setCreatingCostField] = useState(false);

// ---- Step navigation helpers ----

Expand Down Expand Up @@ -120,6 +122,7 @@ export function PMWizard({
state,
dispatch,
);
const { createCustomFieldMutation } = useTrelloCustomFieldCreation(state, dispatch);
const webhookManagement = useWebhookManagement(projectId, state);
const { saveMutation } = useSaveMutation(projectId, state);

Expand All @@ -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()),
Expand Down Expand Up @@ -305,7 +315,9 @@ export function PMWizard({
dispatch={dispatch}
onCreateLabel={handleCreateLabel}
onCreateAllMissingLabels={handleCreateAllMissingLabels}
onCreateCostField={handleCreateCostField}
creatingSlot={creatingSlot}
creatingCostField={creatingCostField}
/>
) : (
<JiraFieldMappingStep state={state} dispatch={dispatch} />
Expand Down
Loading