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