Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import org.acme.service.FormDataTransformer;
import org.acme.service.LibraryApiService;
import org.acme.service.LibraryApiService.LibraryCheckEvaluation;
import org.acme.service.InputSchemaService;

import java.util.*;

Expand All @@ -47,6 +48,9 @@ public class DecisionResource {
@Inject
LibraryApiService libraryApi;

@Inject
InputSchemaService inputSchemaService;

@POST
@Path("/published/{screenerId}/evaluate")
@Consumes(MediaType.APPLICATION_JSON)
Expand Down Expand Up @@ -169,6 +173,9 @@ private Map<String, Object> evaluateBenefit(Benefit benefit, Map<String, Object>
checkResultMap.put("parameters", checkConfig.getParameters() != null ? checkConfig.getParameters() : Map.of());
checkResultMap.put("effectiveParameters", effectiveParameters);
checkResultMap.put("defaultedParameters", defaultedParameters);
checkResultMap.put("inputPaths", inputSchemaService.extractInputPaths(checkConfig).stream()
.map(formPath -> formPath.getPath())
.toList());
checkResults.put(uniqueCheckKey, checkResultMap);
checkNum += 1;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ public List<FormPath> extractUniqueInputPaths(List<Benefit> benefits) {
if (checks == null) continue;

for (CheckConfig check : checks) {
JsonNode transformedSchema = transformInputDefinitionSchema(check);
List<FormPath> checkFormPaths = extractJsonSchemaPaths(transformedSchema);
for (FormPath checkFormPath : checkFormPaths) {
for (FormPath checkFormPath : extractInputPaths(check)) {
// If the same path exists with different types, keep the first one found
pathTypeMap.putIfAbsent(checkFormPath.getPath(), checkFormPath.getType());
}
Expand All @@ -48,6 +46,14 @@ public List<FormPath> extractUniqueInputPaths(List<Benefit> benefits) {
.toList();
}

/**
* Extracts the form paths requested by one configured eligibility check.
* These paths contribute to the configured benefit's votes for questions on the screener form.
*/
public List<FormPath> extractInputPaths(CheckConfig check) {
return extractJsonSchemaPaths(transformInputDefinitionSchema(check));
}

/**
* Transforms a CheckConfig's inputDefinition JSON Schema by applying all schema transformations.
* Currently applies:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.acme.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.core.Response;
import org.acme.enums.EvaluationResult;
import org.acme.model.domain.Benefit;
import org.acme.model.domain.CheckConfig;
import org.acme.model.domain.Screener;
import org.acme.persistence.PublishedScreenerRepository;
import org.acme.persistence.StorageService;
import org.acme.service.DmnService;
import org.acme.service.InputSchemaService;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Map;
import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class DecisionResourceTest {

@Test
@SuppressWarnings("unchecked")
void evaluatePublishedScreener_includesEachChecksQuestionVotes() throws Exception {
PublishedScreenerRepository repository = mock(PublishedScreenerRepository.class);
StorageService storageService = mock(StorageService.class);
DmnService dmnService = mock(DmnService.class);
DecisionResource resource = new DecisionResource();
resource.publishedScreenerRepository = repository;
resource.storageService = storageService;
resource.dmnService = dmnService;
resource.inputSchemaService = new InputSchemaService();

CheckConfig check = new CheckConfig();
check.setCheckId("income-check");
check.setCheckName("IncomeCheck");
check.setParameters(Map.of());
check.setInputDefinition(new ObjectMapper().readTree("""
{
"type": "object",
"properties": {
"custom": {
"type": "object",
"properties": {
"householdIncome": { "type": "number" }
}
}
}
}
"""));

Screener screener = new Screener();
Benefit benefit = new Benefit("benefit-1", "Benefit", "", "owner", List.of(check));
when(repository.getScreener("published-1")).thenReturn(Optional.of(screener));
when(repository.getBenefitsInScreener(screener)).thenReturn(List.of(benefit));
when(storageService.getCheckDmnModelPath("income-check")).thenReturn("income-check.dmn");
when(dmnService.evaluateDmn(
eq("income-check.dmn"),
eq("IncomeCheck"),
any(),
eq(Map.of())
)).thenReturn(EvaluationResult.TRUE);

Response response = resource.evaluatePublishedScreener(
"published-1",
Map.of("custom", Map.of("householdIncome", 12_000))
);

assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
Map<String, Object> screenerResults = (Map<String, Object>) response.getEntity();
Map<String, Object> benefitResult = (Map<String, Object>) screenerResults.get("benefit-1");
Map<String, Object> checkResults = (Map<String, Object>) benefitResult.get("check_results");
Map<String, Object> checkResult = (Map<String, Object>) checkResults.get("income-check0");
assertEquals(List.of("custom.householdIncome"), checkResult.get("inputPaths"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -710,4 +710,33 @@ void extractJsonSchemaPaths_withMultiplePersonIds_extractsPathsForAll() throws E
assertTrue(paths.contains(new FormPath("people.spouse.dateOfBirth", "date")));
assertEquals(2, paths.size());
}

@Test
void extractInputPaths_returnsTransformedQuestionVotesForOneCheck() throws Exception {
CheckConfig check = new CheckConfig();
check.setParameters(Map.of("personId", "applicant"));
check.setInputDefinition(objectMapper.readTree("""
{
"type": "object",
"properties": {
"people": {
"type": "array",
"items": {
"type": "object",
"properties": {
"dateOfBirth": { "type": "string", "format": "date" }
}
}
},
"income": { "type": "number" }
}
}
"""));

List<FormPath> paths = service.extractInputPaths(check);

assertEquals(2, paths.size());
assertTrue(paths.contains(new FormPath("people.applicant.dateOfBirth", "date")));
assertTrue(paths.contains(new FormPath("income", "number")));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";

import { YesNoQuestion } from "./YesNoQuestion";

describe("YesNoQuestion", () => {
it("preserves boolean answers when form-js reimports form data", () => {
const sanitizeValue = YesNoQuestion.config.sanitizeValue;

expect(sanitizeValue({ value: true })).toBe(true);
expect(sanitizeValue({ value: false })).toBe(false);
expect(sanitizeValue({ value: "true" })).toBeNull();
expect(sanitizeValue({ value: null })).toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ YesNoQuestion.config = {
type: YES_NO_TYPE,
name: 'Yes/No',
label: 'Yes/No question',
sanitizeValue: ({ value }: { value: unknown }) =>
typeof value === 'boolean' ? value : null,
icon: iconsByType("radio"),
group: 'selection',
propertiesPanelEntries: [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,81 @@
import { Accessor, onMount } from "solid-js";
import { Accessor, createEffect, on, onCleanup, onMount } from "solid-js";
import debounce from "lodash.debounce";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";

import { Form } from "@bpmn-io/form-js-viewer";

import { PreviewFormData } from "./types";
import CustomFormFieldsModule from "../formJsExtensions/customFormFields";
import { hideQuestions } from "@/utils/questionVotes";

import "@bpmn-io/form-js/dist/assets/form-js.css";

function FormRenderer({ schema, submitForm }: { schema: Accessor<any>; submitForm: (data: PreviewFormData) => void }) {
function FormRenderer({
schema,
formData,
hiddenQuestionPaths,
submitForm,
}: {
schema: Accessor<any>;
formData: Accessor<PreviewFormData>;
hiddenQuestionPaths: Accessor<string[]>;
submitForm: (data: PreviewFormData) => void;
}) {
let container: HTMLDivElement | undefined;
let form: Form | undefined;
let currentData: PreviewFormData = {};
let importing = false;

const importVisibleSchema = async () => {
if (!form) return;
importing = true;
try {
// Preserve the live form state, not the last submitted data: answers
// typed since the pending submit would otherwise be discarded.
const preservedData = cloneDeep(currentData);
await form.importSchema(
hideQuestions(schema(), hiddenQuestionPaths()),
preservedData,
);
} finally {
importing = false;
}
};

createEffect(
on(
() => [schema(), hiddenQuestionPaths()] as const,
() => void importVisibleSchema(),
{ defer: true },
),
);

onMount(() => {
const form = new Form({ container, additionalModules: [ CustomFormFieldsModule ] });
form = new Form({ container, additionalModules: [CustomFormFieldsModule] });
currentData = cloneDeep(formData());

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

form
.importSchema(schema())
.importSchema(hideQuestions(schema(), hiddenQuestionPaths()), formData())
.then(() => {
form.on("changed", (event) => {
form?.on("changed", (event) => {
const dataChanged = !isEqual(currentData, event.data);
currentData = cloneDeep(event.data);
if (importing || !dataChanged) return;
debouncedSubmit(event.data);
});
})
.catch(console.error);

onCleanup(() => {
debouncedSubmit.cancel();
form?.destroy();
});
});

return (
Expand Down
32 changes: 30 additions & 2 deletions builder-frontend/src/components/project/preview/Preview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Accessor, createSignal } from "solid-js";
import { Accessor, createMemo, createSignal } from "solid-js";

import FormRenderer from "./FormRenderer";
import Results from "./Results";
Expand All @@ -7,12 +7,28 @@ import { evaluateScreener } from "../../../api/screener";

import { PreviewFormData, ScreenerResult } from "./types";
import Tooltip from "@/components/shared/Tooltip";
import HiddenQuestionsNotice from "@/components/shared/HiddenQuestionsNotice";
import {
getUnneededQuestionPaths,
haveSameQuestionPaths,
} from "@/utils/questionVotes";

const Preview = ({ project, formSchema }) => {
const [lastInputDataSent, setLastInputDataSent] =
createSignal<PreviewFormData>({});
const [results, setResults] = createSignal<ScreenerResult>();
const [resultsLoading, setResultsLoading] = createSignal(false);
const [showAllQuestions, setShowAllQuestions] = createSignal(false);
const unneededQuestionPaths = createMemo(
() => getUnneededQuestionPaths(results()),
undefined,
{ equals: haveSameQuestionPaths },
);
const hiddenQuestionPaths = createMemo(
() => (showAllQuestions() ? [] : unneededQuestionPaths()),
undefined,
{ equals: haveSameQuestionPaths },
);

let schema: Accessor<any> = () => {
if (formSchema()) {
Expand Down Expand Up @@ -40,7 +56,19 @@ const Preview = ({ project, formSchema }) => {
<div>
<div class="m-4 p-4 border-2 border-gray-200 rounded">
<div class="text-lg text-gray-800 text-md font-bold">Form</div>
<FormRenderer schema={schema} submitForm={handleSubmitForm} />
<FormRenderer
schema={schema}
formData={lastInputDataSent}
hiddenQuestionPaths={hiddenQuestionPaths}
submitForm={handleSubmitForm}
/>
<HiddenQuestionsNotice
unneededQuestionCount={() => unneededQuestionPaths().length}
showAllQuestions={showAllQuestions}
onToggleShowAllQuestions={() =>
setShowAllQuestions((current) => !current)
}
/>
</div>
<div class="m-4 p-4 border-2 border-gray-200 rounded">
<div class="flex flex-row gap-2 items-baseline">
Expand Down
Loading
Loading