Skip to content

Commit 112442a

Browse files
authored
Merge pull request #212 from pneumaticapp/frontend/template/46553__checkboxes_toggle_wrong_task_in_template_editor
46553 frontend [ Templates ] Checkboxes toggle wrong task in template editor
2 parents 62bd0d3 + 44fefb7 commit 112442a

4 files changed

Lines changed: 227 additions & 3 deletions

File tree

frontend/src/public/components/TemplateEdit/TaskForm/TaskPerformers.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,15 +113,15 @@ export function TaskPerformers({ task, users, variables, setCurrentTask }: ITask
113113
<div className={classNames(styles['task-fields-wrapper'], stylesTaskForm['content-mt16'])}>
114114
<div className="mb-3">
115115
<Checkbox
116-
checkboxId="completeByAll"
116+
checkboxId={`completeByAll-${task.apiName}`}
117117
title={formatMessage({ id: 'templates.task-require-completion-by-all' })}
118118
checked={task.requireCompletionByAll}
119119
onChange={(e) => handleRequireCompletionByAllChange(e.currentTarget.checked)}
120120
/>
121121
</div>
122122
<div className="mb-3">
123123
<Checkbox
124-
checkboxId="skipForStarter"
124+
checkboxId={`skipForStarter-${task.apiName}`}
125125
title={formatMessage({ id: 'templates.task-skip-for-starter' })}
126126
checked={task.skipForStarter}
127127
onChange={(e) => setCurrentTask({ skipForStarter: e.currentTarget.checked })}

frontend/src/public/components/TemplateEdit/TaskForm/__tests__/TaskPerformers.test.tsx

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,130 @@ describe('TaskPerformers', () => {
8787
const getSkipCheckboxCall = () => {
8888
const calls = (Checkbox as jest.Mock).mock.calls;
8989
return calls.find(
90-
(c: any[]) => c[0].checkboxId === 'skipForStarter',
90+
(c: any[]) => c[0].checkboxId === 'skipForStarter-task-1',
9191
);
9292
};
9393

94+
const getCompleteByAllCall = () => {
95+
const calls = (Checkbox as jest.Mock).mock.calls;
96+
return calls.find(
97+
(c: any[]) => c[0].checkboxId === 'completeByAll-task-1',
98+
);
99+
};
100+
101+
const renderTwoTasks = () => {
102+
const task1 = makeTask({ apiName: 'task-1' });
103+
const task2 = makeTask({ apiName: 'task-2' });
104+
const setCurrentTask1 = jest.fn();
105+
const setCurrentTask2 = jest.fn();
106+
107+
render(
108+
React.createElement('div', null,
109+
React.createElement(TaskPerformers, {
110+
task: task1,
111+
tasks: [task1, task2],
112+
users: [],
113+
variables: [],
114+
isTeamInvitesModalOpen: false,
115+
setCurrentTask: setCurrentTask1,
116+
}),
117+
React.createElement(TaskPerformers, {
118+
task: task2,
119+
tasks: [task1, task2],
120+
users: [],
121+
variables: [],
122+
isTeamInvitesModalOpen: false,
123+
setCurrentTask: setCurrentTask2,
124+
}),
125+
),
126+
);
127+
128+
return { setCurrentTask1, setCurrentTask2 };
129+
};
130+
94131
beforeEach(() => {
95132
jest.clearAllMocks();
96133
});
97134

135+
describe('checkbox identifiers', () => {
136+
it('generates unique checkboxId using task.apiName to prevent HTML id collisions', () => {
137+
renderComponent({ apiName: 'custom-task-123' });
138+
139+
const calls = (Checkbox as jest.Mock).mock.calls;
140+
const completeByAllCall = calls.find((c: any[]) => c[0].checkboxId?.startsWith('completeByAll-'));
141+
const skipForStarterCall = calls.find((c: any[]) => c[0].checkboxId?.startsWith('skipForStarter-'));
142+
143+
expect(completeByAllCall).toBeDefined();
144+
expect(skipForStarterCall).toBeDefined();
145+
146+
expect(completeByAllCall![0].checkboxId).toBe('completeByAll-custom-task-123');
147+
expect(skipForStarterCall![0].checkboxId).toBe('skipForStarter-custom-task-123');
148+
});
149+
150+
it('two tasks produce different checkboxIds (no DOM id collisions)', () => {
151+
renderTwoTasks();
152+
153+
const calls = (Checkbox as jest.Mock).mock.calls;
154+
const checkboxIds = calls.map((c: any[]) => c[0].checkboxId);
155+
156+
const completeByAllIds = checkboxIds.filter((id: string) => id?.startsWith('completeByAll-'));
157+
const skipForStarterIds = checkboxIds.filter((id: string) => id?.startsWith('skipForStarter-'));
158+
159+
expect(completeByAllIds).toEqual(['completeByAll-task-1', 'completeByAll-task-2']);
160+
expect(skipForStarterIds).toEqual(['skipForStarter-task-1', 'skipForStarter-task-2']);
161+
});
162+
163+
it('uses checkboxId prop (not id) to ensure proper label-input binding', () => {
164+
renderComponent();
165+
166+
const calls = (Checkbox as jest.Mock).mock.calls;
167+
168+
calls.forEach((c: any[]) => {
169+
expect(c[0].checkboxId).toBeDefined();
170+
expect(c[0].id).toBeUndefined();
171+
});
172+
});
173+
});
174+
175+
describe('requireCompletionByAll checkbox', () => {
176+
it('passes title with correct localization text', () => {
177+
renderComponent();
178+
179+
const call = getCompleteByAllCall();
180+
181+
expect(call).toBeDefined();
182+
expect(call![0].title).toBe(t('templates.task-require-completion-by-all'));
183+
});
184+
185+
it('passes checked=true when requireCompletionByAll=true', () => {
186+
renderComponent({ requireCompletionByAll: true });
187+
188+
const call = getCompleteByAllCall();
189+
190+
expect(call![0].checked).toBe(true);
191+
});
192+
193+
it('passes checked=false when requireCompletionByAll=false', () => {
194+
renderComponent({ requireCompletionByAll: false });
195+
196+
const call = getCompleteByAllCall();
197+
198+
expect(call![0].checked).toBe(false);
199+
});
200+
201+
it('calls setCurrentTask with requireCompletionByAll on onChange', () => {
202+
renderComponent({ requireCompletionByAll: false });
203+
204+
const call = getCompleteByAllCall();
205+
const onChangeFn = call![0].onChange;
206+
onChangeFn({ currentTarget: { checked: true } });
207+
208+
expect(mockSetCurrentTask).toHaveBeenCalledWith(
209+
{ requireCompletionByAll: true },
210+
);
211+
});
212+
});
213+
98214
describe('skipForStarter checkbox', () => {
99215
it('passes title with correct localization text', () => {
100216
renderComponent();
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/// <reference types="jest" />
2+
import { mapOutputToCompleteTask } from '../mappers';
3+
import { EExtraFieldType, IExtraField } from '../../types/template';
4+
5+
jest.mock('../dateTime', () => ({
6+
getEndOfDayTsp: jest.fn((v: string) => `endOfDay(${v})`),
7+
toDateString: jest.fn(),
8+
toTspDate: jest.fn(),
9+
toISOStringFromTsp: jest.fn(),
10+
formatDateToISOInWorkflow: jest.fn(),
11+
formatDateToISOInTask: jest.fn(),
12+
}));
13+
14+
const makeField = (overrides: Partial<IExtraField>): IExtraField => ({
15+
apiName: 'field-1',
16+
name: 'Test',
17+
type: EExtraFieldType.String,
18+
order: 0,
19+
userId: null,
20+
groupId: null,
21+
...overrides,
22+
});
23+
24+
describe('mapOutputToCompleteTask', () => {
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
29+
it('returns empty array as-is', () => {
30+
expect(mapOutputToCompleteTask([])).toEqual([]);
31+
});
32+
33+
describe('checkbox normalization', () => {
34+
it('splits a single-option string into array', () => {
35+
const output = [makeField({ type: EExtraFieldType.Checkbox, value: 'New option' })];
36+
const [first] = mapOutputToCompleteTask(output);
37+
expect(first.value).toEqual(['New option']);
38+
});
39+
40+
it('splits a multi-option comma-separated string into array', () => {
41+
const output = [makeField({ type: EExtraFieldType.Checkbox, value: 'opt1, opt2, opt3' })];
42+
const [first] = mapOutputToCompleteTask(output);
43+
expect(first.value).toEqual(['opt1', 'opt2', 'opt3']);
44+
});
45+
46+
it('keeps array value as-is', () => {
47+
const output = [makeField({ type: EExtraFieldType.Checkbox, value: ['opt1', 'opt2'] })];
48+
const [first] = mapOutputToCompleteTask(output);
49+
expect(first.value).toEqual(['opt1', 'opt2']);
50+
});
51+
52+
it.each([null, undefined, ''])('returns empty array for falsy value: %p', (falsy) => {
53+
const output = [makeField({ type: EExtraFieldType.Checkbox, value: falsy as any })];
54+
const [first] = mapOutputToCompleteTask(output);
55+
expect(first.value).toEqual([]);
56+
});
57+
58+
it('keeps empty array as empty array', () => {
59+
const output = [makeField({ type: EExtraFieldType.Checkbox, value: [] })];
60+
const [first] = mapOutputToCompleteTask(output);
61+
expect(first.value).toEqual([]);
62+
});
63+
64+
it('preserves other field properties', () => {
65+
const output = [makeField({
66+
apiName: 'cb-field',
67+
name: 'My Checkbox',
68+
type: EExtraFieldType.Checkbox,
69+
value: 'opt',
70+
isRequired: true,
71+
})];
72+
const [first] = mapOutputToCompleteTask(output);
73+
expect(first).toEqual(expect.objectContaining({
74+
apiName: 'cb-field',
75+
name: 'My Checkbox',
76+
type: 'checkbox',
77+
value: ['opt'],
78+
isRequired: true,
79+
}));
80+
});
81+
});
82+
83+
it('handles mixed field types in one output array', () => {
84+
const output = [
85+
makeField({ apiName: 'cb', type: EExtraFieldType.Checkbox, value: 'opt1, opt2' }),
86+
makeField({ apiName: 'num', type: EExtraFieldType.Number, value: '1,5' }),
87+
makeField({ apiName: 'txt', type: EExtraFieldType.Text, value: 'hi' }),
88+
];
89+
const result = mapOutputToCompleteTask(output);
90+
expect(result).toEqual([
91+
expect.objectContaining({ apiName: 'cb', value: ['opt1', 'opt2'] }),
92+
expect.objectContaining({ apiName: 'num', value: '1.5' }),
93+
expect.objectContaining({ apiName: 'txt', value: 'hi' }),
94+
]);
95+
});
96+
});

frontend/src/public/utils/mappers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,18 @@ export const mapOutputToCompleteTask = (output: IExtraField[]): IExtraField[] =>
106106
value: String(item.value).replace(',', '.'),
107107
};
108108
}
109+
if (item.type === 'checkbox') {
110+
let checkboxValue: string[];
111+
if (Array.isArray(item.value)) {
112+
checkboxValue = item.value;
113+
} else if (item.value) {
114+
checkboxValue = (item.value as string).split(', ');
115+
} else {
116+
checkboxValue = [];
117+
}
118+
119+
return { ...item, value: checkboxValue };
120+
}
109121
return item;
110122
});
111123
};

0 commit comments

Comments
 (0)