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 e0ef4560..e0cc7b02 100644 --- a/builder-api/src/main/java/org/acme/service/FormDataTransformer.java +++ b/builder-api/src/main/java/org/acme/service/FormDataTransformer.java @@ -10,22 +10,50 @@ * * The Form-JS editor uses a "people" object with personId keys (e.g., {applicant: {...}, spouse: {...}}), * while DMN models expect a "people" array with id fields (e.g., [{id: "applicant", ...}, {id: "spouse", ...}]). + * + * Enrollments are stored per-person as arrays of benefit strings (e.g., {applicant: {enrollments: ["SNAP", "Medicaid"]}}), + * while DMN models expect a flat enrollments array (e.g., [{personId: "applicant", benefit: "SNAP"}, ...]). */ public class FormDataTransformer { /** - * Transforms form data by converting a "people" object (with personId keys) into a "people" array - * (with id fields). This is the reverse of the frontend's transformInputDefinitionSchema function. + * Transforms form data by applying all data transformations. + * Currently applies: + * 1. People transformation: converts people object to array with id fields + * 2. Enrollments transformation: extracts enrollments from people objects into flat array * - * @param formData The form data from the user, potentially containing a "people" object - * @return A new Map with the "people" object converted to an array, or the original data if no transformation needed + * @param formData The form data from the user + * @return A new Map with all transformations applied */ - @SuppressWarnings("unchecked") public static Map transformFormData(Map formData) { if (formData == null) { return new HashMap<>(); } + // Apply each transformation in sequence + Map result = new HashMap<>(formData); + result = transformPeopleData(result); + result = transformEnrollmentsData(result); + + return result; + } + + /** + * Transforms a "people" object (with personId keys) into a "people" array (with id fields). + * + * Example: + * Input: { people: { applicant: { dateOfBirth: "1960-01-01" } } } + * Output: { people: [{ id: "applicant", dateOfBirth: "1960-01-01" }] } + * + * @param formData The form data potentially containing a "people" object + * @return A new Map with the "people" object converted to an array + */ + @SuppressWarnings("unchecked") + public static Map transformPeopleData(Map formData) { + if (formData == null) { + return new HashMap<>(); + } + Object peopleValue = formData.get("people"); // If no people key, or it's already a List (array), return a copy of the original @@ -64,4 +92,70 @@ public static Map transformFormData(Map formData return result; } + + /** + * Extracts enrollments from people objects and creates a flat enrollments array. + * Must be called after transformPeopleData (expects people to be an array). + * + * Example: + * Input: { people: [{ id: "applicant", enrollments: ["SNAP", "Medicaid"] }] } + * Output: { people: [{ id: "applicant" }], enrollments: [{ personId: "applicant", benefit: "SNAP" }, { personId: "applicant", benefit: "Medicaid" }] } + * + * @param formData The form data with people as an array + * @return A new Map with enrollments extracted into a flat array + */ + @SuppressWarnings("unchecked") + public static Map transformEnrollmentsData(Map formData) { + if (formData == null) { + return new HashMap<>(); + } + + Object peopleValue = formData.get("people"); + + // If no people key or it's not a List, return a copy of the original + if (!(peopleValue instanceof List)) { + return new HashMap<>(formData); + } + + List> peopleArray = (List>) peopleValue; + List> enrollmentsList = new ArrayList<>(); + List> transformedPeopleArray = new ArrayList<>(); + + // Extract enrollments from each person + for (Map person : peopleArray) { + String personId = (String) person.get("id"); + Object personEnrollments = person.get("enrollments"); + + // 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) + personCopy.remove("enrollments"); + + // Convert each enrollment string to an enrollment object + for (Object enrollment : (List) personEnrollments) { + if (enrollment instanceof String) { + Map enrollmentEntry = new HashMap<>(); + enrollmentEntry.put("personId", personId); + enrollmentEntry.put("benefit", enrollment); + enrollmentsList.add(enrollmentEntry); + } + } + } + + transformedPeopleArray.add(personCopy); + } + + // Create the result + Map result = new HashMap<>(formData); + result.put("people", transformedPeopleArray); + + // Only add enrollments if we extracted any + if (!enrollmentsList.isEmpty()) { + result.put("enrollments", enrollmentsList); + } + + return result; + } } diff --git a/builder-api/src/main/java/org/acme/service/InputSchemaService.java b/builder-api/src/main/java/org/acme/service/InputSchemaService.java index 4c13eebc..40431db1 100644 --- a/builder-api/src/main/java/org/acme/service/InputSchemaService.java +++ b/builder-api/src/main/java/org/acme/service/InputSchemaService.java @@ -42,15 +42,13 @@ public Set extractAllInputPaths(List benefits) { } /** - * Transforms a CheckConfig's inputDefinition JSON Schema by converting the `people` - * array property into an object with personId-keyed properties nested under it. - * - * Example: - * Input: { people: { type: "array", items: { properties: { dateOfBirth: ... } } } } - * Output: { people: { type: "object", properties: { [personId]: { properties: { dateOfBirth: ... } } } } } + * Transforms a CheckConfig's inputDefinition JSON Schema by applying all schema transformations. + * Currently applies: + * 1. People transformation: converts people array to object keyed by personId + * 2. Enrollments transformation: moves enrollments under people.{personId}.enrollments * * @param checkConfig The CheckConfig containing inputDefinition and parameters - * @return A new JsonNode with `people` transformed to an object with personId-keyed properties + * @return A new JsonNode with all transformations applied */ public JsonNode transformInputDefinitionSchema(CheckConfig checkConfig) { JsonNode inputDefinition = checkConfig.getInputDefinition(); @@ -59,26 +57,49 @@ public JsonNode transformInputDefinitionSchema(CheckConfig checkConfig) { return inputDefinition != null ? inputDefinition.deepCopy() : objectMapper.createObjectNode(); } - JsonNode properties = inputDefinition.get("properties"); - JsonNode peopleProperty = properties.get("people"); - boolean hasPeopleProperty = peopleProperty != null; - // Extract personId from parameters Map parameters = checkConfig.getParameters(); String personId = parameters != null ? (String) parameters.get("personId") : null; - // If people property exists but no personId, return original (can't transform) - if (hasPeopleProperty && (personId == null || personId.isEmpty())) { - return inputDefinition.deepCopy(); + // Apply each transformation in sequence + JsonNode schema = inputDefinition.deepCopy(); + schema = transformPeopleSchema(schema, personId); + schema = transformEnrollmentsSchema(schema, personId); + + return schema; + } + + /** + * Transforms the `people` array property into an object with personId-keyed properties. + * + * Example: + * Input: { people: { type: "array", items: { properties: { dateOfBirth: ... } } } } + * Output: { people: { type: "object", properties: { [personId]: { properties: { dateOfBirth: ... } } } } } + * + * @param schema The JSON Schema to transform + * @param personId The personId to use as the key under people + * @return A new JsonNode with `people` transformed, or the original if no transformation needed + */ + public JsonNode transformPeopleSchema(JsonNode schema, String personId) { + if (schema == null || !schema.has("properties")) { + return schema != null ? schema.deepCopy() : objectMapper.createObjectNode(); } + JsonNode properties = schema.get("properties"); + JsonNode peopleProperty = properties.get("people"); + // If no people property, return a copy of the original schema - if (!hasPeopleProperty) { - return inputDefinition.deepCopy(); + if (peopleProperty == null) { + return schema.deepCopy(); + } + + // If people property exists but no personId, return original (can't transform) + if (personId == null || personId.isEmpty()) { + return schema.deepCopy(); } // Deep clone the schema to avoid mutations - ObjectNode transformedSchema = inputDefinition.deepCopy(); + ObjectNode transformedSchema = schema.deepCopy(); ObjectNode transformedProperties = (ObjectNode) transformedSchema.get("properties"); // Get the items schema from the people array @@ -97,6 +118,92 @@ public JsonNode transformInputDefinitionSchema(CheckConfig checkConfig) { return transformedSchema; } + /** + * Transforms the `enrollments` array property by moving it under people.{personId}.enrollments + * as an array of strings (benefit names). + * + * Example: + * Input: { enrollments: { type: "array", items: { properties: { personId: ..., benefit: ... } } } } + * Output: { people: { type: "object", properties: { [personId]: { properties: { enrollments: { type: "array", items: { type: "string" } } } } } } } + * + * @param schema The JSON Schema to transform + * @param personId The personId to use as the key under people + * @return A new JsonNode with `enrollments` transformed, or the original if no transformation needed + */ + public JsonNode transformEnrollmentsSchema(JsonNode schema, String personId) { + if (schema == null || !schema.has("properties")) { + return schema != null ? schema.deepCopy() : objectMapper.createObjectNode(); + } + + JsonNode properties = schema.get("properties"); + JsonNode enrollmentsProperty = properties.get("enrollments"); + + // If no enrollments property, return a copy of the original schema + if (enrollmentsProperty == null) { + return schema.deepCopy(); + } + + // If enrollments property exists but no personId, return original (can't transform) + if (personId == null || personId.isEmpty()) { + return schema.deepCopy(); + } + + // Deep clone the schema to avoid mutations + ObjectNode transformedSchema = schema.deepCopy(); + ObjectNode transformedProperties = (ObjectNode) transformedSchema.get("properties"); + + // Remove the top-level enrollments property + transformedProperties.remove("enrollments"); + + // Create enrollments schema as array of strings + ObjectNode enrollmentsSchema = objectMapper.createObjectNode(); + enrollmentsSchema.put("type", "array"); + ObjectNode itemsSchema = objectMapper.createObjectNode(); + itemsSchema.put("type", "string"); + enrollmentsSchema.set("items", itemsSchema); + + // Get or create the people property + JsonNode existingPeople = transformedProperties.get("people"); + ObjectNode peopleSchema; + ObjectNode peopleProps; + + if (existingPeople != null && existingPeople.has("properties")) { + // People already exists (from transformPeopleSchema) + peopleSchema = (ObjectNode) existingPeople; + peopleProps = (ObjectNode) peopleSchema.get("properties"); + } else { + // Create new people structure + peopleSchema = objectMapper.createObjectNode(); + peopleSchema.put("type", "object"); + peopleProps = objectMapper.createObjectNode(); + peopleSchema.set("properties", peopleProps); + transformedProperties.set("people", peopleSchema); + } + + // Get or create the personId property under people + JsonNode existingPersonId = peopleProps.get(personId); + ObjectNode personIdSchema; + ObjectNode personIdProps; + + if (existingPersonId != null && existingPersonId.has("properties")) { + // PersonId already exists + personIdSchema = (ObjectNode) existingPersonId; + personIdProps = (ObjectNode) personIdSchema.get("properties"); + } else { + // Create new personId structure + personIdSchema = objectMapper.createObjectNode(); + personIdSchema.put("type", "object"); + personIdProps = objectMapper.createObjectNode(); + personIdSchema.set("properties", personIdProps); + peopleProps.set(personId, personIdSchema); + } + + // Add enrollments under the personId + personIdProps.set("enrollments", enrollmentsSchema); + + return transformedSchema; + } + /** * Extracts all property paths from a JSON Schema inputDefinition. * Recursively traverses nested objects to build dot-separated paths. diff --git a/builder-api/src/main/java/org/acme/service/LibraryApiService.java b/builder-api/src/main/java/org/acme/service/LibraryApiService.java index b08677c3..d667ad1b 100644 --- a/builder-api/src/main/java/org/acme/service/LibraryApiService.java +++ b/builder-api/src/main/java/org/acme/service/LibraryApiService.java @@ -148,8 +148,11 @@ public EvaluationResult evaluateCheck(CheckConfig checkConfig, Map applicantData = new HashMap<>(); + applicantData.put("dateOfBirth", "1960-01-01"); + + Map peopleObject = new HashMap<>(); + peopleObject.put("applicant", applicantData); + + Map formData = new HashMap<>(); + formData.put("people", peopleObject); + + Map result = FormDataTransformer.transformPeopleData(formData); + + // Verify people is now an array + assertTrue(result.get("people") instanceof List); + @SuppressWarnings("unchecked") + List> peopleArray = (List>) result.get("people"); + assertEquals(1, peopleArray.size()); + + // Verify the person has id field and original data + Map person = peopleArray.get(0); + assertEquals("applicant", person.get("id")); + assertEquals("1960-01-01", person.get("dateOfBirth")); + } + + @Test + void transformPeopleData_withMultiplePeople_convertsAll() { + Map applicantData = new HashMap<>(); + applicantData.put("dateOfBirth", "1960-01-01"); + + Map spouseData = new HashMap<>(); + spouseData.put("dateOfBirth", "1965-05-15"); + + Map peopleObject = new HashMap<>(); + peopleObject.put("applicant", applicantData); + peopleObject.put("spouse", spouseData); + + Map formData = new HashMap<>(); + formData.put("people", peopleObject); + + Map result = FormDataTransformer.transformPeopleData(formData); + + @SuppressWarnings("unchecked") + List> peopleArray = (List>) result.get("people"); + assertEquals(2, peopleArray.size()); + + // Find both people by id + Set ids = new HashSet<>(); + for (Map person : peopleArray) { + ids.add((String) person.get("id")); + } + assertTrue(ids.contains("applicant")); + assertTrue(ids.contains("spouse")); + } + + @Test + void transformPeopleData_withPeopleAlreadyArray_returnsOriginal() { + List> peopleArray = new ArrayList<>(); + Map person = new HashMap<>(); + person.put("id", "applicant"); + peopleArray.add(person); + + Map formData = new HashMap<>(); + formData.put("people", peopleArray); + + Map result = FormDataTransformer.transformPeopleData(formData); + + // Should return a copy of the original + assertTrue(result.get("people") instanceof List); + @SuppressWarnings("unchecked") + List> resultArray = (List>) result.get("people"); + assertEquals(1, resultArray.size()); + } + + @Test + void transformPeopleData_withNoPeople_returnsOriginal() { + Map formData = new HashMap<>(); + formData.put("income", 50000); + + Map result = FormDataTransformer.transformPeopleData(formData); + + assertEquals(50000, result.get("income")); + assertFalse(result.containsKey("people")); + } + + @Test + void transformPeopleData_withNullInput_returnsEmptyMap() { + Map result = FormDataTransformer.transformPeopleData(null); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + // ==== transformEnrollmentsData tests ==== + + @Test + void transformEnrollmentsData_withPersonEnrollments_extractsToFlatArray() { + // Setup: people array with enrollments inside person object + Map person = new HashMap<>(); + person.put("id", "applicant"); + person.put("dateOfBirth", "1960-01-01"); + person.put("enrollments", Arrays.asList("SNAP", "Medicaid")); + + List> peopleArray = new ArrayList<>(); + peopleArray.add(person); + + Map formData = new HashMap<>(); + formData.put("people", peopleArray); + + Map result = FormDataTransformer.transformEnrollmentsData(formData); + + // Verify enrollments is now a flat array at top level + assertTrue(result.containsKey("enrollments")); + @SuppressWarnings("unchecked") + List> enrollments = (List>) result.get("enrollments"); + assertEquals(2, enrollments.size()); + + // Verify enrollment entries have personId and benefit + Map enrollment1 = enrollments.get(0); + assertEquals("applicant", enrollment1.get("personId")); + assertTrue(enrollment1.get("benefit").equals("SNAP") || enrollment1.get("benefit").equals("Medicaid")); + + // Verify enrollments removed from person object + @SuppressWarnings("unchecked") + List> resultPeople = (List>) result.get("people"); + Map resultPerson = resultPeople.get(0); + assertFalse(resultPerson.containsKey("enrollments")); + assertEquals("applicant", resultPerson.get("id")); + assertEquals("1960-01-01", resultPerson.get("dateOfBirth")); + } + + @Test + void transformEnrollmentsData_withMultiplePeopleEnrollments_extractsAll() { + Map applicant = new HashMap<>(); + applicant.put("id", "applicant"); + applicant.put("enrollments", Arrays.asList("SNAP")); + + Map spouse = new HashMap<>(); + spouse.put("id", "spouse"); + spouse.put("enrollments", Arrays.asList("Medicare", "Medicaid")); + + List> peopleArray = new ArrayList<>(); + peopleArray.add(applicant); + peopleArray.add(spouse); + + Map formData = new HashMap<>(); + formData.put("people", peopleArray); + + Map result = FormDataTransformer.transformEnrollmentsData(formData); + + @SuppressWarnings("unchecked") + List> enrollments = (List>) result.get("enrollments"); + assertEquals(3, enrollments.size()); + + // Count enrollments per person + int applicantCount = 0; + int spouseCount = 0; + for (Map enrollment : enrollments) { + if ("applicant".equals(enrollment.get("personId"))) { + applicantCount++; + } else if ("spouse".equals(enrollment.get("personId"))) { + spouseCount++; + } + } + assertEquals(1, applicantCount); + assertEquals(2, spouseCount); + } + + @Test + void transformEnrollmentsData_withNoEnrollments_doesNotAddEnrollmentsKey() { + Map person = new HashMap<>(); + person.put("id", "applicant"); + person.put("dateOfBirth", "1960-01-01"); + + List> peopleArray = new ArrayList<>(); + peopleArray.add(person); + + Map formData = new HashMap<>(); + formData.put("people", peopleArray); + + Map result = FormDataTransformer.transformEnrollmentsData(formData); + + assertFalse(result.containsKey("enrollments")); + } + + @Test + void transformEnrollmentsData_withEmptyEnrollmentsArray_doesNotAddEnrollmentsKey() { + Map person = new HashMap<>(); + person.put("id", "applicant"); + person.put("enrollments", new ArrayList<>()); + + List> peopleArray = new ArrayList<>(); + peopleArray.add(person); + + Map formData = new HashMap<>(); + formData.put("people", peopleArray); + + Map result = FormDataTransformer.transformEnrollmentsData(formData); + + assertFalse(result.containsKey("enrollments")); + } + + @Test + void transformEnrollmentsData_withPeopleNotArray_returnsOriginal() { + Map formData = new HashMap<>(); + formData.put("people", "not an array"); + + Map result = FormDataTransformer.transformEnrollmentsData(formData); + + assertEquals("not an array", result.get("people")); + } + + @Test + void transformEnrollmentsData_withNullInput_returnsEmptyMap() { + Map result = FormDataTransformer.transformEnrollmentsData(null); + + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + // ==== transformFormData composition tests ==== + + @Test + void transformFormData_appliesBothTransforms() { + // Setup: people as object with enrollments inside + Map applicantData = new HashMap<>(); + applicantData.put("dateOfBirth", "1960-01-01"); + applicantData.put("enrollments", Arrays.asList("SNAP", "Medicaid")); + + Map peopleObject = new HashMap<>(); + peopleObject.put("applicant", applicantData); + + Map formData = new HashMap<>(); + formData.put("people", peopleObject); + + Map result = FormDataTransformer.transformFormData(formData); + + // Verify people is now an array with id field + assertTrue(result.get("people") instanceof List); + @SuppressWarnings("unchecked") + List> peopleArray = (List>) result.get("people"); + assertEquals(1, peopleArray.size()); + assertEquals("applicant", peopleArray.get(0).get("id")); + assertEquals("1960-01-01", peopleArray.get(0).get("dateOfBirth")); + assertFalse(peopleArray.get(0).containsKey("enrollments")); + + // Verify enrollments is now a flat array + assertTrue(result.containsKey("enrollments")); + @SuppressWarnings("unchecked") + List> enrollments = (List>) result.get("enrollments"); + assertEquals(2, enrollments.size()); + } + + @Test + void transformFormData_preservesOtherFields() { + Map applicantData = new HashMap<>(); + applicantData.put("dateOfBirth", "1960-01-01"); + + Map peopleObject = new HashMap<>(); + peopleObject.put("applicant", applicantData); + + Map formData = new HashMap<>(); + formData.put("people", peopleObject); + formData.put("income", 50000); + formData.put("householdSize", 3); + + Map result = FormDataTransformer.transformFormData(formData); + + assertEquals(50000, result.get("income")); + assertEquals(3, result.get("householdSize")); + } +} diff --git a/builder-api/src/test/java/org/acme/service/InputSchemaServiceTest.java b/builder-api/src/test/java/org/acme/service/InputSchemaServiceTest.java new file mode 100644 index 00000000..45f64096 --- /dev/null +++ b/builder-api/src/test/java/org/acme/service/InputSchemaServiceTest.java @@ -0,0 +1,344 @@ +package org.acme.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.acme.model.domain.CheckConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +public class InputSchemaServiceTest { + + private InputSchemaService service; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + service = new InputSchemaService(); + objectMapper = new ObjectMapper(); + } + + // ==== transformPeopleSchema tests ==== + + @Test + void transformPeopleSchema_withPeopleArrayAndPersonId_transformsToObject() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "people": { + "type": "array", + "items": { + "type": "object", + "properties": { + "dateOfBirth": { "type": "string", "format": "date" } + } + } + } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + JsonNode result = service.transformPeopleSchema(schema, "applicant"); + + // Verify people is now an object with personId key + assertTrue(result.has("properties")); + JsonNode people = result.get("properties").get("people"); + assertEquals("object", people.get("type").asText()); + assertTrue(people.has("properties")); + assertTrue(people.get("properties").has("applicant")); + // Verify the items schema is nested under the personId + assertTrue(people.get("properties").get("applicant").has("properties")); + assertTrue(people.get("properties").get("applicant").get("properties").has("dateOfBirth")); + } + + @Test + void transformPeopleSchema_withoutPersonId_returnsOriginal() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "people": { + "type": "array", + "items": { "type": "object", "properties": { "dateOfBirth": { "type": "string" } } } + } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + JsonNode result = service.transformPeopleSchema(schema, null); + + // Should return a copy of the original + assertEquals("array", result.get("properties").get("people").get("type").asText()); + } + + @Test + void transformPeopleSchema_withoutPeopleProperty_returnsOriginal() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "income": { "type": "number" } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + JsonNode result = service.transformPeopleSchema(schema, "applicant"); + + // Should return a copy of the original + assertTrue(result.get("properties").has("income")); + assertFalse(result.get("properties").has("people")); + } + + // ==== transformEnrollmentsSchema tests ==== + + @Test + void transformEnrollmentsSchema_withEnrollmentsAndPersonId_movesUnderPeople() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "enrollments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "personId": { "type": "string" }, + "benefit": { "type": "string" } + } + } + } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + JsonNode result = service.transformEnrollmentsSchema(schema, "applicant"); + + // Verify top-level enrollments is removed + assertFalse(result.get("properties").has("enrollments")); + + // Verify people.applicant.enrollments exists + assertTrue(result.get("properties").has("people")); + JsonNode people = result.get("properties").get("people"); + assertEquals("object", people.get("type").asText()); + assertTrue(people.get("properties").has("applicant")); + + JsonNode applicant = people.get("properties").get("applicant"); + assertTrue(applicant.get("properties").has("enrollments")); + + // Verify enrollments is now array of strings + JsonNode enrollments = applicant.get("properties").get("enrollments"); + assertEquals("array", enrollments.get("type").asText()); + assertEquals("string", enrollments.get("items").get("type").asText()); + } + + @Test + void transformEnrollmentsSchema_withExistingPeopleStructure_mergesEnrollments() throws Exception { + // Simulate a schema that already has people transformed (e.g., from transformPeopleSchema) + String schemaJson = """ + { + "type": "object", + "properties": { + "people": { + "type": "object", + "properties": { + "applicant": { + "type": "object", + "properties": { + "dateOfBirth": { "type": "string", "format": "date" } + } + } + } + }, + "enrollments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "personId": { "type": "string" }, + "benefit": { "type": "string" } + } + } + } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + JsonNode result = service.transformEnrollmentsSchema(schema, "applicant"); + + // Verify top-level enrollments is removed + assertFalse(result.get("properties").has("enrollments")); + + // Verify people.applicant now has both dateOfBirth and enrollments + JsonNode applicant = result.get("properties").get("people").get("properties").get("applicant"); + assertTrue(applicant.get("properties").has("dateOfBirth")); + assertTrue(applicant.get("properties").has("enrollments")); + } + + @Test + void transformEnrollmentsSchema_withoutPersonId_returnsOriginal() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "enrollments": { + "type": "array", + "items": { "type": "object", "properties": { "benefit": { "type": "string" } } } + } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + JsonNode result = service.transformEnrollmentsSchema(schema, null); + + // Should return a copy of the original + assertTrue(result.get("properties").has("enrollments")); + } + + @Test + void transformEnrollmentsSchema_withoutEnrollmentsProperty_returnsOriginal() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "income": { "type": "number" } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + JsonNode result = service.transformEnrollmentsSchema(schema, "applicant"); + + // Should return a copy of the original + assertTrue(result.get("properties").has("income")); + assertFalse(result.get("properties").has("enrollments")); + } + + // ==== transformInputDefinitionSchema composition tests ==== + + @Test + void transformInputDefinitionSchema_withPeopleAndEnrollments_appliesBothTransforms() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "people": { + "type": "array", + "items": { + "type": "object", + "properties": { + "dateOfBirth": { "type": "string", "format": "date" } + } + } + }, + "enrollments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "personId": { "type": "string" }, + "benefit": { "type": "string" } + } + } + } + } + } + """; + JsonNode inputDefinition = objectMapper.readTree(schemaJson); + + CheckConfig checkConfig = new CheckConfig(); + checkConfig.setInputDefinition(inputDefinition); + Map params = new HashMap<>(); + params.put("personId", "applicant"); + checkConfig.setParameters(params); + + JsonNode result = service.transformInputDefinitionSchema(checkConfig); + + // Verify top-level enrollments is removed + assertFalse(result.get("properties").has("enrollments")); + + // Verify people.applicant has both dateOfBirth and enrollments + JsonNode applicant = result.get("properties").get("people").get("properties").get("applicant"); + assertTrue(applicant.get("properties").has("dateOfBirth")); + assertTrue(applicant.get("properties").has("enrollments")); + assertEquals("string", applicant.get("properties").get("enrollments").get("items").get("type").asText()); + } + + // ==== extractJsonSchemaPaths tests ==== + + @Test + void extractJsonSchemaPaths_withTransformedSchema_extractsCorrectPaths() throws Exception { + String schemaJson = """ + { + "type": "object", + "properties": { + "people": { + "type": "object", + "properties": { + "applicant": { + "type": "object", + "properties": { + "dateOfBirth": { "type": "string", "format": "date" }, + "enrollments": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + List paths = service.extractJsonSchemaPaths(schema); + + assertTrue(paths.contains("people.applicant.dateOfBirth")); + assertTrue(paths.contains("people.applicant.enrollments")); + assertEquals(2, paths.size()); + } + + @Test + void extractJsonSchemaPaths_withEnrollmentOnlySchema_extractsCorrectPath() throws Exception { + // This tests the result after transformEnrollmentsSchema is applied to an enrollment-only check + String schemaJson = """ + { + "type": "object", + "properties": { + "people": { + "type": "object", + "properties": { + "applicant": { + "type": "object", + "properties": { + "enrollments": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + """; + JsonNode schema = objectMapper.readTree(schemaJson); + + List paths = service.extractJsonSchemaPaths(schema); + + assertTrue(paths.contains("people.applicant.enrollments")); + assertEquals(1, paths.size()); + } +} diff --git a/builder-frontend/src/components/project/FormEditorView.tsx b/builder-frontend/src/components/project/FormEditorView.tsx index ee108d8e..a90befcc 100644 --- a/builder-frontend/src/components/project/FormEditorView.tsx +++ b/builder-frontend/src/components/project/FormEditorView.tsx @@ -72,6 +72,22 @@ function FormEditorView({ formSchema, setFormSchema }) { setFormSchema(e.schema); }); + // Set default key to field ID when a new form field is added + const eventBus = formEditor.get("eventBus") as any; + const modeling = formEditor.get("modeling") as any; + eventBus.on("formField.add", (event: { formField: any }) => { + const field = event.formField; + + // Only set key if the field supports keys and doesn't already have one set + // Skip group components as they don't use keys + if (field && field.id && field.type !== 'group' && field.type !== 'default') { + // Use setTimeout to ensure the field is fully added before modifying + setTimeout(() => { + modeling.editFormField(field, 'key', field.id); + }, 0); + } + }); + onCleanup(() => { if (formEditor) { formEditor.destroy(); @@ -103,7 +119,13 @@ function FormEditorView({ formSchema, setFormSchema }) { for (const field of allFields) { // If field has a key that's not in valid paths (and not empty), reset it - if (field.key && !validPathSet.has(field.key) && field.key !== field.id) { + // Skip for expressions, which can have custom-defined keys + if ( + field.key && + !validPathSet.has(field.key) && + field.key !== field.id && + field.type !== 'expression' + ) { invalidFields.push(field.key); modeling.editFormField(field, 'key', field.id); } diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts index b1336f1e..709692b8 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts @@ -36,6 +36,12 @@ class CustomKeyDropdownProvider { ); } + // For Expression components, keep the original free-text key input + // (expression keys store computed results, not form input paths) + if (field.type === 'expression') { + return groups; + } + const curKeyEntry = generalGroup.entries.find((entry: any) => entry.id === 'key'); // Only replace the Key input with dropdown if it exists if (curKeyEntry) {