diff --git a/CHANGELOG.md b/CHANGELOG.md index cf78d536..defcf4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Previous changes not listed in this document can be traced using Git history. +## Unreleased + +### Added +- Added `displayingDependencies` property to `Form` widget to show / hide fields + ## [1.0.25] - 2026-05-12 ### Changed diff --git a/src/components/ListEditor/ListEditor.tsx b/src/components/ListEditor/ListEditor.tsx index 099c5443..41611571 100644 --- a/src/components/ListEditor/ListEditor.tsx +++ b/src/components/ListEditor/ListEditor.tsx @@ -1,6 +1,6 @@ import { DeleteOutlined, PlusCircleOutlined } from '@ant-design/icons' import { Button, Input, List, Space, Typography } from 'antd' -import { useEffect, useState } from 'react' +import { useState } from 'react' type ListEditorType = { data?: string[] @@ -8,39 +8,46 @@ type ListEditorType = { } const ListEditor = ({ data = [], onChange }: ListEditorType) => { - const [currentString, setCurrentString] = useState() - const [list, setList] = useState(data) - - useEffect(() => { - setList(data) - }, [data]) + const [currentString, setCurrentString] = useState('') const onAdd = () => { - if (currentString && currentString !== '') { - const newList = [...list, currentString] - onChange(newList) - setList(newList) + const trimmed = currentString.trim() + + if (trimmed !== '') { + onChange([...data, trimmed]) + setCurrentString('') } } const onRemove = (index: number) => { - const newList = list.filter((_, i) => i !== index) - onChange(newList) - setList(newList) + onChange(data.filter((_, i) => i !== index)) } return ( - setCurrentString(value.target.value)} /> - + ( - } onClick={() => onRemove(index)} shape='circle' type='text' />]}> + } + onClick={() => onRemove(index)} + shape='circle' + type='text' + />, + ]} + > {item} )} diff --git a/src/examples/widgets/Form/Form.example.yaml b/src/examples/widgets/Form/Form.example.yaml index 0fc9bd6a..2b4000b8 100644 --- a/src/examples/widgets/Form/Form.example.yaml +++ b/src/examples/widgets/Form/Form.example.yaml @@ -1260,6 +1260,190 @@ spec: resource: restactions verb: GET --- +# Displaying dependencies field +# Target: validate fields whose displaying depend on the value of another field +# Expected behavior: initially one field is rendered, with the following rendered only if the previous one is compiled +kind: Form +apiVersion: widgets.templates.krateo.io/v1beta1 +metadata: + name: example-form-displaying-dependencies + namespace: krateo-system +spec: + widgetData: + autocomplete: + - name: typeOptionAutocompleteTest + - name: regions + resourceRefId: get-italian-regions + displayingDependencies: + - name: surname + dependsOn: + name: name + conditionType: notEmpty + - name: middleName + dependsOn: + name: name + conditionType: notEmpty + - name: tagDescription + dependsOn: + name: tags + conditionType: notEmpty + - name: stringTest + dependsOn: + name: typeStringTest + conditionType: value + value: + type: string + stringValue: test + - name: integerTest + dependsOn: + name: typeIntegerTest + conditionType: value + value: + type: integer + integerValue: 2 + - name: booleanTest + dependsOn: + name: typeBooleanTest + conditionType: value + value: + type: boolean + booleanValue: true + - name: arrayTest + dependsOn: + name: typeArrayTest + conditionType: value + value: + type: array + arrayValue: ['test'] + - name: optionRadioTest + dependsOn: + name: typeOptionRadioTest + conditionType: value + value: + type: option + optionValue: + value: "1" + - name: optionSelectTest + dependsOn: + name: typeOptionSelectTest + conditionType: value + value: + type: option + optionValue: + value: ["1", "2"] + - name: optionAutocompleteTest + dependsOn: + name: typeOptionAutocompleteTest + conditionType: value + value: + type: option + optionValue: + value: "1" + - name: optionAutocompleteLabelTest + dependsOn: + name: regions + conditionType: value + value: + type: option + optionValue: + value: "7" + label: "Liguria" + submitActionId: submit-autocomplete + stringSchema: | + { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name (tests not empty string)", + "description": "Type something to see two dependent fields displayed ('Middle Name' and 'Surname')" + }, + "middleName": { "type": "string", "title": "Middle Name" }, + "surname": { "type": "string", "title": "Surname" }, + "tags": { + "type": "array", + "title": "Tags (tests not empty array)", + "items": { "type": "string" }, + "description": "Add a value to see a dependent field displayed ('Tag Description')" + }, + "tagDescription": { "type": "string", "title": "Tag Description" }, + "typeStringTest": { + "type": "string", + "title": "String test (tests equality on string value)", + "description": "Type 'test' to see a dependent fields displayed ('String test')" + }, + "stringTest": { "type": "string", "title": "String test" }, + "typeIntegerTest": { + "type": "integer", + "title": "Integer test (tests equality on integer value)", + "description": "Type '2' to see a dependent fields displayed ('Integer test')" + }, + "integerTest": { "type": "integer", "title": "Integer test" }, + "typeBooleanTest": { + "type": "boolean", + "title": "Boolean test (tests equality on boolean value)", + "description": "Set switch to true to see a dependent field displayed ('Boolean test'" + }, + "booleanTest": { "type": "boolean", "title": "Boolean test" }, + "typeArrayTest": { + "type": "array", + "title": "Array test (tests equality on array value)", + "description": "Add a 'test' value to see a dependent field displayed ('Array test')" + }, + "arrayTest": { "type": "array", "title": "Array test" }, + "typeOptionRadioTest": { + "type": "string", + "title": "Option test (tests equality on option value - radio button)", + "enum": ["1","2","3"], + "description": "Select the '1' value to see a dependent field displayed ('Option radio test')" + }, + "optionRadioTest": { "type": "string", "title": "Option radio test" }, + "typeOptionSelectTest": { + "type": "array", + "title": "Option test (tests equality on option value - multi select)", + "description": "Select the '1' and '2' value to see a dependent field displayed ('Option multi select test')", + "items": { + "type": "string", + "enum": ["1","2","3"] + } + }, + "optionSelectTest": { "type": "string", "title": "Option multi select test" }, + "typeOptionAutocompleteTest": { + "type": "string", + "title": "Option test (tests equality on option value - autocomplete)", + "description": "Select the '1' value to see a dependent field displayed ('Autocomplete test')", + "enum": ["1","2","3"] + }, + "optionAutocompleteTest": { "type": "string", "title": "Autocomplete test" }, + "regions": { + "type": "string", + "title": "Region (tests equality on option value and label - autocomplete)", + "description": "Select the 'Liguria' value to see a dependent field displayed ('Autocomplete with label test')" + }, + "optionAutocompleteLabelTest": { "type": "string", "title": "Autocomplete with label test" } + } + } + actions: + rest: + - id: submit-autocomplete + type: rest + resourceRefId: resource-ref-1 + headers: [] + resourcesRefs: + items: + - id: resource-ref-1 + apiVersion: composition.krateo.io/v1 + name: submit-autocomplete + namespace: krateo-system + resource: fireworksapps + verb: POST + - id: get-italian-regions + apiVersion: templates.krateo.io/v1 + name: get-italian-regions + namespace: krateo-system + resource: restactions + verb: GET +--- # Missing action # Target: validate error handling when action is missing. # Expected behavior: an error is displayed on submit to inform that the action is not defined. diff --git a/src/examples/widgets/Form/Form.menu.yaml b/src/examples/widgets/Form/Form.menu.yaml index 2c0cac2e..8020a834 100644 --- a/src/examples/widgets/Form/Form.menu.yaml +++ b/src/examples/widgets/Form/Form.menu.yaml @@ -100,6 +100,7 @@ spec: - resourceRefId: example-form-autocomplete-restaction-parameter-panel - resourceRefId: example-form-dependencies-panel - resourceRefId: example-form-autocomplete-dependencies-array-objects-panel + - resourceRefId: example-form-displaying-dependencies-panel resourcesRefs: items: - id: example-form-missing-schema-panel @@ -228,6 +229,12 @@ spec: namespace: krateo-system resource: panels verb: GET + - id: example-form-displaying-dependencies-panel + apiVersion: widgets.templates.krateo.io/v1beta1 + name: example-form-displaying-dependencies-panel + namespace: krateo-system + resource: panels + verb: GET --- kind: Column apiVersion: widgets.templates.krateo.io/v1beta1 @@ -796,6 +803,26 @@ spec: --- kind: Panel apiVersion: widgets.templates.krateo.io/v1beta1 +metadata: + name: example-form-displaying-dependencies-panel + namespace: krateo-system +spec: + widgetData: + actions: {} + title: 'Displaying dependencies field' + items: + - resourceRefId: example-form-displaying-dependencies + resourcesRefs: + items: + - id: example-form-displaying-dependencies + apiVersion: widgets.templates.krateo.io/v1beta1 + name: example-form-displaying-dependencies + namespace: krateo-system + resource: forms + verb: GET +--- +kind: Panel +apiVersion: widgets.templates.krateo.io/v1beta1 metadata: name: example-form-missing-action-panel namespace: krateo-system diff --git a/src/hooks/useFieldVisibility.tsx b/src/hooks/useFieldVisibility.tsx new file mode 100644 index 00000000..360a99e4 --- /dev/null +++ b/src/hooks/useFieldVisibility.tsx @@ -0,0 +1,13 @@ +import { Form, type FormInstance } from 'antd' + +import type { DisplayDependency } from '../widgets/Form/FormGenerator' +import { evaluateVisibility } from '../widgets/Form/utils' + +export const useFieldVisibility = (dependency: DisplayDependency | undefined, form: FormInstance) => { + const watchedValue = Form.useWatch( + dependency?.dependsOn.name?.split('.') ?? [], + form, + ) as unknown + + return evaluateVisibility(dependency, watchedValue) +} diff --git a/src/widgets/Form/FieldContainer.tsx b/src/widgets/Form/FieldContainer.tsx new file mode 100644 index 00000000..f64a945d --- /dev/null +++ b/src/widgets/Form/FieldContainer.tsx @@ -0,0 +1,72 @@ +import { Form, type FormInstance } from 'antd' +import type { FormItemProps, Rule } from 'antd/es/form' + +import { useFieldVisibility } from '../../hooks/useFieldVisibility' + +import styles from './Form.module.css' +import type { DisplayDependency } from './FormGenerator' + +type FieldContainerProps = { + children: React.ReactNode + displayingDependency?: DisplayDependency + description?: string + descriptionTooltip: boolean + form: FormInstance + id: string + label: React.ReactNode + name: string[] + optionalHidden: boolean + preserve?: boolean + required: boolean + initialValue?: FormItemProps['initialValue'] + rules?: Rule[] + shouldUpdate?: FormItemProps['shouldUpdate'] + valuePropName?: FormItemProps['valuePropName'] +} + +const FieldContainer = ({ + children, + description, + descriptionTooltip, + displayingDependency, + form, + id, + initialValue, + label, + name, + optionalHidden, + preserve, + required, + rules, + shouldUpdate, + valuePropName, +}: FieldContainerProps) => { + const visible = useFieldVisibility(displayingDependency, form) + + if (!visible) { + return null + } + + const hidden = optionalHidden && !required + + return ( +
+ +
+ ) +} + +export default FieldContainer diff --git a/src/widgets/Form/Form.schema.json b/src/widgets/Form/Form.schema.json index 03364ca9..6c4377dd 100644 --- a/src/widgets/Form/Form.schema.json +++ b/src/widgets/Form/Form.schema.json @@ -422,6 +422,92 @@ } } }, + "displayingDependencies": { + "type": "array", + "description": "Configuration for the form fields whose displaying is dependent from other form fields.", + "items": { + "type": "object", + "required": ["dependsOn", "name"], + "additionalProperties": false, + "properties": { + "name": { + "description": "the name of the field", + "type": "string" + }, + "dependsOn": { + "description": "the field on which this field depends on", + "type": "object", + "additionalProperties": false, + "required": ["name", "conditionType"], + "properties": { + "name": { + "type": "string", + "description": "the name of the field on which this field depends on" + }, + "conditionType": { + "type": "string", + "description": "`notEmpty` (default) indicates that the current field is displayed if the dependency field has a non-empty value. `value` indicates that the current field is displayed only when the dependency field matches a specific value.", + "enum": ["notEmpty", "value"] + }, + "value": { + "type": "object", + "description": "Expected value used when `type = value`.", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "integer", + "boolean", + "array", + "option", + "null" + ], + "description": "Expected dependency value type." + }, + "stringValue": { + "type": "string", + "description": "Value if type = string" + }, + "integerValue": { + "type": "integer", + "description": "Value if type = integer" + }, + "booleanValue": { + "type": "boolean", + "description": "Value if type = boolean" + }, + "arrayValue": { + "type": "array", + "description": "Value if type = array", + "items": { + "type": "string" + } + }, + "optionValue": { + "description": "Value if type = option: includes a single value or a { label, value } object", + "additionalProperties": false, + "required": ["value"], + "properties": { + "label": { + "description": "Label of the option", + "type": "string" + }, + "value": { + "description": "Value of the option", + "type": ["string", "number", "boolean"] + } + } + } + } + } + } + } + } + } + }, "objectFields": { "type": "array", "description": "configuration for object fields in the form", diff --git a/src/widgets/Form/Form.tsx b/src/widgets/Form/Form.tsx index 8e9ea6c2..b5f30edf 100644 --- a/src/widgets/Form/Form.tsx +++ b/src/widgets/Form/Form.tsx @@ -80,6 +80,7 @@ const Form = ({ resourcesRefs, widget, widgetData }: WidgetProps buttonConfig, dependencies, displayMenu, + displayingDependencies, fieldDescription, initialValues, objectFields, @@ -172,6 +173,7 @@ const Form = ({ resourcesRefs, widget, widgetData }: WidgetProps dependencies={dependencies} descriptionTooltip={fieldDescription === 'tooltip'} displayMenu={displayMenu} + displayingDependencies={displayingDependencies} formId={formId} initialValues={initialValues} objectFields={objectFields} diff --git a/src/widgets/Form/Form.type.d.ts b/src/widgets/Form/Form.type.d.ts index 2b913950..6b0ad93d 100644 --- a/src/widgets/Form/Form.type.d.ts +++ b/src/widgets/Form/Form.type.d.ts @@ -306,6 +306,66 @@ export interface Form { */ resourceRefId: string }[] + /** + * Configuration for the form fields whose displaying is dependent from other form fields. + */ + displayingDependencies?: { + /** + * the name of the field + */ + name: string + /** + * the field on which this field depends on + */ + dependsOn: { + /** + * the name of the field on which this field depends on + */ + name: string + /** + * `notEmpty` (default) indicates that the current field is displayed if the dependency field has a non-empty value. `value` indicates that the current field is displayed only when the dependency field matches a specific value. + */ + conditionType: 'notEmpty' | 'value' + /** + * Expected value used when `type = value`. + */ + value?: { + /** + * Expected dependency value type. + */ + type: 'string' | 'integer' | 'boolean' | 'array' | 'option' | 'null' + /** + * Value if type = string + */ + stringValue?: string + /** + * Value if type = integer + */ + integerValue?: number + /** + * Value if type = boolean + */ + booleanValue?: boolean + /** + * Value if type = array + */ + arrayValue?: string[] + /** + * Value if type = option + */ + optionValue?: { + /** + * Label of the option + */ + label?: string + /** + * Value of the option + */ + value: string | number | boolean + } + } + } + }[] /** * configuration for object fields in the form */ diff --git a/src/widgets/Form/FormGenerator.tsx b/src/widgets/Form/FormGenerator.tsx index c9c28fd5..e765c5cc 100644 --- a/src/widgets/Form/FormGenerator.tsx +++ b/src/widgets/Form/FormGenerator.tsx @@ -5,18 +5,19 @@ import type { AnchorLinkItemProps } from 'antd/es/anchor/Anchor' import type { Rule } from 'antd/es/form' import type { DefaultOptionType } from 'antd/es/select' import type { JSONSchema4 } from 'json-schema' -import type { ValidateErrorEntity } from 'rc-field-form/lib/interface' +import type { Store, ValidateErrorEntity } from 'rc-field-form/lib/interface' import { useCallback, useEffect, useMemo, useState } from 'react' import ListEditor from '../../components/ListEditor' import ListObjectFields from '../../components/ListObjectFields' import type { ResourcesRefs } from '../../types/Widget' +import FieldContainer from './FieldContainer' import AsyncSelect from './fields/AsyncSelect' import AutoComplete from './fields/AutoComplete' import type { FormWidgetData } from './Form' import styles from './Form.module.css' -import { getOptionsFromEnum } from './utils' +import { evaluateVisibility, getOptionsFromEnum, isObjectSchema, isRecord } from './utils' type FormGeneratorType = { descriptionTooltip: boolean @@ -26,11 +27,14 @@ type FormGeneratorType = { schema: JSONSchema4 autocomplete?: FormWidgetData['autocomplete'] dependencies?: FormWidgetData['dependencies'] + displayingDependencies?: FormWidgetData['displayingDependencies'] displayMenu?: FormWidgetData['displayMenu'] objectFields?: FormWidgetData['objectFields'] initialValues?: FormWidgetData['initialValues'] } +export type DisplayDependency = NonNullable[number] + const getOptionalCount = (node: JSONSchema4, requiredFields: string[]) => { const currentProperties = node.properties let totalCount = 0 @@ -54,14 +58,6 @@ const getOptionalCount = (node: JSONSchema4, requiredFields: string[]) => { return { optionalCount, totalCount } } -const isObjectSchema = (property: JSONSchema4) => { - return property.type === 'object' || - (Array.isArray(property.type) && property.type.includes('object')) || - (!!property.properties && typeof property.properties === 'object') -} - -const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null - const getInitialValue = (object: unknown, path: string): unknown => { const pathParts = path.split('.') @@ -82,6 +78,7 @@ const FormGenerator = ({ dependencies, descriptionTooltip = false, displayMenu = true, + displayingDependencies, formId, initialValues, objectFields, @@ -92,6 +89,12 @@ const FormGenerator = ({ const [form] = Form.useForm() const requiredFields: string[] = Array.isArray(schema.required) ? schema.required : [] const [optionalHidden, setOptionalHidden] = useState(false) + const [anchorValues, setAnchorValues] = useState({}) + + const displayingDependenciesMap = useMemo( + () => new Map((displayingDependencies ?? []).map(dep => [dep.name, dep])), + [displayingDependencies], + ) const [transformedInitialValues, setTransformedInitialValues] = useState(undefined) @@ -316,184 +319,121 @@ const FormGenerator = ({ return [] } - const renderField = (label: string, name: string, node: JSONSchema4, required: boolean, formInstance?: FormInstance) => { - const currentForm = formInstance ?? form - - if (!node.type) { - console.error(`Field ${name} does not have a type in the schema or stringSchema`) - return null - } - - if (Array.isArray(node.type)) { - console.error(`type as array is not supported: ${node.type.join(',')} for field ${name}`) - return null + const shouldUpdateField = (prev: Store, curr: Store, displayingDependencyPath?: string) => { + if (!displayingDependencyPath) { + return false } - const rules: Rule[] = [] - - if (required) { - rules.push({ message: 'Insert a value', required: true }) - } - - if (node.pattern) { - rules.push({ message: 'Insert right value', pattern: new RegExp(node.pattern) }) - } + return (getInitialValue(prev, displayingDependencyPath) !== getInitialValue(curr, displayingDependencyPath)) + } + const renderFieldContent = ( + node: JSONSchema4, + name: string, + currentForm: FormInstance, + ) => { switch (node.type) { case 'string': { - const formItemContent = (() => { - // AsyncSelect - if (dependencies) { - const data = dependencies.find(field => field.name === name) + // AsyncSelect + if (dependencies) { + const data = dependencies.find(field => field.name === name) - if (data) { - return ( - - ) - } + if (data) { + return ( + + ) } + } - const options = getOptionsFromEnum(node.enum) + const options = getOptionsFromEnum(node.enum) - // Autocomplete - if (autocomplete) { - const data = autocomplete.find(field => field.name === name) + // Autocomplete + if (autocomplete) { + const data = autocomplete.find(field => field.name === name) - if (data) { - return ( - - ) - } + if (data) { + return ( + + ) } + } - // Enum - if (options) { - const currentValue = getInitialValue(transformedInitialValues, name) - const optionExists = options.some(({ value }) => String(value) === String(currentValue)) - - if (currentValue !== undefined && !optionExists) { - console.warn(`Invalid initial value for "${name}"`, currentValue) - form.setFieldValue(name.split('.'), undefined) - } + // Enum + if (options) { + const currentValue = getInitialValue(transformedInitialValues, name) + const optionExists = options.some(({ value }) => String(value) === String(currentValue)) - if (options.length > 4) { - return } - // Default - return - })() + return ( + + {options.map(({ label, value }) => ( + + {label} + + ))} + + ) + } - return ( -
- -
- ) + // Default + return } case 'boolean': return ( -
- - - -
+ ) case 'array': { - const formItemContent = (() => { - // objects - if (objectFields && node.items) { - const objFields = objectFields.find(({ path }) => path === name) - if (objFields) { - return ( - parseData(node.items as JSONSchema4, [], true, drawerForm)} - onChange={(values) => form.setFieldValue(name.split('.'), values)} - schema={node.items as JSONSchema4} - value={(form.getFieldValue(name.split('.')) as unknown[]) || []} - /> - ) - } - } - - // strings - const options = node.items && !Array.isArray(node.items) ? getOptionsFromEnum(node.items.enum) : undefined - if (options) { - return + } return ( -
- -
+ { + currentForm.setFieldValue(name.split('.'), values) + }} + /> ) } @@ -501,35 +441,68 @@ const FormGenerator = ({ const min = node.minimum const max = node.maximum - const existing = form.getFieldValue(name.split('.')) as number | undefined - if (existing === undefined && min !== undefined) { - form.setFieldValue(name.split('.'), min) - } - return ( -
- -
+ <> + {min !== undefined && max !== undefined && max - min < 100 + ? + : + } + ) } } } - const getAnchorList = (): AnchorLinkItemProps[] => { + const renderField = (label: string, name: string, node: JSONSchema4, required: boolean, formInstance?: FormInstance) => { + const currentForm = formInstance ?? form + + if (!node.type) { + console.error(`Field ${name} does not have a type in the schema or stringSchema`) + return null + } + + if (Array.isArray(node.type)) { + console.error(`type as array is not supported: ${node.type.join(',')} for field ${name}`) + return null + } + + const rules: Rule[] = [] + + if (required) { + rules.push({ message: 'Insert a value', required: true }) + } + + if (node.pattern) { + rules.push({ message: 'Insert right value', pattern: new RegExp(node.pattern) }) + } + + const displayingDependency = displayingDependenciesMap.get(name) + const displayingDependencyPath = displayingDependency?.dependsOn.name + + const preserve = node.type === 'boolean' + + return ( + shouldUpdateField(prev as Store, curr as Store, displayingDependencyPath)} + valuePropName={node.type === 'boolean' ? 'checked' : undefined} + > + {renderFieldContent(node, name, currentForm)} + + ) + } + + const getAnchorList = useCallback((values: Store): AnchorLinkItemProps[] => { const parseData = ( node: JSONSchema4, name = '', @@ -550,19 +523,25 @@ const FormGenerator = ({ ? parseData(schemaItem, currentName, isRequired) : [] - const shouldShow = isRequired || children.length > 0 || !optionalHidden - if (!shouldShow) { return [] } + const dependency = displayingDependenciesMap.get(currentName) + const dependencyValue = dependency + ? getInitialValue(values, dependency.dependsOn.name) + : undefined + + const isVisible = evaluateVisibility(dependency, dependencyValue) + + const shouldShow = isVisible && (isRequired || children.length > 0 || !optionalHidden) + if (!shouldShow) { + return [] + } return [ { - href: schemaItem.type === 'object' ? '#' : `#${currentName}`, + href: isObjectSchema(schemaItem) ? '#' : `#${currentName}`, key: currentName, - title: - schemaItem.type === 'object' ? ( - {key} - ) : ( - key - ), + title: isObjectSchema(schemaItem) + ? {key} + : key, ...(children.length > 0 ? { children } : {}), }, ] @@ -570,7 +549,9 @@ const FormGenerator = ({ } return parseData(schema) - } + }, [displayingDependenciesMap, optionalHidden, schema]) + + const anchorItems = useMemo(() => getAnchorList(anchorValues), [anchorValues, getAnchorList]) const onFinishFailed = useCallback(({ errorFields }: ValidateErrorEntity) => { const errorField = errorFields[0].name.join('.') @@ -638,6 +619,9 @@ const FormGenerator = ({ event.preventDefault() setInitialValues() }} + onValuesChange={(_, allValues) => { + setAnchorValues(allValues as Store) + }} > {parseData(schema)} @@ -649,7 +633,7 @@ const FormGenerator = ({ document.getElementById('anchor-content') as HTMLDivElement} - items={getAnchorList()} + items={anchorItems} onClick={handleAnchorClick} /> diff --git a/src/widgets/Form/fields/AsyncSelect.tsx b/src/widgets/Form/fields/AsyncSelect.tsx index 31fea50a..25b118e5 100644 --- a/src/widgets/Form/fields/AsyncSelect.tsx +++ b/src/widgets/Form/fields/AsyncSelect.tsx @@ -5,6 +5,7 @@ import useApp from 'antd/es/app/useApp' import type { DefaultOptionType } from 'antd/es/select' import { useEffect, useMemo, useRef } from 'react' +import { useAuth } from '../../../context/AuthContext' import { useConfigContext } from '../../../context/ConfigContext' import type { ResourcesRefs } from '../../../types/Widget' import type { FormWidgetData } from '../Form' @@ -21,6 +22,7 @@ type AsyncSelectProps = { const AsyncSelect = ({ data, form, initialValue, resourcesRefs }: AsyncSelectProps) => { const { notification } = useApp() const { config } = useConfigContext() + const { accessToken } = useAuth() const { dependsOn, extra: { key }, name, resourceRefId } = data @@ -56,10 +58,18 @@ const AsyncSelect = ({ data, form, initialValue, resourcesRefs }: AsyncSelectPro } }, [dependFieldValue, form, name]) + // eslint-disable-next-line @tanstack/query/exhaustive-deps const { data: options = [], isLoading } = useQuery({ enabled: !!(queryValue && config), - queryFn: () => getOptionsFromResourceRefId(queryValue as string, resourceRefId, resourcesRefs, key, notification, config), - // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryFn: () => getOptionsFromResourceRefId( + queryValue as string, + resourceRefId, + resourcesRefs, + key, + notification, + config, + accessToken + ), queryKey: ['async-select-options', resourceRefId, dependFieldValue, key], refetchOnWindowFocus: false, staleTime: 5 * 60 * 1000, diff --git a/src/widgets/Form/fields/AutoComplete.tsx b/src/widgets/Form/fields/AutoComplete.tsx index 5fe38471..c2caf92f 100644 --- a/src/widgets/Form/fields/AutoComplete.tsx +++ b/src/widgets/Form/fields/AutoComplete.tsx @@ -7,6 +7,7 @@ import type { DefaultOptionType } from 'antd/es/select' import debounce from 'lodash/debounce' import { useEffect, useMemo, useRef, useState } from 'react' +import { useAuth } from '../../../context/AuthContext' import { useConfigContext } from '../../../context/ConfigContext' import type { ResourcesRefs } from '../../../types/Widget' import type { FormWidgetData } from '../Form' @@ -26,6 +27,8 @@ const AutoComplete = ({ data, form, initialValue, options, resourcesRefs }: Auto const { config } = useConfigContext() const { extra, name, resourceRefId } = data + const { accessToken } = useAuth() + const [searchValue, setSearchValue] = useState('') const [debouncedValue, setDebouncedValue] = useState('') const [inputValue, setInputValue] = useState('') @@ -44,11 +47,11 @@ const AutoComplete = ({ data, form, initialValue, options, resourcesRefs }: Auto return () => debouncedUpdate.cancel() }, [searchValue, debouncedUpdate]) + // eslint-disable-next-line @tanstack/query/exhaustive-deps const { data: queriedOptions = [], isLoading } = useQuery({ enabled: !!(queryValue && resourceRefId && config), queryFn: () => - getOptionsFromResourceRefId(queryValue as string, resourceRefId, resourcesRefs, extra?.key, notification, config), - // eslint-disable-next-line @tanstack/query/exhaustive-deps + getOptionsFromResourceRefId(queryValue as string, resourceRefId, resourcesRefs, extra?.key, notification, config, accessToken), queryKey: ['autocomplete-options', resourceRefId, queryValue, extra?.key], refetchOnWindowFocus: false, staleTime: 5 * 60 * 1000, diff --git a/src/widgets/Form/fields/utils.ts b/src/widgets/Form/fields/utils.ts index e8945a1b..48f6aec7 100644 --- a/src/widgets/Form/fields/utils.ts +++ b/src/widgets/Form/fields/utils.ts @@ -1,7 +1,6 @@ import type { NotificationInstance } from 'antd/es/notification/interface' import type { DefaultOptionType } from 'antd/es/select' -import { useAuth } from '../../../context/AuthContext' import type { Config } from '../../../context/ConfigContext' import type { ResourcesRefs } from '../../../types/Widget' import { getResourceRef } from '../../../utils/utils' @@ -12,10 +11,9 @@ export const getOptionsFromResourceRefId = async ( resourcesRefs: ResourcesRefs, valueKey: string | undefined, notification: NotificationInstance, - config: Config | undefined + config: Config | undefined, + accessToken: string | null ): Promise => { - const { accessToken } = useAuth() - if (!resourceRefId) { return [] } diff --git a/src/widgets/Form/utils.ts b/src/widgets/Form/utils.ts index d0985748..be84785f 100644 --- a/src/widgets/Form/utils.ts +++ b/src/widgets/Form/utils.ts @@ -1,6 +1,79 @@ import type { DefaultOptionType } from 'antd/es/select' import type { JSONSchema4, JSONSchema4Type } from 'json-schema' +import type { DisplayDependency } from './FormGenerator' + +const isOptionValue = (value: unknown): value is { label?: string; value: string | number | boolean } => { + return (typeof value === 'object' && value !== null && 'value' in value) +} + +export const evaluateVisibility = (dependency: DisplayDependency | undefined, dependencyValue: unknown): boolean => { + if (!dependency) { + return true + } + + if (dependency.dependsOn.conditionType === 'notEmpty') { + if (dependencyValue === undefined || dependencyValue === null) { + return false + } + + if (typeof dependencyValue === 'string') { + return dependencyValue.trim().length > 0 + } + + if (Array.isArray(dependencyValue)) { + return dependencyValue.length > 0 + } + + return true + } + + if (dependency.dependsOn.conditionType === 'value') { + const expected = dependency.dependsOn.value + if (!expected) { + return true + } + + switch (expected.type) { + case 'string': + return dependencyValue === expected.stringValue + case 'integer': + return dependencyValue === expected.integerValue + case 'boolean': + return dependencyValue === expected.booleanValue + case 'array': + return JSON.stringify(dependencyValue) === JSON.stringify(expected.arrayValue) + case 'option': { + const expectedValue = expected.optionValue?.value + + // { label, value } + if (isOptionValue(dependencyValue)) { + return String(dependencyValue.value) === String(expectedValue) + } + + // ['a', 'b'] + if (Array.isArray(dependencyValue)) { + const expectedValues = Array.isArray(expectedValue) ? expectedValue : [expectedValue] + + return ( + dependencyValue.length === expectedValues.length + && expectedValues.every(expectedItem => dependencyValue.includes(String(expectedItem))) + ) + } + + // 'a' | 1 | true + return String(dependencyValue) === String(expectedValue) + } + case 'null': + return dependencyValue === null + default: + return true + } + } + + return true +} + export const getDefaultsFromSchema = (schema: JSONSchema4): Record => { const defaults: Record = {} @@ -25,3 +98,11 @@ export const getOptionsFromEnum = (enumValues: JSONSchema4Type[] | undefined): D .filter((value): value is string | number => typeof value === 'string' || typeof value === 'number') .map((value) => ({ label: String(value), value })) } + +export const isObjectSchema = (property: JSONSchema4) => { + return property.type === 'object' + || (Array.isArray(property.type) && property.type.includes('object')) + || (!!property.properties && typeof property.properties === 'object') +} + +export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null