diff --git a/builder-api/src/main/java/org/acme/constants/CheckStatus.java b/builder-api/src/main/java/org/acme/constants/CheckStatus.java index 8e11a1e2..b4724c79 100644 --- a/builder-api/src/main/java/org/acme/constants/CheckStatus.java +++ b/builder-api/src/main/java/org/acme/constants/CheckStatus.java @@ -1,7 +1,5 @@ package org.acme.constants; -import org.apache.hc.client5.http.impl.auth.AuthCacheKeeper; - public enum CheckStatus { WORKING('W'), PUBLISHED('P'); diff --git a/builder-api/src/main/java/org/acme/controller/DecisionResource.java b/builder-api/src/main/java/org/acme/controller/DecisionResource.java index af288f50..c3717c46 100644 --- a/builder-api/src/main/java/org/acme/controller/DecisionResource.java +++ b/builder-api/src/main/java/org/acme/controller/DecisionResource.java @@ -20,6 +20,7 @@ import org.acme.persistence.ScreenerRepository; import org.acme.persistence.StorageService; import org.acme.service.DmnService; +import org.acme.service.FormDataTransformer; import org.acme.service.LibraryApiService; import java.util.*; @@ -66,11 +67,14 @@ public Response evaluatePublishedScreener( return Response.status(Response.Status.NOT_FOUND).build(); } + // Transform form data: convert people object to people array + Map transformedData = FormDataTransformer.transformFormData(inputData); + try { Map screenerResults = new HashMap(); for (Benefit benefit : benefits) { // Evaluate benefit - Map benefitResults = evaluateBenefit(benefit, inputData); + Map benefitResults = evaluateBenefit(benefit, transformedData); screenerResults.put(benefit.getId(), benefitResults); } return Response.ok().entity(screenerResults).build(); @@ -106,12 +110,15 @@ public Response evaluateScreener( return Response.status(Response.Status.NOT_FOUND).build(); } + // Transform form data: convert people object to people array + Map transformedData = FormDataTransformer.transformFormData(formData); + try { Map screenerResults = new HashMap(); //TODO: consider ways of processing benefits in parallel for (Benefit benefit : benefits) { // Evaluate benefit - Map benefitResults = evaluateBenefit(benefit, formData); + Map benefitResults = evaluateBenefit(benefit, transformedData); screenerResults.put(benefit.getId(), benefitResults); } return Response.ok().entity(screenerResults).build(); 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 9e42abb8..941f6b68 100644 --- a/builder-api/src/main/java/org/acme/controller/ScreenerResource.java +++ b/builder-api/src/main/java/org/acme/controller/ScreenerResource.java @@ -11,6 +11,7 @@ import jakarta.ws.rs.core.Response; import org.acme.auth.AuthUtils; import org.acme.model.domain.*; +import org.acme.model.dto.FormPathsResponse; import org.acme.model.dto.PublishScreenerRequest; import org.acme.model.dto.SaveSchemaRequest; import org.acme.persistence.EligibilityCheckRepository; @@ -18,7 +19,9 @@ import org.acme.persistence.PublishedScreenerRepository; import org.acme.persistence.StorageService; import org.acme.service.DmnService; +import org.acme.service.InputSchemaService; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -42,6 +45,9 @@ public class ScreenerResource { @Inject DmnService dmnService; + @Inject + InputSchemaService inputSchemaService; + @GET @Path("/screeners") public Response getScreeners(@Context SecurityIdentity identity) { @@ -252,6 +258,40 @@ public Response getScreenerBenefits(@Context SecurityIdentity identity, } } + /** + * Returns the list of unique input paths required by all checks in a screener. + * This endpoint transforms inputDefinition schemas and extracts paths, + * replacing the frontend's transformInputDefinitionSchema and extractJsonSchemaPaths logic. + */ + @GET + @Path("/screener/{screenerId}/form-paths") + public Response getScreenerFormPaths(@Context SecurityIdentity identity, + @PathParam("screenerId") String screenerId) { + String userId = AuthUtils.getUserId(identity); + + Optional screenerOpt = screenerRepository.getWorkingScreener(screenerId); + if (screenerOpt.isEmpty()) { + throw new NotFoundException(); + } + Screener screener = screenerOpt.get(); + + if (!isUserAuthorizedToAccessScreenerByScreener(userId, screener)) { + return Response.status(Response.Status.UNAUTHORIZED).build(); + } + + try { + List benefits = screenerRepository.getBenefitsInScreener(screener); + List paths = new ArrayList<>(inputSchemaService.extractAllInputPaths(benefits)); + Collections.sort(paths); + return Response.ok().entity(new FormPathsResponse(paths)).build(); + } catch (Exception e) { + Log.error(e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Could not extract form paths")) + .build(); + } + } + @GET @Path("/screener/{screenerId}/benefit/{benefitId}") public Response getScreenerBenefit(@Context SecurityIdentity identity, diff --git a/builder-api/src/main/java/org/acme/enums/EvaluationResult.java b/builder-api/src/main/java/org/acme/enums/EvaluationResult.java index 1eda5e3d..135413ef 100644 --- a/builder-api/src/main/java/org/acme/enums/EvaluationResult.java +++ b/builder-api/src/main/java/org/acme/enums/EvaluationResult.java @@ -1,7 +1,5 @@ package org.acme.enums; -import java.util.Optional; - public enum EvaluationResult { TRUE("TRUE"), FALSE("FALSE"), 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 new file mode 100644 index 00000000..b50779ff --- /dev/null +++ b/builder-api/src/main/java/org/acme/model/dto/FormPathsResponse.java @@ -0,0 +1,26 @@ +package org.acme.model.dto; + +import java.util.List; + +/** + * 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; + + public FormPathsResponse() { + } + + public FormPathsResponse(List paths) { + this.paths = paths; + } + + public List getPaths() { + return paths; + } + + public void setPaths(List paths) { + this.paths = paths; + } +} diff --git a/builder-api/src/main/java/org/acme/persistence/impl/EligibilityCheckRepositoryImpl.java b/builder-api/src/main/java/org/acme/persistence/impl/EligibilityCheckRepositoryImpl.java index 29b56dba..2e4eef73 100644 --- a/builder-api/src/main/java/org/acme/persistence/impl/EligibilityCheckRepositoryImpl.java +++ b/builder-api/src/main/java/org/acme/persistence/impl/EligibilityCheckRepositoryImpl.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; -import io.quarkus.logging.Log; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; diff --git a/builder-api/src/main/java/org/acme/service/FormDataTransformer.java b/builder-api/src/main/java/org/acme/service/FormDataTransformer.java new file mode 100644 index 00000000..e0ef4560 --- /dev/null +++ b/builder-api/src/main/java/org/acme/service/FormDataTransformer.java @@ -0,0 +1,67 @@ +package org.acme.service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility class for transforming form data between different formats. + * + * 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", ...}]). + */ +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. + * + * @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 + */ + @SuppressWarnings("unchecked") + public static Map transformFormData(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 + if (peopleValue == null || peopleValue instanceof List) { + return new HashMap<>(formData); + } + + // If people is not a Map (object), return a copy of the original + if (!(peopleValue instanceof Map)) { + return new HashMap<>(formData); + } + + Map peopleObject = (Map) peopleValue; + List> peopleArray = new ArrayList<>(); + + // Convert each entry in the people object to an array element with an "id" field + for (Map.Entry entry : peopleObject.entrySet()) { + String personId = entry.getKey(); + Object personValue = entry.getValue(); + + if (personValue instanceof Map) { + Map personData = new HashMap<>((Map) personValue); + personData.put("id", personId); + peopleArray.add(personData); + } else { + // If the value is not a Map, create a simple object with just the id + Map personData = new HashMap<>(); + personData.put("id", personId); + peopleArray.add(personData); + } + } + + // Create the result with the transformed people array + Map result = new HashMap<>(formData); + result.put("people", peopleArray); + + 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 new file mode 100644 index 00000000..4c13eebc --- /dev/null +++ b/builder-api/src/main/java/org/acme/service/InputSchemaService.java @@ -0,0 +1,170 @@ +package org.acme.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.enterprise.context.ApplicationScoped; + +import org.acme.model.domain.Benefit; +import org.acme.model.domain.CheckConfig; + +import java.util.*; + +/** + * Service for transforming and extracting paths from JSON Schema input definitions. + */ +@ApplicationScoped +public class InputSchemaService { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Extracts all unique input paths from all benefits in a screener. + * + * @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<>(); + + for (Benefit benefit : benefits) { + List checks = benefit.getChecks(); + if (checks == null) continue; + + for (CheckConfig check : checks) { + JsonNode transformedSchema = transformInputDefinitionSchema(check); + List paths = extractJsonSchemaPaths(transformedSchema); + pathSet.addAll(paths); + } + } + + return pathSet; + } + + /** + * 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: ... } } } } } + * + * @param checkConfig The CheckConfig containing inputDefinition and parameters + * @return A new JsonNode with `people` transformed to an object with personId-keyed properties + */ + public JsonNode transformInputDefinitionSchema(CheckConfig checkConfig) { + JsonNode inputDefinition = checkConfig.getInputDefinition(); + + if (inputDefinition == null || !inputDefinition.has("properties")) { + 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(); + } + + // If no people property, return a copy of the original schema + if (!hasPeopleProperty) { + return inputDefinition.deepCopy(); + } + + // Deep clone the schema to avoid mutations + ObjectNode transformedSchema = inputDefinition.deepCopy(); + ObjectNode transformedProperties = (ObjectNode) transformedSchema.get("properties"); + + // Get the items schema from the people array + JsonNode itemsSchema = peopleProperty.get("items"); + + // Transform people from array to object with personId as a nested property + ObjectNode newPeopleSchema = objectMapper.createObjectNode(); + newPeopleSchema.put("type", "object"); + ObjectNode newPeopleProperties = objectMapper.createObjectNode(); + if (itemsSchema != null) { + newPeopleProperties.set(personId, itemsSchema.deepCopy()); + } + + newPeopleSchema.set("properties", newPeopleProperties); + transformedProperties.set("people", newPeopleSchema); + return transformedSchema; + } + + /** + * Extracts all property paths from a JSON Schema inputDefinition. + * Recursively traverses nested objects to build dot-separated paths. + * Excludes the top-level "parameters" property and "id" properties. + * + * @param jsonSchema The JSON Schema to parse + * @return List of dot-separated paths (e.g., ["people.applicant.dateOfBirth", "income"]) + */ + public List extractJsonSchemaPaths(JsonNode jsonSchema) { + if (jsonSchema == null || !jsonSchema.has("properties")) { + return new ArrayList<>(); + } + + return traverseSchema(jsonSchema, ""); + } + + private List traverseSchema(JsonNode schema, String parentPath) { + List paths = new ArrayList<>(); + + if (schema == null || !schema.has("properties")) { + return paths; + } + + JsonNode propertiesJsonNode = schema.get("properties"); + Iterator> nestedProperties = propertiesJsonNode.properties().iterator(); + + while (nestedProperties.hasNext()) { + Map.Entry currentProperty = nestedProperties.next(); + String propKey = currentProperty.getKey(); + JsonNode propValue = currentProperty.getValue(); + + // Skip top-level "parameters" property + if (parentPath.isEmpty() && "parameters".equals(propKey)) { + continue; + } + + // Skip "id" properties + if ("id".equals(propKey)) { + continue; + } + + String currentPath = parentPath.isEmpty() ? propKey : parentPath + "." + propKey; + + // 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")) { + // Handle arrays - recurse into items schema with the current path + JsonNode itemsSchema = propValue.get("items"); + if (itemsSchema.has("properties")) { + paths.addAll(traverseSchema(itemsSchema, currentPath)); + } else { + // Array of primitives - add the path + paths.add(currentPath); + } + } else { + // Leaf property - add the path + paths.add(currentPath); + } + } + + return paths; + } + + private String getType(JsonNode schema) { + if (schema == null || !schema.has("type")) { + return null; + } + return schema.get("type").asText(); + } +} diff --git a/builder-frontend/package-lock.json b/builder-frontend/package-lock.json index ce71e521..ab95209a 100644 --- a/builder-frontend/package-lock.json +++ b/builder-frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@bpmn-io/form-js": "^1.15.2", "@bpmn-io/form-js-editor": "^1.15.2", + "@bpmn-io/properties-panel": "^3.36.0", "@corvu/drawer": "^0.2.4", "@kie-tools-core/editor": "^10.0.0", "@kogito-tooling/kie-editors-standalone": "^0.16.0", @@ -451,12 +452,43 @@ "preact": "^10.5.14" } }, + "node_modules/@bpmn-io/lang-feel": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/lang-feel/-/lang-feel-3.0.0.tgz", + "integrity": "sha512-t/k0z5AW18J7Qz2i/bIFAlvlcS63RuyQB9OQ0YiC1WyMosxXRAnv98LHnVbwirx9vje1DLll85tkBT2B8KLjmQ==", + "license": "MIT", + "dependencies": { + "@bpmn-io/lezer-feel": "^2.0.0", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/language": "^6.11.3", + "@lezer/common": "^1.4.0" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@bpmn-io/lezer-feel": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/lezer-feel/-/lezer-feel-2.0.0.tgz", + "integrity": "sha512-+jIly5GA5qUg7DKlLUAN2RdJojkpFKMySoUW4OPLQrQYsEv55vYky53ni2rB3+bbBh4otPJ0sk9rGix3DEwCSg==", + "license": "MIT", + "dependencies": { + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.4", + "min-dash": "^4.2.3" + }, + "engines": { + "node": ">= 20.12.0" + } + }, "node_modules/@bpmn-io/properties-panel": { - "version": "3.27.3", - "resolved": "https://registry.npmjs.org/@bpmn-io/properties-panel/-/properties-panel-3.27.3.tgz", - "integrity": "sha512-uGHdm63C/l97pEIKEcgJs8cusBxvjdGWQUwEgpgIIVKZpXtXJ7EyT5BV76+OoRby9MTZIoRRcHoTGTdm7U0lDA==", + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/properties-panel/-/properties-panel-3.36.0.tgz", + "integrity": "sha512-LKurxzz+HzWwl0F1bovpTzSjlg4FFqbqINPL86IKFquWh0ESSS8vU0zyTpygXvkbGnDy50HcJC+DIuBZEa+srg==", + "license": "MIT", "dependencies": { - "@bpmn-io/feel-editor": "^1.10.1", + "@bpmn-io/feel-editor": "^2.0.0", + "@carbon/icons": "^11.69.0", "@codemirror/view": "^6.28.1", "classnames": "^2.3.1", "feelers": "^1.4.0", @@ -468,12 +500,53 @@ "node": "*" } }, + "node_modules/@bpmn-io/properties-panel/node_modules/@bpmn-io/feel-editor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/feel-editor/-/feel-editor-2.0.0.tgz", + "integrity": "sha512-cYDbG+bGN9wRCFi3/WG6oYB7+SH5eAV6BeN679crvjPlrTLK+dq89b1B8K445tWMYcg/4a/uCJPV53sfBTpFaw==", + "license": "MIT", + "dependencies": { + "@bpmn-io/feel-lint": "^3.0.0", + "@bpmn-io/lang-feel": "^3.0.0", + "@camunda/feel-builtins": "^0.2.0", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.0", + "@codemirror/language": "^6.11.3", + "@codemirror/lint": "^6.9.2", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.38.8", + "@lezer/highlight": "^1.2.3", + "min-dom": "^4.2.1" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@bpmn-io/properties-panel/node_modules/@bpmn-io/feel-lint": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/feel-lint/-/feel-lint-3.0.0.tgz", + "integrity": "sha512-z1q1dBkMTvuczuhGerXuD6IRXY0JN2uBXYR5Eyq2Ltp8YAw2jIeimHLii119gaiKhimQ1OMdp9lLRYJ0oKIL3Q==", + "license": "MIT", + "dependencies": { + "@bpmn-io/lezer-feel": "^2.0.0", + "@codemirror/language": "^6.11.3" + }, + "engines": { + "node": "*" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.5.2.tgz", "integrity": "sha512-foZ7qr0IsUBjzWIq+SuBLfdQCpJ1j8cTuNNT4owngTHoN5KsJb8L9t65fzz7SCeSWzescoOil/0ldqiL041ABg==", "dev": true }, + "node_modules/@camunda/feel-builtins": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@camunda/feel-builtins/-/feel-builtins-0.2.0.tgz", + "integrity": "sha512-Jusm8x3Onqze9E5Y0lGGdPj66bnFKLYNwDz+uG4otsEXgSL0FpF+koGHK48LkF9Jqo67KaP1y3zr2y/HIWRePw==", + "license": "MIT" + }, "node_modules/@carbon/grid": { "version": "11.36.0", "resolved": "https://registry.npmjs.org/@carbon/grid/-/grid-11.36.0.tgz", @@ -484,6 +557,16 @@ "@ibm/telemetry-js": "^1.5.0" } }, + "node_modules/@carbon/icons": { + "version": "11.73.0", + "resolved": "https://registry.npmjs.org/@carbon/icons/-/icons-11.73.0.tgz", + "integrity": "sha512-84UttPUEwNEQ6prGf6HWuYy6We9vGb012+emynqSQSFAyl3L44GJMsmztLRkFGfRK/AeBTAIGVn7/dEadggEaw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@ibm/telemetry-js": "^1.5.0" + } + }, "node_modules/@carbon/layout": { "version": "11.34.0", "resolved": "https://registry.npmjs.org/@carbon/layout/-/layout-11.34.0.tgz", @@ -494,9 +577,10 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.18.6", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", - "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -505,9 +589,10 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", - "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==", + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz", + "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -525,22 +610,24 @@ } }, "node_modules/@codemirror/language": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.0.tgz", - "integrity": "sha512-A7+f++LodNNc1wGgoRDTt78cOwWm9KVezApgjOMp1W4hM0898nsqBXwF+sbePE7ZRcjN7Sa1Z5m2oN27XkmEjQ==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "node_modules/@codemirror/lint": { - "version": "6.8.5", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", - "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", + "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", @@ -566,9 +653,10 @@ } }, "node_modules/@codemirror/view": { - "version": "6.37.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.37.1.tgz", - "integrity": "sha512-Qy4CAUwngy/VQkEz0XzMKVRcckQuqLYWKqVpDDDghBe5FSXSqfVrJn49nw3ePZHxRUz4nRmb05Lgi+9csWo4eg==", + "version": "6.39.11", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.11.tgz", + "integrity": "sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1924,16 +2012,18 @@ "integrity": "sha512-sludTj9AHWtTVT1g0F7YuhMtDvwiN1slBK56wKkO8KcTNJKi0k3RU8JktgnKFaLzmtVdcCQ0KUqabor/wUKPhQ==" }, "node_modules/@lezer/common": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", - "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz", + "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", + "license": "MIT" }, "node_modules/@lezer/highlight": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", - "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0" + "@lezer/common": "^1.3.0" } }, "node_modules/@lezer/json": { @@ -1947,9 +2037,10 @@ } }, "node_modules/@lezer/lr": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", - "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.7.tgz", + "integrity": "sha512-wNIFWdSUfX9Jc6ePMzxSPVgTVB4EOfDIwLQLWASyiUdHKaMsiilj9bYiGkGQCKVodd0x6bgQCV207PILGFCF9Q==", + "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" } diff --git a/builder-frontend/package.json b/builder-frontend/package.json index ea56fdbb..9118f9aa 100644 --- a/builder-frontend/package.json +++ b/builder-frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@bpmn-io/form-js": "^1.15.2", "@bpmn-io/form-js-editor": "^1.15.2", + "@bpmn-io/properties-panel": "^3.36.0", "@corvu/drawer": "^0.2.4", "@kie-tools-core/editor": "^10.0.0", "@kogito-tooling/kie-editors-standalone": "^0.16.0", diff --git a/builder-frontend/src/App.tsx b/builder-frontend/src/App.tsx index edd7ffb1..0e06f32a 100644 --- a/builder-frontend/src/App.tsx +++ b/builder-frontend/src/App.tsx @@ -13,18 +13,23 @@ import { Match, Switch } from "solid-js"; const ProtectedRoute = (props) => { const { user, isAuthLoading } = useAuth(); + const userThing = () => { + console.log(user()) + return user(); + } + // If user is logged in, render the requested component, otherwise redirect to login return ( - - - - + + + + ); }; diff --git a/builder-frontend/src/api/screener.ts b/builder-frontend/src/api/screener.ts index 92aabfb1..891995ee 100644 --- a/builder-frontend/src/api/screener.ts +++ b/builder-frontend/src/api/screener.ts @@ -1,6 +1,7 @@ -import { authDelete, authGet, authPost, authPut } from "@/api/auth"; import { env } from "@/config/environment"; +import { authDelete, authGet, authPost, authPut } from "@/api/auth"; + import type { BenefitDetail, ScreenerResult } from "@/types"; const apiUrl = env.apiUrl; @@ -148,6 +149,26 @@ export const removeCustomBenefit = async ( } }; +export interface FormPathsResponse { + paths: string[]; +} + +export const fetchFormPaths = async (screenerId: string): Promise => { + const url = apiUrl + "/screener/" + screenerId + "/form-paths"; + try { + const response = await authGet(url); + + if (!response.ok) { + throw new Error(`Fetch form paths failed with status: ${response.status}`); + } + const data = await response.json(); + return data; + } catch (error) { + console.error("Error fetching form paths:", error); + throw error; + } +}; + export const evaluateScreener = async ( screenerId: string, inputData: any, diff --git a/builder-frontend/src/components/project/FormEditorView.tsx b/builder-frontend/src/components/project/FormEditorView.tsx index 3a6dfc57..ee108d8e 100644 --- a/builder-frontend/src/components/project/FormEditorView.tsx +++ b/builder-frontend/src/components/project/FormEditorView.tsx @@ -1,48 +1,37 @@ import { - onMount, - onCleanup, - createSignal, - createResource, - Switch, - Match, - For, - Show, + onCleanup, onMount, + createEffect, createSignal, createResource, + For, Match, Show, Switch, } from "solid-js"; +import toast from "solid-toast"; import { useParams } from "@solidjs/router"; import { FormEditor } from "@bpmn-io/form-js-editor"; import Drawer from "@corvu/drawer"; // 'corvu/drawer' -import FilterFormComponentsModule from "./formJsExtensions/FilterFormComponentsModule"; import CustomFormFieldsModule from "./formJsExtensions/customFormFields"; +import { customKeyModule } from './formJsExtensions/customKeyDropdown/customKeyDropdownProvider'; +import PathOptionsService, { pathOptionsModule } from './formJsExtensions/customKeyDropdown/pathOptionsService'; -import { saveFormSchema } from "../../api/screener"; -import { fetchScreenerBenefit } from "../../api/benefit"; -import { - extractFormPaths, - extractJsonSchemaPaths, -} from "../../utils/formSchemaUtils"; +import { saveFormSchema, fetchFormPaths } from "../../api/screener"; +import { extractFormPaths } from "../../utils/formSchemaUtils"; import Loading from "../Loading"; -import type { Benefit, BenefitDetail } from "../../types"; - import "@bpmn-io/form-js/dist/assets/form-js.css"; import "@bpmn-io/form-js-editor/dist/assets/form-js-editor.css"; -function FormEditorView({ project, formSchema, setFormSchema }) { +function FormEditorView({ formSchema, setFormSchema }) { const [isUnsaved, setIsUnsaved] = createSignal(false); const [isSaving, setIsSaving] = createSignal(false); const params = useParams(); - // Fetch all benefits with their checks - const [benefits] = createResource( - () => project()?.benefits, - async (benefitDetails: BenefitDetail[]) => { - if (!benefitDetails?.length) return []; - const screenerId = params.projectId; - return Promise.all( - benefitDetails.map((b) => fetchScreenerBenefit(screenerId, b.id)) - ); + // Fetch form paths from backend (replaces local transformation logic) + const [formPaths] = createResource( + () => params.projectId, + async (screenerId: string) => { + if (!screenerId) return []; + const response = await fetchFormPaths(screenerId); + return response.paths; } ); @@ -52,7 +41,7 @@ function FormEditorView({ project, formSchema, setFormSchema }) { let emptySchema = { components: [], exporter: { name: "form-js (https://demo.bpmn.io)", version: "1.15.0" }, - id: "Form_1sgem74", + id: "BDT Form", schemaVersion: 18, type: "default", }; @@ -63,6 +52,8 @@ function FormEditorView({ project, formSchema, setFormSchema }) { additionalModules: [ // FilterFormComponentsModule, CustomFormFieldsModule, + pathOptionsModule, + customKeyModule ], }); @@ -90,6 +81,47 @@ function FormEditorView({ project, formSchema, setFormSchema }) { }); }); + // Update path options when form paths load from backend + createEffect(() => { + if (!formEditor || formPaths.loading) return; + + const paths = formPaths() || []; + const validPathSet = new Set(paths); + + const pathOptionsService = formEditor.get("pathOptionsService") as PathOptionsService; + pathOptionsService.setOptions( + paths.map((path) => ({ value: path, label: path })) + ); + + // Clean up any form fields with keys that are no longer valid options + const formFieldRegistry = formEditor.get("formFieldRegistry") as any; + const modeling = formEditor.get("modeling") as any; + + if (formFieldRegistry && modeling) { + const allFields = formFieldRegistry.getAll(); + const invalidFields: string[] = []; + + 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) { + invalidFields.push(field.key); + modeling.editFormField(field, 'key', field.id); + } + } + + // Notify user if we reset any fields + if (invalidFields.length > 0) { + setIsUnsaved(true); + const fieldCount = invalidFields.length; + const message = fieldCount === 1 + ? `1 field had an invalid key "${invalidFields[0]}" and was reset.` + : `${fieldCount} fields had invalid keys and were reset: ${invalidFields.join(', ')}`; + toast(message, { duration: 5000, icon: '⚠️' }); + handleSave(); + } + } + }); + const handleSave = async () => { const projectId = params.projectId; const schema = formSchema(); @@ -102,7 +134,7 @@ function FormEditorView({ project, formSchema, setFormSchema }) { return ( <> - +
@@ -133,29 +165,18 @@ function FormEditorView({ project, formSchema, setFormSchema }) {
- + ); } -const FormValidationDrawer = ({ formSchema, benefits }) => { +const FormValidationDrawer = ({ formSchema, expectedInputPaths }) => { const formOutputs = () => formSchema() ? extractFormPaths(formSchema()) : []; - // Extract expected inputs from all benefits' checks - const expectedInputs = () => { - const allBenefits: Benefit[] = benefits() || []; - const pathSet = new Set(); - - for (const benefit of allBenefits) { - for (const check of benefit.checks || []) { - const paths = extractJsonSchemaPaths(check.inputDefinition); - paths.forEach((p) => pathSet.add(p)); - } - } - return Array.from(pathSet); - }; + // Expected inputs come directly from backend API + const expectedInputs = () => expectedInputPaths() || []; // Compute which expected inputs are satisfied vs missing const formOutputSet = () => new Set(formOutputs()); diff --git a/builder-frontend/src/components/project/Project.tsx b/builder-frontend/src/components/project/Project.tsx index ee99ba53..5fb28868 100644 --- a/builder-frontend/src/components/project/Project.tsx +++ b/builder-frontend/src/components/project/Project.tsx @@ -60,7 +60,6 @@ function Project() { {activeTab() == "formEditor" && ( diff --git a/builder-frontend/src/components/project/formJsExtensions/FilterFormComponentsModule.tsx b/builder-frontend/src/components/project/formJsExtensions/FilterFormComponentsModule.tsx deleted file mode 100644 index c7805d10..00000000 --- a/builder-frontend/src/components/project/formJsExtensions/FilterFormComponentsModule.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Form-js module that overwrites the "formFields" service. - * The Module extends FormFields, but has different registration logic - * to skip certain field types. - * - * Based on FormFields from the following location: - * https://github.com/bpmn-io/form-js/blob/develop/packages/form-js-viewer/src/render/FormFields.js - */ -import { FormFields } from "@bpmn-io/form-js-viewer"; - -const FIELD_TYPES_TO_SKIP = [ - "documentPreview", - "expression", - "file", - "filepicker", - "html", - "iframe", - "image", -] - -class FilterFormComponentsModule extends FormFields { - register(type: string, formField: any) { - if (FIELD_TYPES_TO_SKIP.includes(type)) { - // Skip registering this form field type - return; - } - this._formFields[type] = formField; - } -} -export default { - __init__: ['formFields'], - formFields: ['type', FilterFormComponentsModule] -}; diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/bpmn-io-dependencies.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/bpmn-io-dependencies.ts new file mode 100644 index 00000000..fcc02b49 --- /dev/null +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/bpmn-io-dependencies.ts @@ -0,0 +1,295 @@ +/** + * WARNING: This Code is rewritten from https://github.com/bpmn-io/properties-panel + * + * Due to an oddity with using contexts from compiled libraries, + * SelectEntry and its context-using dependencies need to be + * rewritten here in order for customKeyDropdownProvider.ts to function properly. + * + * Specific files used here: + * - https://github.com/bpmn-io/properties-panel/blob/main/src/components/entries/Select.js + * - https://github.com/bpmn-io/properties-panel/blob/main/src/hooks/useError.js + * - https://github.com/bpmn-io/properties-panel/blob/main/src/hooks/useEvent.js + * - https://github.com/bpmn-io/properties-panel/blob/main/src/hooks/useShowEntryEvent.js + */ + +import classNames from 'classnames'; +import { isFunction } from 'min-dash'; + +import { html } from 'htm/preact'; +import { + useCallback, useContext, useEffect, useRef, useState +} from 'preact/hooks'; + +import { + useEvent, ErrorsContext, EventContext, PropertiesPanelContext +} from '@bpmn-io/properties-panel'; + +export function useEvent(event, callback, eventBus) { + const eventContext = useContext(EventContext); + + if (!eventBus) { + ({ eventBus } = eventContext); + } + + const didMount = useRef(false); + + // (1) subscribe immediately + if (eventBus && !didMount.current) { + eventBus.on(event, callback); + } + + // (2) update subscription after inputs changed + useEffect(() => { + if (eventBus && didMount.current) { + eventBus.on(event, callback); + } + + didMount.current = true; + + return () => { + if (eventBus) { + eventBus.off(event, callback); + } + }; + }, [ callback, event, eventBus ]); +} + +/** + * Subscribe to `propertiesPanel.showEntry`. + * + * @param {string} id + * + * @returns {import('preact').Ref} + */ +export function useShowEntryEvent(id) { + const { onShow } = useContext(PropertiesPanelContext); + + const ref = useRef(); + + const focus = useRef(false); + + const onShowEntry = useCallback((event) => { + if (event.id === id) { + onShow(); + + if (!focus.current) { + focus.current = true; + } + } + }, [ id ]); + + useEffect(() => { + if (focus.current && ref.current) { + if (isFunction(ref.current.focus)) { + ref.current.focus(); + } + + if (isFunction(ref.current.select)) { + ref.current.select(); + } + + focus.current = false; + } + }); + + useEvent('propertiesPanel.showEntry', onShowEntry); + + return ref; +} + +export function useError(id) { + const { errors } = useContext(ErrorsContext); + + return errors[ id ]; +} + +{ /* Required to break up imports, see https://github.com/babel/babel/issues/15156 */ } + +/** + * @typedef { { value: string, label: string, disabled: boolean, children: { value: string, label: string, disabled: boolean } } } Option + */ + +/** + * Provides basic select input. + * + * @param {object} props + * @param {string} props.id + * @param {string[]} props.path + * @param {string} props.label + * @param {Function} props.onChange + * @param {Function} props.onFocus + * @param {Function} props.onBlur + * @param {Array