diff --git a/builder-frontend/src/components/project/FormEditorView.tsx b/builder-frontend/src/components/project/FormEditorView.tsx index 72a1be5c..573db764 100644 --- a/builder-frontend/src/components/project/FormEditorView.tsx +++ b/builder-frontend/src/components/project/FormEditorView.tsx @@ -4,15 +4,15 @@ import { For, Match, Show, Switch, Accessor, } from "solid-js"; +import { Portal } from "solid-js/web"; 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 CustomFormFieldsModule from "./formJsExtensions/customFormFields"; import { customKeyModule } from './formJsExtensions/customKeyDropdown/customKeyDropdownProvider'; -import PathOptionsService, { pathOptionsModule } from './formJsExtensions/customKeyDropdown/pathOptionsService'; +import PathOptionsService, { compatibleComponentLabels, pathOptionsModule, TYPE_COMPATIBILITY } from './formJsExtensions/customKeyDropdown/pathOptionsService'; import { saveFormSchema, fetchFormPaths } from "../../api/screener"; import { extractFormPaths } from "../../utils/formSchemaUtils"; @@ -25,6 +25,7 @@ import { FormPath } from "@/types"; function FormEditorView({ formSchema, setFormSchema }) { const [isUnsaved, setIsUnsaved] = createSignal(false); const [isSaving, setIsSaving] = createSignal(false); + const [highlightedTypes, setHighlightedTypes] = createSignal([], { equals: false }); const params = useParams(); // Fetch form paths from backend (replaces local transformation logic) @@ -71,7 +72,7 @@ function FormEditorView({ formSchema, setFormSchema }) { formEditor.on("changed", (e) => { setIsUnsaved(true); - setFormSchema(e.schema); + setFormSchema({ ...e.schema }); }); // Set default key to field ID when a new form field is added @@ -148,6 +149,11 @@ function FormEditorView({ formSchema, setFormSchema }) { } }); + // Highlight compatible palette fields when an input pill is hovered/pinned + const PALETTE_STYLE_ID = 'bdt-palette-highlight'; + onCleanup(() => document.getElementById(PALETTE_STYLE_ID)?.remove()); + createEffect(() => updatePaletteHighlight(highlightedTypes(), PALETTE_STYLE_ID)); + const handleSave = async () => { const projectId = params.projectId; const schema = formSchema(); @@ -163,12 +169,10 @@ function FormEditorView({ formSchema, setFormSchema }) { -
-
-
(container = el)} /> -
-
-
+
+
+ +
@@ -176,10 +180,7 @@ function FormEditorView({ formSchema, setFormSchema }) {
-
+
Saving...
@@ -191,136 +192,228 @@ function FormEditorView({ formSchema, setFormSchema }) {
- +
+
(container = el)} /> +
); } -const FormValidationDrawer = ( - { formSchema, expectedInputPaths }: - {formSchema: any, expectedInputPaths: Accessor} -) => { - const formOutputs = () => - formSchema() ? extractFormPaths(formSchema()) : []; +// --------------------------------------------------------------------------- +// Palette highlight helper (module-level, no reactive dependencies) +// --------------------------------------------------------------------------- + +function updatePaletteHighlight(types: string[], styleId: string) { + let styleEl = document.getElementById(styleId) as HTMLStyleElement | null; + if (types.length === 0) { + if (styleEl) styleEl.textContent = ''; + return; + } + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = styleId; + document.head.appendChild(styleEl); + } + const compatibleSelectors = types + .map((t) => `.fjs-palette-field[data-field-type='${t}']`) + .join(', '); + styleEl.textContent = ` + .fjs-palette-field { + opacity: 0.2; + transition: opacity 0.15s ease; + } + ${compatibleSelectors} { + opacity: 1 !important; + outline: 2px solid #0ea5e9; + outline-offset: 1px; + border-radius: 4px; + } + `; +} + +// --------------------------------------------------------------------------- +// InputPill — shared pill component for missing and mapped inputs +// --------------------------------------------------------------------------- + +type TooltipPosition = { x: number; y: number }; + +const InputPill = (props: { + formPath: FormPath; + status: 'missing' | 'mapped'; + isPinned: boolean; + onShowTooltip: (path: string, pos: TooltipPosition) => void; + onHideTooltip: () => void; + onClick: (path: string) => void; +}) => { + // Derive isMissing from props (not destructured) so status stays reactive + const isMissing = () => props.status === 'missing'; + + const pillClass = () => [ + 'flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs font-mono', + 'cursor-pointer select-none', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-sky-500', + isMissing() + ? `border-red-300 ${props.isPinned ? 'bg-red-100' : 'bg-red-50'} text-red-800` + : `border-green-300 ${props.isPinned ? 'bg-green-100' : 'bg-green-50'} text-green-800`, + ].filter(Boolean).join(' '); + + const ariaLabel = () => isMissing() + ? `${props.formPath.path}: not yet mapped. Needs ${compatibleComponentLabels(props.formPath.type).join(' or ')}.` + : `${props.formPath.path}: mapped. Compatible with ${compatibleComponentLabels(props.formPath.type).join(' or ')}.`; + + const handleInteract = (e: MouseEvent | FocusEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + props.onShowTooltip(props.formPath.path, { x: rect.left, y: rect.top }); + }; + + return ( +
props.onClick(props.formPath.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + props.onClick(props.formPath.path); + } + }} + > + + {props.formPath.path} + +
+ ); +}; + +// --------------------------------------------------------------------------- +// InputsPanel +// --------------------------------------------------------------------------- + +const InputsPanel = ({ + formSchema, + expectedInputPaths, + setHighlightedTypes, +}: { + formSchema: any; + expectedInputPaths: Accessor; + setHighlightedTypes: (types: string[]) => void; +}) => { + const [hoveredPath, setHoveredPath] = createSignal(null); + const [pinnedPath, setPinnedPath] = createSignal(null); + const [tooltipState, setTooltipState] = createSignal<{ path: string; x: number; y: number } | null>(null); + + const formOutputSet = () => + new Set(formSchema() ? extractFormPaths(formSchema()) : []); - // Expected inputs come directly from backend API const expectedInputs = () => expectedInputPaths() || []; + const missingInputs = () => expectedInputs().filter((p) => !formOutputSet().has(p.path)); + const mappedInputs = () => expectedInputs().filter((p) => formOutputSet().has(p.path)); + + const highlight = (activePath: string | null) => { + const fp = activePath ? expectedInputs().find((p) => p.path === activePath) : null; + setHighlightedTypes(fp ? (TYPE_COMPATIBILITY[fp.type] ?? []) : []); + }; + + const handleShowTooltip = (path: string, pos: TooltipPosition) => { + setTooltipState({ path, ...pos }); + setHoveredPath(path); + highlight(path); + }; - // Compute which expected inputs are satisfied vs missing - const formOutputSet = () => new Set(formOutputs()); + const handleHideTooltip = () => { + setTooltipState(null); + setHoveredPath(null); + // Restore highlight from pinned pill (if any) when hover ends + highlight(pinnedPath()); + }; - const satisfiedInputs = () => - expectedInputs().filter((formPath) => formOutputSet().has(formPath.path)); + const handleClick = (path: string) => { + const newPin = pinnedPath() === path ? null : path; + setPinnedPath(newPin); + // Hover takes precedence; otherwise drive highlight from the new pin state + highlight(hoveredPath() ?? newPin); + }; - const missingInputs = () => - expectedInputs().filter((formPath) => !formOutputSet().has(formPath.path)); + const tooltipLabel = () => { + const state = tooltipState(); + if (!state) return ''; + const fp = expectedInputs().find((p) => p.path === state.path); + return fp ? `Use: ${compatibleComponentLabels(fp.type).join(', ')}` : ''; + }; return ( - - {(props) => ( - <> - + + Form Inputs + + + + + No benefits configured. Add benefits to see required inputs. + + + + 0}> + {/* py-1.5 and px-0.5 give the pinned ring enough room to avoid clipping */} +
+ + {(formPath) => ( + + )} + + + {(formPath) => ( + + )} + +
+
+ + {/* Tooltip rendered via Portal to escape the overflow-x:auto clipping context */} + + + + + +
); }; diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts index a29e7c63..6fdbd6c8 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/customKeyDropdownProvider.ts @@ -75,8 +75,6 @@ function CustomKeyDropdown(props: any) { // 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, componentType) || []; diff --git a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts index 1c862cc0..6d45ffa8 100644 --- a/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts +++ b/builder-frontend/src/components/project/formJsExtensions/customKeyDropdown/pathOptionsService.ts @@ -5,7 +5,7 @@ interface PathOption { disabled?: boolean; } -const TYPE_COMPATIBILITY: Record = { +export const TYPE_COMPATIBILITY: Record = { // String types 'string': ['textfield', 'textarea', 'select', 'radio', 'checklist', 'taglist'], // Number types @@ -25,6 +25,29 @@ const TYPE_COMPATIBILITY: Record = { 'any': ['textfield', 'textarea', 'number', 'checkbox', 'select', 'radio', 'checklist', 'taglist', 'datetime', 'yes_no'], }; +/** Human-readable labels for Form-JS component types. */ +export const COMPONENT_TYPE_LABELS: Record = { + textfield: "Text field", + textarea: "Text area", + number: "Number", + checkbox: "Checkbox", + yes_no: "Yes / No", + radio: "Radio buttons", + select: "Dropdown", + checklist: "Checklist", + taglist: "Tag list", + datetime: "Date picker", +}; + +/** + * Returns the compatible Form-JS component types for a given JSON Schema type, + * as human-readable labels. + */ +export function compatibleComponentLabels(schemaType: string): string[] { + const componentTypes = TYPE_COMPATIBILITY[schemaType] ?? []; + return componentTypes.map((t) => COMPONENT_TYPE_LABELS[t] ?? t); +} + interface EventBus { fire(event: string, payload: { options: PathOption[] }): void; } @@ -95,7 +118,6 @@ export default class PathOptionsService { */ getOptions(currentFieldKey?: string, componentType?: string): PathOption[] { const usedKeys = this.getUsedKeys(); - console.log(this.pathOptions); return this.pathOptions .filter(option => { diff --git a/builder-frontend/src/components/project/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx b/builder-frontend/src/components/project/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx index 7fa68da9..c1c3d0ab 100644 --- a/builder-frontend/src/components/project/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx +++ b/builder-frontend/src/components/project/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx @@ -3,6 +3,8 @@ import { Accessor, createSignal, For, Show } from "solid-js"; import ConfigureCheckModal from "./modals/ConfigureCheckModal"; import { titleCase } from "@/utils/title_case"; +import { extractInputPaths, transformInputDefinitionSchema } from "@/utils/formSchemaUtils"; +import { compatibleComponentLabels } from "@/components/project/formJsExtensions/customKeyDropdown/pathOptionsService"; import type { CheckConfig, @@ -60,9 +62,24 @@ const SelectedEligibilityCheck = ({
{checkConfig().checkDescription}
- { - // Place to display information about expected inputs for check - } + 0}> +
+
Required Form Inputs
+ + {(input) => ( +
+
+
{input.path}
+
({input.type})
+
+
+ Use: {compatibleComponentLabels(input.type).join(", ")} +
+
+ )} +
+
+
{checkConfig().parameterDefinitions && checkConfig().parameterDefinitions.length > 0 && (
diff --git a/builder-frontend/src/utils/formSchemaUtils.ts b/builder-frontend/src/utils/formSchemaUtils.ts index fd632293..811bcb27 100644 --- a/builder-frontend/src/utils/formSchemaUtils.ts +++ b/builder-frontend/src/utils/formSchemaUtils.ts @@ -1,3 +1,133 @@ +import type { JSONSchema7 } from "json-schema"; + +export interface InputPath { + path: string; + type: string; +} + +/** + * Mirrors the backend InputSchemaService.transformInputDefinitionSchema. + * Applies the people-array → personId-keyed-object and enrollments-nesting + * transformations before path extraction, so paths match what the backend + * returns from /screener/{id}/form-paths. + * + * @param schema - Raw inputDefinition JSONSchema7 from a CheckConfig + * @param parameters - CheckConfig parameters (may contain personId / peopleIds) + * @returns Transformed schema suitable for extractInputPaths + */ +export function transformInputDefinitionSchema( + schema: JSONSchema7 | undefined, + parameters: Record = {} +): JSONSchema7 { + if (!schema) return {}; + + const personIds: string[] = []; + if (typeof parameters.personId === "string" && parameters.personId) { + personIds.push(parameters.personId); + } + if (Array.isArray(parameters.peopleIds)) { + for (const id of parameters.peopleIds) { + if (typeof id === "string" && id) personIds.push(id); + } + } + + let result = _transformPeopleSchema(schema, personIds); + result = _transformEnrollmentsSchema(result, personIds); + return result; +} + +function _transformPeopleSchema(schema: JSONSchema7, personIds: string[]): JSONSchema7 { + const props = schema.properties; + if (!props || !props.people || personIds.length === 0) return { ...schema }; + + const peopleSchema = props.people as JSONSchema7; + const itemsSchema = (peopleSchema.items as JSONSchema7) ?? {}; + + const newPeopleProps: Record = {}; + for (const id of personIds) { + newPeopleProps[id] = { ...itemsSchema }; + } + + return { + ...schema, + properties: { ...props, people: { type: "object", properties: newPeopleProps } }, + }; +} + +function _transformEnrollmentsSchema(schema: JSONSchema7, personIds: string[]): JSONSchema7 { + const props = schema.properties; + if (!props || !props.enrollments || personIds.length === 0) return { ...schema }; + + const enrollmentsSchema: JSONSchema7 = { type: "array", items: { type: "string" } }; + const { enrollments: _removed, people, ...restProps } = props as any; + const existingPeople = (people ?? { type: "object", properties: {} }) as JSONSchema7; + const existingPeopleProps = (existingPeople.properties ?? {}) as Record; + + const newPeopleProps: Record = {}; + for (const id of personIds) { + const existing = existingPeopleProps[id] ?? ({ type: "object", properties: {} } as JSONSchema7); + newPeopleProps[id] = { + ...existing, + properties: { + ...(existing.properties as Record ?? {}), + enrollments: enrollmentsSchema, + }, + }; + } + + return { + ...schema, + properties: { ...restProps, people: { ...existingPeople, properties: newPeopleProps } }, + }; +} + +/** + * Recursively flattens a JSONSchema7 inputDefinition into dot-separated + * { path, type } pairs. Types match the keys used in TYPE_COMPATIBILITY + * (e.g. "string", "number", "boolean", "date", "date-time", "array:string"). + * + * @param schema - The JSONSchema7 inputDefinition from a CheckConfig + * @returns Array of { path, type } pairs for every leaf field + */ +export function extractInputPaths(schema: JSONSchema7 | undefined): InputPath[] { + if (!schema) return []; + + const results: InputPath[] = []; + + function resolveLeafType(node: JSONSchema7): string { + const type = Array.isArray(node.type) + ? node.type.find((t) => t !== "null") ?? node.type[0] + : node.type; + if (type === "string") { + if (node.format === "date") return "date"; + if (node.format === "date-time") return "date-time"; + if (node.format === "time") return "time"; + } + return (type as string) || "string"; + } + + function traverse(node: JSONSchema7, prefix: string) { + if (node.type === "object" && node.properties) { + for (const [key, value] of Object.entries(node.properties)) { + if (typeof value === "boolean") continue; + traverse(value, prefix ? `${prefix}.${key}` : key); + } + } else if (node.type === "array") { + const items = node.items; + let itemType = "string"; + if (items && typeof items !== "boolean" && !Array.isArray(items)) { + itemType = resolveLeafType(items as JSONSchema7); + } + if (prefix) results.push({ path: prefix, type: `array:${itemType}` }); + } else { + if (prefix) results.push({ path: prefix, type: resolveLeafType(node) }); + } + } + + traverse(schema, ""); + return results; +} + interface FormComponent { type: string; key?: string;