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
Original file line number Diff line number Diff line change
Expand Up @@ -230,19 +230,29 @@ public static Map<String, Object> transformEnrollmentsData(Map<String, Object> f
List<Map<String, Object>> peopleArray = (List<Map<String, Object>>) peopleValue;
List<Map<String, Object>> enrollmentsList = new ArrayList<>();
List<Map<String, Object>> transformedPeopleArray = new ArrayList<>();
boolean hasEnrollmentAnswer = false;
boolean hasUnansweredEnrollments = false;

// Extract enrollments from each person
for (Map<String, Object> 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<String, Object> 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) {
Expand All @@ -261,9 +271,11 @@ public static Map<String, Object> transformEnrollmentsData(Map<String, Object> f
Map<String, Object> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ void transformEnrollmentsData_withNoEnrollments_doesNotAddEnrollmentsKey() {
}

@Test
void transformEnrollmentsData_withEmptyEnrollmentsArray_doesNotAddEnrollmentsKey() {
void transformEnrollmentsData_withEmptyEnrollmentsArray_preservesExplicitNone() {
Map<String, Object> person = new HashMap<>();
person.put("id", "applicant");
person.put("enrollments", new ArrayList<>());
Expand All @@ -397,7 +397,39 @@ void transformEnrollmentsData_withEmptyEnrollmentsArray_doesNotAddEnrollmentsKey

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

assertFalse(result.containsKey("enrollments"));
assertEquals(List.of(), result.get("enrollments"));
}

@Test
void transformEnrollmentsData_withNullEnrollments_preservesUnanswered() {
Map<String, Object> person = new HashMap<>();
person.put("id", "applicant");
person.put("enrollments", null);

Map<String, Object> result = FormDataTransformer.transformEnrollmentsData(
Map.of("people", List.of(person)));

assertTrue(result.containsKey("enrollments"));
assertNull(result.get("enrollments"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> resultPeople = (List<Map<String, Object>>) result.get("people");
assertFalse(resultPeople.getFirst().containsKey("enrollments"));
}

@Test
void transformEnrollmentsData_withOneUnansweredPerson_doesNotInferNone() {
Map<String, Object> applicant = new HashMap<>();
applicant.put("id", "applicant");
applicant.put("enrollments", List.of("SNAP"));
Map<String, Object> spouse = new HashMap<>();
spouse.put("id", "spouse");
spouse.put("enrollments", null);

Map<String, Object> result = FormDataTransformer.transformEnrollmentsData(
Map.of("people", List.of(applicant, spouse)));

assertTrue(result.containsKey("enrollments"));
assertNull(result.get("enrollments"));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>('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();
});
});
Original file line number Diff line number Diff line change
@@ -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'
]
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { YesNoQuestion } from './YesNoQuestion';
import { ChecklistWithNone } from './ChecklistWithNone';

/*
* This is a module definition to register custom
Expand All @@ -7,6 +8,7 @@ import { YesNoQuestion } from './YesNoQuestion';
class CustomFormFieldsModule {
constructor(formFields) {
formFields.register(YesNoQuestion.config.type, YesNoQuestion);
formFields.register(ChecklistWithNone.config.type, ChecklistWithNone);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ const TYPE_COMPATIBILITY: Record<string, string[]> = {
'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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -56,7 +57,7 @@ function FormRenderer({
currentData = cloneDeep(formData());

const debouncedSubmit = debounce(
(data: PreviewFormData) => submitForm(data),
(data: PreviewFormData) => submitForm(normalizeArrayFieldData(schema(), data)),
500,
);

Expand Down
3 changes: 2 additions & 1 deletion builder-frontend/src/components/screener/FormRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -52,7 +53,7 @@ function FormRenderer({
currentData = cloneDeep(formData());

const debouncedSubmit = debounce((data) => {
submitForm(data);
submitForm(normalizeArrayFieldData(schema, data));
}, 1000);

form
Expand Down
Loading
Loading