diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index 59b3e9fe..0a3a4fb0 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -17,6 +17,7 @@ --color-foreground-muted: inherit; --color-foreground-active: inherit; --color-error: inherit; + --color-error-background: inherit; --color-info: inherit; --color-success: inherit; --color-warning: inherit; @@ -33,6 +34,7 @@ --color-foreground-muted: var(--color-neutral-600); --color-foreground-active: var(--color-brand); --color-error: #ff2424; + --color-error-background: #ffcbcb; --color-info: #1389ff; --color-warning: var(--color-brand); --color-success: #247d12; @@ -87,7 +89,8 @@ --color-foreground: var(--color-neutral-100); --color-foreground-muted: var(--color-neutral-400); --color-foreground-active: var(--color-brand); - --color-error: #ff2424; + --color-error: #ffcbcb; + --color-error-background: #ff2424; --color-info: #1389ff; --color-warning: var(--color-brand); --color-success: #247d12; diff --git a/src/main/frontend/app/components/toast.tsx b/src/main/frontend/app/components/toast.tsx index 32ec2fba..2609b463 100644 --- a/src/main/frontend/app/components/toast.tsx +++ b/src/main/frontend/app/components/toast.tsx @@ -16,7 +16,7 @@ const defaultStyle = 'items-end justify-end pointer-events-none' const toastStyles = { ERROR: { container: defaultStyle, - card: `${toastBaseCard} bg-error`, + card: `${toastBaseCard} bg-error-background`, icon: '', defaultDuration: 2000, }, diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/components/missing-requirements.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/missing-requirements.tsx new file mode 100644 index 00000000..89df861f --- /dev/null +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/components/missing-requirements.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react' + +interface MissingRequirementsProps { + missingChildren: string[] + isFulfilled: boolean +} + +function parseRequirement(item: string) { + if (item.startsWith('One of:')) { + const values = item + .replace('One of:', '') + .split(',') + .map((v) => v.trim()) + + return { + label: 'One of', + values, + } + } + + return { + label: null, + values: [item], + } +} + +export default function MissingRequirements({ missingChildren, isFulfilled }: MissingRequirementsProps) { + const [expandedMap, setExpandedMap] = useState>({}) + + if (isFulfilled || missingChildren.length === 0) return null + + const toggle = (index: number) => { + setExpandedMap((prev) => ({ + ...prev, + [index]: !prev[index], + })) + } + + return ( +
+

This node is missing mandatory children:

+ + +
+ ) +} diff --git a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx index dd4f2633..312737b4 100644 --- a/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx +++ b/src/main/frontend/app/routes/studio/canvas/nodetypes/frank-node.tsx @@ -18,6 +18,15 @@ import { DeprecatedPopover } from './components/deprecated-popover' import { showWarningToast } from '~/components/toast' import { useHandleTypes } from '~/hooks/use-handle-types' import AddSubcomponentModal from '~/components/flow/add-subcomponent-modal' +import { fetchFrankConfigXsd } from '~/services/xsd-service' +import { + type Requirement, + getElementRequirements, + getMissingRequirements, + isRequirementFulfilled, + parseXsd, +} from '~/utils/xsd-utils' +import MissingRequirements from './components/missing-requirements' export type FrankNodeType = Node<{ subtype: string @@ -74,6 +83,9 @@ export default function FrankNode(properties: NodeProps) { return Object.values(recordElements).filter((element) => canAcceptChildStatic(frankElement, element.name, filters)) }, [elements, frankElement, filters]) + const [mandatoryChildren, setMandatoryChildren] = useState([]) + const [mandatoryChildrenFulfilled, setMandatoryChildrenFulfilled] = useState(false) + const [missingChildren, setMissingChildren] = useState([]) const updateNodeInternals = useUpdateNodeInternals() const [isHandleMenuOpen, setIsHandleMenuOpen] = useState(false) @@ -110,6 +122,24 @@ export default function FrankNode(properties: NodeProps) { } }, [dragOver, properties.id, updateNodeInternals]) + useEffect(() => { + fetchFrankConfigXsd().then((xsd) => { + const doc = parseXsd(xsd) + const mandatory = getElementRequirements(doc, properties.data.subtype) + setMandatoryChildren(mandatory) + }) + }, [properties.data.subtype]) + + useEffect(() => { + const children = properties.data.children + + const allFulfilled = isRequirementFulfilled(mandatoryChildren, children) + setMandatoryChildrenFulfilled(allFulfilled) + + const missing = getMissingRequirements(mandatoryChildren, children) + setMissingChildren(missing) + }, [mandatoryChildren, properties.data.children]) + useLayoutEffect(() => { if (containerReference.current) { const measuredHeight = containerReference.current.offsetHeight @@ -443,6 +473,9 @@ export default function FrankNode(properties: NodeProps) { )} + {/* Show missing mandatory children if the node is missing any */} + + {possibleChildren.length > 0 && (
(prefix === 'xs' ? 'http://www.w3.org/2001/XMLSchema' : null), + XPathResult.FIRST_ORDERED_NODE_TYPE, + null, + ).singleNodeValue as Element | null + + if (!elementNode) return [] + + let typeName = elementNode.getAttribute('type') + + if (!typeName) { + typeName = `${elementName}Type` + } + + const typeNode = getComplexTypeByName(doc, typeName) + if (!typeNode) { + console.warn(`No type found for element "${elementName}" (tried "${typeName}")`) + return [] + } + + return extractRequirements(doc, typeNode) +} + +function extractRequirements( + doc: Document, + node: Element, + parentRequired = true, + visitedGroups = new Set(), +): Requirement[] { + const results: Requirement[] = [] + + for (const child of node.children) { + const minOccurs = child.getAttribute('minOccurs') + const isRequired = parentRequired && (minOccurs === null || minOccurs !== '0') + + switch (child.localName) { + case 'element': { + const name = child.getAttribute('name') || child.getAttribute('ref') + if (name) { + results.push({ + kind: 'element', + name, + required: isRequired, + }) + } + break + } + + case 'group': { + const ref = child.getAttribute('ref') + if (!ref || visitedGroups.has(ref)) break + + visitedGroups.add(ref) + + const groupDef = getGroupByName(doc, ref) + if (!groupDef) break + + const children = extractRequirements(doc, groupDef, isRequired, visitedGroups) + + if (isRequired) { + // REQUIRED GROUP = "at least one of its children" + results.push({ + kind: 'group', + mode: 'one', + children, + }) + } else { + // optional group -> children optional + results.push(...children.map((c) => (c.kind === 'element' ? { ...c, required: false } : c))) + } + + break + } + + case 'sequence': + case 'all': { + const children = extractRequirements(doc, child, isRequired, visitedGroups) + + results.push({ + kind: 'group', + mode: 'all', + children, + }) + + break + } + + case 'choice': { + const children = extractRequirements(doc, child, isRequired, visitedGroups) + + results.push({ + kind: 'group', + mode: 'one', + children, + }) + + break + } + } + } + + return results +} + +export function isRequirementFulfilled(requirements: Requirement[], children: ChildNode[]): boolean { + return requirements.every((requirement) => evaluateRequirement(requirement, children)) +} + +function evaluateRequirement(requirement: Requirement, children: ChildNode[]): boolean { + if (requirement.kind === 'element') { + if (!requirement.required) return true + + return children.some((child) => child.subtype === requirement.name) + } + + if (requirement.kind === 'group') { + if (requirement.mode === 'all') { + return requirement.children.every((childReq) => evaluateRequirement(childReq, children)) + } + + if (requirement.mode === 'one') { + return requirement.children.some((childReq) => evaluateRequirement(childReq, children)) + } + } + + return true +} + +export function getMissingRequirements(requirements: Requirement[], children: ChildNode[]): string[] { + const missing: string[] = [] + + for (const req of requirements) { + collectMissing(req, children, missing) + } + + return missing +} + +function collectMissing(requirement: Requirement, children: ChildNode[], missing: string[]) { + if (requirement.kind === 'element') { + if (!requirement.required) return + + const exists = children.some((child) => child.subtype === requirement.name) + + if (!exists) { + missing.push(requirement.name) + } + + return + } + + if (requirement.kind === 'group') { + if (requirement.mode === 'all') { + for (const childRequirement of requirement.children) collectMissing(childRequirement, children, missing) + } + + if (requirement.mode === 'one') { + const anySatisfied = requirement.children.some((childRequirement) => + evaluateRequirement(childRequirement, children), + ) + + if (!anySatisfied) { + const requiredChildren = requirement.children + .map((element) => getRequiredOnly(element)) + .filter(Boolean) as Requirement[] + + if (requiredChildren.length === 0) return + + const options = requiredChildren.flatMap((element) => getReadableNames(element)).join(', ') + + missing.push(`One of: ${options}`) + } + } + } +} + +function getReadableNames(requirement: Requirement): string[] { + if (requirement.kind === 'element') { + return [requirement.name] + } + + if (requirement.kind === 'group') { + return requirement.children.flatMap((element) => getReadableNames(element)) + } + + return [] +} + +function getRequiredOnly(requirement: Requirement): Requirement | null { + if (requirement.kind === 'element') { + return requirement.required ? requirement : null + } + + if (requirement.kind === 'group') { + const filteredChildren = requirement.children + .map((element) => getRequiredOnly(element)) + .filter(Boolean) as Requirement[] + + if (filteredChildren.length === 0) return null + + return { + ...requirement, + children: filteredChildren, + } + } + + return null +} + +interface RequirementBase { + kind: 'element' | 'group' +} + +interface ElementRequirement extends RequirementBase { + kind: 'element' + name: string + required: boolean +} + +export type Requirement = ElementRequirement | GroupRequirement + +interface GroupRequirement extends RequirementBase { + kind: 'group' + mode: 'all' | 'one' + children: Requirement[] +}