diff --git a/client/packages/lowcoder-design/src/components/Dropdown.tsx b/client/packages/lowcoder-design/src/components/Dropdown.tsx index 5adb4e1869..b2a9d27663 100644 --- a/client/packages/lowcoder-design/src/components/Dropdown.tsx +++ b/client/packages/lowcoder-design/src/components/Dropdown.tsx @@ -18,9 +18,9 @@ export const DropdownContainer = styled.div<{ $placement: ControlPlacement }>` width: ${(props) => props.$placement === "right" ? "calc(100% - 96px)" - : "bottom" - ? "calc(100% - 112px)" - : "calc(100% - 136px"}; + : props.$placement === "bottom" + ? "calc(100% - 112px)" + : "calc(100% - 136px)"}; flex-grow: 1; .ant-select:not(.ant-select-customize-input) .ant-select-selector { @@ -124,8 +124,8 @@ const FlexDiv = styled.div` const LabelWrapper = styled.div<{ $placement: ControlPlacement }>` flex-shrink: 0; - width: ${(props) => (props.$placement === "right" ? "96px" : "bottom" ? "112px" : "136px")}; -`; + width: ${(props) => props.$placement === "right" ? "96px" : props.$placement === "bottom" ? "112px" : "136px"}; + `; export type OptionType = { readonly label: ReactNode; diff --git a/client/packages/lowcoder-design/src/components/form.tsx b/client/packages/lowcoder-design/src/components/form.tsx index ca469d5072..347f2e729b 100644 --- a/client/packages/lowcoder-design/src/components/form.tsx +++ b/client/packages/lowcoder-design/src/components/form.tsx @@ -1,5 +1,5 @@ import { default as Form } from "antd/es/form"; -import { default as AntdFormItem, FormItemProps as AntdFormItemProps } from "antd/es/form/FormItem"; +import { default as AntdFormItem, FormItemProps as AntdFormItemProps} from "antd/es/form/FormItem"; import { default as Input, InputProps } from "antd/es/input"; import { default as TextArea, TextAreaProps } from "antd/es/input/TextArea"; import { default as InputNumber, InputNumberProps } from "antd/es/input-number"; diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 86576c3eba..2b5ecdca73 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -24,6 +24,7 @@ "@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/react-fontawesome": "latest", + "@jsonforms/core": "^3.5.1", "@lottiefiles/dotlottie-react": "^0.13.0", "@manaflair/redux-batch": "^1.0.0", "@rjsf/antd": "^5.24.9", @@ -45,6 +46,7 @@ "coolshapes-react": "lowcoder-org/coolshapes-react", "copy-to-clipboard": "^3.3.3", "core-js": "^3.25.2", + "dayjs": "^1.11.13", "echarts": "^5.4.3", "echarts-for-react": "^3.0.2", "echarts-wordcloud": "^2.1.0", diff --git a/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/JsonFormsRenderer.tsx b/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/JsonFormsRenderer.tsx new file mode 100644 index 0000000000..cbdab8a0ff --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/JsonFormsRenderer.tsx @@ -0,0 +1,689 @@ +import React, { useState, useCallback, useEffect, ReactNode } from "react"; +import { + Form, + Input, + InputNumber, + Switch, + DatePicker, + Select, + Space, + Button, + Slider, + Tabs, + Row, + Col, + Steps, +} from "antd"; +import styled from "styled-components"; +import type { JsonSchema } from "@jsonforms/core"; +import type { JSONSchema7 } from "json-schema"; +import { debounce } from "lodash"; +import dayjs from "dayjs"; +import { trans } from "i18n"; +import type { + JsonFormsUiSchema, + FieldUiSchema, + Layout, + Categorization, + ValidationState, + JsonFormsRendererProps, + Category, + Control +} from "./types"; +import type { SwitchChangeEventHandler } from "antd/es/switch"; +const { TextArea } = Input; + +const Container = styled.div` + .ant-form-item { + margin-bottom: 16px; + } + + .ant-form-item-label { + padding: 0; + font-weight: 600; + } + + .ant-form-item-extra { + min-height: 0px; + } + + .ant-form-item-explain { + line-height: 24px; + } + + .ant-steps { + margin-bottom: 24px; + } + + .stepper-navigation { + margin-top: 24px; + display: flex; + justify-content: space-between; + } +`; + +interface HorizontalLayout { + type: "HorizontalLayout"; + elements: Control[]; +} + +const JsonFormsRenderer: React.FC = ({ + schema, + data, + onChange, + style, + uiSchema, + onSubmit, + resetAfterSubmit, + validationState: externalValidationState, + onValidationChange, +}) => { + // Local state to handle immediate updates + const [localData, setLocalData] = useState(data); + // Track focused field + const [focusedField, setFocusedField] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [internalValidationState, setInternalValidationState] = useState({}); + const [isSubmitted, setIsSubmitted] = useState(false); + + // Use external validation state if provided, otherwise use internal + const validationState = externalValidationState || internalValidationState; + const setValidationState = useCallback((newState: ValidationState | ((prev: ValidationState) => ValidationState)) => { + if (typeof newState === 'function') { + const updatedState = newState(validationState); + setInternalValidationState(updatedState); + onValidationChange?.(updatedState); + } else { + setInternalValidationState(newState); + onValidationChange?.(newState); + } + }, [validationState, onValidationChange]); + + // Update local data when prop data changes + useEffect(() => { + setLocalData(data); + }, [data]); + + // Debounced onChange handler + const debouncedOnChange = useCallback( + debounce((newData: any) => { + onChange(newData); + }, 300), + [onChange] + ); + + const getFieldUiSchema = (path: string): FieldUiSchema | undefined => { + if (!uiSchema) return undefined; + + // For JSONForms UI schema, we need to find the Control element that matches the path + if (uiSchema.type === "HorizontalLayout" && Array.isArray(uiSchema.elements)) { + const control = uiSchema.elements.find((element: any) => { + if (element.type === "Control") { + // Convert the scope path to match our field path + // e.g., "#/properties/multilineString" -> "multilineString" + const scopePath = element.scope?.replace("#/properties/", ""); + return scopePath === path; + } + return false; + }); + return control; + } + + // Fallback to the old path-based lookup for backward compatibility + const pathParts = path.split('.'); + let current: any = uiSchema; + for (const part of pathParts) { + if (current && typeof current === 'object') { + current = current[part]; + } else { + return undefined; + } + } + return current as FieldUiSchema; + }; + + const evaluateRule = (rule: any, data: any): boolean => { + if (!rule) return true; + + const { scope, schema: ruleSchema } = rule.condition; + const path = scope.replace("#/properties/", "").split("/"); + let value = data; + + for (const part of path) { + value = value?.[part]; + } + + return value === ruleSchema.const; + }; + + const shouldShowElement = (element: any): boolean => { + if (!element.rule) return true; + return evaluateRule(element.rule, data); + }; + + const renderLayout = (layout: Layout) => { + if (layout.type === "HorizontalLayout") { + return ( + + {layout.elements + .filter((element: Control) => shouldShowElement(element)) + .map((element: Control, index: number) => ( + + {renderControl(element)} + + ))} + + ); + } + return null; + }; + + const renderControl = (control: Control) => { + // Convert scope path to actual data path + // e.g., "#/properties/address/properties/street" -> "address.street" + const scopePath = control.scope.replace("#/properties/", "").replace("/properties/", "."); + const path = scopePath.split("."); + let fieldSchema: JSONSchema7 | undefined = schema as JSONSchema7; + let value = data; + + // Navigate through the schema to find the correct field schema + for (const part of path) { + if (fieldSchema?.properties) { + fieldSchema = fieldSchema.properties[part] as JSONSchema7 | undefined; + } + if (value && typeof value === 'object') { + value = value[part]; + } + } + + if (!fieldSchema) return null; + + // Use the last part of the path as the field key + const fieldKey = path[path.length - 1]; + // Use the parent path for nested objects + const parentPath = path.slice(0, -1).join("."); + + return renderField( + fieldKey, + fieldSchema, + value, + parentPath + ); + }; + + const renderCategory = (category: Category) => { + if (!shouldShowElement(category)) return null; + + return ( +
+ {category.elements + .filter((element: Control | Layout) => shouldShowElement(element)) + .map((element: Control | Layout, index: number) => { + if (element.type === "Control") { + return
{renderControl(element as Control)}
; + } else if (element.type === "HorizontalLayout") { + return
{renderLayout(element as Layout)}
; + } + return null; + })} +
+ ); + }; + // Add validation function + const validateField = useCallback((path: string, value: any, fieldSchema: any) => { + const errors: string[] = []; + + // Required field validation - check if field name is in schema.required array + const fieldName = path.split('.').pop() || ''; + if (schema.required?.includes(fieldName) && (value === undefined || value === null || value === '')) { + errors.push('This field is required'); + } + + // Type-specific validation + if (value !== undefined && value !== null) { + switch (fieldSchema.type) { + case 'string': + if (fieldSchema.minLength && value.length < fieldSchema.minLength) { + errors.push(`Minimum length is ${fieldSchema.minLength}`); + } + if (fieldSchema.maxLength && value.length > fieldSchema.maxLength) { + errors.push(`Maximum length is ${fieldSchema.maxLength}`); + } + if (fieldSchema.pattern && !new RegExp(fieldSchema.pattern).test(value)) { + errors.push('Invalid format'); + } + break; + case 'number': + case 'integer': + if (fieldSchema.minimum !== undefined && value < fieldSchema.minimum) { + errors.push(`Minimum value is ${fieldSchema.minimum}`); + } + if (fieldSchema.maximum !== undefined && value > fieldSchema.maximum) { + errors.push(`Maximum value is ${fieldSchema.maximum}`); + } + break; + } + } + + return errors; + }, []) + // Helper to get value at a dot-separated path + const getValueAtPath = (obj: any, path: string) => { + if (!path) return obj; + return path.split('.').reduce((acc, part) => (acc ? acc[part] : undefined), obj); + }; + // Update validation state when data changes + useEffect(() => { + if (isSubmitted) { + const newValidationState: ValidationState = {}; + const validateObject = (obj: any, schema: any, path: string = '') => { + if (schema.properties) { + Object.entries(schema.properties).forEach(([key, fieldSchema]: [string, any]) => { + const fullPath = path ? `${path}.${key}` : key; + const value = getValueAtPath(obj, key); + newValidationState[fullPath] = { + errors: validateField(fullPath, getValueAtPath(obj, key), fieldSchema), + touched: true + }; + if (fieldSchema.type === 'object' && fieldSchema.properties) { + validateObject(getValueAtPath(obj, key) || {}, fieldSchema, fullPath); + } + }); + } + }; + validateObject(data, schema); + setValidationState(newValidationState); + } + }, [data, schema, validateField, isSubmitted]); + const handleValueChange = (newValue: any, fieldKey: string, fieldPath?: string) => { + const newData = { ...localData }; + if (fieldPath) { + const pathParts = fieldPath.split("."); + let current = newData; + for (let i = 0; i < pathParts.length; i++) { + if (i === pathParts.length - 1) { + current[pathParts[i]] = { + ...current[pathParts[i]], + [fieldKey]: newValue, + }; + } else { + current = current[pathParts[i]]; + } + } + } else { + newData[fieldKey] = newValue; + } + + setLocalData(newData); + debouncedOnChange(newData); + }; + + const createInputHandler = (fieldKey: string, fieldPath?: string) => { + return (e: React.ChangeEvent) => { + handleValueChange(e.target.value, fieldKey, fieldPath); + }; + }; + + const createNumberHandler = (fieldKey: string, fieldPath?: string) => { + return (value: number | null) => { + handleValueChange(value, fieldKey, fieldPath); + }; + }; + + const createSwitchHandler = (fieldKey: string, fieldPath?: string) => { + return (checked: boolean) => { + handleValueChange(checked, fieldKey, fieldPath); + }; + }; + + const createArrayHandler = (fieldKey: string, fieldPath?: string) => { + return (newItems: any[]) => { + handleValueChange(newItems, fieldKey, fieldPath); + }; + }; + + const renderField = ( + key: string, + fieldSchema: any, + value: any, + path: string = "" + ): ReactNode => { + const fullPath = path ? `${path}.${key}` : key; + const label = fieldSchema.title || key; + const required = schema.required?.includes(key); + const uiSchemaForField = getFieldUiSchema(fullPath); + const isMultiline = uiSchemaForField?.options?.multi === true; + const isSlider = uiSchemaForField?.options?.slider === true; + const isRestrict = uiSchemaForField?.options?.restrict === true; + const isFocused = focusedField === fullPath; + const fieldValidation = validationState[fullPath]; + const showErrors = isSubmitted && fieldValidation?.touched; + + const handleFocus = () => setFocusedField(fullPath); const handleBlur = () => { + setFocusedField(null); + // Validate field on blur + const errors = validateField(fullPath, value, fieldSchema); + setValidationState(prev => { + const newState = { + ...prev, + [fullPath]: { + errors, + touched: true + } + }; + return newState; + }); + }; + + // Modify Form.Item to include validation + const formItemProps = { + key: fullPath, + label: label, + required: required, + extra: isFocused ? fieldSchema.description : undefined, + validateStatus: (fieldValidation?.touched && fieldValidation?.errors.length ? 'error' : undefined) as "" | "error" | "success" | "warning" | "validating" | undefined, + help: fieldValidation?.touched ? fieldValidation?.errors.join(', ') : undefined, + }; + + // Handle nested objects + if (fieldSchema.type === "object" && fieldSchema.properties) { + return ( + + + {Object.entries(fieldSchema.properties).map( + ([subKey, subSchema]: [string, any]) => + renderField(subKey, subSchema, value?.[subKey], fullPath) + )} + + + ); + } + + // Handle arrays + if (fieldSchema.type === "array") { + const items = value || []; + return ( + + + {items.map((item: any, index: number) => ( + + {renderField(`${index}`, fieldSchema.items, item, fullPath)} + + + ))} + + + + ); + } + + // Handle different field types + switch (fieldSchema.type) { + case "string": + if (fieldSchema.format === "date") { + return ( + + { + if (date && date.isValid()) { + handleValueChange(date.format('YYYY-MM-DD'), key, path); + } else { + handleValueChange(null, key, path); + } + }} + onFocus={handleFocus} + onBlur={handleBlur} + format="YYYY-MM-DD" + allowClear={true} + inputReadOnly={true} + disabledDate={(current) => { + // Disable future dates + return current && current.isAfter(dayjs().endOf('day')); + }} + picker="date" + /> + + ); + } + if (fieldSchema.enum) { + return ( + +