diff --git a/builder-api/src/main/java/org/acme/service/FormDataTransformer.java b/builder-api/src/main/java/org/acme/service/FormDataTransformer.java index b4cd73d9..6d6c715e 100644 --- a/builder-api/src/main/java/org/acme/service/FormDataTransformer.java +++ b/builder-api/src/main/java/org/acme/service/FormDataTransformer.java @@ -230,19 +230,29 @@ public static Map transformEnrollmentsData(Map f List> peopleArray = (List>) peopleValue; List> enrollmentsList = new ArrayList<>(); List> transformedPeopleArray = new ArrayList<>(); + boolean hasEnrollmentAnswer = false; + boolean hasUnansweredEnrollments = false; // Extract enrollments from each person for (Map person : peopleArray) { String personId = (String) person.get("id"); Object personEnrollments = person.get("enrollments"); + if (person.containsKey("enrollments")) { + hasEnrollmentAnswer = true; + if (personEnrollments == null) { + hasUnansweredEnrollments = true; + } + } // Create a copy of the person data without enrollments Map personCopy = new HashMap<>(person); - if (personEnrollments instanceof List) { - // Remove enrollments from person object (DMN doesn't expect it there) + if (person.containsKey("enrollments")) { + // The DMN receives a flat enrollments field, not per-person fields. personCopy.remove("enrollments"); + } + if (personEnrollments instanceof List) { // Convert each enrollment string to an enrollment object for (Object enrollment : (List) personEnrollments) { if (enrollment instanceof String) { @@ -261,9 +271,11 @@ public static Map transformEnrollmentsData(Map f Map result = new HashMap<>(formData); result.put("people", transformedPeopleArray); - // Only add enrollments if we extracted any - if (!enrollmentsList.isEmpty()) { - result.put("enrollments", enrollmentsList); + // Preserve the distinction between unanswered (null), an explicit + // "None of these" answer ([]), and selected enrollments. If any + // person's answer is unknown, the combined answer is still unknown. + if (hasEnrollmentAnswer) { + result.put("enrollments", hasUnansweredEnrollments ? null : enrollmentsList); } return result; diff --git a/builder-api/src/test/java/org/acme/service/FormDataTransformerTest.java b/builder-api/src/test/java/org/acme/service/FormDataTransformerTest.java index d7d93907..d8b4a3c2 100644 --- a/builder-api/src/test/java/org/acme/service/FormDataTransformerTest.java +++ b/builder-api/src/test/java/org/acme/service/FormDataTransformerTest.java @@ -384,7 +384,7 @@ void transformEnrollmentsData_withNoEnrollments_doesNotAddEnrollmentsKey() { } @Test - void transformEnrollmentsData_withEmptyEnrollmentsArray_doesNotAddEnrollmentsKey() { + void transformEnrollmentsData_withEmptyEnrollmentsArray_preservesExplicitNone() { Map person = new HashMap<>(); person.put("id", "applicant"); person.put("enrollments", new ArrayList<>()); @@ -397,7 +397,39 @@ void transformEnrollmentsData_withEmptyEnrollmentsArray_doesNotAddEnrollmentsKey Map result = FormDataTransformer.transformEnrollmentsData(formData); - assertFalse(result.containsKey("enrollments")); + assertEquals(List.of(), result.get("enrollments")); + } + + @Test + void transformEnrollmentsData_withNullEnrollments_preservesUnanswered() { + Map person = new HashMap<>(); + person.put("id", "applicant"); + person.put("enrollments", null); + + Map result = FormDataTransformer.transformEnrollmentsData( + Map.of("people", List.of(person))); + + assertTrue(result.containsKey("enrollments")); + assertNull(result.get("enrollments")); + @SuppressWarnings("unchecked") + List> resultPeople = (List>) result.get("people"); + assertFalse(resultPeople.getFirst().containsKey("enrollments")); + } + + @Test + void transformEnrollmentsData_withOneUnansweredPerson_doesNotInferNone() { + Map applicant = new HashMap<>(); + applicant.put("id", "applicant"); + applicant.put("enrollments", List.of("SNAP")); + Map spouse = new HashMap<>(); + spouse.put("id", "spouse"); + spouse.put("enrollments", null); + + Map result = FormDataTransformer.transformEnrollmentsData( + Map.of("people", List.of(applicant, spouse))); + + assertTrue(result.containsKey("enrollments")); + assertNull(result.get("enrollments")); } @Test diff --git a/builder-frontend/src/components/project/formJsExtensions/customFormFields/ChecklistWithNone.test.ts b/builder-frontend/src/components/project/formJsExtensions/customFormFields/ChecklistWithNone.test.ts new file mode 100644 index 00000000..29646f6b --- /dev/null +++ b/builder-frontend/src/components/project/formJsExtensions/customFormFields/ChecklistWithNone.test.ts @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; + +import { Form } from '@bpmn-io/form-js-viewer'; +import CustomFormFieldsModule from './index'; +import { NONE_OF_THESE_VALUE } from './ChecklistWithNone'; +import { normalizeArrayFieldData } from '@/utils/arrayFieldData'; + +let form: Form | undefined; + +afterEach(() => { + form?.destroy(); + form = undefined; + document.body.innerHTML = ''; +}); + +describe('ChecklistWithNone', () => { + it.each([ + { + source: 'input data', + options: { valuesKey: 'benefitOptions' }, + data: { benefitOptions: [{ label: 'Medicaid', value: 'Medicaid' }] }, + }, + { + source: 'expression', + options: { valuesExpression: '=[{"label":"Medicaid","value":"Medicaid"}]' }, + data: {}, + }, + ])('places None last when options come from $source', async ({ options, data }) => { + const schema = { + components: [{ + type: 'checklist_none', + id: 'Field_enrollments', + key: 'enrollments', + label: 'Benefits', + description: 'Choose all that apply.', + ...options, + }], + type: 'default', + id: 'Form_test', + schemaVersion: 18, + }; + const container = document.body.appendChild(document.createElement('div')); + form = new Form({ container, additionalModules: [CustomFormFieldsModule] }); + await form.importSchema(schema, data); + + const optionRows = Array.from(container.querySelectorAll('.fjs-form-field-checklist > .fjs-inline-label')); + expect(optionRows.map((row) => row.textContent?.trim())).toEqual(['Medicaid', 'None of these']); + expect(optionRows[1].nextElementSibling?.textContent).toContain('Choose all that apply.'); + }); + + it('makes None exclusive and preserves the answer across reimport', async () => { + const schema = { + components: [{ + type: 'checklist_none', + id: 'Field_enrollments', + key: 'enrollments', + label: 'Benefits', + description: 'Choose all that apply.', + values: [{ label: 'SNAP', value: 'SNAP' }], + }], + type: 'default', + id: 'Form_test', + schemaVersion: 18, + }; + const container = document.body.appendChild(document.createElement('div')); + form = new Form({ container, additionalModules: [CustomFormFieldsModule] }); + await form.importSchema(schema, {}); + + const checkboxes = () => Array.from(container.querySelectorAll('input[type="checkbox"]')); + expect(checkboxes()).toHaveLength(2); + const optionRows = Array.from(container.querySelectorAll('.fjs-form-field-checklist > .fjs-inline-label')); + expect(optionRows.map((row) => row.textContent?.trim())).toEqual(['SNAP', 'None of these']); + expect(optionRows[1].nextElementSibling?.textContent).toContain('Choose all that apply.'); + + checkboxes()[1].click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(form._getState().data.enrollments).toEqual([NONE_OF_THESE_VALUE]); + + await form.importSchema(schema, form._getState().data); + expect(checkboxes()[1].checked).toBe(true); + + checkboxes()[0].click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(form._getState().data.enrollments).toEqual(['SNAP']); + expect(checkboxes()[1].checked).toBe(false); + + checkboxes()[1].click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(form._getState().data.enrollments).toEqual([NONE_OF_THESE_VALUE]); + expect(checkboxes()[0].checked).toBe(false); + + expect(normalizeArrayFieldData(schema, form._getState().data).enrollments).toEqual([]); + checkboxes()[1].click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(normalizeArrayFieldData(schema, form._getState().data).enrollments).toBeNull(); + }); +}); diff --git a/builder-frontend/src/components/project/formJsExtensions/customFormFields/ChecklistWithNone.ts b/builder-frontend/src/components/project/formJsExtensions/customFormFields/ChecklistWithNone.ts new file mode 100644 index 00000000..bd616000 --- /dev/null +++ b/builder-frontend/src/components/project/formJsExtensions/customFormFields/ChecklistWithNone.ts @@ -0,0 +1,72 @@ +import { html } from 'htm/preact'; +import { useContext } from 'preact/hooks'; + +import { Checklist, FormContext, iconsByType, useExpressionEvaluation } from '@bpmn-io/form-js-viewer'; + +export const CHECKLIST_WITH_NONE_TYPE = 'checklist_none'; +export const NONE_OF_THESE_VALUE = '__bdt_none_of_these__'; + +/** + * The marker distinguishes an explicit "None" answer from form-js's default + * empty checklist value. It is converted to [] before a screener is evaluated. + */ +export function ChecklistWithNone(props: any) { + const { field, value } = props; + const { values, valuesExpression, valuesKey } = field; + const { getService } = useContext(FormContext); + const initialData = getService('form')._getState().initialData; + const evaluatedOptions = useExpressionEvaluation(valuesExpression); + const noneSelected = Array.isArray(value) && value.includes(NONE_OF_THESE_VALUE); + // Resolve options in the same order as form-js's Checklist: input data, + // static values, then expression. Pass the result back to Checklist as + // static values so its built-in renderer places None in the same list. + const options = valuesKey !== undefined + ? initialData?.[valuesKey] + : values !== undefined ? values : evaluatedOptions; + const { valuesKey: _valuesKey, valuesExpression: _valuesExpression, ...fieldWithoutSource } = field; + const fieldWithNone = { + ...fieldWithoutSource, + values: [ + ...(Array.isArray(options) ? options : []).filter( + (option: any) => option?.value !== NONE_OF_THESE_VALUE && option !== NONE_OF_THESE_VALUE + ), + { label: 'None of these', value: NONE_OF_THESE_VALUE }, + ], + }; + + return html` + <${Checklist} + ...${props} + field=${fieldWithNone} + onChange=${({ value: selections }: { value: unknown[] }) => { + const selectedNone = selections.includes(NONE_OF_THESE_VALUE); + props.onChange({ value: selectedNone + ? noneSelected + ? selections.filter((selection) => selection !== NONE_OF_THESE_VALUE) + : [NONE_OF_THESE_VALUE] + : selections }); + }} + /> + `; +} + +ChecklistWithNone.config = { + ...Checklist.config, + type: CHECKLIST_WITH_NONE_TYPE, + name: 'Checkbox group with None', + label: 'Checkbox group with None', + icon: iconsByType('checklist'), + // Preserve the internal marker when form-js imports saved answers. + sanitizeValue: ({ value, ...options }: any) => + Array.isArray(value) && value.includes(NONE_OF_THESE_VALUE) + ? [NONE_OF_THESE_VALUE] + : Checklist.config.sanitizeValue({ value, ...options }), + create: (options: any = {}) => ({ + ...Checklist.config.create(options), + label: 'Checkbox group with None', + ...options + }), + propertiesPanelEntries: [ + 'key', 'label', 'description', 'values', 'required', 'disabled', 'readonly' + ] +}; diff --git a/builder-frontend/src/components/project/formJsExtensions/customFormFields/index.js b/builder-frontend/src/components/project/formJsExtensions/customFormFields/index.js index 7156826d..eb6a00da 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customFormFields/index.js +++ b/builder-frontend/src/components/project/formJsExtensions/customFormFields/index.js @@ -1,4 +1,5 @@ import { YesNoQuestion } from './YesNoQuestion'; +import { ChecklistWithNone } from './ChecklistWithNone'; /* * This is a module definition to register custom @@ -7,6 +8,7 @@ import { YesNoQuestion } from './YesNoQuestion'; class CustomFormFieldsModule { constructor(formFields) { formFields.register(YesNoQuestion.config.type, YesNoQuestion); + formFields.register(ChecklistWithNone.config.type, ChecklistWithNone); } } diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.test.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.test.ts index 4525f307..8c294f03 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.test.ts +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.test.ts @@ -10,6 +10,12 @@ describe("isTypeCompatible", () => { expect(isTypeCompatible("boolean", "checklist")).toBe(false); expect(isTypeCompatible("boolean", "radio")).toBe(false); expect(isTypeCompatible("boolean", "select")).toBe(false); + expect(isTypeCompatible("boolean", "checklist_none")).toBe(false); + }); + + it("offers array paths to checkbox groups with None", () => { + expect(isTypeCompatible("array:string", "checklist_none")).toBe(true); + expect(isTypeCompatible("array:number", "checklist_none")).toBe(true); }); }); diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts index 83201079..f4e705cd 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts @@ -19,11 +19,11 @@ const TYPE_COMPATIBILITY: Record = { 'date-time': ['datetime'], 'time': ['datetime'], // Array types (arrays of primitives) - 'array:string': ['checklist', 'taglist', 'select'], - 'array:number': ['checklist', 'taglist', 'select'], - 'array:boolean': ['checklist'], + 'array:string': ['checklist', 'checklist_none', 'taglist', 'select'], + 'array:number': ['checklist', 'checklist_none', 'taglist', 'select'], + 'array:boolean': ['checklist', 'checklist_none'], // Fallback for any/unknown types - compatible with all - 'any': ['textfield', 'textarea', 'number', 'checkbox', 'select', 'radio', 'checklist', 'taglist', 'datetime', 'yes_no'], + 'any': ['textfield', 'textarea', 'number', 'checkbox', 'select', 'radio', 'checklist', 'checklist_none', 'taglist', 'datetime', 'yes_no'], }; interface EventBus { diff --git a/builder-frontend/src/components/project/preview/FormRenderer.tsx b/builder-frontend/src/components/project/preview/FormRenderer.tsx index aef3f415..8eb3aa24 100644 --- a/builder-frontend/src/components/project/preview/FormRenderer.tsx +++ b/builder-frontend/src/components/project/preview/FormRenderer.tsx @@ -8,6 +8,7 @@ import { Form } from "@bpmn-io/form-js-viewer"; import { PreviewFormData } from "./types"; import CustomFormFieldsModule from "../formJsExtensions/customFormFields"; import { hideQuestions } from "@/utils/questionVotes"; +import { normalizeArrayFieldData } from "@/utils/arrayFieldData"; import "@bpmn-io/form-js/dist/assets/form-js.css"; @@ -56,7 +57,7 @@ function FormRenderer({ currentData = cloneDeep(formData()); const debouncedSubmit = debounce( - (data: PreviewFormData) => submitForm(data), + (data: PreviewFormData) => submitForm(normalizeArrayFieldData(schema(), data)), 500, ); diff --git a/builder-frontend/src/components/screener/FormRenderer.tsx b/builder-frontend/src/components/screener/FormRenderer.tsx index 04ac6d10..0c262e36 100644 --- a/builder-frontend/src/components/screener/FormRenderer.tsx +++ b/builder-frontend/src/components/screener/FormRenderer.tsx @@ -8,6 +8,7 @@ import { State } from "@bpmn-io/form-js-viewer/dist/types/Form"; import CustomFormFieldsModule from "../project/formJsExtensions/customFormFields"; import { hideQuestions } from "@/utils/questionVotes"; +import { normalizeArrayFieldData } from "@/utils/arrayFieldData"; import "@bpmn-io/form-js/dist/assets/form-js.css"; @@ -52,7 +53,7 @@ function FormRenderer({ currentData = cloneDeep(formData()); const debouncedSubmit = debounce((data) => { - submitForm(data); + submitForm(normalizeArrayFieldData(schema, data)); }, 1000); form diff --git a/builder-frontend/src/utils/arrayFieldData.test.ts b/builder-frontend/src/utils/arrayFieldData.test.ts new file mode 100644 index 00000000..2fbdf277 --- /dev/null +++ b/builder-frontend/src/utils/arrayFieldData.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeArrayFieldData } from './arrayFieldData'; +import { NONE_OF_THESE_VALUE } from '@/components/project/formJsExtensions/customFormFields/ChecklistWithNone'; + +const schema = { + components: [{ + type: 'checklist_none', + key: 'people.client.enrollments', + }], +}; + +describe('normalizeArrayFieldData', () => { + it('distinguishes unanswered, explicit None, and selected values', () => { + expect(normalizeArrayFieldData(schema, { people: { client: {} } })).toEqual({ + people: { client: { enrollments: null } }, + }); + expect(normalizeArrayFieldData(schema, { + people: { client: { enrollments: [] } }, + })).toEqual({ people: { client: { enrollments: null } } }); + expect(normalizeArrayFieldData(schema, { + people: { client: { enrollments: [NONE_OF_THESE_VALUE] } }, + })).toEqual({ people: { client: { enrollments: [] } } }); + expect(normalizeArrayFieldData(schema, { + people: { client: { enrollments: ['SNAP', 'Medicaid'] } }, + })).toEqual({ + people: { client: { enrollments: ['SNAP', 'Medicaid'] } }, + }); + }); + + it('sends null for other empty array fields and does not mutate form-js data', () => { + const data = { + regular: [] as string[], + tags: ['a'], + name: '', + people: { client: { enrollments: [NONE_OF_THESE_VALUE] } }, + }; + const normalized = normalizeArrayFieldData({ + components: [ + ...schema.components, + { type: 'checklist', key: 'regular' }, + { type: 'taglist', key: 'tags' }, + { type: 'textfield', key: 'name' }, + ], + }, data); + + expect(normalized.regular).toBeNull(); + expect(normalized.tags).toEqual(['a']); + expect(normalized.name).toBe(''); + expect(normalized.people.client.enrollments).toEqual([]); + expect(data.people.client.enrollments).toEqual([NONE_OF_THESE_VALUE]); + }); + + it('does not create missing parent objects or lists', () => { + expect(normalizeArrayFieldData(schema, { people: {} })).toEqual({ people: {} }); + expect(normalizeArrayFieldData({ + components: [{ + type: 'dynamiclist', + path: 'household', + components: [{ type: 'checklist_none', key: 'enrollments' }], + }], + }, {})).toEqual({}); + }); + + it('normalizes fields inside repeated groups', () => { + const data = { household: [{ enrollments: [] }, { enrollments: ['SNAP'] }] }; + expect(normalizeArrayFieldData({ + components: [{ + type: 'dynamiclist', + path: 'household', + components: [{ type: 'checklist_none', key: 'enrollments' }], + }], + }, data)).toEqual({ + household: [{ enrollments: null }, { enrollments: ['SNAP'] }], + }); + }); +}); diff --git a/builder-frontend/src/utils/arrayFieldData.ts b/builder-frontend/src/utils/arrayFieldData.ts new file mode 100644 index 00000000..11b864ec --- /dev/null +++ b/builder-frontend/src/utils/arrayFieldData.ts @@ -0,0 +1,64 @@ +import { + CHECKLIST_WITH_NONE_TYPE, + NONE_OF_THESE_VALUE, +} from '@/components/project/formJsExtensions/customFormFields/ChecklistWithNone'; + +interface FormComponent { + type: string; + key?: string; + path?: string; + components?: FormComponent[]; +} + +// Field types whose answers are arrays of selected options. +const ARRAY_FIELD_TYPES = ['checklist', 'taglist', CHECKLIST_WITH_NONE_TYPE]; + +/** + * Convert array answers to eligibility input data. form-js uses [] for an + * untouched field, so an empty answer is sent as null (unanswered). Only an + * explicit "None of these" answer is sent as []. + */ +export function normalizeArrayFieldData( + schema: { components?: FormComponent[] }, + data: T, +): T { + const result = structuredClone(data); + + function normalizeAtPath(target: any, segments: string[]) { + if (Array.isArray(target)) { + target.forEach((item) => normalizeAtPath(item, segments)); + return; + } + if (target === null || typeof target !== 'object') return; + + const [segment, ...rest] = segments; + if (['__proto__', 'prototype', 'constructor'].includes(segment)) return; + if (rest.length > 0) { + // A missing parent means the field's section wasn't submitted (e.g. it + // is hidden). Creating it would add people or list items to the data. + normalizeAtPath(target[segment], rest); + return; + } + + const value = target[segment]; + target[segment] = Array.isArray(value) && value.includes(NONE_OF_THESE_VALUE) + ? [] + : Array.isArray(value) && value.length > 0 ? value : null; + } + + function visit(components: FormComponent[] | undefined, parentPath = '') { + for (const component of components ?? []) { + const prefix = [parentPath, component.path].filter(Boolean).join('.'); + + if (ARRAY_FIELD_TYPES.includes(component.type) && component.key) { + const path = [prefix, component.key].filter(Boolean).join('.'); + normalizeAtPath(result, path.split('.')); + } + + visit(component.components, prefix); + } + } + + visit(schema?.components); + return result; +} diff --git a/docs/src/content/docs/user/user-guide.md b/docs/src/content/docs/user/user-guide.md index b99e84e5..6ac23215 100644 --- a/docs/src/content/docs/user/user-guide.md +++ b/docs/src/content/docs/user/user-guide.md @@ -140,6 +140,10 @@ The editor provides a visual drag-and-drop canvas powered by Form-JS. You can ad ![Form Editor showing the component palette and the two-question homeowner support form.](../../../assets/screenshots/form-editor-components.png) +For questions where people can select several options or explicitly answer **None of these**, use **Checkbox group with None** from the Selection components. Configure its options using static values, input data, or an expression, and map its key to an array input. Until someone answers, the input is `null`; choosing **None of these** sends an empty array; choosing other options sends their selected values. **None of these** clears the other choices automatically. + +A **Checkbox group** or **Tag list** with nothing selected also sends `null`, because an empty selection can't be told apart from an unanswered question. Use **Checkbox group with None** when people need to be able to answer that none apply. + **Saving your work**: Select **Save** to persist your form. The save button turns yellow when there are unsaved changes, so you can tell at a glance whether your current edits have been saved.