From bdf5a26e3b01c9829efe5814fb779d3f5a4f3652 Mon Sep 17 00:00:00 2001 From: Kamal Qureshi Date: Fri, 30 May 2025 01:49:03 +0500 Subject: [PATCH 1/5] Added suport for JSONForms through ANTd renderer --- client/package.json | 12 + .../comps/calendarComp/calendarConstants.tsx | 25 +- .../src/components/Dropdown.tsx | 9 +- .../lowcoder-design/src/components/form.tsx | 6 +- .../lowcoder/src/components/JSLibraryTree.tsx | 6 +- .../jsonSchemaFormComp/JsonFormsRenderer.tsx | 655 ++++++++++++++++++ .../ObjectFieldTemplate.tsx | 136 ++-- .../jsonSchemaFormComp/jsonSchemaFormComp.tsx | 277 +++++--- .../comps/selectInputComp/checkboxComp.tsx | 11 +- .../comps/comps/selectInputComp/radioComp.tsx | 8 +- .../columnTypeComps/columnMarkdownComp.tsx | 2 +- .../lowcoder/src/comps/comps/textComp.tsx | 4 +- .../src/comps/controls/multiSelectControl.tsx | 7 +- .../lowcoder/src/comps/queries/esQuery.tsx | 7 +- .../src/comps/queries/resourceDropdown.tsx | 6 +- .../src/layout/compSelectionWrapper.tsx | 5 +- .../components/CreateApiKeyModal.tsx | 2 +- .../src/pages/common/previewHeader.tsx | 6 +- .../src/pages/queryLibrary/LeftNav.tsx | 5 +- .../setting/idSource/styledComponents.tsx | 11 +- client/yarn.lock | 413 ++++++++++- package-lock.json | 108 +++ package.json | 2 + translations/locales/en.js | 4 +- yarn.lock | 76 +- 25 files changed, 1576 insertions(+), 227 deletions(-) create mode 100644 client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/JsonFormsRenderer.tsx diff --git a/client/package.json b/client/package.json index 38e4182542..fe18db2b32 100644 --- a/client/package.json +++ b/client/package.json @@ -72,14 +72,25 @@ "eslint-plugin-only-ascii@^0.0.0": "patch:eslint-plugin-only-ascii@npm%3A0.0.0#./.yarn/patches/eslint-plugin-only-ascii-npm-0.0.0-29e3417685.patch" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@jsonforms/core": "3.5.1", + "@jsonforms/material-renderers": "^3.5.1", + "@jsonforms/react": "3.5.1", "@lottiefiles/react-lottie-player": "^3.5.3", "@remixicon/react": "^4.1.1", + "@rjsf/antd": "^6.0.0-beta.10", + "@rjsf/core": "^6.0.0-beta.10", + "@rjsf/utils": "^6.0.0-beta.10", + "@rjsf/validator-ajv8": "^6.0.0-beta.10", "@supabase/supabase-js": "^2.45.4", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.1", "@types/styled-components": "^5.1.34", + "antd": "^5.25.3", "antd-mobile": "^5.34.0", "chalk": "4", + "dayjs": "^1.11.13", "flag-icons": "^7.2.1", "number-precision": "^1.6.0", "react-countup": "^6.5.3", @@ -88,6 +99,7 @@ "resize-observer-polyfill": "^1.5.1", "rollup": "^4.22.5", "simplebar": "^6.2.5", + "styled-components": "^6.1.18", "tui-image-editor": "^3.15.3" } } diff --git a/client/packages/lowcoder-comps/src/comps/calendarComp/calendarConstants.tsx b/client/packages/lowcoder-comps/src/comps/calendarComp/calendarConstants.tsx index 306f90a79d..c680763be4 100644 --- a/client/packages/lowcoder-comps/src/comps/calendarComp/calendarConstants.tsx +++ b/client/packages/lowcoder-comps/src/comps/calendarComp/calendarConstants.tsx @@ -28,7 +28,7 @@ import { SlotLabelContentArg, ViewContentArg, } from "@fullcalendar/core"; -import { default as Form } from "antd/es/form"; +import { default as Form, FormProps } from "antd/es/form"; type Theme = typeof Theme; type EventModalStyleType = typeof EventModalStyleType; @@ -727,7 +727,7 @@ export const Event = styled.div<{ margin-left: 15px; white-space: pre-wrap; word-break: break-word; - margin-top: 2px; + margin-top: 2px; } &.small { @@ -761,10 +761,10 @@ export const Event = styled.div<{ } &.past { background-color: ${(props) => - `rgba(${props?.$extendedProps?.color}, 0.3)`}; + `rgba(${props?.$extendedProps?.color}, 0.3)`}; &::before { - background-color: ${(props) => props?.$extendedProps?.color}; - opacity: 0.3; + background-color: ${(props) => props?.$extendedProps?.color}; + opacity: 0.3; } &::before, .event-title, @@ -776,9 +776,11 @@ export const Event = styled.div<{ } `; -export const FormWrapper = styled(Form)<{ - $modalStyle?: EventModalStyleType -}>` +export const FormWrapper = styled(Form)< + FormProps & { + $modalStyle?: EventModalStyleType; + } +>` .ant-form-item-label { width: 125px; text-align: left; @@ -787,12 +789,11 @@ export const FormWrapper = styled(Form)<{ label:not(.ant-form-item-required) { margin-left: 2px; } - label.ant-form-item-required{ + label.ant-form-item-required { margin-left: 2px; } label span { ${UnderlineCss} - } } @@ -1117,12 +1118,12 @@ export const slotLabelFormat = [ { hour: "2-digit", minute: "2-digit", - }, + }, ] as FormatterInput[]; export const slotLabelFormatWeek = [ { week: "short" }, - { hour: "2-digit" }, + { hour: "2-digit" }, ] as FormatterInput[]; export const slotLabelFormatMonth = [ diff --git a/client/packages/lowcoder-design/src/components/Dropdown.tsx b/client/packages/lowcoder-design/src/components/Dropdown.tsx index 5adb4e1869..e02508e8c0 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,7 +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 = { diff --git a/client/packages/lowcoder-design/src/components/form.tsx b/client/packages/lowcoder-design/src/components/form.tsx index ca469d5072..52b65415a4 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 Form, FormProps } from "antd/es/form"; +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"; @@ -429,7 +429,7 @@ export const FormKeyValueItem = (props: FormItemProps) => ( ); -export const DatasourceForm = styled(Form)` +export const DatasourceForm = styled(Form)` display: flex; flex-direction: column; gap: 8px; diff --git a/client/packages/lowcoder/src/components/JSLibraryTree.tsx b/client/packages/lowcoder/src/components/JSLibraryTree.tsx index e7c464c605..19a4c971f2 100644 --- a/client/packages/lowcoder/src/components/JSLibraryTree.tsx +++ b/client/packages/lowcoder/src/components/JSLibraryTree.tsx @@ -134,7 +134,11 @@ const JSLibraryCollapse = styled(Collapse)<{ $mode: "row" | "column" }>` } .lib-label-name { - ${EllipsisTextCss}; + ${css` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + `} } `; 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..0d9d4f8657 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/jsonSchemaFormComp/JsonFormsRenderer.tsx @@ -0,0 +1,655 @@ +import React, { useState, useCallback, useEffect } 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/debounce"; +import dayjs from "dayjs"; +import { trans } from "i18n"; + +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 Category { + type: "Category"; + label?: string; + i18n?: string; + elements: Array; + rule?: { + effect: "SHOW" | "HIDE"; + condition: { + scope: string; + schema: { + const: any; + }; + }; + }; +} + +interface Control { + type: "Control"; + scope: string; + options?: { + multi?: boolean; + slider?: boolean; + restrict?: boolean; + }; + rule?: { + effect: "SHOW" | "HIDE"; + condition: { + scope: string; + schema: { + const: any; + }; + }; + }; +} + +interface HorizontalLayout { + type: "HorizontalLayout"; + elements: Control[]; +} + +type Layout = HorizontalLayout; + +interface Categorization { + type: "Categorization"; + elements: Category[]; + options?: { + variant?: "tabs" | "stepper"; + showNavButtons?: boolean; + }; +} + +interface FieldUiSchema { + type?: string; + scope?: string; + options?: { + multi?: boolean; + slider?: boolean; + restrict?: boolean; + }; + [key: string]: any; +} + +export interface JsonFormsUiSchema { + type?: string; + elements?: Array; + options?: { + variant?: "tabs" | "stepper"; + showNavButtons?: boolean; + }; + [key: string]: FieldUiSchema | JsonFormsUiSchema | any; +} + +export interface JsonFormsRendererProps { + schema: JsonSchema; + data: any; + onChange: (data: any) => void; + style?: any; + showVerticalScrollbar?: boolean; + autoHeight?: boolean; + resetAfterSubmit?: boolean; + uiSchema?: JsonFormsUiSchema; + onSubmit?: () => void; +} + +const JsonFormsRenderer: React.FC = ({ + schema, + data, + onChange, + style, + uiSchema, + onSubmit, + resetAfterSubmit, +}) => { + // Local state to handle immediate updates + const [localData, setLocalData] = useState(data); + // Track focused field + const [focusedField, setFocusedField] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + + // 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(shouldShowElement) + .map((element, index) => ( + + {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(shouldShowElement) + .map((element, index) => { + if (element.type === "Control") { + return
{renderControl(element)}
; + } else if (element.type === "HorizontalLayout") { + return
{renderLayout(element)}
; + } + return null; + })} +
+ ); + }; + + const renderField = ( + key: string, + fieldSchema: any, + value: any, + path: string = "" + ) => { + 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 handleFocus = () => setFocusedField(fullPath); + const handleBlur = () => setFocusedField(null); + + const handleChange = (newValue: any) => { + const newData = { ...localData }; + if (path) { + const pathParts = path.split("."); + let current = newData; + for (let i = 0; i < pathParts.length; i++) { + if (i === pathParts.length - 1) { + current[pathParts[i]] = { + ...current[pathParts[i]], + [key]: newValue, + }; + } else { + current = current[pathParts[i]]; + } + } + } else { + newData[key] = newValue; + } + + // Update local state immediately + setLocalData(newData); + // Debounce the parent update + debouncedOnChange(newData); + }; + + // 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()) { + handleChange(date.format('YYYY-MM-DD')); + } else { + handleChange(null); + } + }} + 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 ( + +