From eaef0e7c0a900941b954531029232b8b85604a23 Mon Sep 17 00:00:00 2001 From: Justin-MacIntosh Date: Mon, 23 Feb 2026 21:18:49 -0500 Subject: [PATCH] feat: Updated FormPath dropdown to preserve types in the FormEditor. --- .../org/acme/controller/ScreenerResource.java | 10 ++- .../java/org/acme/model/domain/FormPath.java | 39 ++++++++++++ .../org/acme/model/dto/FormPathsResponse.java | 10 +-- .../org/acme/service/InputSchemaService.java | 55 +++++++++++++---- .../acme/service/InputSchemaServiceTest.java | 11 ++-- builder-frontend/src/api/screener.ts | 4 +- .../src/components/project/FormEditorView.tsx | 29 +++++---- .../customKeyDropdownProvider.ts | 8 ++- .../customKeyDropdown/pathOptionsService.ts | 61 +++++++++++++++++-- builder-frontend/src/types.ts | 6 ++ 10 files changed, 188 insertions(+), 45 deletions(-) create mode 100644 builder-api/src/main/java/org/acme/model/domain/FormPath.java diff --git a/builder-api/src/main/java/org/acme/controller/ScreenerResource.java b/builder-api/src/main/java/org/acme/controller/ScreenerResource.java index 2eec5392..8ab5d697 100644 --- a/builder-api/src/main/java/org/acme/controller/ScreenerResource.java +++ b/builder-api/src/main/java/org/acme/controller/ScreenerResource.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -284,8 +285,13 @@ public Response getScreenerFormPaths(@Context SecurityIdentity identity, try { List benefits = screenerRepository.getBenefitsInScreener(screener); - List paths = new ArrayList<>(inputSchemaService.extractAllInputPaths(benefits)); - Collections.sort(paths); + List paths = new ArrayList<>(inputSchemaService.extractAllInputPaths(benefits)); + Collections.sort(paths, new Comparator() { + public int compare(FormPath fp1, FormPath fp2) { + // compare two instance of `Score` and return `int` as result. + return fp1.getPath().compareTo(fp2.getPath()); + } + }); return Response.ok().entity(new FormPathsResponse(paths)).build(); } catch (Exception e) { Log.error(e); diff --git a/builder-api/src/main/java/org/acme/model/domain/FormPath.java b/builder-api/src/main/java/org/acme/model/domain/FormPath.java new file mode 100644 index 00000000..9000f49d --- /dev/null +++ b/builder-api/src/main/java/org/acme/model/domain/FormPath.java @@ -0,0 +1,39 @@ +package org.acme.model.domain; + +public class FormPath { + private String path; + private String type; + + public FormPath() { + } + + public FormPath(String path, String type) { + this.path = path; + this.type = type; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; // Check for null and type equality + FormPath formPath = (FormPath) o; // Cast the object + // Compare relevant fields using Objects.equals() for objects and == for primitives + return path.equals(formPath.path) && type.equals(formPath.type); + } +} diff --git a/builder-api/src/main/java/org/acme/model/dto/FormPathsResponse.java b/builder-api/src/main/java/org/acme/model/dto/FormPathsResponse.java index b50779ff..6dc7162c 100644 --- a/builder-api/src/main/java/org/acme/model/dto/FormPathsResponse.java +++ b/builder-api/src/main/java/org/acme/model/dto/FormPathsResponse.java @@ -2,25 +2,27 @@ import java.util.List; +import org.acme.model.domain.FormPath; + /** * Response DTO for the form paths endpoint. * Contains the list of unique input paths required by all checks in a screener. */ public class FormPathsResponse { - private List paths; + private List paths; public FormPathsResponse() { } - public FormPathsResponse(List paths) { + public FormPathsResponse(List paths) { this.paths = paths; } - public List getPaths() { + public List getPaths() { return paths; } - public void setPaths(List paths) { + public void setPaths(List paths) { this.paths = paths; } } 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 40431db1..eb577f2e 100644 --- a/builder-api/src/main/java/org/acme/service/InputSchemaService.java +++ b/builder-api/src/main/java/org/acme/service/InputSchemaService.java @@ -7,6 +7,7 @@ import org.acme.model.domain.Benefit; import org.acme.model.domain.CheckConfig; +import org.acme.model.domain.FormPath; import java.util.*; @@ -24,8 +25,8 @@ public class InputSchemaService { * @param benefits List of benefits containing checks with inputDefinitions * @return Set of unique dot-separated paths (e.g., "people.applicant.dateOfBirth") */ - public Set extractAllInputPaths(List benefits) { - Set pathSet = new HashSet<>(); + public Set extractAllInputPaths(List benefits) { + Set pathSet = new HashSet<>(); for (Benefit benefit : benefits) { List checks = benefit.getChecks(); @@ -33,7 +34,7 @@ public Set extractAllInputPaths(List benefits) { for (CheckConfig check : checks) { JsonNode transformedSchema = transformInputDefinitionSchema(check); - List paths = extractJsonSchemaPaths(transformedSchema); + List paths = extractJsonSchemaPaths(transformedSchema); pathSet.addAll(paths); } } @@ -212,7 +213,7 @@ public JsonNode transformEnrollmentsSchema(JsonNode schema, String personId) { * @param jsonSchema The JSON Schema to parse * @return List of dot-separated paths (e.g., ["people.applicant.dateOfBirth", "income"]) */ - public List extractJsonSchemaPaths(JsonNode jsonSchema) { + public List extractJsonSchemaPaths(JsonNode jsonSchema) { if (jsonSchema == null || !jsonSchema.has("properties")) { return new ArrayList<>(); } @@ -220,11 +221,11 @@ public List extractJsonSchemaPaths(JsonNode jsonSchema) { return traverseSchema(jsonSchema, ""); } - private List traverseSchema(JsonNode schema, String parentPath) { - List paths = new ArrayList<>(); + private List traverseSchema(JsonNode schema, String parentPath) { + List formPaths = new ArrayList<>(); if (schema == null || !schema.has("properties")) { - return paths; + return formPaths; } JsonNode propertiesJsonNode = schema.get("properties"); @@ -246,26 +247,54 @@ private List traverseSchema(JsonNode schema, String parentPath) { } String currentPath = parentPath.isEmpty() ? propKey : parentPath + "." + propKey; + String currentType = getEffectiveType(propValue); // If this property has nested properties, recurse into it if (propValue.has("properties")) { - paths.addAll(traverseSchema(propValue, currentPath)); - } else if ("array".equals(getType(propValue)) && propValue.has("items")) { + formPaths.addAll(traverseSchema(propValue, currentPath)); + } else if ("array".equals(currentType) && propValue.has("items")) { // Handle arrays - recurse into items schema with the current path JsonNode itemsSchema = propValue.get("items"); if (itemsSchema.has("properties")) { - paths.addAll(traverseSchema(itemsSchema, currentPath)); + formPaths.addAll(traverseSchema(itemsSchema, currentPath)); } else { // Array of primitives - add the path - paths.add(currentPath); + String itemType = getType(itemsSchema); + formPaths.add(new FormPath(currentPath, "array:" + (itemType != null ? itemType : "any"))); } } else { // Leaf property - add the path - paths.add(currentPath); + formPaths.add(new FormPath(currentPath, currentType)); } } - return paths; + return formPaths; + } + + /** + * Determines the effective type of a JSON Schema property, considering format hints. + * For example, a string with format "date" returns "date" instead of "string". + */ + private String getEffectiveType(JsonNode schema) { + if (schema == null) { + return "any"; + } + + String type = getType(schema); + if (type == null) { + return "any"; + } + + // Check for format hints that provide more specific type info + if (type.equals("string") && schema.has("format")) { + String format = schema.get("format").asText(); + // Common date/time formats + if ("date".equals(format) || "date-time".equals(format) || "time".equals(format)) { + return format; + } + } + + return type; } private String getType(JsonNode schema) { diff --git a/builder-api/src/test/java/org/acme/service/InputSchemaServiceTest.java b/builder-api/src/test/java/org/acme/service/InputSchemaServiceTest.java index 45f64096..28b8d3df 100644 --- a/builder-api/src/test/java/org/acme/service/InputSchemaServiceTest.java +++ b/builder-api/src/test/java/org/acme/service/InputSchemaServiceTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.acme.model.domain.CheckConfig; +import org.acme.model.domain.FormPath; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -303,10 +304,10 @@ void extractJsonSchemaPaths_withTransformedSchema_extractsCorrectPaths() throws """; JsonNode schema = objectMapper.readTree(schemaJson); - List paths = service.extractJsonSchemaPaths(schema); + List paths = service.extractJsonSchemaPaths(schema); - assertTrue(paths.contains("people.applicant.dateOfBirth")); - assertTrue(paths.contains("people.applicant.enrollments")); + assertTrue(paths.contains(new FormPath("people.applicant.dateOfBirth", "string"))); + assertTrue(paths.contains(new FormPath("people.applicant.enrollments", "array"))); assertEquals(2, paths.size()); } @@ -336,9 +337,9 @@ void extractJsonSchemaPaths_withEnrollmentOnlySchema_extractsCorrectPath() throw """; JsonNode schema = objectMapper.readTree(schemaJson); - List paths = service.extractJsonSchemaPaths(schema); + List paths = service.extractJsonSchemaPaths(schema); - assertTrue(paths.contains("people.applicant.enrollments")); + assertTrue(paths.contains(new FormPath("people.applicant.enrollments", "array"))); assertEquals(1, paths.size()); } } diff --git a/builder-frontend/src/api/screener.ts b/builder-frontend/src/api/screener.ts index ccf149e1..2fb960a6 100644 --- a/builder-frontend/src/api/screener.ts +++ b/builder-frontend/src/api/screener.ts @@ -2,7 +2,7 @@ import { env } from "@/config/environment"; import { authDelete, authGet, authPatch, authPost } from "@/api/auth"; -import type { BenefitDetail, ScreenerResult } from "@/types"; +import type { BenefitDetail, FormPath, ScreenerResult } from "@/types"; const apiUrl = env.apiUrl; @@ -159,7 +159,7 @@ export const removeCustomBenefit = async ( }; export interface FormPathsResponse { - paths: string[]; + paths: FormPath[]; } export const fetchFormPaths = async (screenerId: string): Promise => { diff --git a/builder-frontend/src/components/project/FormEditorView.tsx b/builder-frontend/src/components/project/FormEditorView.tsx index a90befcc..72a1be5c 100644 --- a/builder-frontend/src/components/project/FormEditorView.tsx +++ b/builder-frontend/src/components/project/FormEditorView.tsx @@ -2,6 +2,7 @@ import { onCleanup, onMount, createEffect, createSignal, createResource, For, Match, Show, Switch, + Accessor, } from "solid-js"; import toast from "solid-toast"; import { useParams } from "@solidjs/router"; @@ -19,6 +20,7 @@ import Loading from "../Loading"; import "@bpmn-io/form-js/dist/assets/form-js.css"; import "@bpmn-io/form-js-editor/dist/assets/form-js-editor.css"; +import { FormPath } from "@/types"; function FormEditorView({ formSchema, setFormSchema }) { const [isUnsaved, setIsUnsaved] = createSignal(false); @@ -26,7 +28,7 @@ function FormEditorView({ formSchema, setFormSchema }) { const params = useParams(); // Fetch form paths from backend (replaces local transformation logic) - const [formPaths] = createResource( + const [formPaths] = createResource( () => params.projectId, async (screenerId: string) => { if (!screenerId) return []; @@ -101,12 +103,14 @@ function FormEditorView({ formSchema, setFormSchema }) { createEffect(() => { if (!formEditor || formPaths.loading) return; - const paths = formPaths() || []; - const validPathSet = new Set(paths); + const currentFormPaths: FormPath[] = formPaths() || []; + const validPathSet = new Set(currentFormPaths.map((formPath: FormPath) => formPath.path)); const pathOptionsService = formEditor.get("pathOptionsService") as PathOptionsService; pathOptionsService.setOptions( - paths.map((path) => ({ value: path, label: path })) + currentFormPaths.map( + (formPath: FormPath) => ({ value: formPath.path, label: formPath.path, type: formPath.type }) + ) ); // Clean up any form fields with keys that are no longer valid options @@ -193,7 +197,10 @@ function FormEditorView({ formSchema, setFormSchema }) { ); } -const FormValidationDrawer = ({ formSchema, expectedInputPaths }) => { +const FormValidationDrawer = ( + { formSchema, expectedInputPaths }: + {formSchema: any, expectedInputPaths: Accessor} +) => { const formOutputs = () => formSchema() ? extractFormPaths(formSchema()) : []; @@ -204,10 +211,10 @@ const FormValidationDrawer = ({ formSchema, expectedInputPaths }) => { const formOutputSet = () => new Set(formOutputs()); const satisfiedInputs = () => - expectedInputs().filter((p) => formOutputSet().has(p)); + expectedInputs().filter((formPath) => formOutputSet().has(formPath.path)); const missingInputs = () => - expectedInputs().filter((p) => !formOutputSet().has(p)); + expectedInputs().filter((formPath) => !formOutputSet().has(formPath.path)); return ( @@ -281,9 +288,9 @@ const FormValidationDrawer = ({ formSchema, expectedInputPaths }) => {

} > - {(path) => ( + {(formPath) => (
- {path} + {formPath.path} ({formPath.type})
)} @@ -302,9 +309,9 @@ const FormValidationDrawer = ({ formSchema, expectedInputPaths }) => {

} > - {(path) => ( + {(formPath) => (
- {path} + {formPath.path} ({formPath.type})
)} diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts index 709692b8..a29e7c63 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts @@ -1,6 +1,5 @@ import SelectEntry from './bpmn-io-dependencies'; - /** * Custom properties provider that replaces the path entry */ @@ -73,8 +72,13 @@ function CustomKeyDropdown(props: any) { // Get current field's key so it won't be disabled in the dropdown const currentKey = field.key || ''; + // Get the component type to filter compatible options + const componentType = field.type || ''; + + console.log(componentType); + // Get options from the injected service, passing current key to exclude from disabling - const options = pathOptionsService?.getOptions(currentKey) || []; + const options = pathOptionsService?.getOptions(currentKey, componentType) || []; // Add empty option return [{ value: field.id, label: '(none)' }, ...options]; diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts index e39e084c..1c862cc0 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts @@ -1,13 +1,51 @@ interface PathOption { value: string; label: string; + type: string; disabled?: boolean; } +const TYPE_COMPATIBILITY: Record = { + // String types + 'string': ['textfield', 'textarea', 'select', 'radio', 'checklist', 'taglist'], + // Number types + 'number': ['number'], + 'integer': ['number'], + // Boolean types + 'boolean': ['checkbox', 'yes_no', 'radio', 'select'], + // Date/time types + 'date': ['datetime'], + 'date-time': ['datetime'], + 'time': ['datetime'], + // Array types (arrays of primitives) + 'array:string': ['checklist', 'taglist', 'select'], + 'array:number': ['checklist', 'taglist', 'select'], + 'array:boolean': ['checklist'], + // Fallback for any/unknown types - compatible with all + 'any': ['textfield', 'textarea', 'number', 'checkbox', 'select', 'radio', 'checklist', 'taglist', 'datetime', 'yes_no'], +}; + interface EventBus { fire(event: string, payload: { options: PathOption[] }): void; } +/** + * Checks if a Form-JS component type is compatible with a JSON Schema type. + */ +export function isTypeCompatible(schemaType: string | undefined, componentType: string): boolean { + if (!schemaType) { + return true; // If no schema type, allow all + } + + const compatibleComponents = TYPE_COMPATIBILITY[schemaType]; + if (!compatibleComponents) { + // Unknown schema type - allow all to be safe + return true; + } + + return compatibleComponents.includes(componentType); +} + export default class PathOptionsService { static $inject = ['eventBus', 'formFieldRegistry']; @@ -51,16 +89,27 @@ export default class PathOptionsService { } /** - * Get options with already-used keys marked as disabled + * Get options with already-used keys marked as disabled and filtered by component type. * @param currentFieldKey - The key of the current field being edited (won't be disabled) + * @param componentType - The Form-JS component type to filter compatible options */ - getOptions(currentFieldKey?: string): PathOption[] { + getOptions(currentFieldKey?: string, componentType?: string): PathOption[] { const usedKeys = this.getUsedKeys(); + console.log(this.pathOptions); - return this.pathOptions.map(option => ({ - ...option, - disabled: option.value !== currentFieldKey && usedKeys.has(option.value) - })); + return this.pathOptions + .filter(option => { + // If no component type filter, show all options + if (!componentType) { + return true; + } + // Filter by type compatibility + return isTypeCompatible(option.type, componentType); + }) + .map(option => ({ + ...option, + disabled: option.value !== currentFieldKey && usedKeys.has(option.value) + })); } /** diff --git a/builder-frontend/src/types.ts b/builder-frontend/src/types.ts index 47ab8f62..634ba6f0 100644 --- a/builder-frontend/src/types.ts +++ b/builder-frontend/src/types.ts @@ -125,3 +125,9 @@ export interface PublishedScreener { screenerName: string; formSchema: any; } + +// Selectable Form Path in the Form Editor view +export interface FormPath { + path: string; + type: string; +} \ No newline at end of file