Skip to content

Commit 3c61d69

Browse files
authored
Merge pull request #517 from CodeForPhilly/515-checkbox-group-behavior-on-forms
Add checkbox group with None and distinguish unanswered arrays
2 parents 679493a + 8e3cec4 commit 3c61d69

12 files changed

Lines changed: 382 additions & 13 deletions

File tree

‎builder-api/src/main/java/org/acme/service/FormDataTransformer.java‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -230,19 +230,29 @@ public static Map<String, Object> transformEnrollmentsData(Map<String, Object> f
230230
List<Map<String, Object>> peopleArray = (List<Map<String, Object>>) peopleValue;
231231
List<Map<String, Object>> enrollmentsList = new ArrayList<>();
232232
List<Map<String, Object>> transformedPeopleArray = new ArrayList<>();
233+
boolean hasEnrollmentAnswer = false;
234+
boolean hasUnansweredEnrollments = false;
233235

234236
// Extract enrollments from each person
235237
for (Map<String, Object> person : peopleArray) {
236238
String personId = (String) person.get("id");
237239
Object personEnrollments = person.get("enrollments");
240+
if (person.containsKey("enrollments")) {
241+
hasEnrollmentAnswer = true;
242+
if (personEnrollments == null) {
243+
hasUnansweredEnrollments = true;
244+
}
245+
}
238246

239247
// Create a copy of the person data without enrollments
240248
Map<String, Object> personCopy = new HashMap<>(person);
241249

242-
if (personEnrollments instanceof List) {
243-
// Remove enrollments from person object (DMN doesn't expect it there)
250+
if (person.containsKey("enrollments")) {
251+
// The DMN receives a flat enrollments field, not per-person fields.
244252
personCopy.remove("enrollments");
253+
}
245254

255+
if (personEnrollments instanceof List) {
246256
// Convert each enrollment string to an enrollment object
247257
for (Object enrollment : (List<?>) personEnrollments) {
248258
if (enrollment instanceof String) {
@@ -261,9 +271,11 @@ public static Map<String, Object> transformEnrollmentsData(Map<String, Object> f
261271
Map<String, Object> result = new HashMap<>(formData);
262272
result.put("people", transformedPeopleArray);
263273

264-
// Only add enrollments if we extracted any
265-
if (!enrollmentsList.isEmpty()) {
266-
result.put("enrollments", enrollmentsList);
274+
// Preserve the distinction between unanswered (null), an explicit
275+
// "None of these" answer ([]), and selected enrollments. If any
276+
// person's answer is unknown, the combined answer is still unknown.
277+
if (hasEnrollmentAnswer) {
278+
result.put("enrollments", hasUnansweredEnrollments ? null : enrollmentsList);
267279
}
268280

269281
return result;

‎builder-api/src/test/java/org/acme/service/FormDataTransformerTest.java‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,7 @@ void transformEnrollmentsData_withNoEnrollments_doesNotAddEnrollmentsKey() {
384384
}
385385

386386
@Test
387-
void transformEnrollmentsData_withEmptyEnrollmentsArray_doesNotAddEnrollmentsKey() {
387+
void transformEnrollmentsData_withEmptyEnrollmentsArray_preservesExplicitNone() {
388388
Map<String, Object> person = new HashMap<>();
389389
person.put("id", "applicant");
390390
person.put("enrollments", new ArrayList<>());
@@ -397,7 +397,39 @@ void transformEnrollmentsData_withEmptyEnrollmentsArray_doesNotAddEnrollmentsKey
397397

398398
Map<String, Object> result = FormDataTransformer.transformEnrollmentsData(formData);
399399

400-
assertFalse(result.containsKey("enrollments"));
400+
assertEquals(List.of(), result.get("enrollments"));
401+
}
402+
403+
@Test
404+
void transformEnrollmentsData_withNullEnrollments_preservesUnanswered() {
405+
Map<String, Object> person = new HashMap<>();
406+
person.put("id", "applicant");
407+
person.put("enrollments", null);
408+
409+
Map<String, Object> result = FormDataTransformer.transformEnrollmentsData(
410+
Map.of("people", List.of(person)));
411+
412+
assertTrue(result.containsKey("enrollments"));
413+
assertNull(result.get("enrollments"));
414+
@SuppressWarnings("unchecked")
415+
List<Map<String, Object>> resultPeople = (List<Map<String, Object>>) result.get("people");
416+
assertFalse(resultPeople.getFirst().containsKey("enrollments"));
417+
}
418+
419+
@Test
420+
void transformEnrollmentsData_withOneUnansweredPerson_doesNotInferNone() {
421+
Map<String, Object> applicant = new HashMap<>();
422+
applicant.put("id", "applicant");
423+
applicant.put("enrollments", List.of("SNAP"));
424+
Map<String, Object> spouse = new HashMap<>();
425+
spouse.put("id", "spouse");
426+
spouse.put("enrollments", null);
427+
428+
Map<String, Object> result = FormDataTransformer.transformEnrollmentsData(
429+
Map.of("people", List.of(applicant, spouse)));
430+
431+
assertTrue(result.containsKey("enrollments"));
432+
assertNull(result.get("enrollments"));
401433
}
402434

403435
@Test
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, describe, expect, it } from 'vitest';
3+
4+
import { Form } from '@bpmn-io/form-js-viewer';
5+
import CustomFormFieldsModule from './index';
6+
import { NONE_OF_THESE_VALUE } from './ChecklistWithNone';
7+
import { normalizeArrayFieldData } from '@/utils/arrayFieldData';
8+
9+
let form: Form | undefined;
10+
11+
afterEach(() => {
12+
form?.destroy();
13+
form = undefined;
14+
document.body.innerHTML = '';
15+
});
16+
17+
describe('ChecklistWithNone', () => {
18+
it.each([
19+
{
20+
source: 'input data',
21+
options: { valuesKey: 'benefitOptions' },
22+
data: { benefitOptions: [{ label: 'Medicaid', value: 'Medicaid' }] },
23+
},
24+
{
25+
source: 'expression',
26+
options: { valuesExpression: '=[{"label":"Medicaid","value":"Medicaid"}]' },
27+
data: {},
28+
},
29+
])('places None last when options come from $source', async ({ options, data }) => {
30+
const schema = {
31+
components: [{
32+
type: 'checklist_none',
33+
id: 'Field_enrollments',
34+
key: 'enrollments',
35+
label: 'Benefits',
36+
description: 'Choose all that apply.',
37+
...options,
38+
}],
39+
type: 'default',
40+
id: 'Form_test',
41+
schemaVersion: 18,
42+
};
43+
const container = document.body.appendChild(document.createElement('div'));
44+
form = new Form({ container, additionalModules: [CustomFormFieldsModule] });
45+
await form.importSchema(schema, data);
46+
47+
const optionRows = Array.from(container.querySelectorAll('.fjs-form-field-checklist > .fjs-inline-label'));
48+
expect(optionRows.map((row) => row.textContent?.trim())).toEqual(['Medicaid', 'None of these']);
49+
expect(optionRows[1].nextElementSibling?.textContent).toContain('Choose all that apply.');
50+
});
51+
52+
it('makes None exclusive and preserves the answer across reimport', async () => {
53+
const schema = {
54+
components: [{
55+
type: 'checklist_none',
56+
id: 'Field_enrollments',
57+
key: 'enrollments',
58+
label: 'Benefits',
59+
description: 'Choose all that apply.',
60+
values: [{ label: 'SNAP', value: 'SNAP' }],
61+
}],
62+
type: 'default',
63+
id: 'Form_test',
64+
schemaVersion: 18,
65+
};
66+
const container = document.body.appendChild(document.createElement('div'));
67+
form = new Form({ container, additionalModules: [CustomFormFieldsModule] });
68+
await form.importSchema(schema, {});
69+
70+
const checkboxes = () => Array.from(container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'));
71+
expect(checkboxes()).toHaveLength(2);
72+
const optionRows = Array.from(container.querySelectorAll('.fjs-form-field-checklist > .fjs-inline-label'));
73+
expect(optionRows.map((row) => row.textContent?.trim())).toEqual(['SNAP', 'None of these']);
74+
expect(optionRows[1].nextElementSibling?.textContent).toContain('Choose all that apply.');
75+
76+
checkboxes()[1].click();
77+
await new Promise((resolve) => setTimeout(resolve, 0));
78+
expect(form._getState().data.enrollments).toEqual([NONE_OF_THESE_VALUE]);
79+
80+
await form.importSchema(schema, form._getState().data);
81+
expect(checkboxes()[1].checked).toBe(true);
82+
83+
checkboxes()[0].click();
84+
await new Promise((resolve) => setTimeout(resolve, 0));
85+
expect(form._getState().data.enrollments).toEqual(['SNAP']);
86+
expect(checkboxes()[1].checked).toBe(false);
87+
88+
checkboxes()[1].click();
89+
await new Promise((resolve) => setTimeout(resolve, 0));
90+
expect(form._getState().data.enrollments).toEqual([NONE_OF_THESE_VALUE]);
91+
expect(checkboxes()[0].checked).toBe(false);
92+
93+
expect(normalizeArrayFieldData(schema, form._getState().data).enrollments).toEqual([]);
94+
checkboxes()[1].click();
95+
await new Promise((resolve) => setTimeout(resolve, 0));
96+
expect(normalizeArrayFieldData(schema, form._getState().data).enrollments).toBeNull();
97+
});
98+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { html } from 'htm/preact';
2+
import { useContext } from 'preact/hooks';
3+
4+
import { Checklist, FormContext, iconsByType, useExpressionEvaluation } from '@bpmn-io/form-js-viewer';
5+
6+
export const CHECKLIST_WITH_NONE_TYPE = 'checklist_none';
7+
export const NONE_OF_THESE_VALUE = '__bdt_none_of_these__';
8+
9+
/**
10+
* The marker distinguishes an explicit "None" answer from form-js's default
11+
* empty checklist value. It is converted to [] before a screener is evaluated.
12+
*/
13+
export function ChecklistWithNone(props: any) {
14+
const { field, value } = props;
15+
const { values, valuesExpression, valuesKey } = field;
16+
const { getService } = useContext(FormContext);
17+
const initialData = getService('form')._getState().initialData;
18+
const evaluatedOptions = useExpressionEvaluation(valuesExpression);
19+
const noneSelected = Array.isArray(value) && value.includes(NONE_OF_THESE_VALUE);
20+
// Resolve options in the same order as form-js's Checklist: input data,
21+
// static values, then expression. Pass the result back to Checklist as
22+
// static values so its built-in renderer places None in the same list.
23+
const options = valuesKey !== undefined
24+
? initialData?.[valuesKey]
25+
: values !== undefined ? values : evaluatedOptions;
26+
const { valuesKey: _valuesKey, valuesExpression: _valuesExpression, ...fieldWithoutSource } = field;
27+
const fieldWithNone = {
28+
...fieldWithoutSource,
29+
values: [
30+
...(Array.isArray(options) ? options : []).filter(
31+
(option: any) => option?.value !== NONE_OF_THESE_VALUE && option !== NONE_OF_THESE_VALUE
32+
),
33+
{ label: 'None of these', value: NONE_OF_THESE_VALUE },
34+
],
35+
};
36+
37+
return html`
38+
<${Checklist}
39+
...${props}
40+
field=${fieldWithNone}
41+
onChange=${({ value: selections }: { value: unknown[] }) => {
42+
const selectedNone = selections.includes(NONE_OF_THESE_VALUE);
43+
props.onChange({ value: selectedNone
44+
? noneSelected
45+
? selections.filter((selection) => selection !== NONE_OF_THESE_VALUE)
46+
: [NONE_OF_THESE_VALUE]
47+
: selections });
48+
}}
49+
/>
50+
`;
51+
}
52+
53+
ChecklistWithNone.config = {
54+
...Checklist.config,
55+
type: CHECKLIST_WITH_NONE_TYPE,
56+
name: 'Checkbox group with None',
57+
label: 'Checkbox group with None',
58+
icon: iconsByType('checklist'),
59+
// Preserve the internal marker when form-js imports saved answers.
60+
sanitizeValue: ({ value, ...options }: any) =>
61+
Array.isArray(value) && value.includes(NONE_OF_THESE_VALUE)
62+
? [NONE_OF_THESE_VALUE]
63+
: Checklist.config.sanitizeValue({ value, ...options }),
64+
create: (options: any = {}) => ({
65+
...Checklist.config.create(options),
66+
label: 'Checkbox group with None',
67+
...options
68+
}),
69+
propertiesPanelEntries: [
70+
'key', 'label', 'description', 'values', 'required', 'disabled', 'readonly'
71+
]
72+
};

‎builder-frontend/src/components/project/formJsExtensions/customFormFields/index.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { YesNoQuestion } from './YesNoQuestion';
2+
import { ChecklistWithNone } from './ChecklistWithNone';
23

34
/*
45
* This is a module definition to register custom
@@ -7,6 +8,7 @@ import { YesNoQuestion } from './YesNoQuestion';
78
class CustomFormFieldsModule {
89
constructor(formFields) {
910
formFields.register(YesNoQuestion.config.type, YesNoQuestion);
11+
formFields.register(ChecklistWithNone.config.type, ChecklistWithNone);
1012
}
1113
}
1214

‎builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ describe("isTypeCompatible", () => {
1010
expect(isTypeCompatible("boolean", "checklist")).toBe(false);
1111
expect(isTypeCompatible("boolean", "radio")).toBe(false);
1212
expect(isTypeCompatible("boolean", "select")).toBe(false);
13+
expect(isTypeCompatible("boolean", "checklist_none")).toBe(false);
14+
});
15+
16+
it("offers array paths to checkbox groups with None", () => {
17+
expect(isTypeCompatible("array:string", "checklist_none")).toBe(true);
18+
expect(isTypeCompatible("array:number", "checklist_none")).toBe(true);
1319
});
1420
});
1521

‎builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ const TYPE_COMPATIBILITY: Record<string, string[]> = {
1919
'date-time': ['datetime'],
2020
'time': ['datetime'],
2121
// Array types (arrays of primitives)
22-
'array:string': ['checklist', 'taglist', 'select'],
23-
'array:number': ['checklist', 'taglist', 'select'],
24-
'array:boolean': ['checklist'],
22+
'array:string': ['checklist', 'checklist_none', 'taglist', 'select'],
23+
'array:number': ['checklist', 'checklist_none', 'taglist', 'select'],
24+
'array:boolean': ['checklist', 'checklist_none'],
2525
// Fallback for any/unknown types - compatible with all
26-
'any': ['textfield', 'textarea', 'number', 'checkbox', 'select', 'radio', 'checklist', 'taglist', 'datetime', 'yes_no'],
26+
'any': ['textfield', 'textarea', 'number', 'checkbox', 'select', 'radio', 'checklist', 'checklist_none', 'taglist', 'datetime', 'yes_no'],
2727
};
2828

2929
interface EventBus {

‎builder-frontend/src/components/project/preview/FormRenderer.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Form } from "@bpmn-io/form-js-viewer";
88
import { PreviewFormData } from "./types";
99
import CustomFormFieldsModule from "../formJsExtensions/customFormFields";
1010
import { hideQuestions } from "@/utils/questionVotes";
11+
import { normalizeArrayFieldData } from "@/utils/arrayFieldData";
1112

1213
import "@bpmn-io/form-js/dist/assets/form-js.css";
1314

@@ -56,7 +57,7 @@ function FormRenderer({
5657
currentData = cloneDeep(formData());
5758

5859
const debouncedSubmit = debounce(
59-
(data: PreviewFormData) => submitForm(data),
60+
(data: PreviewFormData) => submitForm(normalizeArrayFieldData(schema(), data)),
6061
500,
6162
);
6263

‎builder-frontend/src/components/screener/FormRenderer.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { State } from "@bpmn-io/form-js-viewer/dist/types/Form";
88

99
import CustomFormFieldsModule from "../project/formJsExtensions/customFormFields";
1010
import { hideQuestions } from "@/utils/questionVotes";
11+
import { normalizeArrayFieldData } from "@/utils/arrayFieldData";
1112

1213
import "@bpmn-io/form-js/dist/assets/form-js.css";
1314

@@ -52,7 +53,7 @@ function FormRenderer({
5253
currentData = cloneDeep(formData());
5354

5455
const debouncedSubmit = debounce((data) => {
55-
submitForm(data);
56+
submitForm(normalizeArrayFieldData(schema, data));
5657
}, 1000);
5758

5859
form

0 commit comments

Comments
 (0)