diff --git a/client/VERSION b/client/VERSION index 74500cee16..460b6fd404 100644 --- a/client/VERSION +++ b/client/VERSION @@ -1 +1 @@ -2.7.4 \ No newline at end of file +2.7.5 \ No newline at end of file diff --git a/client/package.json b/client/package.json index 2eb560f555..37849e6fa6 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-frontend", - "version": "2.7.4", + "version": "2.7.5", "type": "module", "private": true, "workspaces": [ diff --git a/client/packages/lowcoder-comps/package.json b/client/packages/lowcoder-comps/package.json index fdd37e0d2f..9dbd281533 100644 --- a/client/packages/lowcoder-comps/package.json +++ b/client/packages/lowcoder-comps/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-comps", - "version": "2.7.4", + "version": "2.7.5", "type": "module", "license": "MIT", "dependencies": { diff --git a/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts b/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts index 94621fcb16..d0f0e7d6f6 100644 --- a/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts +++ b/client/packages/lowcoder-design/src/components/colorSelect/colorUtils.ts @@ -75,6 +75,26 @@ const isValidColor = (str?: string) => { return colord(str).isValid(); }; +const isTransparentColor = (color?: string) => { + if (!color) return true; + + // Check for common transparent values + if (color === 'transparent' || color === '') return true; + + // Check if it's a valid color with alpha = 0 + try { + const colorObj = colord(color); + if (colorObj.isValid()) { + return colorObj.alpha() === 0; + } + } catch (e) { + // If colord can't parse it, consider it transparent + return true; + } + + return false; +}; + export const isDarkColor = (colorStr: string) => { return brightnessCompare(colorStr, 0.75); }; @@ -122,4 +142,4 @@ export const darkenColor = (colorStr: string, intensity: number) => { return color.darken(intensity).toHex().toUpperCase(); }; -export { toRGBA, toHex, alphaOfRgba, isValidColor, isValidGradient }; +export { toRGBA, toHex, alphaOfRgba, isValidColor, isValidGradient, isTransparentColor }; diff --git a/client/packages/lowcoder-sdk-webpack-bundle/package.json b/client/packages/lowcoder-sdk-webpack-bundle/package.json index e0851a7e69..e83e77363d 100644 --- a/client/packages/lowcoder-sdk-webpack-bundle/package.json +++ b/client/packages/lowcoder-sdk-webpack-bundle/package.json @@ -1,7 +1,7 @@ { "name": "lowcoder-sdk-webpack-bundle", "description": "", - "version": "2.7.3", + "version": "2.7.5", "main": "index.jsx", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", diff --git a/client/packages/lowcoder-sdk/package.json b/client/packages/lowcoder-sdk/package.json index aeb3d8dd33..522d92b258 100644 --- a/client/packages/lowcoder-sdk/package.json +++ b/client/packages/lowcoder-sdk/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder-sdk", - "version": "2.7.3", + "version": "2.7.5", "type": "module", "files": [ "src", diff --git a/client/packages/lowcoder/package.json b/client/packages/lowcoder/package.json index 2075232dc5..418d72a392 100644 --- a/client/packages/lowcoder/package.json +++ b/client/packages/lowcoder/package.json @@ -1,6 +1,6 @@ { "name": "lowcoder", - "version": "2.7.4", + "version": "2.7.5", "private": true, "type": "module", "main": "src/index.sdk.ts", diff --git a/client/packages/lowcoder/src/api/commonSettingApi.ts b/client/packages/lowcoder/src/api/commonSettingApi.ts index b2f746454b..226617d96c 100644 --- a/client/packages/lowcoder/src/api/commonSettingApi.ts +++ b/client/packages/lowcoder/src/api/commonSettingApi.ts @@ -123,7 +123,7 @@ export function isThemeColorKey(key: string) { case "padding": case "gridColumns": case "textSize": - case "lineHeight": + case "lineHeight": return true; } return false; diff --git a/client/packages/lowcoder/src/base/codeEditor/extensions.tsx b/client/packages/lowcoder/src/base/codeEditor/extensions.tsx index 3682cf4e04..cd36cb9c28 100644 --- a/client/packages/lowcoder/src/base/codeEditor/extensions.tsx +++ b/client/packages/lowcoder/src/base/codeEditor/extensions.tsx @@ -27,7 +27,7 @@ import { foldKeymap, indentOnInput, } from "@codemirror/language"; -import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; +import { defaultKeymap, history, historyKeymap, insertTab, indentLess, indentMore } from "@codemirror/commands"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; import { Diagnostic, linter, lintKeymap } from "@codemirror/lint"; import { type EditorState, Prec } from "@codemirror/state"; @@ -282,7 +282,20 @@ export function useFocusExtension(onFocus?: (focused: boolean) => void): [Extens } function indentWithTabExtension(open?: boolean) { - return open ?? true ? keymap.of([indentWithTab]) : []; + if (!(open ?? true)) return []; + return keymap.of([ + { + key: "Tab", + run: (view: EditorView) => { + const { main } = view.state.selection; + if (!main.empty && main.from !== main.to) { + return indentMore(view); + } + return insertTab(view); + }, + }, + { key: "Shift-Tab", run: indentLess }, + ]); } export function lineNoExtension(showLineNumber?: boolean) { @@ -493,26 +506,26 @@ export function useExtensions(props: CodeEditorProps) { basicSetup, defaultTheme, highlightJsExt, - autocompletionExtension, focusExtension, lineNoExt, languageExt, onChangeExt, placeholderExt, indentWithTabExt, + autocompletionExtension, tooltipExt, lintExt, iconExt, ], [ highlightJsExt, - autocompletionExtension, focusExtension, lineNoExt, languageExt, onChangeExt, placeholderExt, indentWithTabExt, + autocompletionExtension, tooltipExt, lintExt, iconExt, diff --git a/client/packages/lowcoder/src/components/table/EditableCell.tsx b/client/packages/lowcoder/src/components/table/EditableCell.tsx index b886167448..5064fffa13 100644 --- a/client/packages/lowcoder/src/components/table/EditableCell.tsx +++ b/client/packages/lowcoder/src/components/table/EditableCell.tsx @@ -225,6 +225,7 @@ function EditableCellComp(props: EditableCellProps) { key={`normal-view-${cellIndex}`} tabIndex={editable ? 0 : -1 } onFocus={enterEditFn} + style={{ width: '100%', height: '100%'}} > {normalView} diff --git a/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx b/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx index 2b33bf3766..0339aea4de 100644 --- a/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/autoCompleteComp/autoCompleteComp.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useState, useCallback } from "react"; +import { ReactNode, useEffect, useState, useCallback, useRef } from "react"; import { Input, Section, sectionNames } from "lowcoder-design"; import { BoolControl } from "comps/controls/boolControl"; import { styleControl } from "comps/controls/styleControl"; @@ -148,12 +148,19 @@ let AutoCompleteCompBase = (function () { const [activationFlag, setActivationFlag] = useState(false); const [searchtext, setsearchtext] = useState(props.value.value); const [validateState, setvalidateState] = useState({}); + + // Use simple refs like text input components + const changeRef = useRef(false); + const touchRef = useRef(false); // 是否中文环境 const [chineseEnv, setChineseEnv] = useState(getDayJSLocale() === "zh-cn"); useEffect(() => { - setsearchtext(props.value.value); + // Only update local state from props if user hasn't touched the input + if (!touchRef.current) { + setsearchtext(props.value.value); + } activationFlag && setvalidateState(textInputValidate(getTextInputValidate())); }, [ @@ -247,19 +254,27 @@ let AutoCompleteCompBase = (function () { props.valueInItems.onChange(false); setvalidateState(textInputValidate(getTextInputValidate())); setsearchtext(value); + changeRef.current = true; + touchRef.current = true; + + // Update parent value immediately to prevent sync issues props.value.onChange(value); props.onEvent("change"); + if(!Boolean(value)) { props.selectedOption.onChange({}); } }, [props.valueInItems, getTextInputValidate, props.value, props.onEvent, props.selectedOption]); const handleSelect = useCallback((data: string, option: any) => { - setsearchtext(option[valueOrLabel]); + const selectedValue = option[valueOrLabel]; + setsearchtext(selectedValue); props.valueInItems.onChange(true); - props.value.onChange(option[valueOrLabel]); + props.value.onChange(selectedValue); props.selectedOption.onChange(option); props.onEvent("submit"); + changeRef.current = true; + touchRef.current = true; }, [valueOrLabel, props.valueInItems, props.value, props.onEvent, props.selectedOption]); const handleFocus = useCallback(() => { @@ -268,6 +283,7 @@ let AutoCompleteCompBase = (function () { }, [props.onEvent]); const handleBlur = useCallback(() => { + touchRef.current = false; props.onEvent("blur"); }, [props.onEvent]); diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx new file mode 100644 index 0000000000..ea35e2c529 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/fileComp/ImageCaptureModal.tsx @@ -0,0 +1,202 @@ +import React, { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { default as Button } from "antd/es/button"; +import Dropdown from "antd/es/dropdown"; +import type { ItemType } from "antd/es/menu/interface"; +import Skeleton from "antd/es/skeleton"; +import Menu from "antd/es/menu"; +import Flex from "antd/es/flex"; +import styled from "styled-components"; +import { trans } from "i18n"; +import { CustomModal } from "lowcoder-design"; + +const CustomModalStyled = styled(CustomModal)` + top: 10vh; + .react-draggable { + max-width: 100%; + width: 500px; + + video { + width: 100%; + } + } +`; + +const Error = styled.div` + color: #f5222d; + height: 100px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +`; + +const Wrapper = styled.div` + img, + video, + .ant-skeleton { + width: 100%; + height: 400px; + max-height: 70vh; + position: relative; + object-fit: cover; + background-color: #000; + } + .ant-skeleton { + h3, + li { + background-color: transparent; + } + } +`; + +const ReactWebcam = React.lazy(() => import("react-webcam")); + +export const ImageCaptureModal = (props: { + showModal: boolean; + onModalClose: () => void; + onImageCapture: (image: string) => void; +}) => { + const [errMessage, setErrMessage] = useState(""); + const [videoConstraints, setVideoConstraints] = useState({ + facingMode: "environment", + }); + const [modeList, setModeList] = useState([]); + const [dropdownShow, setDropdownShow] = useState(false); + const [imgSrc, setImgSrc] = useState(); + const webcamRef = useRef(null); + + useEffect(() => { + if (props.showModal) { + setImgSrc(""); + setErrMessage(""); + setVideoConstraints({ facingMode: "environment" }); + setDropdownShow(false); + } + }, [props.showModal]); + + const handleMediaErr = (err: any) => { + if (typeof err === "string") { + setErrMessage(err); + } else { + if (err.message === "getUserMedia is not implemented in this browser") { + setErrMessage(trans("scanner.errTip")); + } else { + setErrMessage(err.message); + } + } + }; + + const handleCapture = useCallback(() => { + const imageSrc = webcamRef.current?.getScreenshot?.(); + setImgSrc(imageSrc); + }, [webcamRef]); + + const getModeList = () => { + navigator.mediaDevices.enumerateDevices().then((data) => { + const videoData = data.filter((item) => item.kind === "videoinput"); + const faceModeList = videoData.map((item, index) => ({ + label: item.label || trans("scanner.camera", { index: index + 1 }), + key: item.deviceId, + })); + setModeList(faceModeList); + }); + }; + + return ( + + {!!errMessage ? ( + {errMessage} + ) : ( + props.showModal && ( + + {imgSrc ? ( + webcam + ) : ( + }> + + + )} + {imgSrc ? ( + + + + + ) : ( + + + setDropdownShow(value)} + popupRender={() => ( + { + setVideoConstraints({ deviceId: { exact: value.key } }); + setDropdownShow(false); + }} + /> + )} + > + + + + )} + + ) + )} + + ); +}; + +export default ImageCaptureModal; + + diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx new file mode 100644 index 0000000000..2f230ad38e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/fileComp/draggerUpload.tsx @@ -0,0 +1,326 @@ +import { default as AntdUpload } from "antd/es/upload"; +import { default as Button } from "antd/es/button"; +import { UploadFile, UploadChangeParam, UploadFileStatus, RcFile } from "antd/es/upload/interface"; +import { useState, useEffect } from "react"; +import styled, { css } from "styled-components"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeValueAction, + CompAction, + multiChangeAction, +} from "lowcoder-core"; +import { hasIcon } from "comps/utils"; +import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import { resolveValue, resolveParsedValue, commonProps } from "./fileComp"; +import { FileStyleType, AnimationStyleType, heightCalculator, widthCalculator } from "comps/controls/styleControlConstants"; +import { ImageCaptureModal } from "./ImageCaptureModal"; +import { v4 as uuidv4 } from "uuid"; +import { checkIsMobile } from "@lowcoder-ee/util/commonUtils"; +import { darkenColor } from "components/colorSelect/colorUtils"; + +const IconWrapper = styled.span` + display: flex; +`; + +const getDraggerStyle = (style: FileStyleType) => { + return css` + .ant-upload-drag { + border-radius: ${style.radius}; + rotate: ${style.rotation}; + margin: ${style.margin}; + padding: ${style.padding}; + width: ${widthCalculator(style.margin)}; + height: ${heightCalculator(style.margin)}; + border-width: ${style.borderWidth}; + border-style: ${style.borderStyle}; + border-color: ${style.border}; + background: ${style.background}; + transition: all 0.3s; + .ant-upload-drag-container { + .ant-upload-text { + color: ${style.text}; + font-family: ${style.fontFamily}; + font-size: ${style.textSize}; + font-weight: ${style.textWeight}; + font-style: ${style.fontStyle}; + text-decoration: ${style.textDecoration}; + text-transform: ${style.textTransform}; + } + + .ant-upload-hint { + color: ${darkenColor(style.text, 0.3)}; + font-family: ${style.fontFamily}; + font-size: calc(${style.textSize} * 0.9); + } + + .ant-upload-drag-icon { + span { + color: ${style.accent}; + } + } + } + } + + .ant-upload-list { + .ant-upload-list-item { + border-color: ${style.border}; + + .ant-upload-list-item-name { + color: ${style.text}; + } + } + } + `; +}; + +const DragAreaOverlay = styled.div` + // make it position fixed, transparent and match the parent + position: fixed; + background-color: transparent; + width: 100%; + height: 100%; + z-index: 1; + top: 0; + left: 0; +`; + +const StyledDraggerUpload = styled(AntdUpload.Dragger)<{ + $auto: boolean; + $style: FileStyleType; + $animationStyle: AnimationStyleType; +}>` + height: ${(p) => (p.$auto ? "auto" : "100%")}; + position: relative; + + /* AntD wraps dragger + list in this */ + &.ant-upload-wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: auto; /* allows list to be visible if it grows */ + } + + /* The drag area itself */ + .ant-upload-drag { + ${(p) => + !p.$auto && + ` + flex: 1 1 auto; + min-height: 120px; + min-width: 0; + `} + position: relative; + ${(props) => props.$animationStyle} + + .ant-upload-drag-container { + .ant-upload-drag-icon { + display: flex; + justify-content: center; + } + } + } + + /* The list sits below the dragger */ + .ant-upload-list { + ${(p) => + !p.$auto && + ` + flex: 0 0 auto; + `} + position: relative; + z-index: 2; + } + + /* Apply custom styling */ + ${(props) => props.$style && getDraggerStyle(props.$style)} +`; +interface DraggerUploadProps { + value: Array; + files: any[]; + fileType: string[]; + showUploadList: boolean; + disabled: boolean; + onEvent: (eventName: string) => Promise; + style: FileStyleType; + animationStyle: AnimationStyleType; + parseFiles: boolean; + parsedValue: Array; + prefixIcon: any; + suffixIcon: any; + forceCapture: boolean; + minSize: number; + maxSize: number; + maxFiles: number; + uploadType: "single" | "multiple" | "directory"; + text: string; + dragHintText?: string; + dispatch: (action: CompAction) => void; + autoHeight: boolean; + tabIndex?: number; +} + +export const DraggerUpload = (props: DraggerUploadProps) => { + const { dispatch, files, style, autoHeight, animationStyle } = props; + const [fileList, setFileList] = useState( + files.map((f) => ({ ...f, status: "done" })) as UploadFile[] + ); + const [showModal, setShowModal] = useState(false); + const isMobile = checkIsMobile(window.innerWidth); + + useEffect(() => { + if (files.length === 0 && fileList.length !== 0) { + setFileList([]); + } + }, [files]); + + const handleOnChange = (param: UploadChangeParam) => { + const uploadingFiles = param.fileList.filter((f) => f.status === "uploading"); + if (uploadingFiles.length !== 0) { + setFileList(param.fileList); + return; + } + + let maxFiles = props.maxFiles; + if (props.uploadType === "single") { + maxFiles = 1; + } else if (props.maxFiles <= 0) { + maxFiles = 100; + } + + const uploadedFiles = param.fileList.filter((f) => f.status === "done"); + + if (param.file.status === "removed") { + const index = props.files.findIndex((f) => f.uid === param.file.uid); + dispatch( + multiChangeAction({ + value: changeValueAction( + [...props.value.slice(0, index), ...props.value.slice(index + 1)], + false + ), + files: changeValueAction( + [...props.files.slice(0, index), ...props.files.slice(index + 1)], + false + ), + parsedValue: changeValueAction( + [...props.parsedValue.slice(0, index), ...props.parsedValue.slice(index + 1)], + false + ), + }) + ); + props.onEvent("change"); + } else { + const unresolvedValueIdx = Math.min(props.value.length, uploadedFiles.length); + const unresolvedParsedValueIdx = Math.min(props.parsedValue.length, uploadedFiles.length); + + Promise.all([ + resolveValue(uploadedFiles.slice(unresolvedValueIdx)), + resolveParsedValue(uploadedFiles.slice(unresolvedParsedValueIdx)), + ]).then(([value, parsedValue]) => { + dispatch( + multiChangeAction({ + value: changeValueAction([...props.value, ...value].slice(-maxFiles), false), + files: changeValueAction( + uploadedFiles + .map((file) => _.pick(file, ["uid", "name", "type", "size", "lastModified"])) + .slice(-maxFiles), + false + ), + ...(props.parseFiles + ? { + parsedValue: changeValueAction( + [...props.parsedValue, ...parsedValue].slice(-maxFiles), + false + ), + } + : {}), + }) + ); + props.onEvent("change"); + props.onEvent("parse"); + }); + } + + setFileList(uploadedFiles.slice(-maxFiles)); + }; + + return ( + <> + { + if (!file.size || file.size <= 0) { + messageInstance.error(`${file.name} ` + trans("file.fileEmptyErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + + if ( + (!!props.minSize && file.size < props.minSize) || + (!!props.maxSize && file.size > props.maxSize) + ) { + messageInstance.error(`${file.name} ` + trans("file.fileSizeExceedErrorMsg")); + return AntdUpload.LIST_IGNORE; + } + return true; + }} + onChange={handleOnChange} + > +

+ {hasIcon(props.prefixIcon) ? ( + {props.prefixIcon} + ) : ( + + )} +

+

+ {props.text || trans("file.dragAreaText")} +

+

+ {props.dragHintText} +

+ {/* we need a custom overlay to add the onClick handler */} + {props.forceCapture && !isMobile && ( + { + e.preventDefault(); + e.stopPropagation(); + setShowModal(true); + }} + /> + )} + +
+ + setShowModal(false)} + onImageCapture={async (image) => { + setShowModal(false); + const res: Response = await fetch(image); + const blob: Blob = await res.blob(); + const file = new File([blob], "image.jpg", { type: "image/jpeg" }); + const fileUid = uuidv4(); + const uploadFile = { + uid: fileUid, + name: file.name, + type: file.type, + size: file.size, + lastModified: file.lastModified, + lastModifiedDate: (file as any).lastModifiedDate, + status: "done" as UploadFileStatus, + originFileObj: file as RcFile, + }; + handleOnChange({ file: uploadFile, fileList: [...fileList, uploadFile] }); + }} + /> + + ); +}; diff --git a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx index 8580e2d5e1..360a815569 100644 --- a/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/fileComp/fileComp.tsx @@ -42,14 +42,12 @@ import { CommonNameConfig, NameConfig, withExposingConfigs } from "../../generat import { formDataChildren, FormDataPropertyView } from "../formComp/formDataConstants"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; import { CustomModal } from "lowcoder-design"; - -import React, { useContext } from "react"; +import { DraggerUpload } from "./draggerUpload"; +import { ImageCaptureModal } from "./ImageCaptureModal"; +import { useContext } from "react"; import { EditorContext } from "comps/editorState"; -import type { ItemType } from "antd/es/menu/interface"; -import Skeleton from "antd/es/skeleton"; -import Menu from "antd/es/menu"; -import Flex from "antd/es/flex"; import { checkIsMobile } from "@lowcoder-ee/util/commonUtils"; +import { AutoHeightControl } from "@lowcoder-ee/comps/controls/autoHeightControl"; const FileSizeControl = codeControl((value) => { if (typeof value === "number") { @@ -131,7 +129,7 @@ const commonValidationFields = (children: RecordConstructorToComp & { uploadType: "single" | "multiple" | "directory"; } @@ -211,45 +209,9 @@ const IconWrapper = styled.span` display: flex; `; -const CustomModalStyled = styled(CustomModal)` - top: 10vh; - .react-draggable { - max-width: 100%; - width: 500px; - - video { - width: 100%; - } - } -`; - -const Error = styled.div` - color: #f5222d; - height: 100px; - width: 100%; - display: flex; - align-items: center; - justify-content: center; -`; - -const Wrapper = styled.div` - img, - video, - .ant-skeleton { - width: 100%; - height: 400px; - max-height: 70vh; - position: relative; - object-fit: cover; - background-color: #000; - } - .ant-skeleton { - h3, - li { - background-color: transparent; - } - } -`; +const CustomModalStyled = styled(CustomModal)``; +const Error = styled.div``; +const Wrapper = styled.div``; export function resolveValue(files: UploadFile[]) { return Promise.all( @@ -290,163 +252,13 @@ export function resolveParsedValue(files: UploadFile[]) { ); } -const ReactWebcam = React.lazy(() => import("react-webcam")); - -const ImageCaptureModal = (props: { - showModal: boolean, - onModalClose: () => void; - onImageCapture: (image: string) => void; -}) => { - const [errMessage, setErrMessage] = useState(""); - const [videoConstraints, setVideoConstraints] = useState({ - facingMode: "environment", - }); - const [modeList, setModeList] = useState([]); - const [dropdownShow, setDropdownShow] = useState(false); - const [imgSrc, setImgSrc] = useState(); - const webcamRef = useRef(null); - - useEffect(() => { - if (props.showModal) { - setImgSrc(''); - setErrMessage(''); - } - }, [props.showModal]); - - const handleMediaErr = (err: any) => { - if (typeof err === "string") { - setErrMessage(err); - } else { - if (err.message === "getUserMedia is not implemented in this browser") { - setErrMessage(trans("scanner.errTip")); - } else { - setErrMessage(err.message); - } - } - }; - - const handleCapture = useCallback(() => { - const imageSrc = webcamRef.current?.getScreenshot?.(); - setImgSrc(imageSrc); - }, [webcamRef]); - - const getModeList = () => { - navigator.mediaDevices.enumerateDevices().then((data) => { - const videoData = data.filter((item) => item.kind === "videoinput"); - const faceModeList = videoData.map((item, index) => ({ - label: item.label || trans("scanner.camera", { index: index + 1 }), - key: item.deviceId, - })); - setModeList(faceModeList); - }); - }; - - return ( - - {!!errMessage ? ( - {errMessage} - ) : ( - props.showModal && ( - - {imgSrc - ? webcam - : ( - }> - - - ) - } - {imgSrc - ? ( - - - - - ) - : ( - - - setDropdownShow(value)} - popupRender={() => ( - - setVideoConstraints({ ...videoConstraints, deviceId: value.key }) - } - /> - )} - > - - - - ) - } - - ) - )} - - ) -} +// ImageCaptureModal moved to its own file for reuse const Upload = ( props: RecordConstructorToView & { uploadType: "single" | "multiple" | "directory"; text: string; + dragHintText?: string; dispatch: (action: CompAction) => void; forceCapture: boolean; tabIndex?: number; @@ -619,25 +431,48 @@ const UploadTypeOptions = [ { label: trans("file.directory"), value: "directory" }, ] as const; +const UploadModeOptions = [ + { label: trans("file.button"), value: "button" }, + { label: trans("file.dragArea"), value: "dragArea" }, +] as const; + const childrenMap = { text: withDefault(StringControl, trans("file.upload")), + dragHintText: withDefault(StringControl, trans("file.dragAreaHint")), uploadType: dropdownControl(UploadTypeOptions, "single"), + uploadMode: dropdownControl(UploadModeOptions, "button"), + autoHeight: withDefault(AutoHeightControl, "auto"), tabIndex: NumberControl, ...commonChildren, ...formDataChildren, }; let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { - return( - - )}) + const uploadMode = props.uploadMode; + const autoHeight = props.autoHeight; + + if (uploadMode === "dragArea") { + return ; + } + + return ; +}) .setPropertyViewFn((children) => ( <>
{children.text.propertyView({ label: trans("text"), })} + {children.uploadMode.propertyView({ + label: trans("file.uploadMode"), + radioButton: true, + })} + {children.uploadMode.getView() === "dragArea" && + children.dragHintText.propertyView({ + label: trans("file.dragHintText"), + })} {children.uploadType.propertyView({ label: trans("file.uploadType") })} + {children.autoHeight.getPropertyView()}
@@ -693,7 +528,15 @@ let FileTmpComp = new UICompBuilder(childrenMap, (props, dispatch) => { )) .build(); -FileTmpComp = withMethodExposing(FileTmpComp, [ + class FileImplComp extends FileTmpComp { + override autoHeight(): boolean { + // Both button and dragArea modes should respect the autoHeight setting + const h = this.children.autoHeight.getView(); + return h; + } + } + +const FileWithMethods = withMethodExposing(FileImplComp, [ { method: { name: "clearValue", @@ -711,7 +554,7 @@ FileTmpComp = withMethodExposing(FileTmpComp, [ }, ]); -export const FileComp = withExposingConfigs(FileTmpComp, [ +export const FileComp = withExposingConfigs(FileWithMethods, [ new NameConfig("value", trans("file.filesValueDesc")), new NameConfig( "files", diff --git a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx index 1ee33fa616..e3727508ec 100644 --- a/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/navComp/navComp.tsx @@ -153,11 +153,13 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { if (hidden) { return null; } - const visibleSubItems = items.filter((item) => !item.children.hidden.getView()); const subMenuItems: Array<{ key: string; label: string }> = []; const subMenuSelectedKeys: Array = []; - visibleSubItems.forEach((subItem, index) => { - const key = index + ""; + items.forEach((subItem, originalIndex) => { + if (subItem.children.hidden.getView()) { + return; + } + const key = originalIndex + ""; subItem.children.active.getView() && subMenuSelectedKeys.push(key); subMenuItems.push({ key: key, @@ -184,7 +186,7 @@ const NavCompBase = new UICompBuilder(childrenMap, (props) => { {items.length > 0 && } ); - if (visibleSubItems.length > 0) { + if (subMenuItems.length > 0) { const subMenu = ( { diff --git a/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx b/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx index 15db21305f..9573e2a3b1 100644 --- a/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/numberInputComp/numberInputComp.tsx @@ -293,7 +293,7 @@ const childrenMap = { viewRef: RefControl, style: styleControl(InputFieldStyle , 'style') , labelStyle: styleControl(LabelStyle , 'labelStyle'), - prefixText : stringExposingStateControl("defaultValue"), + prefixText: StringControl, animationStyle: styleControl(AnimationStyle , 'animationStyle'), prefixIcon: IconControl, inputFieldStyle: styleControl(InputLikeStyle , 'inputFieldStyle'), @@ -388,7 +388,6 @@ const CustomInputNumber = (props: RecordConstructorToView) = return; } if ( - cursor !== 0 && props.precision > 0 && (event.key === "." || event.key === "。") && !/[.]/.test(value) @@ -415,7 +414,7 @@ const CustomInputNumber = (props: RecordConstructorToView) = precision={props.precision} $style={props.inputFieldStyle} $disabledStyle={props.disabledStyle} - prefix={hasIcon(props.prefixIcon) ? props.prefixIcon : props.prefixText.value} + prefix={hasIcon(props.prefixIcon) ? props.prefixIcon : props.prefixText} tabIndex={typeof props.tabIndex === 'number' ? props.tabIndex : undefined} onPressEnter={() => { handleFinish(); diff --git a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx index 64ec62a952..979b7fbfa3 100644 --- a/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/richTextEditorComp.tsx @@ -171,7 +171,8 @@ const toolbarOptions = [ ]; const childrenMap = { - value: stringExposingStateControl("value"), + value: stringExposingStateControl("value"), + delta: stringExposingStateControl("delta"), hideToolbar: BoolControl, readOnly: BoolControl, autoHeight: withDefault(AutoHeightControl, "fixed"), @@ -194,7 +195,7 @@ interface IProps { hideToolbar: boolean; readOnly: boolean; autoHeight: boolean; - onChange: (value: string) => void; + onChange: (html: string, deltaJSON: string, text: string) => void; $style: RichTextEditorStyleType; contentScrollBar: boolean; tabIndex?: number; @@ -207,6 +208,28 @@ function RichTextEditor(props: IProps) { const [content, setContent] = useState(""); const wrapperRef = useRef(null); const editorRef = useRef(null); + + // know exactly when the editor mounts + const [editorReady, setEditorReady] = useState(false); + const setEditorRef = (node: ReactQuill | null) => { + (editorRef as any).current = node as any; + setEditorReady(!!node); + }; + + const getQuill = () => (editorRef.current as any)?.getEditor?.(); + + const tryParseDelta = (v: unknown) => { + if (!v) return null; + if (typeof v === "string") { + try { + const d = JSON.parse(v); + return Array.isArray(d?.ops) ? d : null; + } catch { return null; } + } + if (typeof v === "object" && Array.isArray((v as any).ops)) return v as any; + return null; + }; + const isTypingRef = useRef(0); const debounce = INPUT_DEFAULT_ONCHANGE_DEBOUNCE; @@ -214,8 +237,8 @@ function RichTextEditor(props: IProps) { const originOnChangeRef = useRef(props.onChange); originOnChangeRef.current = props.onChange; - const onChangeRef = useRef( - (v: string) => originOnChangeRef.current?.(v) + const onChangeRef = useRef((html: string, deltaJSON: string, text: string) => + originOnChangeRef.current?.(html, deltaJSON, text) ); // react-quill will not take effect after the placeholder is updated @@ -235,7 +258,7 @@ function RichTextEditor(props: IProps) { (editor.scroll.domNode as HTMLElement).tabIndex = props.tabIndex; } } - }, [props.tabIndex, key]); // Also re-run when key changes due to placeholder update + }, [props.tabIndex, key]); const contains = (parent: HTMLElement, descendant: HTMLElement) => { try { @@ -248,19 +271,26 @@ function RichTextEditor(props: IProps) { return parent.contains(descendant); }; - const handleChange = (value: string) => { - setContent(value); - // props.onChange(value); - onChangeRef.current(value); - }; useEffect(() => { - let finalValue = props.value; - if (!/^<\w+>.+<\/\w+>$/.test(props.value)) { - finalValue = `

${props.value}

`; + const q = getQuill(); + if (!q) { + return; + } + + const asDelta = tryParseDelta(props.value); + if (asDelta) { + q.setContents(asDelta, "api"); + const html = q.root?.innerHTML ?? ""; + setContent(html); + return; } - setContent(finalValue); - }, [props.value]); + const v = props.value ?? ""; + const looksHtml = /<\/?[a-z][\s\S]*>/i.test(v); + const html = looksHtml ? v : `

${v}

`; + setContent(html); + }, [props.value, editorReady]); + const handleClickWrapper = (e: React.MouseEvent) => { // grid item prevents bubbling, quill can't listen to events on document.body, so it can't close the toolbar drop-down box @@ -288,7 +318,7 @@ function RichTextEditor(props: IProps) { }> { + setContent(html); + const quill = editorRef.current?.getEditor?.(); + const fullDelta = quill?.getContents?.() ?? { ops: [] }; + const text = quill?.getText?.() ?? ""; + onChangeRef.current(html, JSON.stringify(fullDelta), text); + }} /> @@ -305,15 +341,47 @@ function RichTextEditor(props: IProps) { } const RichTextEditorCompBase = new UICompBuilder(childrenMap, (props) => { + const propsRef = useRef(props); + propsRef.current = props; + + // Local state to manage editor content + const [localValue, setLocalValue] = useState(props.value.value); + const isUserTyping = useRef(false); + + // Sync local state with props when they change externally (not from user typing) + useEffect(() => { + if (!isUserTyping.current) { + setLocalValue(props.value.value); + } + }, [props.value.value]); + const debouncedOnChangeRef = useRef( - debounce((value: string) => { - props.value.onChange(value); - props.onEvent("change"); + debounce((html: string, deltaJSON: string, text: string) => { + // Update delta first as it's the primary source of truth + propsRef.current.delta.onChange(deltaJSON); + // Update value with the HTML representation + propsRef.current.value.onChange(html); + propsRef.current.onEvent("change"); }, 1000) ); - const handleChange = (value: string) => { - debouncedOnChangeRef.current?.(value); + useEffect(() => { + return () => { + debouncedOnChangeRef.current?.cancel(); + }; + }, []); + + const handleChange = (html: string, deltaJSON: string, text: string) => { + // Mark that user is typing + isUserTyping.current = true; + // Update local state immediately for responsive UI + setLocalValue(html); + // Debounce the prop updates + debouncedOnChangeRef.current?.(html, deltaJSON, text); + // Reset the flag after a brief delay + setTimeout(() => { + isUserTyping.current = false; + }, 100); }; return ( @@ -322,7 +390,7 @@ const RichTextEditorCompBase = new UICompBuilder(childrenMap, (props) => { hideToolbar={props.hideToolbar} toolbar={props.toolbar} readOnly={props.readOnly} - value={props.value.value} + value={localValue} placeholder={props.placeholder} onChange={handleChange} $style={props.style} @@ -379,6 +447,7 @@ class RichTextEditorCompAutoHeight extends RichTextEditorCompBase { export const RichTextEditorComp = withExposingConfigs(RichTextEditorCompAutoHeight, [ new NameConfig("value", trans("export.richTextEditorValueDesc")), + new NameConfig("delta", trans("export.richTextEditorDeltaDesc")), new NameConfig("readOnly", trans("export.richTextEditorReadOnlyDesc")), new NameConfig("hideToolbar", trans("export.richTextEditorHideToolBarDesc")), NameConfigHidden, diff --git a/client/packages/lowcoder/src/comps/comps/rootComp.tsx b/client/packages/lowcoder/src/comps/comps/rootComp.tsx index 50fe1229ed..7f0f69f761 100644 --- a/client/packages/lowcoder/src/comps/comps/rootComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/rootComp.tsx @@ -100,7 +100,7 @@ const RootView = React.memo((props: RootViewProps) => { return (oldState ? changeEditorStateFn(oldState) : undefined) }); } - }); + }, undefined, isModuleRoot); editorStateRef.current = newEditorState; setEditorState(newEditorState); @@ -109,7 +109,7 @@ const RootView = React.memo((props: RootViewProps) => { editorStateRef.current = undefined; } }; - }, []); + }, [isModuleRoot]); useEffect(() => { if (!mountedRef.current || !editorState) return; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/ResizeableTable.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/ResizeableTable.tsx new file mode 100644 index 0000000000..55bf8d694b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/ResizeableTable.tsx @@ -0,0 +1,225 @@ +import { default as Table, TableProps, ColumnType } from "antd/es/table"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { Resizable } from "react-resizable"; +import styled from "styled-components"; +import _ from "lodash"; +import { useUserViewMode } from "util/hooks"; +import { ReactRef, ResizeHandleAxis } from "layout/gridLayoutPropTypes"; +import { COL_MIN_WIDTH, RecordType, CustomColumnType } from "./tableUtils"; +import { RowColorViewType, RowHeightViewType } from "./tableTypes"; +import { TableColumnStyleType, TableColumnLinkStyleType, TableRowStyleType } from "comps/controls/styleControlConstants"; +import { CellColorViewType } from "./column/tableColumnComp"; +import { TableCellView } from "./TableCell"; +import { TableRowView } from "./TableRow"; + +const TitleResizeHandle = styled.span` + position: absolute; + top: 0; + right: -5px; + width: 10px; + height: 100%; + cursor: col-resize; + z-index: 1; +`; + +const TableTh = styled.th<{ width?: number }>` + overflow: hidden; + + > div { + overflow: hidden; + white-space: pre; + text-overflow: ellipsis; + } + + ${(props) => props.width && `width: ${props.width}px`}; +`; + +const ResizeableTitle = React.forwardRef((props, ref) => { + const { onResize, onResizeStop, width, viewModeResizable, ...restProps } = props; + const [childWidth, setChildWidth] = useState(0); + const resizeRef = useRef(null); + const isUserViewMode = useUserViewMode(); + + const updateChildWidth = useCallback(() => { + if (resizeRef.current) { + const width = resizeRef.current.getBoundingClientRect().width; + setChildWidth(width); + } + }, []); + + React.useEffect(() => { + updateChildWidth(); + const resizeObserver = new ResizeObserver(() => { + updateChildWidth(); + }); + + if (resizeRef.current) { + resizeObserver.observe(resizeRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [updateChildWidth]); + + React.useImperativeHandle(ref, () => resizeRef.current!, []); + + const isNotDataColumn = _.isNil(restProps.title); + if ((isUserViewMode && !restProps.viewModeResizable) || isNotDataColumn) { + return ; + } + + return ( + 0 ? width : childWidth} + height={0} + onResize={(e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + e.stopPropagation(); + onResize(size.width); + }} + onResizeStart={(e: React.SyntheticEvent) => { + updateChildWidth(); + e.stopPropagation(); + e.preventDefault(); + }} + onResizeStop={onResizeStop} + draggableOpts={{ enableUserSelectHack: false }} + handle={(axis: ResizeHandleAxis, ref: ReactRef) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + /> + )} + > + + + ); +}); + +type CustomTableProps = Omit, "components" | "columns"> & { + columns: CustomColumnType[]; + viewModeResizable: boolean; + visibleResizables: boolean; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + columnsStyle: TableColumnStyleType; + rowStyle: TableRowStyleType; + size?: string; + rowAutoHeight?: boolean; + customLoading?: boolean; + onCellClick: (columnName: string, dataIndex: string) => void; + virtual?: boolean; + scroll?: { + x?: number | string; + y?: number | string; + }; +}; + +function ResizeableTableComp(props: CustomTableProps) { + const { + columns, + viewModeResizable, + visibleResizables, + rowColorFn, + rowHeightFn, + columnsStyle, + rowStyle, + size, + rowAutoHeight, + customLoading, + onCellClick, + ...restProps + } = props; + const [resizeData, setResizeData] = useState({ index: -1, width: -1 }); + + // Memoize resize handlers + const handleResize = useCallback((width: number, index: number) => { + setResizeData({ index, width }); + }, []); + + const handleResizeStop = useCallback((width: number, index: number, onWidthResize?: (width: number) => void) => { + setResizeData({ index: -1, width: -1 }); + if (onWidthResize) { + onWidthResize(width); + } + }, []); + + // Memoize cell handlers + const createCellHandler = useCallback((col: CustomColumnType) => { + return (record: RecordType, index: number) => ({ + record, + title: String(col.dataIndex), + rowColorFn, + rowHeightFn, + cellColorFn: col.cellColorFn, + rowIndex: index, + columnsStyle, + columnStyle: col.style, + rowStyle: rowStyle, + linkStyle: col.linkStyle, + tableSize: size, + autoHeight: rowAutoHeight, + onClick: () => onCellClick(col.titleText, String(col.dataIndex)), + loading: customLoading, + customAlign: col.align, + }); + }, [rowColorFn, rowHeightFn, columnsStyle, size, rowAutoHeight, onCellClick, customLoading]); + + // Memoize header cell handlers + const createHeaderCellHandler = useCallback((col: CustomColumnType, index: number, resizeWidth: number) => { + return () => ({ + width: resizeWidth, + title: col.titleText, + viewModeResizable, + onResize: (width: React.SyntheticEvent) => { + if (width) { + handleResize(Number(width), index); + } + }, + onResizeStop: (e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + handleResizeStop(size.width, index, col.onWidthResize); + }, + }); + }, [viewModeResizable, handleResize, handleResizeStop]); + + // Memoize columns to prevent unnecessary re-renders + const memoizedColumns = useMemo(() => { + return columns.map((col, index) => { + const { width, style, linkStyle, cellColorFn, onWidthResize, ...restCol } = col; + const resizeWidth = (resizeData.index === index ? resizeData.width : col.width) ?? 0; + + const column: ColumnType = { + ...restCol, + width: typeof resizeWidth === "number" && resizeWidth > 0 ? resizeWidth : undefined, + minWidth: typeof resizeWidth === "number" && resizeWidth > 0 ? undefined : COL_MIN_WIDTH, + onCell: (record: RecordType, index?: number) => createCellHandler(col)(record, index ?? 0), + onHeaderCell: () => createHeaderCellHandler(col, index, Number(resizeWidth))(), + }; + return column; + }); + }, [columns, resizeData, createCellHandler, createHeaderCellHandler]); + + return ( + + components={{ + header: { + cell: ResizeableTitle, + }, + body: { + cell: TableCellView, + row: TableRowView, + }, + }} + {...restProps} + pagination={false} + columns={memoizedColumns} + /> + ); +} +ResizeableTableComp.whyDidYouRender = true; + +export const ResizeableTable = React.memo(ResizeableTableComp) as typeof ResizeableTableComp; +export type { CustomTableProps }; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/TableCell.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/TableCell.tsx new file mode 100644 index 0000000000..cc46bfe056 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/TableCell.tsx @@ -0,0 +1,240 @@ +import React, { useContext, useMemo, useState } from "react"; +import styled, { css } from "styled-components"; +import { TableCellContext, TableRowContext } from "./tableContext"; +import { TableColumnStyleType, TableColumnLinkStyleType, ThemeDetail, TableRowStyleType } from "comps/controls/styleControlConstants"; +import { RowColorViewType, RowHeightViewType } from "./tableTypes"; +import { CellColorViewType } from "./column/tableColumnComp"; +import { RecordType, OB_ROW_ORI_INDEX } from "./tableUtils"; +import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; +import Skeleton from "antd/es/skeleton"; +import { SkeletonButtonProps } from "antd/es/skeleton/Button"; +import { isTransparentColor } from "lowcoder-design"; + +interface TableTdProps { + $background: string; + $style: TableColumnStyleType & { rowHeight?: string }; + $defaultThemeDetail: ThemeDetail; + $linkStyle?: TableColumnLinkStyleType; + $isEditing: boolean; + $tableSize?: string; + $autoHeight?: boolean; + $customAlign?: 'left' | 'center' | 'right'; +} + +const TableTd = styled.td` + .ant-table-row-expand-icon, + .ant-table-row-indent { + display: ${(props) => (props.$isEditing ? "none" : "initial")}; + } + &.ant-table-row-expand-icon-cell { + background: ${(props) => props.$background}; + border-color: ${(props) => props.$style.border}; + } + background: ${(props) => props.$background} !important; + border-color: ${(props) => props.$style.border} !important; + border-radius: ${(props) => props.$style.radius}; + padding: 0 !important; + text-align: ${(props) => props.$customAlign || 'left'} !important; + + > div:not(.editing-border, .editing-wrapper), + .editing-wrapper .ant-input, + .editing-wrapper .ant-input-number, + .editing-wrapper .ant-picker { + margin: ${(props) => props.$isEditing ? '0px' : props.$style.margin}; + color: ${(props) => props.$style.text}; + font-weight: ${(props) => props.$style.textWeight}; + font-family: ${(props) => props.$style.fontFamily}; + overflow: hidden; + display: flex; + justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; + align-items: center; + text-align: ${(props) => props.$customAlign || 'left'}; + box-sizing: border-box; + ${(props) => props.$tableSize === 'small' && ` + padding: 1px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '14px'}; + line-height: 20px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '28px'}; + `}; + `}; + ${(props) => props.$tableSize === 'middle' && ` + padding: 8px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '24px'}; + line-height: 24px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '48px'}; + `}; + `}; + ${(props) => props.$tableSize === 'large' && ` + padding: 16px 16px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '48px'}; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '96px'}; + `}; + `}; + + > .ant-badge > .ant-badge-status-text, + > div > .markdown-body { + color: ${(props) => props.$style.text}; + } + + > div > svg g { + stroke: ${(props) => props.$style.text}; + } + + // dark link|links color + > a, + > div a { + color: ${(props) => props.$linkStyle?.text}; + + &:hover { + color: ${(props) => props.$linkStyle?.hoverText}; + } + + &:active { + color: ${(props) => props.$linkStyle?.activeText}; + } + } + } +`; + +const TableTdLoading = styled(Skeleton.Button)` + width: 90% !important; + display: table !important; + + .ant-skeleton-button { + min-width: auto !important; + display: block !important; + ${(props) => props.$tableSize === 'small' && ` + height: 20px !important; + `} + ${(props) => props.$tableSize === 'middle' && ` + height: 24px !important; + `} + ${(props) => props.$tableSize === 'large' && ` + height: 28px !important; + `} + } +`; + +export const TableCellView = React.forwardRef((props, ref) => { + const { + record, + title, + rowIndex, + rowColorFn, + rowHeightFn, + cellColorFn, + children, + columnsStyle, + columnStyle, + rowStyle, + linkStyle, + tableSize, + autoHeight, + loading, + customAlign, + ...restProps + } = props; + + const [editing, setEditing] = useState(false); + const rowContext = useContext(TableRowContext); + + // Memoize style calculations + const style = useMemo(() => { + if (!record) return null; + const rowColor = rowColorFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const rowHeight = rowHeightFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const cellColor = cellColorFn({ + currentCell: record[title], + currentRow: record, + }); + + return { + background: cellColor || rowColor || columnStyle.background || columnsStyle.background, + margin: columnStyle.margin || columnsStyle.margin, + text: columnStyle.text || columnsStyle.text, + border: columnStyle.border || columnsStyle.border, + radius: columnStyle.radius || columnsStyle.radius, + // borderWidth: columnStyle.borderWidth || columnsStyle.borderWidth, + textSize: columnStyle.textSize || columnsStyle.textSize, + textWeight: columnsStyle.textWeight || columnStyle.textWeight, + fontFamily: columnsStyle.fontFamily || columnStyle.fontFamily, + fontStyle: columnsStyle.fontStyle || columnStyle.fontStyle, + rowHeight: rowHeight, + }; + }, [record, rowIndex, title, rowColorFn, rowHeightFn, cellColorFn, columnStyle, columnsStyle]); + + if (!record) { + return ( + + {children} + + ); + } + + let { background } = style!; + if (rowContext.hover && !isTransparentColor(rowStyle.hoverRowBackground)) { + background = 'transparent'; + } + + return ( + + + {loading + ? + : children + } + + + ); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/TableRow.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/TableRow.tsx new file mode 100644 index 0000000000..84dbbc3808 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/TableRow.tsx @@ -0,0 +1,27 @@ +import React, { useCallback, useState } from "react"; +import { TableRowContext } from "./tableContext"; + +export const TableRowView = React.forwardRef((props, ref) => { + const [hover, setHover] = useState(false); + const [selected, setSelected] = useState(false); + + // Memoize event handlers + const handleMouseEnter = useCallback(() => setHover(true), []); + const handleMouseLeave = useCallback(() => setHover(false), []); + const handleFocus = useCallback(() => setSelected(true), []); + const handleBlur = useCallback(() => setSelected(false), []); + + return ( + + + + ); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx index 1e6a6e1a8a..e1633e0899 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/column/columnTypeComps/columnTagsComp.tsx @@ -57,8 +57,8 @@ const TagsControl = codeControl | string>( { expectedType: "string | Array", codeType: "JSON" } ); -function getTagColor(tagText : any, tagOptions: any[]) { - const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); +function getTagColor(tagText: string, tagOptions: TagOption[]): string | undefined { + const foundOption = tagOptions.find(option => option.label === tagText); if (foundOption) { if (foundOption.colorType === "preset") { return foundOption.presetColor; @@ -73,10 +73,10 @@ function getTagColor(tagText : any, tagOptions: any[]) { return colors[index]; } -function getTagStyle(tagText: any, tagOptions: any[]) { - const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); +function getTagStyle(tagText: string, tagOptions: TagOption[]): React.CSSProperties { + const foundOption = tagOptions.find(option => option.label === tagText); if (foundOption) { - const style: any = {}; + const style: React.CSSProperties = {}; // Handle color styling if (foundOption.colorType === "custom") { @@ -113,11 +113,23 @@ function getTagStyle(tagText: any, tagOptions: any[]) { return {}; } -function getTagIcon(tagText: any, tagOptions: any[]) { +function getTagIcon(tagText: string, tagOptions: TagOption[]): React.ReactNode | undefined { const foundOption = tagOptions.find(option => option.label === tagText); return foundOption ? foundOption.icon : undefined; } +// Utility function to process comma-separated tags into individual tags +function processTagItems(tagItems: string[]): string[] { + const result: string[] = []; + tagItems.forEach((item) => { + if (item.split(",")[1]) { + item.split(",").forEach((tag) => result.push(tag)); + } + result.push(item); + }); + return result; +} + const childrenMap = { text: TagsControl, tagColors: ColoredTagOptionControl, @@ -128,11 +140,25 @@ const getBaseValue: ColumnTypeViewFn props.text; +interface TagOption { + label: string; + colorType?: "preset" | "custom"; + presetColor?: string; + color?: string; + textColor?: string; + border?: string; + radius?: string; + margin?: string; + padding?: string; + icon?: React.ReactNode; + onEvent?: (eventType: string) => void; +} + type TagEditPropsType = { value: string | string[]; onChange: (value: string | string[]) => void; onChangeEnd: () => void; - tagOptions: any[]; + tagOptions: TagOption[]; }; export const Wrapper = styled.div` @@ -240,16 +266,7 @@ export const TagStyled = styled(Tag)` const TagEdit = React.memo((props: TagEditPropsType) => { const defaultTags = useContext(TagsContext); - const [tags, setTags] = useState(() => { - const result: string[] = []; - defaultTags.forEach((item) => { - if (item.split(",")[1]) { - item.split(",").forEach((tag) => result.push(tag)); - } - result.push(item); - }); - return result; - }); + const [tags, setTags] = useState(() => processTagItems(defaultTags)); const [open, setOpen] = useState(false); const mountedRef = useRef(true); @@ -268,24 +285,16 @@ const TagEdit = React.memo((props: TagEditPropsType) => { // Update tags when defaultTags changes useEffect(() => { if (!mountedRef.current) return; - - const result: string[] = []; - defaultTags.forEach((item) => { - if (item.split(",")[1]) { - item.split(",").forEach((tag) => result.push(tag)); - } - result.push(item); - }); - setTags(result); + setTags(processTagItems(defaultTags)); }, [defaultTags]); const handleSearch = useCallback((value: string) => { if (!mountedRef.current) return; if (defaultTags.findIndex((item) => item.includes(value)) < 0) { - setTags([...defaultTags, value]); + setTags([...processTagItems(defaultTags), value]); } else { - setTags(defaultTags); + setTags(processTagItems(defaultTags)); } props.onChange(value); }, [defaultTags, props.onChange]); @@ -399,7 +408,8 @@ export const ColumnTagsComp = (function () { value = typeof value === "string" && value.split(",")[1] ? value.split(",") : value; const tags = _.isArray(value) ? value : (value.length ? [value] : []); - const handleTagClick = (tagText: string) => { + const handleTagClick = (e: React.MouseEvent, tagText: string) => { + e.stopPropagation(); const foundOption = tagOptions.find(option => option.label === tagText); if (foundOption && foundOption.onEvent) { foundOption.onEvent("click"); @@ -409,6 +419,13 @@ export const ColumnTagsComp = (function () { props.onEvent("click"); } }; + + const handleTagWrapperClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (props.onEvent) { + props.onEvent("click"); + } + }; const view = tags.map((tag, index) => { // The actual eval value is of type number or boolean @@ -418,20 +435,25 @@ export const ColumnTagsComp = (function () { const tagStyle = getTagStyle(tagText, tagOptions); return ( -
- handleTagClick(tagText)} - > - {tagText} - -
+ handleTagClick(e, tagText)} + > + {tagText} + ); }); - return view; + return ( +
+ {view} +
+ ); }, (nodeValue) => { const text = nodeValue.text.value; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/hooks/useTableConfiguration.ts b/client/packages/lowcoder/src/comps/comps/tableComp/hooks/useTableConfiguration.ts new file mode 100644 index 0000000000..ab1499b425 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/hooks/useTableConfiguration.ts @@ -0,0 +1,109 @@ +// hooks/useTableConfiguration.ts +import { useMemo, useState, useEffect, useRef } from 'react'; +import { VIRTUAL_ROW_HEIGHTS, VIRTUAL_THRESHOLD, MIN_VIRTUAL_HEIGHT, TOOLBAR_HEIGHT, HEADER_HEIGHT } from '../tableUtils'; + +// ============= HOOK 1: TABLE MODE ============= +export function useTableMode(autoHeight: boolean) { + return useMemo(() => ({ + isAutoMode: autoHeight, + isFixedMode: !autoHeight + }), [autoHeight]); +} + +// ============= HOOK 2: CONTAINER HEIGHT MEASUREMENT ============= +export function useContainerHeight(enabled: boolean) { + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = useRef(null); + + useEffect(() => { + const element = containerRef.current; + if (!enabled || !element) return; + + const measureHeight = () => { + if (element) { + setContainerHeight(element.clientHeight); + } + }; + + measureHeight(); + const resizeObserver = new ResizeObserver(measureHeight); + resizeObserver.observe(element); + + return () => { + resizeObserver.disconnect(); + }; + }, [enabled]); + + return { containerHeight, containerRef }; +} + +// ============= HOOK 3: VIRTUALIZATION CALCULATION ============= +export function useVirtualization( + containerHeight: number, + dataLength: number, + tableSize: 'small' | 'middle' | 'large', + config: { + showToolbar: boolean; + showHeader: boolean; + stickyToolbar: boolean; + isFixedMode: boolean; + } +) { + return useMemo(() => { + if (!config.isFixedMode) { + return { + enabled: false, + scrollY: undefined, + itemHeight: VIRTUAL_ROW_HEIGHTS[tableSize], + reason: 'auto_mode' + }; + } + + // Calculate reserved space + const toolbarSpace = config.showToolbar && config.stickyToolbar ? TOOLBAR_HEIGHT : 0; + const headerSpace = config.showHeader ? HEADER_HEIGHT : 0; + const availableHeight = containerHeight - toolbarSpace - headerSpace; + + // Check if virtualization should be enabled + const canVirtualize = availableHeight > MIN_VIRTUAL_HEIGHT; + const hasEnoughData = dataLength >= VIRTUAL_THRESHOLD; + const enabled = canVirtualize && hasEnoughData; + + return { + enabled, + scrollY: availableHeight > 0 ? availableHeight : undefined, + itemHeight: VIRTUAL_ROW_HEIGHTS[tableSize], + reason: !canVirtualize + ? 'insufficient_height' + : !hasEnoughData + ? 'insufficient_data' + : 'enabled' + }; + }, [containerHeight, dataLength, tableSize, config]); +} + +// ============= HOOK 4: SCROLL CONFIGURATION ============= +export function useScrollConfiguration( + virtualizationEnabled: boolean, + scrollY: number | undefined, + totalColumnsWidth: number +) { + return useMemo(() => { + const baseScroll = { x: totalColumnsWidth }; + + if (!virtualizationEnabled || !scrollY) { + return { + scroll: baseScroll, + virtual: false + }; + } + + return { + scroll: { + x: totalColumnsWidth, + y: scrollY + }, + virtual: true + }; + }, [virtualizationEnabled, scrollY, totalColumnsWidth]); +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx index d3386360c0..725543effe 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableComp.tsx @@ -26,6 +26,7 @@ import { depsConfig, DepsConfig, NameConfig, + NameConfigHidden, withExposingConfigs, } from "comps/generators/withExposing"; import { withMethodExposing } from "comps/generators/withMethodExposing"; @@ -480,7 +481,6 @@ export class TableImplComp extends TableInitComp implements IContainer { return { ...oriRow, ...changeValues }; }) .value(); - // console.info("toUpdateRowsNode. input: ", input, " res: ", res); return res; }); } @@ -516,14 +516,25 @@ export class TableImplComp extends TableInitComp implements IContainer { oriDisplayData: this.oriDisplayDataNode(), withParams: this.children.columns.withParamsNode(), dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + changeSet: this.changeSetNode(), }; const resNode = withFunction(fromRecord(nodes), (input) => { const dataIndexWithParamsDict = _(input.dataIndexes) .mapValues((dataIndex, idx) => input.withParams[idx]) .mapKeys((withParams, idx) => input.dataIndexes[idx]) .value(); - const res = getColumnsAggr(input.oriDisplayData, dataIndexWithParamsDict); - // console.info("columnAggrNode: ", res); + + const columnChangeSets: Record> = {}; + _.forEach(input.changeSet, (rowData, rowId) => { + _.forEach(rowData, (value, dataIndex) => { + if (!columnChangeSets[dataIndex]) { + columnChangeSets[dataIndex] = {}; + } + columnChangeSets[dataIndex][rowId] = value; + }); + }); + + const res = getColumnsAggr(input.oriDisplayData, dataIndexWithParamsDict, columnChangeSets); return res; }); return lastValueIfEqual(this, "columnAggrNode", [resNode, nodes] as const, (a, b) => @@ -703,7 +714,76 @@ TableTmpComp = withMethodExposing(TableTmpComp, [ comp.children.selection.children.selectedRowKey.dispatchChangeValueAction(allKeys[0] || "0"); comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction(allKeys); }, - }, + }, + { + method: { + name: "selectRowsByIndex", + description: "Select rows by index", + params: [ + { name: "rowIndexes", type: "arrayNumberString"}, + ] + }, + execute: (comp, values) => { + const rowIndexes = values[0]; + if (!isArray(rowIndexes)) { + return Promise.reject("selectRowsByIndex function only accepts array of string or number i.e. ['1', '2', '3'] or [1, 2, 3]") + } + const displayData = comp.filterData ?? []; + const selectedKeys: string[] = rowIndexes + .map((index) => { + const numIndex = Number(index); + if (isNaN(numIndex) || numIndex < 0 || numIndex >= displayData.length) { + return null; + } + return displayData[numIndex][OB_ROW_ORI_INDEX]; + }) + .filter((key): key is string => key !== null); + + comp.children.selection.children.selectedRowKey.dispatchChangeValueAction(selectedKeys[0] || "0"); + comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction(selectedKeys); + }, + }, + { + method: { + name: "selectRowsByIds", + description: "Select rows by ids", + params: [ + { name: "rowIds", type: "arrayNumberString"}, + ] + }, + execute: (comp, values) => { + const rowIds = values[0]; + if (!isArray(rowIds)) { + return Promise.reject("selectRowsByIds function only accepts array of string or number i.e. ['1', '2', '3'] or [1, 2, 3]") + } + const displayData = comp.filterData ?? []; + + // Common ID field names to check + const idFields = ['id', 'ID', 'Id', 'key', 'Key', 'KEY']; + + const selectedKeys: string[] = rowIds + .map((id) => { + // First try to find by common ID fields + for (const field of idFields) { + const foundRow = displayData.find((row) => { + const fieldValue = row[field]; + return fieldValue !== undefined && String(fieldValue) === String(id); + }); + if (foundRow) { + return foundRow[OB_ROW_ORI_INDEX]; + } + } + + // If no ID field found, fall back to comparing with OB_ROW_ORI_INDEX + const foundRow = displayData.find((row) => row[OB_ROW_ORI_INDEX] === String(id)); + return foundRow ? foundRow[OB_ROW_ORI_INDEX] : null; + }) + .filter((key): key is string => key !== null); + + comp.children.selection.children.selectedRowKey.dispatchChangeValueAction(selectedKeys[0] || "0"); + comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction(selectedKeys); + }, + }, { method: { name: "cancelChanges", @@ -1016,4 +1096,5 @@ export const TableComp = withExposingConfigs(TableTmpComp, [ }, }), new NameConfig("data", trans("table.dataDesc")), + NameConfigHidden, ]); diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx index dc6c88b0d1..53d4231290 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableCompView.tsx @@ -1,4 +1,3 @@ -import { default as Table, TableProps, ColumnType } from "antd/es/table"; import { TableCellContext, TableRowContext } from "comps/comps/tableComp/tableContext"; import { TableToolbar } from "comps/comps/tableComp/tableToolbarComp"; import { RowColorViewType, RowHeightViewType, TableEventOptionValues } from "comps/comps/tableComp/tableTypes"; @@ -7,808 +6,38 @@ import { COLUMN_CHILDREN_KEY, ColumnsAggrData, columnsToAntdFormat, - CustomColumnType, OB_ROW_ORI_INDEX, onTableChange, RecordType, supportChildrenTree, } from "comps/comps/tableComp/tableUtils"; -import { - handleToHoverRow, - handleToSelectedRow, - TableColumnLinkStyleType, - TableColumnStyleType, - TableHeaderStyleType, - TableRowStyleType, - TableStyleType, - ThemeDetail, - TableToolbarStyleType, -} from "comps/controls/styleControlConstants"; import { CompNameContext, EditorContext } from "comps/editorState"; import { BackgroundColorContext } from "comps/utils/backgroundColorContext"; -import { PrimaryColor } from "constants/style"; import { trans } from "i18n"; import _, { isEqual } from "lodash"; -import { darkenColor, isDarkColor, isValidColor, ScrollBar } from "lowcoder-design"; -import React, { Children, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { Resizable } from "react-resizable"; -import styled, { css } from "styled-components"; +import { ScrollBar } from "lowcoder-design"; +import React, { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useMergeCompStyles, useUserViewMode } from "util/hooks"; import { TableImplComp } from "./tableComp"; import { useResizeDetector } from "react-resize-detector"; import { SlotConfigContext } from "comps/controls/slotControl"; import { EmptyContent } from "pages/common/styledComponent"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; -import { ReactRef, ResizeHandleAxis } from "layout/gridLayoutPropTypes"; -import { CellColorViewType } from "./column/tableColumnComp"; -import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; import { childrenToProps } from "@lowcoder-ee/comps/generators/multi"; -import { getVerticalMargin } from "@lowcoder-ee/util/cssUtil"; import { TableSummary } from "./tableSummaryComp"; -import Skeleton from "antd/es/skeleton"; -import { SkeletonButtonProps } from "antd/es/skeleton/Button"; import { ThemeContext } from "@lowcoder-ee/comps/utils/themeContext"; import { useUpdateEffect } from "react-use"; +import { ResizeableTable } from "./ResizeableTable"; +import { BackgroundWrapper, TableWrapper } from "./tableStyles"; +import { + useTableMode, + useContainerHeight, + useVirtualization, + useScrollConfiguration +} from './hooks/useTableConfiguration'; export const EMPTY_ROW_KEY = 'empty_row'; -function genLinerGradient(color: string) { - return isValidColor(color) ? `linear-gradient(${color}, ${color})` : color; -} - -const getStyle = ( - style: TableStyleType, - rowStyle: TableRowStyleType, - headerStyle: TableHeaderStyleType, - toolbarStyle: TableToolbarStyleType, -) => { - const background = genLinerGradient(style.background); - const selectedRowBackground = genLinerGradient(rowStyle.selectedRowBackground); - const hoverRowBackground = genLinerGradient(rowStyle.hoverRowBackground); - const alternateBackground = genLinerGradient(rowStyle.alternateBackground); - - return css` - .ant-table-body { - background: ${genLinerGradient(style.background)}; - } - .ant-table-tbody { - > tr:nth-of-type(2n + 1) { - background: ${genLinerGradient(rowStyle.background)}; - } - - > tr:nth-of-type(2n) { - background: ${alternateBackground}; - } - - // selected row - > tr:nth-of-type(2n + 1).ant-table-row-selected { - background: ${selectedRowBackground}, ${rowStyle.background} !important; - > td.ant-table-cell { - background: transparent !important; - } - - // > td.ant-table-cell-row-hover, - &:hover { - background: ${hoverRowBackground}, ${selectedRowBackground}, ${rowStyle.background} !important; - } - } - - > tr:nth-of-type(2n).ant-table-row-selected { - background: ${selectedRowBackground}, ${alternateBackground} !important; - > td.ant-table-cell { - background: transparent !important; - } - - // > td.ant-table-cell-row-hover, - &:hover { - background: ${hoverRowBackground}, ${selectedRowBackground}, ${alternateBackground} !important; - } - } - - // hover row - > tr:nth-of-type(2n + 1):hover { - background: ${hoverRowBackground}, ${rowStyle.background} !important; - > td.ant-table-cell-row-hover { - background: transparent; - } - } - > tr:nth-of-type(2n):hover { - background: ${hoverRowBackground}, ${alternateBackground} !important; - > td.ant-table-cell-row-hover { - background: transparent; - } - } - - > tr.ant-table-expanded-row { - background: ${background}; - } - } - `; -}; - -const TitleResizeHandle = styled.span` - position: absolute; - top: 0; - right: -5px; - width: 10px; - height: 100%; - cursor: col-resize; - z-index: 1; -`; - -const BackgroundWrapper = styled.div<{ - $style: TableStyleType; - $tableAutoHeight: boolean; - $showHorizontalScrollbar: boolean; - $showVerticalScrollbar: boolean; - $fixedToolbar: boolean; -}>` - display: flex; - flex-direction: column; - background: ${(props) => props.$style.background} !important; - border-radius: ${(props) => props.$style.radius} !important; - padding: ${(props) => props.$style.padding} !important; - margin: ${(props) => props.$style.margin} !important; - border-style: ${(props) => props.$style.borderStyle} !important; - border-width: ${(props) => `${props.$style.borderWidth} !important`}; - border-color: ${(props) => `${props.$style.border} !important`}; - height: calc(100% - ${(props) => props.$style.margin && getVerticalMargin(props.$style.margin.split(' '))}); - overflow: hidden; - - > div.table-scrollbar-wrapper { - overflow: auto; - ${(props) => props.$fixedToolbar && `height: auto`}; - - ${(props) => (props.$showHorizontalScrollbar || props.$showVerticalScrollbar) && ` - .simplebar-content-wrapper { - overflow: auto !important; - } - `} - - ${(props) => !props.$showHorizontalScrollbar && ` - div.simplebar-horizontal { - visibility: hidden !important; - } - `} - ${(props) => !props.$showVerticalScrollbar && ` - div.simplebar-vertical { - visibility: hidden !important; - } - `} - } -`; - -// TODO: find a way to limit the calc function for max-height only to first Margin value -const TableWrapper = styled.div<{ - $style: TableStyleType; - $headerStyle: TableHeaderStyleType; - $toolbarStyle: TableToolbarStyleType; - $rowStyle: TableRowStyleType; - $toolbarPosition: "above" | "below" | "close"; - $fixedHeader: boolean; - $fixedToolbar: boolean; - $visibleResizables: boolean; - $showHRowGridBorder?: boolean; -}>` - .ant-table-wrapper { - border-top: unset; - border-color: inherit; - } - - .ant-table-row-expand-icon { - color: ${PrimaryColor}; - } - - .ant-table .ant-table-cell-with-append .ant-table-row-expand-icon { - margin: 0; - top: 18px; - left: 4px; - } - - .ant-table.ant-table-small .ant-table-cell-with-append .ant-table-row-expand-icon { - top: 10px; - } - - .ant-table.ant-table-middle .ant-table-cell-with-append .ant-table-row-expand-icon { - top: 14px; - margin-right:5px; - } - - .ant-table { - background: ${(props) =>props.$style.background}; - .ant-table-container { - border-left: unset; - border-top: none !important; - border-inline-start: none !important; - - &::after { - box-shadow: none !important; - } - - .ant-table-content { - overflow: unset !important - } - - // A table expand row contains table - .ant-table-tbody .ant-table-wrapper:only-child .ant-table { - margin: 0; - } - - table { - border-top: unset; - - > .ant-table-thead { - ${(props) => - props.$fixedHeader && ` - position: sticky; - position: -webkit-sticky; - // top: ${props.$fixedToolbar ? '47px' : '0'}; - top: 0; - z-index: 2; - ` - } - > tr { - background: ${(props) => props.$headerStyle.headerBackground}; - } - > tr > th { - background: transparent; - border-color: ${(props) => props.$headerStyle.border}; - border-width: ${(props) => props.$headerStyle.borderWidth}; - color: ${(props) => props.$headerStyle.headerText}; - // border-inline-end: ${(props) => `${props.$headerStyle.borderWidth} solid ${props.$headerStyle.border}`} !important; - - /* Proper styling for fixed header cells */ - &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { - z-index: 1; - background: ${(props) => props.$headerStyle.headerBackground}; - } - - - - > div { - margin: ${(props) => props.$headerStyle.margin}; - - &, .ant-table-column-title > div { - font-size: ${(props) => props.$headerStyle.textSize}; - font-weight: ${(props) => props.$headerStyle.textWeight}; - font-family: ${(props) => props.$headerStyle.fontFamily}; - font-style: ${(props) => props.$headerStyle.fontStyle}; - color:${(props) => props.$headerStyle.text} - } - } - - &:last-child { - border-inline-end: none !important; - } - &.ant-table-column-has-sorters:hover { - background-color: ${(props) => darkenColor(props.$headerStyle.headerBackground, 0.05)}; - } - - > .ant-table-column-sorters > .ant-table-column-sorter { - color: ${(props) => props.$headerStyle.headerText === defaultTheme.textDark ? "#bfbfbf" : props.$headerStyle.headerText}; - } - - &::before { - background-color: ${(props) => props.$headerStyle.border}; - width: ${(props) => (props.$visibleResizables ? "1px" : "0px")} !important; - } - } - } - - > thead > tr > th, - > tbody > tr > td { - border-color: ${(props) => props.$headerStyle.border}; - ${(props) => !props.$showHRowGridBorder && `border-bottom: 0px;`} - } - - td { - padding: 0px 0px; - // ${(props) => props.$showHRowGridBorder ? 'border-bottom: 1px solid #D7D9E0 !important;': `border-bottom: 0px;`} - - /* Proper styling for Fixed columns in the table body */ - &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { - z-index: 1; - background: inherit; - background-color: ${(props) => props.$style.background}; - transition: background-color 0.3s; - } - - } - - /* Fix for selected and hovered rows */ - tr.ant-table-row-selected td.ant-table-cell-fix-left, - tr.ant-table-row-selected td.ant-table-cell-fix-right { - background-color: ${(props) => props.$rowStyle?.selectedRowBackground || '#e6f7ff'} !important; - } - - tr.ant-table-row:hover td.ant-table-cell-fix-left, - tr.ant-table-row:hover td.ant-table-cell-fix-right { - background-color: ${(props) => props.$rowStyle?.hoverRowBackground || '#f5f5f5'} !important; - } - - thead > tr:first-child { - th:last-child { - border-right: unset; - } - } - - tbody > tr > td:last-child { - border-right: unset !important; - } - - .ant-empty-img-simple-g { - fill: #fff; - } - - > thead > tr:first-child { - th:first-child { - border-top-left-radius: 0px; - } - - th:last-child { - border-top-right-radius: 0px; - } - } - } - - .ant-table-expanded-row-fixed:after { - border-right: unset !important; - } - } - } - - ${(props) => - props.$style && getStyle(props.$style, props.$rowStyle, props.$headerStyle, props.$toolbarStyle)} -`; - -const TableTh = styled.th<{ width?: number }>` - overflow: hidden; - - > div { - overflow: hidden; - white-space: pre; - text-overflow: ellipsis; - } - - ${(props) => props.width && `width: ${props.width}px`}; -`; - -interface TableTdProps { - $background: string; - $style: TableColumnStyleType & { rowHeight?: string }; - $defaultThemeDetail: ThemeDetail; - $linkStyle?: TableColumnLinkStyleType; - $isEditing: boolean; - $tableSize?: string; - $autoHeight?: boolean; - $customAlign?: 'left' | 'center' | 'right'; -} -const TableTd = styled.td` - .ant-table-row-expand-icon, - .ant-table-row-indent { - display: ${(props) => (props.$isEditing ? "none" : "initial")}; - } - &.ant-table-row-expand-icon-cell { - background: ${(props) => props.$background}; - border-color: ${(props) => props.$style.border}; - } - background: ${(props) => props.$background} !important; - border-color: ${(props) => props.$style.border} !important; - border-radius: ${(props) => props.$style.radius}; - padding: 0 !important; - text-align: ${(props) => props.$customAlign || 'left'} !important; - - > div:not(.editing-border, .editing-wrapper), - .editing-wrapper .ant-input, - .editing-wrapper .ant-input-number, - .editing-wrapper .ant-picker { - margin: ${(props) => props.$isEditing ? '0px' : props.$style.margin}; - color: ${(props) => props.$style.text}; - font-weight: ${(props) => props.$style.textWeight}; - font-family: ${(props) => props.$style.fontFamily}; - overflow: hidden; - display: flex; - justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; - align-items: center; - text-align: ${(props) => props.$customAlign || 'left'}; - box-sizing: border-box; - ${(props) => props.$tableSize === 'small' && ` - padding: 1px 8px; - font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; - font-style:${props.$style.fontStyle} !important; - min-height: ${props.$style.rowHeight || '14px'}; - line-height: 20px; - ${!props.$autoHeight && ` - overflow-y: auto; - max-height: ${props.$style.rowHeight || '28px'}; - `}; - `}; - ${(props) => props.$tableSize === 'middle' && ` - padding: 8px 8px; - font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; - font-style:${props.$style.fontStyle} !important; - min-height: ${props.$style.rowHeight || '24px'}; - line-height: 24px; - ${!props.$autoHeight && ` - overflow-y: auto; - max-height: ${props.$style.rowHeight || '48px'}; - `}; - `}; - ${(props) => props.$tableSize === 'large' && ` - padding: 16px 16px; - font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; - font-style:${props.$style.fontStyle} !important; - min-height: ${props.$style.rowHeight || '48px'}; - ${!props.$autoHeight && ` - overflow-y: auto; - max-height: ${props.$style.rowHeight || '96px'}; - `}; - `}; - - > .ant-badge > .ant-badge-status-text, - > div > .markdown-body { - color: ${(props) => props.$style.text}; - } - - > div > svg g { - stroke: ${(props) => props.$style.text}; - } - - // dark link|links color - > a, - > div a { - color: ${(props) => props.$linkStyle?.text}; - - &:hover { - color: ${(props) => props.$linkStyle?.hoverText}; - } - - &:active { - color: ${(props) => props.$linkStyle?.activeText}; - } - } - } -`; - -const TableTdLoading = styled(Skeleton.Button)` - width: 90% !important; - display: table !important; - - .ant-skeleton-button { - min-width: auto !important; - display: block !important; - ${(props) => props.$tableSize === 'small' && ` - height: 20px !important; - `} - ${(props) => props.$tableSize === 'middle' && ` - height: 24px !important; - `} - ${(props) => props.$tableSize === 'large' && ` - height: 28px !important; - `} - } -`; - -const ResizeableTitle = (props: any) => { - const { onResize, onResizeStop, width, viewModeResizable, ...restProps } = props; - const [childWidth, setChildWidth] = useState(0); - const resizeRef = useRef(null); - const isUserViewMode = useUserViewMode(); - - const updateChildWidth = useCallback(() => { - if (resizeRef.current) { - const width = resizeRef.current.getBoundingClientRect().width; - setChildWidth(width); - } - }, []); - - useEffect(() => { - updateChildWidth(); - const resizeObserver = new ResizeObserver(() => { - updateChildWidth(); - }); - - if (resizeRef.current) { - resizeObserver.observe(resizeRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [updateChildWidth]); - - const isNotDataColumn = _.isNil(restProps.title); - if ((isUserViewMode && !restProps.viewModeResizable) || isNotDataColumn) { - return ; - } - - return ( - 0 ? width : childWidth} - height={0} - onResize={(e: React.SyntheticEvent, { size }: { size: { width: number } }) => { - e.stopPropagation(); - onResize(size.width); - }} - onResizeStart={(e: React.SyntheticEvent) => { - updateChildWidth(); - e.stopPropagation(); - e.preventDefault(); - }} - onResizeStop={onResizeStop} - draggableOpts={{ enableUserSelectHack: false }} - handle={(axis: ResizeHandleAxis, ref: ReactRef) => ( - { - e.preventDefault(); - e.stopPropagation(); - }} - /> - )} - > - - - ); -}; - -type CustomTableProps = Omit, "components" | "columns"> & { - columns: CustomColumnType[]; - viewModeResizable: boolean; - visibleResizables: boolean; - rowColorFn: RowColorViewType; - rowHeightFn: RowHeightViewType; - columnsStyle: TableColumnStyleType; - size?: string; - rowAutoHeight?: boolean; - customLoading?: boolean; - onCellClick: (columnName: string, dataIndex: string) => void; -}; - -const TableCellView = React.memo((props: { - record: RecordType; - title: string; - rowColorFn: RowColorViewType; - rowHeightFn: RowHeightViewType; - cellColorFn: CellColorViewType; - rowIndex: number; - children: any; - columnsStyle: TableColumnStyleType; - columnStyle: TableColumnStyleType; - linkStyle: TableColumnLinkStyleType; - tableSize?: string; - autoHeight?: boolean; - loading?: boolean; - customAlign?: 'left' | 'center' | 'right'; -}) => { - const { - record, - title, - rowIndex, - rowColorFn, - rowHeightFn, - cellColorFn, - children, - columnsStyle, - columnStyle, - linkStyle, - tableSize, - autoHeight, - loading, - customAlign, - ...restProps - } = props; - - const [editing, setEditing] = useState(false); - const rowContext = useContext(TableRowContext); - - // Memoize style calculations - const style = useMemo(() => { - if (!record) return null; - const rowColor = rowColorFn({ - currentRow: record, - currentIndex: rowIndex, - currentOriginalIndex: record[OB_ROW_ORI_INDEX], - columnTitle: title, - }); - const rowHeight = rowHeightFn({ - currentRow: record, - currentIndex: rowIndex, - currentOriginalIndex: record[OB_ROW_ORI_INDEX], - columnTitle: title, - }); - const cellColor = cellColorFn({ - currentCell: record[title], - currentRow: record, - }); - - return { - background: cellColor || rowColor || columnStyle.background || columnsStyle.background, - margin: columnStyle.margin || columnsStyle.margin, - text: columnStyle.text || columnsStyle.text, - border: columnStyle.border || columnsStyle.border, - radius: columnStyle.radius || columnsStyle.radius, - // borderWidth: columnStyle.borderWidth || columnsStyle.borderWidth, - textSize: columnStyle.textSize || columnsStyle.textSize, - textWeight: columnsStyle.textWeight || columnStyle.textWeight, - fontFamily: columnsStyle.fontFamily || columnStyle.fontFamily, - fontStyle: columnsStyle.fontStyle || columnStyle.fontStyle, - rowHeight: rowHeight, - }; - }, [record, rowIndex, title, rowColorFn, rowHeightFn, cellColorFn, columnStyle, columnsStyle]); - - let tdView; - if (!record) { - tdView = {children}; - } else { - let { background } = style!; - if (rowContext.hover) { - background = 'transparent'; - } - - tdView = ( - - {loading - ? - : children - } - - ); - } - - return ( - - {tdView} - - ); -}); - -const TableRowView = React.memo((props: any) => { - const [hover, setHover] = useState(false); - const [selected, setSelected] = useState(false); - - // Memoize event handlers - const handleMouseEnter = useCallback(() => setHover(true), []); - const handleMouseLeave = useCallback(() => setHover(false), []); - const handleFocus = useCallback(() => setSelected(true), []); - const handleBlur = useCallback(() => setSelected(false), []); - - return ( - - - - ); -}); - -/** - * A table with adjustable column width, width less than 0 means auto column width - */ -function ResizeableTableComp(props: CustomTableProps) { - const { - columns, - viewModeResizable, - visibleResizables, - rowColorFn, - rowHeightFn, - columnsStyle, - size, - rowAutoHeight, - customLoading, - onCellClick, - ...restProps - } = props; - const [resizeData, setResizeData] = useState({ index: -1, width: -1 }); - - // Memoize resize handlers - const handleResize = useCallback((width: number, index: number) => { - setResizeData({ index, width }); - }, []); - - const handleResizeStop = useCallback((width: number, index: number, onWidthResize?: (width: number) => void) => { - setResizeData({ index: -1, width: -1 }); - if (onWidthResize) { - onWidthResize(width); - } - }, []); - - // Memoize cell handlers - const createCellHandler = useCallback((col: CustomColumnType) => { - return (record: RecordType, index: number) => ({ - record, - title: String(col.dataIndex), - rowColorFn, - rowHeightFn, - cellColorFn: col.cellColorFn, - rowIndex: index, - columnsStyle, - columnStyle: col.style, - linkStyle: col.linkStyle, - tableSize: size, - autoHeight: rowAutoHeight, - onClick: () => onCellClick(col.titleText, String(col.dataIndex)), - loading: customLoading, - customAlign: col.align, - }); - }, [rowColorFn, rowHeightFn, columnsStyle, size, rowAutoHeight, onCellClick, customLoading]); - - // Memoize header cell handlers - const createHeaderCellHandler = useCallback((col: CustomColumnType, index: number, resizeWidth: number) => { - return () => ({ - width: resizeWidth, - title: col.titleText, - viewModeResizable, - onResize: (width: React.SyntheticEvent) => { - if (width) { - handleResize(Number(width), index); - } - }, - onResizeStop: (e: React.SyntheticEvent, { size }: { size: { width: number } }) => { - handleResizeStop(size.width, index, col.onWidthResize); - }, - }); - }, [viewModeResizable, handleResize, handleResizeStop]); - - // Memoize columns to prevent unnecessary re-renders - const memoizedColumns = useMemo(() => { - return columns.map((col, index) => { - const { width, style, linkStyle, cellColorFn, onWidthResize, ...restCol } = col; - const resizeWidth = (resizeData.index === index ? resizeData.width : col.width) ?? 0; - - const column: ColumnType = { - ...restCol, - width: typeof resizeWidth === "number" && resizeWidth > 0 ? resizeWidth : undefined, - minWidth: typeof resizeWidth === "number" && resizeWidth > 0 ? undefined : COL_MIN_WIDTH, - onCell: (record: RecordType, index?: number) => createCellHandler(col)(record, index ?? 0), - onHeaderCell: () => createHeaderCellHandler(col, index, Number(resizeWidth))(), - }; - return column; - }); - }, [columns, resizeData, createCellHandler, createHeaderCellHandler]); - - return ( - - components={{ - header: { - cell: ResizeableTitle, - }, - body: { - cell: TableCellView, - row: TableRowView, - }, - }} - {...restProps} - pagination={false} - columns={memoizedColumns} - scroll={{ - x: COL_MIN_WIDTH * columns.length, - }} - /> - ); -} -ResizeableTableComp.whyDidYouRender = true; - -const ResizeableTable = React.memo(ResizeableTableComp) as typeof ResizeableTableComp; - - const createNewEmptyRow = ( rowIndex: number, columnsAggrData: ColumnsAggrData, @@ -848,7 +77,6 @@ export const TableCompView = React.memo((props: { const toolbarStyle = compChildren.toolbarStyle.getView(); const hideToolbar = compChildren.hideToolbar.getView() const rowAutoHeight = compChildren.rowAutoHeight.getView(); - const tableAutoHeight = comp.getTableAutoHeight(); const showHorizontalScrollbar = compChildren.showHorizontalScrollbar.getView(); const showVerticalScrollbar = compChildren.showVerticalScrollbar.getView(); const visibleResizables = compChildren.visibleResizables.getView(); @@ -872,6 +100,7 @@ export const TableCompView = React.memo((props: { const onEvent = useMemo(() => compChildren.onEvent.getView(), [compChildren.onEvent]); const currentExpandedRows = useMemo(() => compChildren.currentExpandedRows.getView(), [compChildren.currentExpandedRows]); const dynamicColumn = compChildren.dynamicColumn.getView(); + const dynamicColumnConfig = useMemo( () => compChildren.dynamicColumnConfig.getView(), [compChildren.dynamicColumnConfig] @@ -1006,6 +235,28 @@ export const TableCompView = React.memo((props: { const childrenProps = childrenToProps(comp.children); +// Table mode and height configuration + const tableMode = useTableMode(comp.getTableAutoHeight()); + const { containerHeight, containerRef } = useContainerHeight(tableMode.isFixedMode); + + const virtualizationConfig = useVirtualization( + containerHeight, + pageDataInfo.data.length, + size as 'small' | 'middle' | 'large', + { + showToolbar: !hideToolbar, + showHeader: !compChildren.hideHeader.getView(), + stickyToolbar: toolbar.fixedToolbar && toolbar.position === 'above', + isFixedMode: tableMode.isFixedMode + } + ); + const totalColumnsWidth = COL_MIN_WIDTH * antdColumns.length; + const scrollConfig = useScrollConfiguration( + virtualizationConfig.enabled, + virtualizationConfig.scrollY, + totalColumnsWidth + ); + useMergeCompStyles( childrenProps as Record, comp.dispatch @@ -1082,7 +333,8 @@ export const TableCompView = React.memo((props: { ); } - const hideScrollbar = !showHorizontalScrollbar && !showVerticalScrollbar; + const hideScrollbar = (!showHorizontalScrollbar && !showVerticalScrollbar) || + (scrollConfig.virtual && (showHorizontalScrollbar || showVerticalScrollbar)); const showTableLoading = loading || // fixme isLoading type ((showDataLoadingIndicators) && @@ -1092,20 +344,20 @@ export const TableCompView = React.memo((props: { return ( - {toolbar.position === "above" && !hideToolbar && (toolbar.fixedToolbar || (tableAutoHeight && showHorizontalScrollbar)) && toolbarView} + {toolbar.position === "above" && !hideToolbar && (toolbar.fixedToolbar || (tableMode.isAutoMode && showHorizontalScrollbar)) && toolbarView} expandable={{ @@ -1148,6 +403,7 @@ export const TableCompView = React.memo((props: { showHeader={!compChildren.hideHeader.getView()} columns={antdColumns} columnsStyle={columnsStyle} + rowStyle={rowStyle} viewModeResizable={compChildren.viewModeResizable.getView()} visibleResizables={compChildren.visibleResizables.getView()} dataSource={pageDataInfo.data.concat(Object.values(emptyRowsMap))} @@ -1162,13 +418,15 @@ export const TableCompView = React.memo((props: { }); }} summary={summaryView} + scroll={scrollConfig.scroll} + virtual={scrollConfig.virtual} /> {expansion.expandModalView} - {toolbar.position === "below" && !hideToolbar && (toolbar.fixedToolbar || (tableAutoHeight && showHorizontalScrollbar)) && toolbarView} + {toolbar.position === "below" && !hideToolbar && (toolbar.fixedToolbar || (tableMode.isAutoMode && showHorizontalScrollbar)) && toolbarView} diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tablePropertyView.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tablePropertyView.tsx index 70bb8ce05a..4f19b3f2e0 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tablePropertyView.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tablePropertyView.tsx @@ -100,7 +100,7 @@ const ColumnDropdown = styled(Dropdown)` const ColumnBatchOptionWrapper = styled.div` display: flex; align-items: center; - color: ${GreyTextColor} + color: ${GreyTextColor}; line-height: 16px; font-size: 13px; `; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableStyles.ts b/client/packages/lowcoder/src/comps/comps/tableComp/tableStyles.ts new file mode 100644 index 0000000000..5e3cc6dcd7 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableStyles.ts @@ -0,0 +1,339 @@ +import styled, { css } from "styled-components"; +import { isValidColor, darkenColor, isTransparentColor } from "lowcoder-design"; +import { PrimaryColor } from "constants/style"; +import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; +import { TableStyleType, TableRowStyleType, TableHeaderStyleType, TableToolbarStyleType } from "comps/controls/styleControlConstants"; +import { getVerticalMargin } from "@lowcoder-ee/util/cssUtil"; + +export function genLinerGradient(color: string) { + return isValidColor(color) ? `linear-gradient(${color}, ${color})` : color; +} + +export const getStyle = ( + style: TableStyleType, + rowStyle: TableRowStyleType, + headerStyle: TableHeaderStyleType, + toolbarStyle: TableToolbarStyleType, +) => { + const background = genLinerGradient(style.background); + const selectedRowBackground = genLinerGradient(rowStyle.selectedRowBackground); + const hoverRowBackground = isTransparentColor(rowStyle.hoverRowBackground) ? null : genLinerGradient(rowStyle.hoverRowBackground); + const alternateBackground = genLinerGradient(rowStyle.alternateBackground); + + return css` + .ant-table-body { + background: ${genLinerGradient(style.background)}; + } + .ant-table-tbody { + > tr:nth-of-type(2n + 1) { + background: ${genLinerGradient(rowStyle.background)}; + } + + > tr:nth-of-type(2n) { + background: ${alternateBackground}; + } + + // selected row + > tr:nth-of-type(2n + 1).ant-table-row-selected { + background: ${selectedRowBackground || rowStyle.background} !important; + > td.ant-table-cell { + background: transparent !important; + } + + // > td.ant-table-cell-row-hover, + &:hover { + background: ${hoverRowBackground || selectedRowBackground || rowStyle.background} !important; + } + } + + > tr:nth-of-type(2n).ant-table-row-selected { + background: ${selectedRowBackground || alternateBackground} !important; + > td.ant-table-cell { + background: transparent !important; + } + + // > td.ant-table-cell-row-hover, + &:hover { + background: ${hoverRowBackground || selectedRowBackground || alternateBackground} !important; + } + } + + // hover row + > tr:nth-of-type(2n + 1):hover { + background: ${hoverRowBackground || rowStyle.background} !important; + > td.ant-table-cell-row-hover { + background: transparent; + } + } + > tr:nth-of-type(2n):hover { + background: ${hoverRowBackground || alternateBackground} !important; + > td.ant-table-cell-row-hover { + background: transparent; + } + } + + > tr.ant-table-expanded-row { + background: ${background}; + } + } + `; +}; + +export const BackgroundWrapper = styled.div<{ + $style: TableStyleType; + $tableAutoHeight: boolean; + $showHorizontalScrollbar: boolean; + $showVerticalScrollbar: boolean; + $fixedToolbar: boolean; +}>` + display: flex; + flex-direction: column; + background: ${(props) => props.$style.background} !important; + border-radius: ${(props) => props.$style.radius} !important; + padding: ${(props) => props.$style.padding} !important; + margin: ${(props) => props.$style.margin} !important; + border-style: ${(props) => props.$style.borderStyle} !important; + border-width: ${(props) => `${props.$style.borderWidth} !important`}; + border-color: ${(props) => `${props.$style.border} !important`}; + height: calc(100% - ${(props) => props.$style.margin && getVerticalMargin(props.$style.margin.split(' '))}); + overflow: hidden; + + > div.table-scrollbar-wrapper { + overflow: auto; + ${(props) => props.$fixedToolbar && `height: auto`}; + + ${(props) => (props.$showHorizontalScrollbar || props.$showVerticalScrollbar) && ` + .simplebar-content-wrapper { + overflow: auto !important; + } + `} + + ${(props) => !props.$showHorizontalScrollbar && ` + div.simplebar-horizontal { + visibility: hidden !important; + } + `} + ${(props) => !props.$showVerticalScrollbar && ` + div.simplebar-vertical { + visibility: hidden !important; + } + `} + } +`; + +// TODO: find a way to limit the calc function for max-height only to first Margin value +export const TableWrapper = styled.div<{ + $style: TableStyleType; + $headerStyle: TableHeaderStyleType; + $toolbarStyle: TableToolbarStyleType; + $rowStyle: TableRowStyleType; + $toolbarPosition: "above" | "below" | "close"; + $fixedHeader: boolean; + $fixedToolbar: boolean; + $visibleResizables: boolean; + $showHRowGridBorder?: boolean; + $isVirtual?: boolean; + $showHorizontalScrollbar?: boolean; + $showVerticalScrollbar?: boolean; +}>` + .ant-table-wrapper { + border-top: unset; + border-color: inherit; + } + + .ant-table-row-expand-icon { + color: ${PrimaryColor}; + } + + .ant-table .ant-table-cell-with-append .ant-table-row-expand-icon { + margin: 0; + top: 18px; + left: 4px; + } + + .ant-table.ant-table-small .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 10px; + } + + .ant-table.ant-table-middle .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 14px; + margin-right:5px; + } + + .ant-table { + background: ${(props) =>props.$style.background}; + .ant-table-container { + border-left: unset; + border-top: none !important; + border-inline-start: none !important; + + &::after { + box-shadow: none !important; + } + + .ant-table-content { + overflow: unset !important + } + + // A table expand row contains table + .ant-table-tbody .ant-table-wrapper:only-child .ant-table { + margin: 0; + } + + table { + border-top: unset; + + > .ant-table-thead { + ${(props) => + props.$fixedHeader && ` + position: sticky; + position: -webkit-sticky; + // top: ${props.$fixedToolbar ? '47px' : '0'}; + top: 0; + z-index: 2; + ` + } + > tr { + background: ${(props) => props.$headerStyle.headerBackground}; + } + > tr > th { + background: transparent; + border-color: ${(props) => props.$headerStyle.border}; + border-width: ${(props) => props.$headerStyle.borderWidth}; + color: ${(props) => props.$headerStyle.headerText}; + // border-inline-end: ${(props) => `${props.$headerStyle.borderWidth} solid ${props.$headerStyle.border}`} !important; + + /* Proper styling for fixed header cells */ + &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { + z-index: 1; + background: ${(props) => props.$headerStyle.headerBackground}; + } + + + } + + + > tr > th { + + > div { + margin: ${(props) => props.$headerStyle.margin}; + + &, .ant-table-column-title > div { + font-size: ${(props) => props.$headerStyle.textSize}; + font-weight: ${(props) => props.$headerStyle.textWeight}; + font-family: ${(props) => props.$headerStyle.fontFamily}; + font-style: ${(props) => props.$headerStyle.fontStyle}; + color:${(props) => props.$headerStyle.headerText} + } + } + + &:last-child { + border-inline-end: none !important; + } + &.ant-table-column-has-sorters:hover { + background-color: ${(props) => darkenColor(props.$headerStyle.headerBackground, 0.05)}; + } + + > .ant-table-column-sorters > .ant-table-column-sorter { + color: ${(props) => props.$headerStyle.headerText === defaultTheme.textDark ? "#bfbfbf" : props.$headerStyle.headerText}; + } + + &::before { + background-color: ${(props) => props.$headerStyle.border}; + width: ${(props) => (props.$visibleResizables ? "1px" : "0px")} !important; + } + } + } + + > thead > tr > th, + > tbody > tr > td { + border-color: ${(props) => props.$headerStyle.border}; + ${(props) => !props.$showHRowGridBorder && `border-bottom: 0px;`} + } + + td { + padding: 0px 0px; + // ${(props) => props.$showHRowGridBorder ? 'border-bottom: 1px solid #D7D9E0 !important;': `border-bottom: 0px;`} + + /* Proper styling for Fixed columns in the table body */ + &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { + z-index: 1; + background: inherit; + background-color: ${(props) => props.$style.background}; + transition: background-color 0.3s; + } + + } + + /* Fix for selected and hovered rows */ + tr.ant-table-row-selected td.ant-table-cell-fix-left, + tr.ant-table-row-selected td.ant-table-cell-fix-right { + background-color: ${(props) => props.$rowStyle?.selectedRowBackground || '#e6f7ff'} !important; + } + + tr.ant-table-row:hover td.ant-table-cell-fix-left, + tr.ant-table-row:hover td.ant-table-cell-fix-right { + background-color: ${(props) => props.$rowStyle?.hoverRowBackground || '#f5f5f5'} !important; + } + + thead > tr:first-child { + th:last-child { + border-right: unset; + } + } + + tbody > tr > td:last-child { + border-right: unset !important; + } + + .ant-empty-img-simple-g { + fill: #fff; + } + + > thead > tr:first-child { + th:first-child { + border-top-left-radius: 0px; + } + + th:last-child { + border-top-right-radius: 0px; + } + } + } + + .ant-table-expanded-row-fixed:after { + border-right: unset !important; + } + } + } + + // ANTD Virtual Scrollbar Styling + .ant-table-tbody-virtual-scrollbar-vertical { + .ant-table-tbody-virtual-scrollbar-thumb { + display: ${(props) => (props.$isVirtual && props.$showVerticalScrollbar) ? 'block' : 'none'} !important; + background: rgba(0, 0, 0, 0.3) !important; + border-radius: 3px !important; + cursor: pointer !important; + + &:hover { + background: rgba(0, 0, 0, 0.5) !important; + } + } + } + + .ant-table-tbody-virtual-scrollbar-horizontal { + .ant-table-tbody-virtual-scrollbar-thumb { + display: ${(props) => (props.$isVirtual && props.$showHorizontalScrollbar) ? 'block' : 'none'} !important; + background: rgba(0, 0, 0, 0.3) !important; + border-radius: 3px !important; + cursor: pointer !important; + + &:hover { + background: rgba(0, 0, 0, 0.5) !important; + } + } + } + + ${(props) => + props.$style && getStyle(props.$style, props.$rowStyle, props.$headerStyle, props.$toolbarStyle)} +`; diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableToolbarComp.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableToolbarComp.tsx index 6b6f619efa..d29299c1ad 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableToolbarComp.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableToolbarComp.tsx @@ -871,6 +871,9 @@ export const TableToolbar = memo(function TableToolbar(props: { if (page !== pagination.current) { onEvent("pageChange"); } + if (pageSize !== pagination.pageSize) { + onEvent("pageSizeChange"); + } }} /> {hasChange && toolbar.showUpdateButtons && ( diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx index f80bcf3b58..4cea243282 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableTypes.tsx @@ -139,6 +139,11 @@ export const TableEventOptions = [ value: "pageChange", description: trans("table.pageChange"), }, + { + label: trans("table.pageSizeChange"), + value: "pageSizeChange", + description: trans("table.pageSizeChange"), + }, { label: trans("table.refresh"), value: "refresh", diff --git a/client/packages/lowcoder/src/comps/comps/tableComp/tableUtils.tsx b/client/packages/lowcoder/src/comps/comps/tableComp/tableUtils.tsx index 025e91e51e..d71ae5a8d4 100644 --- a/client/packages/lowcoder/src/comps/comps/tableComp/tableUtils.tsx +++ b/client/packages/lowcoder/src/comps/comps/tableComp/tableUtils.tsx @@ -30,6 +30,26 @@ export const OB_ROW_RECORD = "__ob_origin_record"; export const COL_MIN_WIDTH = 55; export const COL_MAX_WIDTH = 500; +/* + +======================== Virtualization constants ========================= + +*/ +export const VIRTUAL_ROW_HEIGHTS = { + small: 32, + middle: 48, + large: 80 +} as const; + + +export const VIRTUAL_THRESHOLD = 50; +export const MIN_VIRTUAL_HEIGHT = 200; // Minimum container height needed for virtualization +export const TOOLBAR_HEIGHT = 48; // Standard toolbar height +export const HEADER_HEIGHT = 40; // Standard header height + + /* ========================== End of Virtualization constants ========================== */ + + /** * Add __originIndex__, mainly for the logic of the default key */ @@ -210,17 +230,47 @@ export function getColumnsAggr( oriDisplayData: JSONObject[], dataIndexWithParamsDict: NodeToValue< ReturnType["withParamsNode"]> - > + >, + columnChangeSets?: Record> ): ColumnsAggrData { return _.mapValues(dataIndexWithParamsDict, (withParams, dataIndex) => { const compType = (withParams.wrap() as any).compType; const res: Record & { compType: string } = { compType }; + if (compType === "tag") { - res.uniqueTags = _(oriDisplayData) + const originalTags = _(oriDisplayData) .map((row) => row[dataIndex]!) .filter((tag) => !!tag) + .value(); + + const pendingChanges = columnChangeSets?.[dataIndex] || {}; + const pendingTags = _(pendingChanges) + .values() + .filter((value) => !!value) + .value(); + + const extractTags = (value: any): string[] => { + if (!value) return []; + if (_.isArray(value)) return value.map(String); + if (typeof value === "string") { + // Handle comma-separated tags + if (value.includes(",")) { + return value.split(",").map(tag => tag.trim()).filter(tag => tag); + } + return [value]; + } + return [String(value)]; + }; + + const allTags = [ + ...originalTags.flatMap(extractTags), + ...pendingTags.flatMap(extractTags) + ]; + + res.uniqueTags = _(allTags) .uniq() .value(); + } else if (compType === "badgeStatus") { res.uniqueStatus = _(oriDisplayData) .map((row) => { diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx new file mode 100644 index 0000000000..842057fbb6 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx @@ -0,0 +1,179 @@ +import { DateTimeComp } from "comps/comps/tableComp/column/columnTypeComps/columnDateTimeComp"; +import { TimeComp } from "./columnTypeComps/columnTimeComp"; +import { ButtonComp } from "./simpleColumnTypeComps"; +import { withType } from "comps/generators"; +import { trans } from "i18n"; +import { Dropdown } from "lowcoder-design/src/components/Dropdown"; +import { BooleanComp } from "./columnTypeComps/columnBooleanComp"; +import { SwitchComp } from "./columnTypeComps/columnSwitchComp"; +import { DateComp } from "./columnTypeComps/columnDateComp"; +import { ImageComp } from "./columnTypeComps/columnImgComp"; +import { LinkComp } from "./columnTypeComps/columnLinkComp"; +import { ColumnLinksComp } from "./columnTypeComps/columnLinksComp"; +import { ColumnMarkdownComp } from "./columnTypeComps/columnMarkdownComp"; +import { ProgressComp } from "./columnTypeComps/columnProgressComp"; +import { RatingComp } from "./columnTypeComps/columnRatingComp"; +import { BadgeStatusComp } from "./columnTypeComps/columnStatusComp"; +import { ColumnTagsComp } from "./columnTypeComps/columnTagsComp"; +import { ColumnSelectComp } from "./columnTypeComps/columnSelectComp"; +import { SimpleTextComp } from "./columnTypeComps/simpleTextComp"; +import { ColumnNumberComp } from "./columnTypeComps/ColumnNumberComp"; + +import { ColumnAvatarsComp } from "./columnTypeComps/columnAvatarsComp"; +import { ColumnDropdownComp } from "./columnTypeComps/columnDropdownComp"; + +export type CellProps = { + tableSize?: string; + candidateTags?: string[]; + candidateStatus?: { text: string; status: any }[]; + textOverflow?: boolean; + cellTooltip?: string; + onTableEvent?: (eventName: any) => void; + cellIndex?: string; +}; + +const actionOptions = [ + { + label: trans("table.avatars"), + value: "avatars", + }, + { + label: trans("table.text"), + value: "text", + }, + { + label: trans("table.number"), + value: "number", + }, + { + label: trans("table.link"), + value: "link", + }, + { + label: trans("table.links"), + value: "links", + }, + { + label: trans("table.tag"), + value: "tag", + }, + { + label: trans("table.select"), + value: "select", + }, + { + label: trans("table.dropdown"), + value: "dropdown", + }, + { + label: trans("table.badgeStatus"), + value: "badgeStatus", + }, + { + label: trans("table.button"), + value: "button", + }, + { + label: trans("table.image"), + value: "image", + }, + { + label: trans("table.time"), + value: "time", + }, + + { + label: trans("table.date"), + value: "date", + }, + { + label: trans("table.dateTime"), + value: "dateTime", + }, + { + label: "Markdown", + value: "markdown", + }, + { + label: trans("table.boolean"), + value: "boolean", + }, + { + label: trans("table.switch"), + value: "switch", + }, + { + label: trans("table.rating"), + value: "rating", + }, + { + label: trans("table.progress"), + value: "progress", + }, +] as const; + +export const ColumnTypeCompMap = { + avatars: ColumnAvatarsComp, + text: SimpleTextComp, + number: ColumnNumberComp, + button: ButtonComp, + badgeStatus: BadgeStatusComp, + link: LinkComp, + tag: ColumnTagsComp, + select: ColumnSelectComp, + dropdown: ColumnDropdownComp, + links: ColumnLinksComp, + image: ImageComp, + markdown: ColumnMarkdownComp, + dateTime: DateTimeComp, + boolean: BooleanComp, + switch: SwitchComp, + rating: RatingComp, + progress: ProgressComp, + date: DateComp, + time: TimeComp, +}; + +type ColumnTypeMapType = typeof ColumnTypeCompMap; +export type ColumnTypeKeys = keyof ColumnTypeMapType; + +const TypedColumnTypeComp = withType(ColumnTypeCompMap, "text"); + +export class ColumnTypeComp extends TypedColumnTypeComp { + override getView() { + const childView = this.children.comp.getView(); + return { + view: (cellProps: CellProps) => { + return childView(cellProps); + }, + value: this.children.comp.getDisplayValue(), + }; + } + + private handleTypeChange: (value: ColumnTypeKeys) => void = (value) => { + // Keep the previous text value, some components do not have text, the default value is currentCell + let textRawData = "{{currentCell}}"; + if (this.children.comp.children.hasOwnProperty("text")) { + textRawData = (this.children.comp.children as any).text.toJsonValue(); + } + this.dispatchChangeValueAction({ + compType: value, + comp: { text: textRawData }, + } as any); + } + + override getPropertyView() { + return ( + <> + + {this.children.comp.getPropertyView()} + + ); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx new file mode 100644 index 0000000000..3949f197a2 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx @@ -0,0 +1,186 @@ +import { CellViewReturn } from "components/table/EditableCell"; +import { stateComp } from "comps/generators"; +import { + MultiCompBuilder, + PropertyViewFnTypeForComp, + ToConstructor, + ViewFnTypeForComp, +} from "comps/generators/multi"; +import _ from "lodash"; +import { + CompConstructor, + ConstructorToNodeType, + fromRecord, + NodeToValue, + RecordConstructorToComp, + withFunction, +} from "lowcoder-core"; +import { ReactNode } from "react"; +import { JSONValue } from "util/jsonTypes"; + +export const __COLUMN_DISPLAY_VALUE_FN = "__COLUMN_DISPLAY_VALUE_FN"; + +type RecordConstructorToNodeValue = { + [K in keyof T]: NodeToValue>; +}; + +type ViewValueFnType>> = ( + nodeValue: RecordConstructorToNodeValue +) => JSONValue; + +export type ColumnTypeViewFn = ViewFnTypeForComp< + ViewReturn, + RecordConstructorToComp +>; + +export class ColumnTypeCompBuilder< + ChildrenCtorMap extends Record>, + T extends JSONValue = JSONValue +> { + private childrenMap: ChildrenCtorMap; + private propertyViewFn?: PropertyViewFnTypeForComp< + RecordConstructorToComp + >; + private stylePropertyViewFn?: PropertyViewFnTypeForComp< + RecordConstructorToComp + >; + private cleanupFunctions: (() => void)[] = []; + + constructor( + childrenMap: ChildrenCtorMap, + private viewFn: ColumnTypeViewFn, + private displayValueFn: ViewValueFnType, + private baseValueFn?: ColumnTypeViewFn + ) { + this.childrenMap = { ...childrenMap } as ChildrenCtorMap; + } + + setEditViewFn(_: any) { + // Edit views are disabled in Table Lite; keep chainability without storing + return this; + } + + setPropertyViewFn( + propertyViewFn: PropertyViewFnTypeForComp< + RecordConstructorToComp + > + ) { + this.propertyViewFn = propertyViewFn; + return this; + } + + setStylePropertyViewFn( + stylePropertyViewFn: PropertyViewFnTypeForComp< + RecordConstructorToComp + > + ) { + this.stylePropertyViewFn = stylePropertyViewFn; + return this; + } + + build() { + if (!this.propertyViewFn) { + throw new Error("need property view fn"); + } + + // Memoize the props processing + const memoizedViewFn = _.memoize( + (props: any, dispatch: any) => { + const baseValue = this.baseValueFn?.(props, dispatch); + const normalView = this.viewFn(props, dispatch); + return normalView; + }, + (props) => { + let safeOptions = []; + let safeAvatars = []; + if(props.options) { + safeOptions = props.options.map((option: Record) => { + const {prefixIcon, suffixIcon, ...safeOption} = option; + return safeOption; + }) + } + if(props.avatars) { + safeAvatars = props.avatars.map((avatar: Record) => { + const {AvatarIcon, ...safeAvatar} = avatar; + return safeAvatar; + }) + } + const { + prefixIcon, + suffixIcon, + iconFalse, + iconTrue, + iconNull, + tagColors, + options, + avatars, + ...safeProps + } = props; + return safeProps; + } + ); + + const viewFn: ColumnTypeViewFn = + (props, dispatch): CellViewReturn => + (cellProps) => memoizedViewFn({ ...props, ...cellProps } as any, dispatch); + + const ColumnTypeCompTmp = new MultiCompBuilder( + (this.childrenMap as unknown) as ToConstructor< + RecordConstructorToComp + >, + viewFn + ) + .setPropertyViewFn(this.propertyViewFn) + .build(); + + const displayValueFn = this.displayValueFn; + + return class extends ColumnTypeCompTmp { + // table cell data + private _displayValue: JSONValue = null; + private cleanupFunctions: (() => void)[] = []; + constructor(props: any) { + super(props); + this.cleanupFunctions.push(() => { + this._displayValue = null; + memoizedViewFn.cache.clear?.(); + }); + } + + override extraNode() { + return { + node: { + [__COLUMN_DISPLAY_VALUE_FN]: withFunction( + fromRecord(this.childrenNode()), + () => displayValueFn + ), + }, + updateNodeFields: (value: any) => { + const displayValueFunc = value[__COLUMN_DISPLAY_VALUE_FN]; + this._displayValue = displayValueFunc(value); + return { displayValue: this._displayValue }; + }, + }; + } + + /** + * Get the data actually displayed by the table cell + */ + getDisplayValue() { + return this._displayValue; + } + + componentWillUnmount() { + // Cleanup all registered cleanup functions + this.cleanupFunctions.forEach(cleanup => cleanup()); + this.cleanupFunctions = []; + } + }; + } + + // Cleanup method to be called when the builder is no longer needed + cleanup() { + this.cleanupFunctions.forEach(cleanup => cleanup()); + this.cleanupFunctions = []; + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx new file mode 100644 index 0000000000..ad9e05c545 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useMemo, ReactNode } from "react"; +import { default as InputNumber } from "antd/es/input-number"; +import { NumberControl, RangeControl, StringControl } from "comps/controls/codeControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { withDefault } from "comps/generators"; +import styled from "styled-components"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const NumberViewWrapper = styled.div` + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +`; + +const NumberEventOptions = [clickEvent, doubleClickEvent] as const; + +const childrenMap = { + text: NumberControl, + step: withDefault(NumberControl, 1), + precision: RangeControl.closed(0, 20, 0), + float: BoolControl, + prefix: StringControl, + prefixIcon: IconControl, + suffixIcon: IconControl, + suffix: StringControl, + onEvent: eventHandlerControl(NumberEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type NumberViewProps = { + value: number; + prefix: string; + suffix: string; + prefixIcon: ReactNode; + suffixIcon: ReactNode; + float: boolean; + precision: number; + onEvent?: (eventName: string) => void; +}; + +const ColumnNumberView = React.memo((props: NumberViewProps) => { + const handleClickEvent = useCompClickEventHandler({onEvent: props.onEvent ?? (() => {})}) + + const formattedValue = useMemo(() => { + let result = !props.float ? Math.floor(props.value) : props.value; + if (props.float) { + result = Number(result.toFixed(props.precision + 1)); + } + return result; + }, [props.value, props.float, props.precision]); + + const handleClick = useCallback(() => { + handleClickEvent() + }, [props.onEvent]); + + return ( + + {hasIcon(props.prefixIcon) && ( + {props.prefixIcon} + )} + {props.prefix + formattedValue + props.suffix} + {hasIcon(props.suffixIcon) && ( + {props.suffixIcon} + )} + + ); +}); + +ColumnNumberView.displayName = 'ColumnNumberView'; + +export const ColumnNumberComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.step.propertyView({ + label: trans("table.numberStep"), + tooltip: trans("table.numberStepTooltip"), + onFocus: (focused) => { + if (!focused) { + const value = children.step.getView(); + const isFloat = children.float.getView(); + const newValue = !isFloat ? Math.floor(value) : value; + children.step.dispatchChangeValueAction(String(newValue)); + } + } + })} + {children.float.getView() && ( + children.precision.propertyView({ + label: trans("table.precision"), + }) + )} + {children.prefix.propertyView({ + label: trans("table.prefix"), + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffix.propertyView({ + label: trans("table.suffix"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.float.propertyView({ + label: trans("table.float"), + onChange: (isFloat) => { + const value = children.step.getView(); + const newValue = !isFloat ? Math.floor(value) : value; + children.step.dispatchChangeValueAction(String(newValue)); + } + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx new file mode 100644 index 0000000000..f02ee19943 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx @@ -0,0 +1,251 @@ +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { StringControl } from "comps/controls/codeControl"; +import { MultiCompBuilder, stateComp, withDefault } from "comps/generators"; +import { trans } from "i18n"; +import styled from "styled-components"; +import { LightActiveTextColor, PrimaryColor } from "constants/style"; +import { styleControl } from "comps/controls/styleControl"; +import { avatarGroupStyle, AvatarGroupStyleType } from "comps/controls/styleControlConstants"; +import { AlignCenter, AlignLeft, AlignRight } from "lowcoder-design"; +import { NumberControl } from "comps/controls/codeControl"; +import { Avatar, Tooltip } from "antd"; +import { clickEvent, eventHandlerControl, refreshEvent, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import React, { ReactElement, useCallback, useEffect, useRef } from "react"; +import { IconControl } from "comps/controls/iconControl"; +import { ColorControl } from "comps/controls/colorControl"; +import { optionsControl } from "comps/controls/optionsControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { JSONObject } from "util/jsonTypes"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const MacaroneList = [ + '#fde68a', + '#eecff3', + '#a7f3d0', + '#bfdbfe', + '#bfdbfe', + '#c7d2fe', + '#fecaca', + '#fcd6bb', +] + +const Container = styled.div<{ $style: AvatarGroupStyleType | undefined, alignment: string }>` + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: ${props => props.alignment}; + cursor: pointer; +`; + +const AvatarEventOptions = [clickEvent, refreshEvent] as const; + +const DropdownOption = new MultiCompBuilder( + { + src: StringControl, + AvatarIcon: IconControl, + label: StringControl, + color: ColorControl, + backgroundColor: ColorControl, + Tooltip: StringControl, + onEvent: eventHandlerControl(AvatarEventOptions), + }, + (props) => props +) +.setPropertyViewFn((children) => { + return ( + <> + {children.src.propertyView({ label: trans("avatarComp.src"), placeholder: "", tooltip: trans("avatarComp.avatarCompTooltip") })} + {children.label.propertyView({label: trans("avatarComp.title"), tooltip: trans("avatarComp.avatarCompTooltip"), + })} + {children.AvatarIcon.propertyView({ + label: trans("avatarComp.icon"), + IconType: "All", + tooltip: trans("avatarComp.avatarCompTooltip"), + })} + {children.color.propertyView({ label: trans("style.fill") })} + {children.backgroundColor.propertyView({ label: trans("style.background") })} + {children.Tooltip.propertyView({ label: trans("badge.tooltip") })} + {children.onEvent.propertyView()} + + ); +}) +.build(); + +const EventOptions = [clickEvent, refreshEvent, doubleClickEvent] as const; + +export const alignOptions = [ + { label: , value: "flex-start" }, + { label: , value: "center" }, + { label: , value: "flex-end" }, +] as const; + +// Memoized Avatar component +const MemoizedAvatar = React.memo(({ + item, + index, + style, + autoColor, + avatarSize, + onEvent, + onItemEvent +}: { + item: any; + index: number; + style: any; + autoColor: boolean; + avatarSize: number; + onEvent: (event: string) => void; + onItemEvent?: (event: string) => void; +}) => { + const mountedRef = useRef(true); + const handleClickEvent = useCompClickEventHandler({onEvent}) + + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(() => { + if (!mountedRef.current) return; + + // Trigger individual avatar event first + if (onItemEvent) { + onItemEvent("click"); + } + + // Then trigger main component event + handleClickEvent() + }, [onItemEvent, handleClickEvent]); + + return ( + + + {item.label} + + + ); +}); + +MemoizedAvatar.displayName = 'MemoizedAvatar'; + +// Memoized Avatar Group component +const MemoizedAvatarGroup = React.memo(({ + avatars, + maxCount, + avatarSize, + style, + autoColor, + onEvent +}: { + avatars: any[]; + maxCount: number; + avatarSize: number; + style: any; + autoColor: boolean; + onEvent: (event: string) => void; +}) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + return ( + + {avatars.map((item, index) => ( + + ))} + + ); +}); + +MemoizedAvatarGroup.displayName = 'MemoizedAvatarGroup'; + +export const ColumnAvatarsComp = (function () { + const childrenMap = { + style: styleControl(avatarGroupStyle), + maxCount: withDefault(NumberControl, 3), + avatarSize: withDefault(NumberControl, 40), + alignment: dropdownControl(alignOptions, "center"), + autoColor: BoolControl.DEFAULT_TRUE, + onEvent: eventHandlerControl(EventOptions), + currentAvatar: stateComp({}), + avatars: optionsControl(DropdownOption, { + initOptions: [ + { src: "https://api.dicebear.com/7.x/miniavs/svg?seed=1", label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + { AvatarIcon: "/icon:antd/startwotone" }, + { label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + { label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + ], + }) + }; + + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + return ( + + + + ); + }, + () => "" + ) + .setPropertyViewFn((children) => ( + <> + {children.avatars.propertyView({})} + {children.maxCount.propertyView({ + label: trans("avatarGroup.maxCount") + })} + {children.avatarSize.propertyView({ + label: trans("avatarGroup.avatarSize") + })} + {children.autoColor.propertyView({ + label: trans("avatarGroup.autoColor") + })} + {children.alignment.propertyView({ + label: trans("table.avatarGroupAlignment"), + radioButton: true, + })} + {children.onEvent.propertyView()} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx new file mode 100644 index 0000000000..125b3dd505 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx @@ -0,0 +1,135 @@ +import React, { useMemo } from "react"; +import { BoolCodeControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { getStyle } from "comps/comps/selectInputComp/checkboxComp"; +import styled from "styled-components"; +import { CheckboxStyle, CheckboxStyleType } from "comps/controls/styleControlConstants"; +import { useStyle } from "comps/controls/styleControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { TableCheckedIcon, TableUnCheckedIcon } from "lowcoder-design"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; + +const Wrapper = styled.div` + background: transparent !important; + padding: 0 8px; +`; + +const IconWrapper = styled.span<{ $style: CheckboxStyleType; $ifChecked: boolean }>` + // pointer-events: none; + height: 22px; + display: inline-block; + svg { + width: 14px; + height: 22px; + g { + stroke: ${(props) => props.$ifChecked && props.$style.checkedBackground} !important; + } + } +`; + +const falseValuesOptions = [ + { + label: trans("table.empty"), + value: "", + }, + { + label: "-", + value: "-", + }, + { + label: , + value: "x", + }, +] as const; + +const childrenMap = { + text: BoolCodeControl, + falseValues: dropdownControl(falseValuesOptions, ""), + iconTrue: IconControl, + iconFalse: IconControl, + iconNull: IconControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +// Memoized checkbox view component +const CheckBoxView = React.memo(({ + value, + iconTrue, + iconFalse, + iconNull, + falseValues +}: { + value: boolean; + iconTrue: React.ReactNode; + iconFalse: React.ReactNode; + iconNull: React.ReactNode; + falseValues: string; +}) => { + const style = useStyle(CheckboxStyle); + + const content = useMemo(() => { + if (value === true) { + return hasIcon(iconTrue) ? iconTrue : ; + } else if (value === false) { + return hasIcon(iconFalse) ? iconFalse : (falseValues === "x" ? : falseValues); + } else { + return hasIcon(iconNull) ? iconNull : "No Value"; + } + }, [value, iconTrue, iconFalse, iconNull, falseValues]); + + return ( + + {content} + + ); +}); + +CheckBoxView.displayName = 'CheckBoxView'; + +export const BooleanComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.falseValues.propertyView({ + label: trans("table.falseValues"), + radioButton: true, + })} + {children.iconTrue.propertyView({ + label: trans("table.iconTrue"), + })} + {children.iconFalse.propertyView({ + label: trans("table.iconFalse"), + })} + {children.iconNull.propertyView({ + label: trans("table.iconNull"), + })} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx new file mode 100644 index 0000000000..239832c4c3 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx @@ -0,0 +1,164 @@ +import { default as DatePicker } from "antd/es/date-picker"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { withDefault } from "comps/generators"; +import { formatPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { isNumber } from "lodash"; +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { PrevIcon, SuperPrevIcon } from "lowcoder-design"; +import React from "react"; +import styled from "styled-components"; +import { DateParser, DATE_FORMAT } from "util/dateTimeUtils"; + +dayjs.extend(utc) + +const IconNext = styled(PrevIcon)` + transform: rotate(180deg); +`; +const IconSuperNext = styled(SuperPrevIcon)` + transform: rotate(180deg); +`; + +const DatePickerStyled = styled(DatePicker)<{ $open: boolean }>` + width: 100%; + height: 100%; + position: absolute; + top: 0; + padding: 0; + padding-left: 11px; + .ant-picker-input { + height: 100%; + } + input { + padding-right: 18px; + cursor: pointer; + } + &.ant-picker-focused .ant-picker-suffix svg g { + stroke: ${(props) => props.$open && "#315EFB"}; + } + .ant-picker-suffix { + height: calc(100% - 1px); + position: absolute; + right: 0; + top: 0.5px; + display: flex; + align-items: center; + padding: 0 3px; + } +`; + +const StylePanel = styled.div` + .ant-picker-header { + padding: 0 12px; + .ant-picker-header-super-prev-btn, + .ant-picker-header-prev-btn, + .ant-picker-header-next-btn, + .ant-picker-header-super-next-btn { + display: flex; + align-items: center; + justify-content: center; + svg { + max-width: 12px; + max-height: 12px; + } + &:hover svg g { + fill: #315efb; + } + } + } + .ant-picker-date-panel .ant-picker-body { + padding: 8px 16px; + } + .ant-picker-ranges { + padding: 10px 16px; + } + .ant-picker-now-btn { + color: #4965f2; + &:hover { + color: #315efb; + } + } + .ant-picker-cell { + color: #b8b9bf; + } + .ant-picker-cell-in-view { + color: rgba(0, 0, 0, 0.85); + } + .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner, + .ant-picker-ok .ant-btn-primary { + background: #4965f2; + border: none; + box-shadow: none; + &:hover { + background: #315efb; + border: none; + box-shadow: none; + } + } + .ant-picker-cell:hover:not(.ant-picker-cell-in-view) .ant-picker-cell-inner, + .ant-picker-cell:hover:not(.ant-picker-cell-selected):not(.ant-picker-cell-range-start):not(.ant-picker-cell-range-end):not(.ant-picker-cell-range-hover-start):not(.ant-picker-cell-range-hover-end) + .ant-picker-cell-inner { + background-color: #f2f7fc; + color: #4965f2; + } + .ant-picker-year-panel, + .ant-picker-month-panel { + & + div .ant-picker-now { + display: none; + } + } +`; + +const DatePickerPopup = styled.div` + border-radius: 8px; + box-shadow: 0 0 10px 0 rgba(0,0,0,0.10); + overflow: hidden; +`; + +export function formatDate(date: string, format: string) { + let mom = dayjs(date); + if (isNumber(Number(date)) && !isNaN(Number(date)) && date !== "") { + mom = dayjs(Number(date)); + } + if (!mom.isValid()) { + mom = dayjs.utc(date).local(); + } + + return mom.isValid() ? mom.format(format) : ""; +} + +const childrenMap = { + text: StringControl, + format: withDefault(StringControl, DATE_FORMAT), + inputFormat: withDefault(StringControl, DATE_FORMAT), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +export const DateComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return formatDate(value, props.format); + }, + (nodeValue) => formatDate(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {formatPropertyView({ children, placeholder: DATE_FORMAT })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx new file mode 100644 index 0000000000..b5542615e5 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx @@ -0,0 +1,42 @@ +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { withDefault } from "comps/generators"; +import { formatPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { DATE_TIME_FORMAT } from "util/dateTimeUtils"; +import { formatDate } from "./columnDateComp"; +import React from "react"; + +const childrenMap = { + text: StringControl, + format: withDefault(StringControl, DATE_TIME_FORMAT), + inputFormat: withDefault(StringControl, DATE_TIME_FORMAT), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +export const DateTimeComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return formatDate(value, props.format); + }, + (nodeValue) => formatDate(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {formatPropertyView({ children, placeholder: DATE_TIME_FORMAT })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx new file mode 100644 index 0000000000..b78601a5fa --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx @@ -0,0 +1,200 @@ +import React, { ReactNode, useCallback, useRef, useEffect, useMemo, ReactElement } from "react"; +import { DropdownOptionControl } from "comps/controls/optionsControl"; +import { StringControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import Menu from "antd/es/menu"; +import Dropdown from "antd/es/dropdown"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { IconControl } from "comps/controls/iconControl"; +import { withDefault } from "comps/generators"; +import { IconWrapper } from "util/bottomResUtils"; +import { ButtonTypeOptions } from "../simpleColumnTypeComps"; +import { useStyle } from "comps/controls/styleControl"; +import { ButtonStyle } from "comps/controls/styleControlConstants"; +import { Button100 } from "comps/comps/buttonComp/buttonCompConstants"; +import styled from "styled-components"; +import { ButtonType } from "antd/es/button"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const StyledButton = styled(Button100)` + display: flex; + align-items: center; + gap: 0; + min-width: 30px; + width: auto; +`; + +const StyledIconWrapper = styled(IconWrapper)` + margin: 0; +`; + +const DropdownEventOptions = [clickEvent] as const; + +const childrenMap = { + buttonType: dropdownControl(ButtonTypeOptions, "primary"), + label: withDefault(StringControl, 'Menu'), + prefixIcon: IconControl, + suffixIcon: IconControl, + options: DropdownOptionControl, + onEvent: eventHandlerControl(DropdownEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.label; + +// Memoized dropdown menu component +const DropdownMenu = React.memo(({ items, options, onEvent }: { items: any[]; options: any[]; onEvent: (eventName: string) => void }) => { + const mountedRef = useRef(true); + const handleClickEvent = useCompClickEventHandler({onEvent}) + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(({ key }: { key: string }) => { + if (!mountedRef.current) return; + const item = items.find((o) => o.key === key); + const itemIndex = options.findIndex(option => option.label === item?.label); + item && options[itemIndex]?.onEvent("click"); + // Also trigger the dropdown's main event handler + handleClickEvent(); + }, [items, options, handleClickEvent]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }, []); + + return ( + + ); +}); + +DropdownMenu.displayName = 'DropdownMenu'; + +const DropdownView = React.memo((props: { + buttonType: ButtonType; + label: string; + prefixIcon: ReactNode; + suffixIcon: ReactNode; + options: any[]; + onEvent?: (eventName: string) => void; +}) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const hasOptionIcon = useMemo(() => + props.options.findIndex((option) => (option.prefixIcon as ReactElement)?.props.value) > -1, + [props.options] + ); + + const items = useMemo(() => + props.options + .filter((option) => !option.hidden) + .map((option, index) => ({ + title: option.label, + label: option.label, + key: option.label + " - " + index, + disabled: option.disabled, + icon: hasOptionIcon && {option.prefixIcon}, + index, + })), + [props.options, hasOptionIcon] + ); + + const hasPrefixIcon = useMemo(() => + (props.prefixIcon as ReactElement)?.props.value, + [props.prefixIcon] + ); + + const hasSuffixIcon = useMemo(() => + (props.suffixIcon as ReactElement)?.props.value, + [props.suffixIcon] + ); + + const buttonStyle = useStyle(ButtonStyle); + + const menu = useMemo(() => ( + {})} /> + ), [items, props.options, props.onEvent]); + + return ( + menu} + > + + {hasPrefixIcon && ( + + {props.prefixIcon} + + )} + {props.label || (hasPrefixIcon || hasSuffixIcon ? undefined : " ")} + {hasSuffixIcon && ( + + {props.suffixIcon} + + )} + + + ); +}); + +DropdownView.displayName = 'DropdownView'; + +export const ColumnDropdownComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props) => { + return ; + }, + (nodeValue) => nodeValue.label.value, + getBaseValue, + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.buttonType.propertyView({ + label: trans("table.type"), + radioButton: true, + })} + {children.label.propertyView({ + label: trans("text"), + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.options.propertyView({ + title: trans("optionsControl.optionList"), + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx new file mode 100644 index 0000000000..c33ecdf29e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { StringControl, NumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { withDefault } from "comps/generators"; +import { TacoImage } from "lowcoder-design"; +import styled from "styled-components"; +import { DEFAULT_IMG_URL } from "@lowcoder-ee/util/stringUtils"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; + +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +const childrenMap = { + src: withDefault(StringControl, "{{currentCell}}"), + size: withDefault(NumberControl, "50"), + onEvent: eventHandlerControl([clickEvent]), +}; + +const StyledTacoImage = styled(TacoImage)` + pointer-events: auto !important; + cursor: pointer !important; + + &:hover { + opacity: 0.8; + transition: opacity 0.2s ease; + } +`; + +// Memoized image component +const ImageView = React.memo(({ src, size, onEvent }: { src: string; size: number; onEvent?: (eventName: string) => void }) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(() => { + console.log("Image clicked!", { src, onEvent: !!onEvent }); // Debug log + if (mountedRef.current && onEvent) { + onEvent("click"); + } + }, [onEvent, src]); + + return ( + + ); +}); + +ImageView.displayName = 'ImageView'; + +const getBaseValue: ColumnTypeViewFn = (props) => props.src; + +export const ImageComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.src.value, + getBaseValue + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.src.propertyView({ + label: trans("table.imageSrc"), + tooltip: ColumnValueTooltip, + })} + {children.size.propertyView({ + label: trans("table.imageSize"), + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx new file mode 100644 index 0000000000..8fd4115d7f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx @@ -0,0 +1,91 @@ +import React, { useCallback } from "react"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { disabledPropertyView } from "comps/utils/propertyUtils"; +import styled, { css } from "styled-components"; +import { styleControl } from "comps/controls/styleControl"; +import { TableColumnLinkStyle } from "comps/controls/styleControlConstants"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { fixOldActionData } from "comps/comps/tableComp/column/simpleColumnTypeComps"; + +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +const LinkEventOptions = [clickEvent, doubleClickEvent] as const; + +const childrenMap = { + text: StringControl, + onClick: eventHandlerControl(LinkEventOptions), + disabled: BoolCodeControl, + style: styleControl(TableColumnLinkStyle), +}; + +const disableCss = css` + &, + &:hover { + cursor: not-allowed; + color: rgba(0, 0, 0, 0.25) !important; + } +`; + +const StyledLink = styled.a<{ $disabled: boolean }>` + ${(props) => props.$disabled && disableCss}; +`; + +// Memoized link component +export const ColumnLink = React.memo(({ disabled, label, onClick }: { disabled: boolean; label: string; onClick: (eventName: string) => void }) => { + const handleClickEvent = useCompClickEventHandler({onEvent: onClick}) + const handleClick = useCallback(() => { + if (!disabled) { + handleClickEvent(); + } + }, [disabled, onClick]); + + return ( + + {label} + + ); +}); + +ColumnLink.displayName = 'ColumnLink'; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +const LinkCompTmp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + )) + .setStylePropertyViewFn((children) => ( + <> + {children.style.getPropertyView()} + + )) + .build(); +})(); + +export const LinkComp = migrateOldData(LinkCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx new file mode 100644 index 0000000000..5a7fae3d3e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx @@ -0,0 +1,139 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Menu } from "antd/es/menu"; +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { manualOptionsControl } from "comps/controls/optionsControl"; +import { MultiCompBuilder } from "comps/generators"; +import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import styled from "styled-components"; +import { ColumnLink } from "comps/comps/tableComp/column/columnTypeComps/columnLinkComp"; +import { LightActiveTextColor, PrimaryColor } from "constants/style"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { fixOldActionData } from "comps/comps/tableComp/column/simpleColumnTypeComps"; + +const MenuLinkWrapper = styled.div` + > a { + color: ${PrimaryColor} !important; + + &:hover { + color: ${LightActiveTextColor} !important; + } + } +`; + +const MenuWrapper = styled.div` + ul { + background: transparent !important; + border-bottom: 0; + + li { + padding: 0 10px 0 0 !important; + line-height: normal !important; + + &::after { + content: none !important; + } + } + } +`; + +const LinkEventOptions = [clickEvent, doubleClickEvent] as const; + +// Memoized menu item component +const MenuItem = React.memo(({ option, index }: { option: any; index: number }) => { + return ( + + + + ); +}); + +MenuItem.displayName = 'MenuItem'; + +const OptionItemTmp = new MultiCompBuilder( + { + label: StringControl, + onClick: eventHandlerControl(LinkEventOptions), + hidden: BoolCodeControl, + disabled: BoolCodeControl, + }, + (props) => { + return props; + } +) + .setPropertyViewFn((children) => { + return ( + <> + {children.label.propertyView({ label: trans("label") })} + {hiddenPropertyView(children)} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + ); + }) + .build(); + +const OptionItem = migrateOldData(OptionItemTmp, fixOldActionData); + +// Memoized menu component +const LinksMenu = React.memo(({ options }: { options: any[] }) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const menuItems = useMemo(() => + options + .filter((o) => !o.hidden) + .map((option, index) => ({ + key: index, + label: + })), + [options] + ); + + return ( + + + + ); +}); + +LinksMenu.displayName = 'LinksMenu'; + +const ColumnLinksCompTmp = (function () { + const childrenMap = { + options: manualOptionsControl(OptionItem, { + initOptions: [{ label: trans("table.option1") }], + }), + }; + return new ColumnTypeCompBuilder( + childrenMap, + (props) => { + return ; + }, + () => "" + ) + .setPropertyViewFn((children) => ( + <> + {children.options.propertyView({ + newOptionLabel: trans("table.option"), + title: trans("table.optionList"), + })} + + )) + .build(); +})(); + +export const ColumnLinksComp = migrateOldData(ColumnLinksCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx new file mode 100644 index 0000000000..9fdd5a94bf --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx @@ -0,0 +1,72 @@ +import React, { useCallback, useMemo } from "react"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { markdownCompCss, TacoMarkDown } from "lowcoder-design"; +import styled from "styled-components"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; + +const Wrapper = styled.div` + ${markdownCompCss}; + max-height: 32px; + cursor: pointer; + + > .markdown-body { + margin: 0; + p { + line-height: 21px; + } + } +`; + +const MarkdownEventOptions = [clickEvent] as const; + +const childrenMap = { + text: StringControl, + onEvent: eventHandlerControl(MarkdownEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +// Memoized markdown view component +const MarkdownView = React.memo(({ value, onEvent }: { value: string; onEvent?: (eventName: string) => void }) => { + const handleClick = useCallback(() => { + if (onEvent) { + onEvent("click"); + } + }, [onEvent]); + + return ( + + {value} + + ); +}); + +MarkdownView.displayName = 'MarkdownView'; + +export const ColumnMarkdownComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.onEvent.propertyView()} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx new file mode 100644 index 0000000000..7b8eba4227 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { NumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { ProgressStyle } from "comps/controls/styleControlConstants"; +import { useStyle } from "comps/controls/styleControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { ProgressStyled as Progress } from "comps/comps/progressComp"; +import styled from "styled-components"; + +const ProgressStyled = styled(Progress)` + display: flex; + align-items: center; + .ant-progress-outer { + height: 22px; + display: flex; + align-items: center; + } + .ant-progress-text { + margin-left: 6px; + } +`; + +const childrenMap = { + text: NumberControl, + showValue: BoolControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +export const ProgressComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + const ProgressView = () => { + const style = useStyle(ProgressStyle); + return ( + + ); + }; + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.showValue.propertyView({ + label: trans("table.showValue"), + })} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx new file mode 100644 index 0000000000..3739ee2250 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { NumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import styled from "styled-components"; +import { default as Rate } from "antd/es/rate"; + +const RateStyled = styled(Rate)<{ isEdit?: boolean }>` + display: inline-flex; + align-items: center; + width: 100%; + overflow-x: auto; + overflow-x: overlay; + color: #ffd400; + display: block; + .ant-rate-star > div { + height: 18px; + width: 18px; + } + .ant-rate-star-half .ant-rate-star-first, + .ant-rate-star-full .ant-rate-star-second { + color: #ffd400; + position: absolute; + } + .ant-rate-star-first { + width: 100%; + } + .ant-rate-star-first, + .ant-rate-star-second { + display: inline-flex; + align-items: center; + color: #d7d9e0; + max-height: 20px; + bottom: 0; + } + svg { + height: 18px; + width: 18px; + } +`; + +const childrenMap = { + text: NumberControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +export const RatingComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx new file mode 100644 index 0000000000..30a6ba7b3e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx @@ -0,0 +1,154 @@ +import React from "react"; + +import { SelectUIView } from "comps/comps/selectInputComp/selectCompConstants"; +import { StringControl, BoolCodeControl } from "comps/controls/codeControl"; +import { IconControl } from "comps/controls/iconControl"; +import { MultiCompBuilder } from "comps/generators"; +import { optionsControl } from "comps/controls/optionsControl"; +import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { styled } from "styled-components"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; + +const Wrapper = styled.div` + display: inline-flex; + align-items: center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + background: transparent !important; + padding: 8px; + + > div { + width: 100%; + height: 100%; + } + + .ant-select { + height: 100%; + .ant-select-selector { + padding: 0 7px; + height: 100%; + overflow: hidden; + .ant-select-selection-item { + display: inline-flex; + align-items: center; + padding-right: 24px; + } + } + .ant-select-arrow { + height: calc(100% - 3px); + width: fit-content; + top: 1.5px; + margin-top: 0; + background-color: white; + right: 1.5px; + border-right: 1px solid #d7d9e0; + cursor: pointer; + pointer-events: auto; + svg { + min-width: 18px; + min-height: 18px; + } + &:hover svg path { + fill: #315efb; + } + } + .ant-select-selector .ant-select-selection-search { + left: 7px; + input { + height: 100%; + } + } + &.ant-select-open { + .ant-select-arrow { + border-right: none; + border-left: 1px solid #d7d9e0; + svg g path { + fill: #315efb; + } + } + .ant-select-selection-item { + opacity: 0.4; + } + } + } +`; + +const SelectOptionEventOptions = [clickEvent, doubleClickEvent] as const; + +// Create a new option type with event handlers for each option +const SelectOptionWithEvents = new MultiCompBuilder( + { + value: StringControl, + label: StringControl, + prefixIcon: IconControl, + disabled: BoolCodeControl, + hidden: BoolCodeControl, + onEvent: eventHandlerControl(SelectOptionEventOptions), + }, + (props) => props +) + .setPropertyViewFn((children) => ( + <> + {children.label.propertyView({ label: trans("label") })} + {children.value.propertyView({ label: trans("value") })} + {children.prefixIcon.propertyView({ label: trans("button.prefixIcon") })} + {disabledPropertyView(children)} + {hiddenPropertyView(children)} + {children.onEvent.propertyView()} + + )) + .build(); + +const SelectOptionWithEventsControl = optionsControl(SelectOptionWithEvents, { + initOptions: [ + { label: trans("optionsControl.optionI", { i: 1 }), value: "1" }, + { label: trans("optionsControl.optionI", { i: 2 }), value: "2" }, + ], + uniqField: "value", +}); + +const childrenMap = { + text: StringControl, + options: SelectOptionWithEventsControl, + onEvent: eventHandlerControl(SelectOptionEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +export const ColumnSelectComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + const option = props.options.find(x => x.value === value); + return ( + <> + {option?.prefixIcon} + {option?.label} + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue, + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.options.propertyView({ + title: trans("optionsControl.optionList"), + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx new file mode 100644 index 0000000000..20d0610b57 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx @@ -0,0 +1,64 @@ +import { default as Badge } from "antd/es/badge"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { trans } from "i18n"; +import { StringControl, stringUnionControl } from "comps/controls/codeControl"; +import { DropdownStyled, Wrapper } from "./columnTagsComp"; +import React from "react"; +import { CustomSelect, PackUpIcon, ScrollBar } from "lowcoder-design"; +import { PresetStatusColorType } from "antd/es/_util/colors"; + +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +export const BadgeStatusOptions = [ + "none", + "success", + "error", + "default", + "warning", + "processing", +] as const; + +export type StatusType = PresetStatusColorType | "none"; + +const childrenMap = { + text: StringControl, + status: stringUnionControl(BadgeStatusOptions, "none"), +}; + +const getBaseValue: ColumnTypeViewFn< + typeof childrenMap, + { value: string; status: StatusType }, + { value: string; status: StatusType } +> = (props) => ({ + value: props.text, + status: props.status, +}); + +export const BadgeStatusComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const text = getBaseValue(props, dispatch).value; + const status = getBaseValue(props, dispatch).status; + return status === "none" ? text : ; + }, + (nodeValue) => [nodeValue.status.value, nodeValue.text.value].filter((t) => t).join(" "), + getBaseValue + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.status.propertyView({ + label: trans("table.status"), + tooltip: trans("table.statusTooltip"), + })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx new file mode 100644 index 0000000000..6449ebad22 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx @@ -0,0 +1,101 @@ +import { BoolCodeControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { InputFieldStyle } from "comps/controls/styleControlConstants"; +import styled from "styled-components"; +import { default as Switch } from "antd/es/switch"; +import { styleControl } from "comps/controls/styleControl"; +import { booleanExposingStateControl } from "comps/controls/codeStateControl"; +import { changeEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { disabledPropertyView } from "comps/utils/propertyUtils"; +import React, { useCallback, useRef, useEffect } from "react"; + +const EventOptions = [ + changeEvent, + { + label: trans("switchComp.open"), + value: "true", + description: trans("switchComp.openDesc"), + }, + { + label: trans("switchComp.close"), + value: "false", + description: trans("switchComp.closeDesc"), + }, +] as const; + +const childrenMap = { + value: booleanExposingStateControl("value"), + switchState: BoolCodeControl, + onEvent: eventHandlerControl(EventOptions), + disabled: BoolCodeControl, + style: styleControl(InputFieldStyle), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.switchState; + +const SwitchView = React.memo(({ value, disabled, onEvent, valueControl }: { + value: boolean; + disabled: boolean; + onEvent: (event: string) => void; + valueControl: { onChange: (value: boolean) => void }; +}) => { + const mountedRef = useRef(true); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((checked: boolean) => { + if (!mountedRef.current) return; + valueControl.onChange(checked); + onEvent("change"); + onEvent(checked ? "true" : "false"); + }, [valueControl, onEvent]); + + return ( + + ); +}); + +SwitchView.displayName = 'SwitchView'; + +export const SwitchComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.switchState.value, + getBaseValue + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.switchState.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.onEvent.propertyView()} + {disabledPropertyView(children)} + + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx new file mode 100644 index 0000000000..8ded35b97d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx @@ -0,0 +1,295 @@ +import { default as Tag } from "antd/es/tag"; +import { PresetStatusColorTypes } from "antd/es/_util/colors"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { codeControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import styled from "styled-components"; +import _ from "lodash"; +import React, { ReactNode, useCallback, useMemo } from "react"; +import { toJson } from "really-relaxed-json"; +import { hashToNum } from "util/stringUtils"; +import { CustomSelect, PackUpIcon } from "lowcoder-design"; +import { ScrollBar } from "lowcoder-design"; +import { ColoredTagOptionControl } from "comps/controls/optionsControl"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; + +const colors = PresetStatusColorTypes; + +const isStringArray = (value: any) => { + return ( + _.isArray(value) && + value.every((v) => { + const type = typeof v; + return type === "string" || type === "number" || type === "boolean"; + }) + ); +}; + +// accept string, number, boolean and array input +const TagsControl = codeControl | string>( + (value) => { + if (isStringArray(value)) { + return value; + } + const valueType = typeof value; + if (valueType === "string") { + try { + const result = JSON.parse(toJson(value)); + if (isStringArray(result)) { + return result; + } + return value; + } catch (e) { + return value; + } + } else if (valueType === "number" || valueType === "boolean") { + return value; + } + throw new TypeError( + `Type "Array | string" is required, but find value: ${JSON.stringify(value)}` + ); + }, + { expectedType: "string | Array", codeType: "JSON" } +); + +function getTagColor(tagText : any, tagOptions: any[]) { + const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); + if (foundOption) { + if (foundOption.colorType === "preset") { + return foundOption.presetColor; + } else if (foundOption.colorType === "custom") { + return undefined; // For custom colors, we'll use style instead + } + // Backward compatibility - if no colorType specified, assume it's the old color field + return foundOption.color; + } + // Default fallback + const index = Math.abs(hashToNum(tagText)) % colors.length; + return colors[index]; +} + +function getTagStyle(tagText: any, tagOptions: any[]) { + const foundOption = tagOptions.find((option: { label: any; }) => option.label === tagText); + if (foundOption) { + const style: any = {}; + + // Handle color styling + if (foundOption.colorType === "custom") { + style.backgroundColor = foundOption.color; + style.color = foundOption.textColor; + style.border = `1px solid ${foundOption.color}`; + } + + // Add border styling if specified + if (foundOption.border) { + style.borderColor = foundOption.border; + if (!foundOption.colorType || foundOption.colorType !== "custom") { + style.border = `1px solid ${foundOption.border}`; + } + } + + // Add border radius if specified + if (foundOption.radius) { + style.borderRadius = foundOption.radius; + } + + // Add margin if specified + if (foundOption.margin) { + style.margin = foundOption.margin; + } + + // Add padding if specified + if (foundOption.padding) { + style.padding = foundOption.padding; + } + + return style; + } + return {}; +} + +function getTagIcon(tagText: any, tagOptions: any[]) { + const foundOption = tagOptions.find(option => option.label === tagText); + return foundOption ? foundOption.icon : undefined; +} + +const childrenMap = { + text: TagsControl, + tagColors: ColoredTagOptionControl, + onEvent: eventHandlerControl([clickEvent]), +}; + +const getBaseValue: ColumnTypeViewFn = ( + props +) => props.text; + +export const Wrapper = styled.div` + display: inline-flex; + align-items: center; + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + background: transparent !important; + padding: 8px; + + > div { + width: 100%; + height: 100%; + } + + .ant-select { + height: 100%; + .ant-select-selector { + padding: 0 7px; + height: 100%; + overflow: hidden; + .ant-select-selection-item { + display: inline-flex; + align-items: center; + padding-right: 24px; + } + } + .ant-select-arrow { + height: calc(100% - 3px); + width: fit-content; + top: 1.5px; + margin-top: 0; + background-color: white; + right: 1.5px; + border-right: 1px solid #d7d9e0; + cursor: pointer; + pointer-events: auto; + svg { + min-width: 18px; + min-height: 18px; + } + &:hover svg path { + fill: #315efb; + } + } + .ant-select-selector .ant-select-selection-search { + left: 7px; + input { + height: 100%; + } + } + &.ant-select-open { + .ant-select-arrow { + border-right: none; + border-left: 1px solid #d7d9e0; + svg g path { + fill: #315efb; + } + } + .ant-select-selection-item { + opacity: 0.4; + } + } + } + .ant-tag { + margin-left: 5px; + } + .ant-tag svg { + margin-right: 4px; + } +`; + +export const DropdownStyled = styled.div` + .ant-select-item { + padding: 3px 8px; + margin: 0 0 2px 8px; + border-radius: 4px; + + &.ant-select-item-option-active { + background-color: #f2f7fc; + } + } + .ant-select-item-option-content { + display: flex; + align-items: center; + } + .ant-tag { + margin-right: 0; + } + .ant-tag svg { + margin-right: 4px; + } +`; + +export const TagStyled = styled(Tag)` + margin-right: 8px; + cursor: pointer; + svg { + margin-right: 4px; + } +`; + +export const ColumnTagsComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const tagOptions = props.tagColors; + let value = getBaseValue(props, dispatch); + value = typeof value === "string" && value.split(",")[1] ? value.split(",") : value; + const tags = _.isArray(value) ? value : (value.length ? [value] : []); + + const handleTagClick = (tagText: string) => { + const foundOption = tagOptions.find(option => option.label === tagText); + if (foundOption && foundOption.onEvent) { + foundOption.onEvent("click"); + } + // Also trigger the main component's event handler + if (props.onEvent) { + props.onEvent("click"); + } + }; + + const view = tags.map((tag, index) => { + // The actual eval value is of type number or boolean + const tagText = String(tag); + const tagColor = getTagColor(tagText, tagOptions); + const tagIcon = getTagIcon(tagText, tagOptions); + const tagStyle = getTagStyle(tagText, tagOptions); + + return ( +
+ handleTagClick(tagText)} + > + {tagText} + +
+ ); + }); + return view; + }, + (nodeValue) => { + const text = nodeValue.text.value; + return _.isArray(text) ? text.join(",") : text; + }, + getBaseValue + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.tagColors.propertyView({ + title: "Tag Options", + })} + {children.onEvent.propertyView()} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx new file mode 100644 index 0000000000..f547d7a218 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx @@ -0,0 +1,97 @@ +import { default as TimePicker } from "antd/es/time-picker"; +import { + ColumnTypeCompBuilder, + ColumnTypeViewFn, +} from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ColumnValueTooltip } from "comps/comps/tableComp/column/simpleColumnTypeComps"; +import { StringControl } from "comps/controls/codeControl"; +import { withDefault } from "comps/generators"; +import { formatPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import dayjs from "dayjs"; +import React from "react"; +import styled from "styled-components"; +import { TIME_FORMAT } from "util/dateTimeUtils"; +import { hasIcon } from "comps/utils"; +import { IconControl } from "comps/controls/iconControl"; + +const TimePickerStyled = styled(TimePicker)<{ $open: boolean }>` + width: 100%; + height: 100%; + position: absolute; + top: 0; + padding: 0; + padding-left: 11px; + .ant-picker-input { + height: 100%; + } + input { + padding-right: 18px; + cursor: pointer; + } + &.ant-picker-focused .ant-picker-suffix svg g { + stroke: ${(props) => props.$open && "#315EFB"}; + } + .ant-picker-suffix { + height: calc(100% - 1px); + position: absolute; + right: 0; + top: 0.5px; + display: flex; + align-items: center; + padding: 0 3px; + } +`; + +export function formatTime(time: string, format: string) { + const parsedTime = dayjs(time, TIME_FORMAT); + return parsedTime.isValid() ? parsedTime.format(format) : ""; +} + +const childrenMap = { + text: StringControl, + prefixIcon: IconControl, + suffixIcon: IconControl, + format: withDefault(StringControl, TIME_FORMAT), + inputFormat: withDefault(StringControl, TIME_FORMAT), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +export const TimeComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ( + <> + {hasIcon(props.prefixIcon) && ( + {props.prefixIcon} + )} + {value} + {hasIcon(props.suffixIcon) && ( + {props.suffixIcon} + )} + + ); + }, + (nodeValue) => formatTime(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {formatPropertyView({ children, placeholder: TIME_FORMAT })} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx new file mode 100644 index 0000000000..d9b3de7491 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx @@ -0,0 +1,96 @@ +import { StringOrNumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import { ColumnValueTooltip } from "../simpleColumnTypeComps"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; +import React, { useCallback, useMemo } from "react"; +import { RecordConstructorToComp } from "lowcoder-core"; +import { clickEvent, doubleClickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import styled from "styled-components"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const TextEventOptions = [clickEvent, doubleClickEvent] as const; + +const TextWrapper = styled.div` + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; +`; + +const childrenMap = { + text: StringOrNumberControl, + prefixIcon: IconControl, + suffixIcon: IconControl, + onEvent: eventHandlerControl(TextEventOptions), +}; + +// Memoize the base value function to prevent unnecessary string creation +const getBaseValue: ColumnTypeViewFn = (props) => + typeof props.text === 'string' ? props.text : String(props.text); + +// Memoized icon components to prevent unnecessary re-renders +const IconWrapper = React.memo(({ icon }: { icon: React.ReactNode }) => ( + {icon} +)); + +interface SimpleTextContentProps { + value: string | number; + prefixIcon?: React.ReactNode; + suffixIcon?: React.ReactNode; + onEvent?: (eventName: string) => void; +} + +const SimpleTextContent = React.memo(({ value, prefixIcon, suffixIcon, onEvent }: SimpleTextContentProps) => { + const handleClickEvent = useCompClickEventHandler({onEvent: onEvent ?? (() => {})}) + + const handleClick = useCallback(() => { + handleClickEvent() + }, [handleClickEvent]); + + return ( + + {hasIcon(prefixIcon) && } + {value} + {hasIcon(suffixIcon) && } + + ); +}); + +const SimpleTextPropertyView = React.memo(({ children }: { children: RecordConstructorToComp }) => { + return useMemo(() => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.onEvent.propertyView()} + + ), [children.text, children.prefixIcon, children.suffixIcon, children.onEvent]); +}); + +export const SimpleTextComp = new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setPropertyViewFn((children) => ) + .build(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx new file mode 100644 index 0000000000..f9bedc7549 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx @@ -0,0 +1,129 @@ +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ActionSelectorControlInContext } from "comps/controls/actionSelector/actionSelectorControl"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { disabledPropertyView, loadingPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { useStyle } from "comps/controls/styleControl"; +import { ButtonStyle } from "comps/controls/styleControlConstants"; +import { Button100 } from "comps/comps/buttonComp/buttonCompConstants"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { CSSProperties } from "react"; +import { RecordConstructorToComp } from "lowcoder-core"; +import { ToViewReturn } from "@lowcoder-ee/comps/generators/multi"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { isArray } from "lodash"; + +export const fixOldActionData = (oldData: any) => { + if (!oldData) return oldData; + if (Boolean(oldData.onClick && !isArray(oldData.onClick))) { + return { + ...oldData, + onClick: [{ + name: "click", + handler: oldData.onClick, + }], + }; + } + return oldData; +} +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +export const ButtonTypeOptions = [ + { + label: trans("table.primaryButton"), + value: "primary", + }, + { + label: trans("table.defaultButton"), + value: "default", + }, + { + label: trans("table.text"), + value: "text", + }, +] as const; + +const ButtonEventOptions = [clickEvent, doubleClickEvent] as const; + +const childrenMap = { + text: StringControl, + buttonType: dropdownControl(ButtonTypeOptions, "primary"), + onClick: eventHandlerControl(ButtonEventOptions), + loading: BoolCodeControl, + disabled: BoolCodeControl, + prefixIcon: IconControl, + suffixIcon: IconControl, +}; + +const ButtonStyled = React.memo(({ props }: { props: ToViewReturn>}) => { + const style = useStyle(ButtonStyle); + const hasText = !!props.text; + const hasPrefixIcon = hasIcon(props.prefixIcon); + const hasSuffixIcon = hasIcon(props.suffixIcon); + const iconOnly = !hasText && (hasPrefixIcon || hasSuffixIcon); + const handleClickEvent = useCompClickEventHandler({onEvent: props.onClick}) + + const handleClick = useCallback((e: React.MouseEvent) => { + handleClickEvent() + }, [handleClickEvent]); + + const buttonStyle = useMemo(() => ({ + margin: 0, + width: iconOnly ? 'auto' : undefined, + minWidth: iconOnly ? 'auto' : undefined, + padding: iconOnly ? '0 8px' : undefined + } as CSSProperties), [iconOnly]); + + return ( + + {/* prevent the button from disappearing */} + {hasText ? props.text : (iconOnly ? null : " ")} + {hasSuffixIcon && !props.loading && {props.suffixIcon}} + + ); +}); + +const ButtonCompTmp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props) => , + (nodeValue) => nodeValue.text.value + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.buttonType.propertyView({ + label: trans("table.type"), + radioButton: true, + })} + {loadingPropertyView(children)} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + )) + .build(); +})(); + +export const ButtonComp = migrateOldData(ButtonCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx new file mode 100644 index 0000000000..4951dea4e2 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx @@ -0,0 +1,470 @@ +import { BoolControl } from "comps/controls/boolControl"; +import { ColorOrBoolCodeControl, NumberControl, RadiusControl, StringControl } from "comps/controls/codeControl"; +import { dropdownControl, HorizontalAlignmentControl } from "comps/controls/dropdownControl"; +import { MultiCompBuilder, stateComp, valueComp, withContext, withDefault } from "comps/generators"; +import { withSelectedMultiContext } from "comps/generators/withSelectedMultiContext"; +import { genRandomKey } from "comps/utils/idGenerator"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeChildAction, + changeValueAction, + CompAction, + CompActionTypes, + ConstructorToComp, + ConstructorToDataType, + ConstructorToNodeType, + ConstructorToView, + deferAction, + fromRecord, + multiChangeAction, + withFunction, + wrapChildAction, +} from "lowcoder-core"; +import { AlignClose, AlignLeft, AlignRight, IconRadius, BorderWidthIcon, TextSizeIcon, FontFamilyIcon, TextWeightIcon, ImageCompIcon, controlItem, Dropdown, OptionType } from "lowcoder-design"; +import { ColumnTypeComp, ColumnTypeCompMap } from "./columnTypeComp"; +import { ColorControl } from "comps/controls/colorControl"; +import { JSONValue } from "util/jsonTypes"; +import styled from "styled-components"; +import { TextOverflowControl } from "comps/controls/textOverflowControl"; +import { default as Divider } from "antd/es/divider"; +import { ColumnValueTooltip } from "./simpleColumnTypeComps"; +import { SummaryColumnComp } from "./tableSummaryColumnComp"; +import { list } from "@lowcoder-ee/comps/generators/list"; +import React, { useCallback, useMemo } from "react"; + +export type Render = ReturnType["getOriginalComp"]>; +export const RenderComp = withSelectedMultiContext(ColumnTypeComp); + +const columnWidthOptions = [ + { + label: trans("table.auto"), + value: "auto", + }, + { + label: trans("table.fixed"), + value: "fixed", + }, +] as const; + +const columnFixOptions = [ + { + label: , + value: "left", + }, + { + label: , + value: "close", + }, + { + label: , + value: "right", + }, +] as const; + +const cellColorLabel = trans("table.cellColor"); +const CellColorTempComp = withContext( + new MultiCompBuilder({ color: ColorOrBoolCodeControl }, (props) => props.color) + .setPropertyViewFn((children) => + children.color.propertyView({ + label: cellColorLabel, + tooltip: trans("table.cellColorDesc"), + }) + ) + .build(), + ["currentCell", "currentRow"] as const +); + +// @ts-ignore +export class CellColorComp extends CellColorTempComp { + override getPropertyView() { + return controlItem({ filterText: cellColorLabel }, super.getPropertyView()); + } +} + +// fixme, should be infer from RowColorComp, but withContext type incorrect +export type CellColorViewType = (param: { + currentRow: any; + currentCell: JSONValue | undefined; //number | string; +}) => string; + +const cellTooltipLabel = trans("table.columnTooltip"); +const CellTooltipTempComp = withContext( + new MultiCompBuilder({ tooltip: StringControl }, (props) => props.tooltip) + .setPropertyViewFn((children) => + children.tooltip.propertyView({ + label: cellTooltipLabel, + tooltip: ColumnValueTooltip, + }) + ) + .build(), + ["currentCell", "currentRow", "currentIndex"] as const +); + +// @ts-ignore +export class CellTooltipComp extends CellTooltipTempComp { + override getPropertyView() { + return controlItem({ filterText: cellTooltipLabel }, super.getPropertyView()); + } +} + +// fixme, should be infer from RowColorComp, but withContext type incorrect +export type CellTooltipViewType = (param: { + currentRow: any; + currentCell: JSONValue | undefined; //number | string; +}) => string; + + +export const columnChildrenMap = { + // column title + title: StringControl, + titleTooltip: StringControl, + showTitle: withDefault(BoolControl, true), + cellTooltip: CellTooltipComp, + // a custom column or a data column + isCustom: valueComp(false), + // If it is a data column, it must be the name of the column and cannot be duplicated as a react key + dataIndex: valueComp(""), + hide: BoolControl, + sortable: BoolControl, + // header filters + filterable: withDefault(BoolControl, false), + filteredValue: stateComp([]), + width: NumberControl, + autoWidth: dropdownControl(columnWidthOptions, "auto"), + render: RenderComp, + align: HorizontalAlignmentControl, + tempHide: stateComp(false), + fixed: dropdownControl(columnFixOptions, "close"), + background: withDefault(ColorControl, ""), + margin: withDefault(RadiusControl, ""), + text: withDefault(ColorControl, ""), + border: withDefault(ColorControl, ""), + borderWidth: withDefault(RadiusControl, ""), + radius: withDefault(RadiusControl, ""), + textSize: withDefault(RadiusControl, ""), + textWeight: withDefault(StringControl, "normal"), + fontFamily: withDefault(StringControl, "sans-serif"), + fontStyle: withDefault(StringControl, 'normal'), + cellColor: CellColorComp, + textOverflow: withDefault(TextOverflowControl, "wrap"), + linkColor: withDefault(ColorControl, "#3377ff"), + linkHoverColor: withDefault(ColorControl, ""), + linkActiveColor: withDefault(ColorControl, ""), + summaryColumns: withDefault(list(SummaryColumnComp), [ + {}, {}, {} + ]) +}; + +const StyledBorderRadiusIcon = styled(IconRadius)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledBorderIcon = styled(BorderWidthIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextSizeIcon = styled(TextSizeIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledFontFamilyIcon = styled(FontFamilyIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextWeightIcon = styled(TextWeightIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledBackgroundImageIcon = styled(ImageCompIcon)` width: 24px; margin: 0 0px 0 -12px;`; + +/** + * export for test. + * Put it here temporarily to avoid circular dependencies + */ +const ColumnInitComp = new MultiCompBuilder(columnChildrenMap, (props, dispatch) => { + const onWidthResize = (width: number) => { + dispatch( + multiChangeAction({ + width: changeValueAction(width, true), + autoWidth: changeValueAction("fixed", true), + }) + ); + }; + + return { + ...props, + onWidthResize, + }; +}) + .setPropertyViewFn(() => <>) + .build(); + +const ColumnPropertyView = React.memo(({ + comp, + viewMode, + summaryRowIndex +}: { + comp: ColumnComp; + viewMode: string; + summaryRowIndex: number; +}) => { + const selectedColumn = comp.children.render.getSelectedComp(); + const columnType = useMemo(() => + selectedColumn.getComp().children.compType.getView(), + [selectedColumn] + ); + + const initialColumns = useMemo(() => + selectedColumn.getParams()?.initialColumns as OptionType[] || [], + [selectedColumn] + ); + + const columnValue = useMemo(() => { + const column = selectedColumn.getComp().toJsonValue(); + if (column.comp?.hasOwnProperty('src')) { + return (column.comp as any).src; + } else if (column.comp?.hasOwnProperty('text')) { + const value = (column.comp as any).text; + const isDynamicValue = initialColumns.find((column) => column.value === value); + return !isDynamicValue ? '{{currentCell}}' : value; + } + return '{{currentCell}}'; + }, [selectedColumn, initialColumns]); + + const summaryColumns = comp.children.summaryColumns.getView(); + + return ( + <> + {viewMode === 'summary' && ( + summaryColumns[summaryRowIndex].propertyView('') + )} + {viewMode === 'normal' && ( + <> + {comp.children.title.propertyView({ + label: trans("table.columnTitle"), + placeholder: comp.children.dataIndex.getView(), + })} + {comp.children.titleTooltip.propertyView({ + label: trans("table.columnTitleTooltip"), + })} + {comp.children.cellTooltip.getPropertyView()} + { + // Keep the previous text value, some components do not have text, the default value is currentCell + const compType = columnType; + let compValue: Record = { text: value}; + if(columnType === 'image') { + compValue = { src: value }; + } + comp.children.render.dispatchChangeValueAction({ + compType, + comp: compValue, + } as any); + }} + /> + {/* FIXME: cast type currently, return type of withContext should be corrected later */} + {comp.children.render.getPropertyView()} + {comp.children.showTitle.propertyView({ + label: trans("table.showTitle"), + tooltip: trans("table.showTitleTooltip"), + })} + {comp.children.sortable.propertyView({ + label: trans("table.sortable"), + })} + {comp.children.filterable.propertyView({ + label: "Filterable", + })} + {comp.children.hide.propertyView({ + label: trans("prop.hide"), + })} + {comp.children.align.propertyView({ + label: trans("table.align"), + radioButton: true, + })} + {comp.children.fixed.propertyView({ + label: trans("table.fixedColumn"), + radioButton: true, + })} + {comp.children.autoWidth.propertyView({ + label: trans("table.autoWidth"), + radioButton: true, + })} + {comp.children.autoWidth.getView() === "fixed" && + comp.children.width.propertyView({ label: trans("prop.width") })} + + {(columnType === 'link' || columnType === 'links') && ( + <> + + {controlItem({}, ( +
+ {"Link Style"} +
+ ))} + {comp.children.linkColor.propertyView({ + label: trans('text') // trans('style.background'), + })} + {comp.children.linkHoverColor.propertyView({ + label: "Hover text", // trans('style.background'), + })} + {comp.children.linkActiveColor.propertyView({ + label: "Active text", // trans('style.background'), + })} + + )} + + {controlItem({}, ( +
+ {"Column Style"} +
+ ))} + {comp.children.background.propertyView({ + label: trans('style.background'), + })} + {columnType !== 'link' && comp.children.text.propertyView({ + label: trans('text'), + })} + {comp.children.border.propertyView({ + label: trans('style.border') + })} + {comp.children.borderWidth.propertyView({ + label: trans('style.borderWidth'), + preInputNode: , + placeholder: '1px', + })} + {comp.children.radius.propertyView({ + label: trans('style.borderRadius'), + preInputNode: , + placeholder: '3px', + })} + {columnType !== 'markdown' && comp.children.textSize.propertyView({ + label: trans('style.textSize'), + preInputNode: , + placeholder: '14px', + })} + {comp.children.textWeight.propertyView({ + label: trans('style.textWeight'), + preInputNode: , + placeholder: 'normal', + })} + {comp.children.fontFamily.propertyView({ + label: trans('style.fontFamily'), + preInputNode: , + placeholder: 'sans-serif', + })} + {comp.children.fontStyle.propertyView({ + label: trans('style.fontStyle'), + preInputNode: , + placeholder: 'normal' + })} + {comp.children.textOverflow.getPropertyView()} + {comp.children.cellColor.getPropertyView()} + + )} + + ); +}); + +ColumnPropertyView.displayName = 'ColumnPropertyView'; + +export class ColumnComp extends ColumnInitComp { + override reduce(action: CompAction) { + let comp = super.reduce(action); + if (action.type === CompActionTypes.UPDATE_NODES_V2) { + // Reset context data without cleanup since components are managed by React + comp = comp.setChild( + "cellColor", + comp.children.cellColor.reduce( + CellColorComp.changeContextDataAction({ + currentCell: undefined, + currentRow: {}, + }) + ) + ); + comp = comp.setChild( + "cellTooltip", + comp.children.cellTooltip.reduce( + CellTooltipComp.changeContextDataAction({ + currentCell: undefined, + currentRow: {}, + currentIndex: 0, + }) + ) + ); + } + if (action.type === CompActionTypes.CHANGE_VALUE) { + const title = comp.children.title.unevaledValue; + const dataIndex = comp.children.dataIndex.getView(); + if (!Boolean(title)) { + comp.children.title.dispatchChangeValueAction(dataIndex); + } + } + return comp; + } + + override getView() { + const superView = super.getView(); + const columnType = this.children.render.getSelectedComp().getComp().children.compType.getView(); + return { + ...superView, + columnType, + }; + } + + exposingNode() { + const dataIndexNode = this.children.dataIndex.exposingNode(); + + const renderNode = withFunction(this.children.render.node(), (render) => ({ + wrap: render.__comp__.wrap, + map: _.mapValues(render.__map__, (value) => value.comp), + })); + return fromRecord({ + dataIndex: dataIndexNode, + render: renderNode, + }); + } + + propertyView(key: string, viewMode: string, summaryRowIndex: number) { + return ; + } + + getChangeSet() { + // Editing disabled in Table Lite + return {} as any; + } + + dispatchClearChangeSet() { + // No-op in Table Lite + } + + dispatchClearInsertSet() { + // No-op in Table Lite + } + + static setSelectionAction(key: string) { + return wrapChildAction("render", RenderComp.setSelectionAction(key)); + } +} + +export type RawColumnType = ConstructorToView; +export type ColumNodeType = ConstructorToNodeType; +export type ColumnCompType = ConstructorToComp; + +/** + * Custom column initialization data + */ +export function newCustomColumn(): ConstructorToDataType { + return { + title: trans("table.customColumn"), + dataIndex: genRandomKey(), + isCustom: true, + }; +} + +/** + * Initialization data of primary column + */ +export function newPrimaryColumn( + key: string, + width: number, + title?: string, + isTag?: boolean +): ConstructorToDataType { + return { + title: title ?? key, + dataIndex: key, + isCustom: false, + autoWidth: "fixed", + width: width + "", + render: { compType: isTag ? "tag" : "text", comp: { text: "{{currentCell}}" } }, + }; +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx new file mode 100644 index 0000000000..b693d60371 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx @@ -0,0 +1,236 @@ +import { ColumnComp, newPrimaryColumn } from "./tableColumnComp"; +import { + calcColumnWidth, + COLUMN_CHILDREN_KEY, + supportChildrenTree, +} from "../tableUtils"; +import { list } from "comps/generators/list"; +import { getReduceContext } from "comps/utils/reduceContext"; +import _ from "lodash"; +import { + CompAction, + customAction, + fromRecord, + isMyCustomAction, + RecordNode, + wrapChildAction, + changeChildAction, +} from "lowcoder-core"; +import { shallowEqual } from "react-redux"; +import { JSONObject, JSONValue } from "util/jsonTypes"; +import { lastValueIfEqual } from "util/objectUtils"; + +/** + * column list + */ +const ColumnListTmpComp = list(ColumnComp); + +/** + * rowExample is used for code prompts + */ +type RowExampleType = JSONObject | undefined; + +type DataChangedActionType = { + type: "dataChanged"; + rowExample: RowExampleType; + doGeneColumn: boolean; + dynamicColumn: boolean; + data: Array; +}; + +type SetFilteredValuesActionType = { + type: "setFilteredValues"; + values: Record; // dataIndex -> filtered values +}; + +export function tableDataRowExample(data: Array) { + if (!data?.length) { + return undefined; + } + + if (typeof data[0] === "string") { + // do not parse arrays in string format + return undefined; + } + const rowExample: Record = {}; + // merge head 50 data keys + data.slice(0, 50).forEach((d) => { + Object.keys(d).forEach((key) => { + if (!rowExample.hasOwnProperty(key)) { + rowExample[key] = d[key]; + } + }); + }); + return rowExample; +} + +export class ColumnListComp extends ColumnListTmpComp { + override reduce(action: CompAction): this { + if (isMyCustomAction(action, "dataChanged")) { + const rowExample = action.value.rowExample; + const { readOnly } = getReduceContext(); + let comp = this; + if (action.value.doGeneColumn && (action.value.dynamicColumn || !readOnly)) { + const actions = this.geneColumnsAction(rowExample, action.value.data); + comp = this.reduce(this.multiAction(actions)); + } + return comp; + } + if (isMyCustomAction(action, "setFilteredValues")) { + const values = action.value.values; + const columnsView = this.getView(); + const actions: Array = []; + columnsView.forEach((column, index) => { + const dataIndex = column.getView().dataIndex; + if (values.hasOwnProperty(dataIndex)) { + actions.push( + wrapChildAction( + index + "", + changeChildAction("filteredValue", values[dataIndex] ?? [], false) + ) + ); + } + }); + if (actions.length > 0) { + return this.reduce(this.multiAction(actions)); + } + return this; + } + return super.reduce(action); + } + + setFilteredValuesAction(values: Record) { + return customAction( + { + type: "setFilteredValues", + values, + }, + false + ); + } + + getChangeSet(filterNewRowsChange?: boolean) { + const changeSet: Record> = {}; + const columns = this.getView(); + columns.forEach((column) => { + const columnChangeSet = column.getChangeSet(); + Object.keys(columnChangeSet).forEach((dataIndex) => { + Object.keys(columnChangeSet[dataIndex]).forEach((key) => { + if (!_.isNil(columnChangeSet[dataIndex][key])) { + if (!changeSet[key]) changeSet[key] = {}; + changeSet[key][dataIndex] = columnChangeSet[dataIndex][key]; + } + }); + }); + }); + return changeSet; + } + + dispatchClearChangeSet() { + const columns = this.getView(); + columns.forEach((column) => column.dispatchClearChangeSet()); + } + + dispatchClearInsertSet() { + const columns = this.getView(); + columns.forEach((column) => column.dispatchClearInsertSet()); + } + + /** + * If the table data changes, call this method to trigger the action + */ + dataChangedAction(param: { + rowExample: JSONObject; + doGeneColumn: boolean; + dynamicColumn: boolean; + data: Array; + }) { + return customAction( + { + type: "dataChanged", + ...param, + }, + true + ); + } + + /** + * According to the data, adjust the column + */ + private geneColumnsAction(rowExample: RowExampleType, data: Array) { + // If no data, return directly + if (rowExample === undefined || rowExample === null) { + return []; + } + const dataKeys = Object.keys(rowExample); + if (dataKeys.length === 0) { + return []; + } + const columnsView = this.getView(); + const actions: Array = []; + let deleteCnt = 0; + columnsView.forEach((column, index) => { + if (column.getView().isCustom) { + return; + } + const dataIndex = column.getView().dataIndex; + if (dataIndex === COLUMN_CHILDREN_KEY || !dataKeys.find((key) => dataIndex === key)) { + // to Delete + actions.push(this.deleteAction(index - deleteCnt)); + deleteCnt += 1; + } + }); + // The order should be the same as the data + dataKeys.forEach((key) => { + if (key === COLUMN_CHILDREN_KEY && supportChildrenTree(data)) { + return; + } + if (!columnsView.find((column) => column.getView().dataIndex === key)) { + // to Add + actions.push(this.pushAction(newPrimaryColumn(key, calcColumnWidth(key, data)))); + } + }); + if (actions.length === 0) { + return []; + } + return actions; + } + + withParamsNode() { + const columns = this.getView(); + const nodes = _(columns) + .map((col) => col.children.render.getOriginalComp().node()) + .toPairs() + .fromPairs() + .value(); + const result = lastValueIfEqual( + this, + "withParamsNode", + [fromRecord(nodes), nodes] as const, + (a, b) => shallowEqual(a[1], b[1]) + )[0]; + return result; + } + + getColumnsNode( + field: T + ): RecordNode>> { + const columns = this.getView(); + const nodes = _(columns) + .map((col) => col.children[field].node() as ReturnType) + .toPairs() + .fromPairs() + .value(); + const result = lastValueIfEqual( + this, + "col_nodes_" + field, + [fromRecord(nodes), nodes] as const, + (a, b) => shallowEqual(a[1], b[1]) + )[0]; + return result; + } + + setSelectionAction(key: string) { + return this.forEachAction(ColumnComp.setSelectionAction(key)); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx new file mode 100644 index 0000000000..53de54166c --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx @@ -0,0 +1,193 @@ +import { RadiusControl, StringControl } from "comps/controls/codeControl"; +import { HorizontalAlignmentControl } from "comps/controls/dropdownControl"; +import { MultiCompBuilder, valueComp, withDefault } from "comps/generators"; +import { withSelectedMultiContext } from "comps/generators/withSelectedMultiContext"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeChildAction, + CompAction, + ConstructorToComp, + deferAction, + fromRecord, + withFunction, + wrapChildAction, +} from "lowcoder-core"; +import { IconRadius, TextSizeIcon, FontFamilyIcon, TextWeightIcon, controlItem } from "lowcoder-design"; +import { ColumnTypeComp } from "./columnTypeComp"; +import { ColorControl } from "comps/controls/colorControl"; +import styled from "styled-components"; +import { TextOverflowControl } from "comps/controls/textOverflowControl"; +import { default as Divider } from "antd/es/divider"; +export type Render = ReturnType["getOriginalComp"]>; +export const RenderComp = withSelectedMultiContext(ColumnTypeComp); + +export const columnChildrenMap = { + cellTooltip: StringControl, + // a custom column or a data column + isCustom: valueComp(false), + // If it is a data column, it must be the name of the column and cannot be duplicated as a react key + dataIndex: valueComp(""), + render: RenderComp, + align: HorizontalAlignmentControl, + background: withDefault(ColorControl, ""), + margin: withDefault(RadiusControl, ""), + text: withDefault(ColorControl, ""), + border: withDefault(ColorControl, ""), + radius: withDefault(RadiusControl, ""), + textSize: withDefault(RadiusControl, ""), + textWeight: withDefault(StringControl, "normal"), + fontFamily: withDefault(StringControl, "sans-serif"), + fontStyle: withDefault(StringControl, 'normal'), + cellColor: StringControl, + textOverflow: withDefault(TextOverflowControl, "wrap"), + linkColor: withDefault(ColorControl, "#3377ff"), + linkHoverColor: withDefault(ColorControl, ""), + linkActiveColor: withDefault(ColorControl, ""), +}; + +const StyledBorderRadiusIcon = styled(IconRadius)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextSizeIcon = styled(TextSizeIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledFontFamilyIcon = styled(FontFamilyIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; +const StyledTextWeightIcon = styled(TextWeightIcon)` width: 24px; margin: 0 8px 0 -3px; padding: 3px;`; + +/** + * export for test. + * Put it here temporarily to avoid circular dependencies + */ +const ColumnInitComp = new MultiCompBuilder(columnChildrenMap, (props, dispatch) => { + return { + ...props, + }; +}) + .setPropertyViewFn(() => <>) + .build(); + +export class SummaryColumnComp extends ColumnInitComp { + override reduce(action: CompAction) { + const comp = super.reduce(action); + return comp; + } + + override getView() { + const superView = super.getView(); + const columnType = this.children.render.getSelectedComp().getComp().children.compType.getView(); + return { + ...superView, + columnType, + }; + } + + exposingNode() { + const dataIndexNode = this.children.dataIndex.exposingNode(); + + const renderNode = withFunction(this.children.render.node(), (render) => ({ + wrap: render.__comp__.wrap, + map: _.mapValues(render.__map__, (value) => value.comp), + })); + return fromRecord({ + dataIndex: dataIndexNode, + render: renderNode, + }); + } + + propertyView(key: string) { + const columnType = this.children.render.getSelectedComp().getComp().children.compType.getView(); + const column = this.children.render.getSelectedComp().getComp().toJsonValue(); + let columnValue = '{{currentCell}}'; + if (column.comp?.hasOwnProperty('src')) { + columnValue = (column.comp as any).src; + } else if (column.comp?.hasOwnProperty('text')) { + columnValue = (column.comp as any).text; + } + + return ( + <> + {this.children.cellTooltip.propertyView({ + label: trans("table.columnTooltip"), + })} + {this.children.render.getPropertyView()} + {this.children.align.propertyView({ + label: trans("table.align"), + radioButton: true, + })} + {(columnType === 'link' || columnType === 'links') && ( + <> + + {controlItem({}, ( +
+ {"Link Style"} +
+ ))} + {this.children.linkColor.propertyView({ + label: trans('text') + })} + {this.children.linkHoverColor.propertyView({ + label: "Hover text", + })} + {this.children.linkActiveColor.propertyView({ + label: "Active text", + })} + + )} + + {controlItem({}, ( +
+ {"Column Style"} +
+ ))} + {this.children.background.propertyView({ + label: trans('style.background'), + })} + {columnType !== 'link' && this.children.text.propertyView({ + label: trans('text'), + })} + {this.children.border.propertyView({ + label: trans('style.border') + })} + {this.children.radius.propertyView({ + label: trans('style.borderRadius'), + preInputNode: , + placeholder: '3px', + })} + {this.children.textSize.propertyView({ + label: trans('style.textSize'), + preInputNode: , + placeholder: '14px', + })} + {this.children.textWeight.propertyView({ + label: trans('style.textWeight'), + preInputNode: , + placeholder: 'normal', + })} + {this.children.fontFamily.propertyView({ + label: trans('style.fontFamily'), + preInputNode: , + placeholder: 'sans-serif', + })} + {this.children.fontStyle.propertyView({ + label: trans('style.fontStyle'), + preInputNode: , + placeholder: 'normal' + })} + {/* {this.children.textOverflow.getPropertyView()} */} + {this.children.cellColor.propertyView({ + label: trans("table.cellColor"), + })} + + ); + } + + getChangeSet() { + // Editing disabled in Table Lite + return {} as any; + } + + dispatchClearChangeSet() { + // No-op in Table Lite + } + + static setSelectionAction(key: string) { + return wrapChildAction("render", RenderComp.setSelectionAction(key)); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/hooks/useTableConfiguration.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/hooks/useTableConfiguration.ts new file mode 100644 index 0000000000..288d99f00d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/hooks/useTableConfiguration.ts @@ -0,0 +1,124 @@ +// hooks/useTableConfiguration.ts +import { useMemo, useState, useEffect, useRef } from 'react'; + +// ============= HOOK 1: TABLE MODE ============= +export function useTableMode(autoHeight: boolean) { + return useMemo(() => ({ + mode: autoHeight ? 'AUTO' : 'FIXED' as const, + isAutoMode: autoHeight, + isFixedMode: !autoHeight + }), [autoHeight]); +} + +// ============= HOOK 2: CONTAINER HEIGHT MEASUREMENT ============= +export function useContainerHeight(enabled: boolean) { + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = useRef(null); + + useEffect(() => { + if (!enabled || !containerRef.current) return; + + const measureHeight = () => { + const element = containerRef.current; + if (element) { + // clientHeight gives us the inner height available for content + setContainerHeight(element.clientHeight); + } + }; + + // Initial measurement + measureHeight(); + + // Watch for size changes + const resizeObserver = new ResizeObserver(measureHeight); + resizeObserver.observe(containerRef.current); + + return () => resizeObserver.disconnect(); + }, [enabled]); + + return { containerHeight, containerRef }; +} + +// ============= HOOK 3: HEIGHT CALCULATIONS ============= +interface HeightConfig { + showToolbar: boolean; + showHeader: boolean; + toolbarHeight?: number; + headerHeight?: number; + containerPadding?: number; + stickyToolbar?: boolean; // when true, toolbar is outside scroll and should be reserved +} + +export function useTableHeights( + mode: 'AUTO' | 'FIXED', + containerHeight: number, + config: HeightConfig +) { + return useMemo(() => { + if (mode === 'AUTO') { + return { + containerStyle: { + height: '100%', + display: 'flex', + flexDirection: 'column' as const + }, + tableHeight: undefined, + bodyHeight: undefined, + scrollY: undefined, + canVirtualize: false + }; + } + + // FIXED mode calculations + const { + showToolbar = false, + showHeader = true, + toolbarHeight = 48, + headerHeight = 40, + containerPadding = 0, + stickyToolbar = false, + } = config; + + // Reserve space for toolbar ONLY if it's sticky (outside the scroll area) + const toolbarSpace = showToolbar && stickyToolbar ? toolbarHeight : 0; + const headerSpace = showHeader ? headerHeight : 0; + const totalUsedSpace = toolbarSpace + headerSpace + containerPadding; + + // Calculate available height for table body + const bodyHeight = Math.max(0, containerHeight - totalUsedSpace); + + return { + containerStyle: { + height: '100%', + display: 'flex', + flexDirection: 'column' as const + }, + tableHeight: containerHeight, + bodyHeight, + scrollY: bodyHeight > 0 ? bodyHeight : undefined, + canVirtualize: bodyHeight > 100 // Reasonable minimum for virtualization + }; + }, [mode, containerHeight, config]); +} + +// ============= HOOK 4: VIRTUALIZATION CONFIG ============= +export function useVirtualization( + canVirtualize: boolean, + dataLength: number, + threshold: number = 50 +) { + return useMemo(() => { + const shouldVirtualize = canVirtualize && dataLength >= threshold; + + return { + enabled: shouldVirtualize, + threshold, + itemHeight: 40, // Could be made configurable later + reason: !canVirtualize + ? 'height_insufficient' + : dataLength < threshold + ? 'data_too_small' + : 'enabled' + }; + }, [canVirtualize, dataLength, threshold]); +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx new file mode 100644 index 0000000000..3caa488f33 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx @@ -0,0 +1 @@ +export { TableLiteComp } from "./tableComp"; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/methods/tableMethodExposings.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/methods/tableMethodExposings.ts new file mode 100644 index 0000000000..4dcca95d9a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/methods/tableMethodExposings.ts @@ -0,0 +1,81 @@ +import _ from "lodash"; +import { OB_ROW_ORI_INDEX } from "../tableUtils"; +import { SortValue } from "../tableTypes"; + +export const tableMethodExposings = [ + { + method: { + name: "setPage", + description: "", + params: [{ name: "page", type: "number" as const }], + }, + execute: (comp: any, values: any[]) => { + const page = values[0] as number; + if (page && page > 0) { + comp.children.pagination.children.pageNo.dispatchChangeValueAction(page); + } + }, + }, + { + method: { + name: "setSort", + description: "", + params: [ + { name: "sortColumn", type: "string" as const }, + { name: "sortDesc", type: "boolean" as const }, + ], + }, + execute: (comp: any, values: any[]) => { + if (values[0]) { + comp.children.sort.dispatchChangeValueAction([ + { + column: values[0] as string, + desc: values[1] as boolean, + }, + ]); + } + }, + }, + { + method: { + name: "setMultiSort", + description: "", + params: [{ name: "sortColumns", type: "arrayObject" as const }], + }, + execute: (comp: any, values: any[]) => { + const sortColumns = values[0]; + if (!Array.isArray(sortColumns)) { + return Promise.reject( + "setMultiSort function only accepts array of sort objects i.e. [{column: column_name, desc: boolean}]" + ); + } + if (sortColumns && Array.isArray(sortColumns)) { + comp.children.sort.dispatchChangeValueAction(sortColumns as SortValue[]); + } + }, + }, + { + method: { + name: "resetSelections", + description: "", + params: [], + }, + execute: (comp: any) => { + comp.children.selection.children.selectedRowKey.dispatchChangeValueAction("0"); + comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction([]); + }, + }, + { + method: { + name: "selectAll", + description: "Select all rows in the current filtered view", + params: [], + }, + execute: (comp: any) => { + const displayData = comp.filterData ?? []; + const allKeys = displayData.map((row: any) => row[OB_ROW_ORI_INDEX] + ""); + comp.children.selection.children.selectedRowKey.dispatchChangeValueAction(allKeys[0] || "0"); + comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction(allKeys); + }, + }, +]; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx new file mode 100644 index 0000000000..65e1660f3f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx @@ -0,0 +1,72 @@ +import { withDefault } from "comps/generators"; +import { TableLiteComp } from "."; +import { newPrimaryColumn } from "comps/comps/tableComp/column/tableColumnComp"; +import { NameGenerator } from "../../utils"; +import { ConstructorToDataType } from "lowcoder-core"; +import { EditorState } from "../../editorState"; +import { isArrayLikeObject } from "lodash"; +import { i18nObjs } from "i18n"; +import { calcColumnWidth } from "comps/comps/tableComp/tableUtils"; +// for test only +const dataSource = [ + { + key: 0, + date: "2018-02-11", + amount: 120, + type: "income", + note: "transfer", + }, + { + key: 1, + date: "2018-03-11", + amount: 243, + type: "income", + note: "transfer", + }, + { + key: 2, + date: "2018-04-11", + amount: 98, + type: "income", + note: "transfer", + }, +]; +for (let i = 0; i < 53; i += 1) { + dataSource.push({ + key: 3 + i, + date: "2018-04-11", + amount: 98 + i, + type: "income" + (i % 3), + note: "transfer" + (i % 5), + }); +} + +const tableInitValue = { + toolbar: { + showDownload: true, + showFilter: true, + showRefresh: true, + }, +}; + +const tableData = { + ...tableInitValue, + data: JSON.stringify(i18nObjs.table.defaultData, null, " "), + columns: i18nObjs.table.columns.map((t: any) => + newPrimaryColumn(t.key, calcColumnWidth(t.key, i18nObjs.table.defaultData), t.title, t.isTag) + ), +}; +export const MockTableComp = withDefault(TableLiteComp, tableData); + +export function defaultTableData( + compName: string, + nameGenerator: NameGenerator, + editorState?: EditorState +): ConstructorToDataType { + const selectedQueryComp = editorState?.selectedQueryComp(); + const data = selectedQueryComp?.children.data.getView(); + const queryName = selectedQueryComp?.children.name.getView(); + return isArrayLikeObject(data) + ? { ...tableInitValue, data: `{{ ${queryName}.data }}` } + : tableData; +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/nodes/dataNodes.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/nodes/dataNodes.ts new file mode 100644 index 0000000000..d1efe5bc4f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/nodes/dataNodes.ts @@ -0,0 +1,151 @@ +import _ from "lodash"; +import { fromRecord, Node, RecordNode, ValueAndMsg, withFunction } from "lowcoder-core"; +import { lastValueIfEqual, shallowEqual } from "util/objectUtils"; +import { JSONObject } from "util/jsonTypes"; +import { getPageSize } from "../paginationControl"; +import { + getOriDisplayData, + getColumnsAggr, + OB_ROW_ORI_INDEX, + RecordType, + sortData, + tranToTableRecord, +} from "../tableUtils"; +import type { SortValue } from "../tableTypes"; +import type { TableImplComp } from "../tableComp"; + +export function buildSortedDataNode(comp: TableImplComp) { + const nodes: { + data: Node; + sort: Node; + dataIndexes: RecordNode>>; + sortables: RecordNode>>>; + withParams: RecordNode<_.Dictionary>; + } = { + data: comp.children.data.exposingNode(), + sort: comp.children.sort.node(), + dataIndexes: comp.children.columns.getColumnsNode("dataIndex"), + sortables: comp.children.columns.getColumnsNode("sortable"), + withParams: comp.children.columns.withParamsNode(), + }; + + const sortedDataNode = withFunction(fromRecord(nodes), (input) => { + const { data, sort, dataIndexes, sortables } = input; + const sortColumns = _(dataIndexes) + .mapValues((dataIndex, idx) => ({ sortable: !!sortables[idx] })) + .mapKeys((sortable, idx) => dataIndexes[idx]) + .value(); + const dataColumns = _(dataIndexes) + .mapValues((dataIndex, idx) => ({ + dataIndex, + render: input.withParams[idx] as any, + })) + .value(); + + const updatedData: Array = data.map((row, index) => ({ + ...row, + [OB_ROW_ORI_INDEX]: index + "", + })); + + const updatedDataMap: Record = {}; + updatedData.forEach((row) => { + updatedDataMap[row[OB_ROW_ORI_INDEX]] = row; + }); + + const originalData = getOriDisplayData(updatedData, 1000, Object.values(dataColumns)); + const sorted = sortData(originalData, sortColumns, sort); + + const newData = sorted.map((row) => ({ + ...row, + ...updatedDataMap[row[OB_ROW_ORI_INDEX]], + })); + return newData; + }); + + return lastValueIfEqual(comp, "sortedDataNode", [sortedDataNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; +} + +export function buildFilteredDataNode(comp: TableImplComp) { + const nodes = { + data: buildSortedDataNode(comp), + // Add filter state node + headerFilters: comp.children.headerFilters.node(), + }; + const filteredDataNode = withFunction(fromRecord(nodes), ({ data, headerFilters }) => { + let filteredData = data; + + // Apply ANTD header filters if any exist + if (headerFilters && Object.keys(headerFilters).length > 0) { + filteredData = data.filter((record) => { + return Object.entries(headerFilters).every(([columnKey, filterValues]) => { + if (!filterValues || !Array.isArray(filterValues) || filterValues.length === 0) { + return true; // No filter applied for this column + } + + const cellValue = record[columnKey]; + // Check if cell value matches any of the selected filter values + return filterValues.some(filterValue => { + if (cellValue == null) return filterValue == null; + return String(cellValue) === String(filterValue); + }); + }); + }); + } + + return filteredData.map((row) => tranToTableRecord(row, (row as any)[OB_ROW_ORI_INDEX])); + }); + return lastValueIfEqual(comp, "filteredDataNode", [filteredDataNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; +} + +export function buildOriDisplayDataNode(comp: TableImplComp) { + const nodes = { + data: buildFilteredDataNode(comp), + showSizeChanger: comp.children.pagination.children.showSizeChanger.node(), + pageSize: comp.children.pagination.children.pageSize.node(), + pageSizeOptions: comp.children.pagination.children.pageSizeOptions.node(), + changablePageSize: comp.children.pagination.children.changeablePageSize.node(), + withParams: comp.children.columns.withParamsNode(), + dataIndexes: comp.children.columns.getColumnsNode("dataIndex"), + }; + const resNode = withFunction(fromRecord(nodes), (input) => { + const columns = _(input.dataIndexes) + .mapValues((dataIndex, idx) => ({ + dataIndex, + render: input.withParams[idx], + })) + .value(); + const pageSize = getPageSize( + input.showSizeChanger.value, + input.pageSize.value, + input.pageSizeOptions.value, + input.changablePageSize + ); + return getOriDisplayData(input.data, pageSize, Object.values(columns)); + }); + return lastValueIfEqual(comp, "oriDisplayDataNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; +} + +export function buildColumnAggrNode(comp: TableImplComp) { + const nodes = { + oriDisplayData: buildOriDisplayDataNode(comp), + withParams: comp.children.columns.withParamsNode(), + dataIndexes: comp.children.columns.getColumnsNode("dataIndex"), + }; + const resNode = withFunction(fromRecord(nodes), (input) => { + const dataIndexWithParamsDict = _(input.dataIndexes) + .mapValues((dataIndex, idx) => input.withParams[idx]) + .mapKeys((withParams, idx) => input.dataIndexes[idx]) + .value(); + const res = getColumnsAggr(input.oriDisplayData, dataIndexWithParamsDict); + return res; + }); + return lastValueIfEqual(comp, "columnAggrNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx new file mode 100644 index 0000000000..eb5b4a0f8a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx @@ -0,0 +1,90 @@ +import { BoolControl } from "comps/controls/boolControl"; +import { ArrayNumberControl, NumberControl } from "comps/controls/codeControl"; +import { stateComp, valueComp, withDefault } from "comps/generators"; +import { ControlNodeCompBuilder } from "comps/generators/controlCompBuilder"; +import { migrateOldData } from "comps/generators/simpleGenerators"; +import { trans } from "i18n"; +import { changeChildAction, ConstructorToNodeType } from "lowcoder-core"; + +const DEFAULT_PAGE_SIZE = 5; + +export function getPageSize( + showSizeChanger: boolean, + pageSize: number, + pageSizeOptions: number[], + changeablePageSize: number +) { + if (showSizeChanger) { + return changeablePageSize || pageSizeOptions[0] || DEFAULT_PAGE_SIZE; + } else { + return pageSize || DEFAULT_PAGE_SIZE; + } +} + +export const PaginationTmpControl = (function () { + const childrenMap = { + showQuickJumper: BoolControl, + showSizeChanger: BoolControl, + hideOnSinglePage: BoolControl, + changeablePageSize: migrateOldData(valueComp(5), Number), + pageSize: NumberControl, + total: NumberControl, + pageNo: stateComp(1), + pageSizeOptions: withDefault(ArrayNumberControl, "[5, 10, 20, 50]"), + }; + return new ControlNodeCompBuilder(childrenMap, (props, dispatch) => { + return { + showQuickJumper: props.showQuickJumper, + showSizeChanger: props.showSizeChanger, + total: props.total, + hideOnSinglePage: props.hideOnSinglePage, + pageSize: getPageSize( + props.showSizeChanger, + props.pageSize, + props.pageSizeOptions, + props.changeablePageSize + ), + current: props.pageNo, + pageSizeOptions: props.pageSizeOptions, + onChange: (page: number, pageSize: number) => { + props.showSizeChanger && + pageSize !== props.changeablePageSize && + dispatch(changeChildAction("changeablePageSize", pageSize, true)); + page !== props.pageNo && dispatch(changeChildAction("pageNo", page, false)); + }, + }; + }) + .setPropertyViewFn((children) => [ + children.showQuickJumper.propertyView({ + label: trans("table.showQuickJumper"), + }), + children.hideOnSinglePage.propertyView({ + label: trans("table.hideOnSinglePage"), + }), + children.showSizeChanger.propertyView({ + label: trans("table.showSizeChanger"), + }), + children.showSizeChanger.getView() + ? children.pageSizeOptions.propertyView({ + label: trans("table.pageSizeOptions"), + }) + : children.pageSize.propertyView({ + label: trans("table.pageSize"), + placeholder: String(DEFAULT_PAGE_SIZE), + }), + children.total.propertyView({ + label: trans("table.total"), + tooltip: trans("table.totalTooltip"), + }), + ]) + .build(); +})(); + +export class PaginationControl extends PaginationTmpControl { + getOffset() { + const pagination = this.getView(); + return (pagination.current - 1) * pagination.pageSize; + } +} + +export type PaginationNodeType = ConstructorToNodeType; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/AutoModeTable.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/AutoModeTable.tsx new file mode 100644 index 0000000000..3848bce4a5 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/AutoModeTable.tsx @@ -0,0 +1,12 @@ +// AutoModeTable.tsx +import React from "react"; +import BaseTable, { BaseTableProps } from "./BaseTable"; + +export interface AutoModeTableProps extends BaseTableProps {} + +function AutoModeTableComp(props: AutoModeTableProps) { + return {...props} />; +} + +const AutoModeTable = React.memo(AutoModeTableComp) as typeof AutoModeTableComp; +export default AutoModeTable; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/BaseTable.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/BaseTable.tsx new file mode 100644 index 0000000000..31aa30f33e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/BaseTable.tsx @@ -0,0 +1,220 @@ +import React, { + useCallback, + useMemo, + useState, + } from "react"; + import { default as Table, TableProps, ColumnType } from "antd/es/table"; + import ResizeableTitle from "./ResizeableTitle"; + import TableCellView from "./TableCellView"; + import { COL_MIN_WIDTH, CustomColumnType } from "../tableUtils"; + import { TableColumnStyleType } from "comps/controls/styleControlConstants"; + import { RowColorViewType, RowHeightViewType } from "../tableTypes"; + import { + TableContainer, + HeaderStyleProvider, + CellStyleProvider, + ScrollbarStyleProvider, + RowStyleProvider + } from "../styles"; + + export interface BaseTableProps extends Omit, "components" | "columns"> { + columns: CustomColumnType[]; + viewModeResizable: boolean; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + columnsStyle: TableColumnStyleType; + rowAutoHeight?: boolean; + customLoading?: boolean; + onCellClick: (columnName: string, dataIndex: string) => void; + scroll?: { x?: number | string; y?: number }; + virtual?: boolean; + + // NEW: Add these style props + style?: any; + headerStyle?: any; + rowStyle?: any; + toolbarStyle?: any; + showHeader?: boolean; + fixedHeader?: boolean; + showHRowGridBorder?: boolean; + showVerticalScrollbar?: boolean; + showHorizontalScrollbar?: boolean; + } + + /** + * A table with adjustable column width, width less than 0 means auto column width + */ + function BaseTableComp( + props: BaseTableProps + ) { + const { + columns, + viewModeResizable, + rowColorFn, + rowHeightFn, + columnsStyle, + rowAutoHeight, + customLoading, + onCellClick, + scroll, + virtual, + dataSource, + size = "large", + // the style props + style, + headerStyle, + rowStyle, + toolbarStyle, + showHeader = true, + fixedHeader = false, + showHRowGridBorder = false, + showVerticalScrollbar = false, + showHorizontalScrollbar = false, + ...restProps + } = props; + + const [resizeData, setResizeData] = useState({ index: -1, width: -1 }); + + + const handleResize = useCallback((width: number, index: number) => { + setResizeData({ index, width }); + }, []); + + const handleResizeStop = useCallback( + (width: number, index: number, onWidthResize?: (width: number) => void) => { + setResizeData({ index: -1, width: -1 }); + if (onWidthResize) { + onWidthResize(width); + } + }, + [] + ); + + const createCellHandler = useCallback( + (col: CustomColumnType) => { + return (record: RecordType, index: number) => ({ + record, + title: String(col.dataIndex), + rowColorFn, + rowHeightFn, + cellColorFn: col.cellColorFn, + rowIndex: index, + columnsStyle, + columnStyle: col.style, + linkStyle: col.linkStyle, + autoHeight: rowAutoHeight, + onClick: () => onCellClick(col.titleText, String(col.dataIndex)), + loading: customLoading, + customAlign: col.align, + }); + }, + [ + rowColorFn, + rowHeightFn, + columnsStyle, + rowAutoHeight, + onCellClick, + customLoading, + ] + ); + + const createHeaderCellHandler = useCallback( + (col: CustomColumnType, index: number, resizeWidth: number) => { + return () => ({ + width: resizeWidth, + title: col.titleText, + viewModeResizable, + onResize: (width: React.SyntheticEvent) => { + if (width) { + handleResize(Number(width), index); + } + }, + onResizeStop: ( + e: React.SyntheticEvent, + { size }: { size: { width: number } } + ) => { + handleResizeStop(size.width, index, col.onWidthResize); + }, + }); + }, + [viewModeResizable, handleResize, handleResizeStop] + ); + + // AntD Table ignores `minWidth` prop on columns. + // We enforce a *real* minimum by giving auto columns a concrete width = COL_MIN_WIDTH + const memoizedColumns = useMemo(() => { + return columns.map((col: CustomColumnType, index: number) => { + const { + width, + style, + linkStyle, + cellColorFn, + onWidthResize, + ...restCol + } = col; + const resizeWidth = + (resizeData.index === index ? resizeData.width : col.width) ?? 0; + + const column: ColumnType = { + ...restCol, + // If no explicit width, use COL_MIN_WIDTH as a *real* floor + width: + typeof resizeWidth === "number" && resizeWidth > 0 + ? resizeWidth + : COL_MIN_WIDTH, + onCell: (record: RecordType, rowIndex?: number) => + createCellHandler(col)(record, rowIndex ?? 0), + onHeaderCell: () => + createHeaderCellHandler(col, index, Number(resizeWidth))(), + }; + return column; + }); + }, [columns, resizeData, createCellHandler, createHeaderCellHandler]); + + + return ( + + + + + + + + components={{ + header: { + cell: ResizeableTitle, + }, + body: { + cell: TableCellView, + }, + }} + {...(restProps as any)} + dataSource={dataSource} + pagination={false} + columns={memoizedColumns} + virtual={virtual || false} + scroll={scroll || { x: 'max-content' }} + showHeader={showHeader} + sticky={fixedHeader ? { offsetHeader: 0 } : false} + size={size} + /> + + + + + + ); + } + + const BaseTable = React.memo(BaseTableComp) as typeof BaseTableComp; + export default BaseTable; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/FixedModeTable.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/FixedModeTable.tsx new file mode 100644 index 0000000000..1503ab2226 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/FixedModeTable.tsx @@ -0,0 +1,14 @@ +// FixedModeTable.tsx +import React from "react"; +import BaseTable, { BaseTableProps } from "./BaseTable"; + +export interface FixedModeTableProps extends BaseTableProps { + bodyHeight: number; +} + +function FixedModeTableComp(props: FixedModeTableProps) { + return {...props} />; +} + +const FixedModeTable = React.memo(FixedModeTableComp) as typeof FixedModeTableComp; +export default FixedModeTable; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/ResizeableTable.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/ResizeableTable.tsx new file mode 100644 index 0000000000..c7a5d39c2b --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/ResizeableTable.tsx @@ -0,0 +1,235 @@ +import React, { + useCallback, + useMemo, + useState, + useRef, + useEffect, +} from "react"; +import { default as Table, TableProps, ColumnType } from "antd/es/table"; +import ResizeableTitle from "./ResizeableTitle"; +import TableCellView from "./TableCellView"; +import { COL_MIN_WIDTH, CustomColumnType } from "../tableUtils"; +import { TableColumnStyleType } from "comps/controls/styleControlConstants"; +import { RowColorViewType, RowHeightViewType } from "../tableTypes"; +import styled from "styled-components"; + + +const StyledTableWrapper = styled.div` + /* Hide AntD's virtual horizontal scrollbar overlay */ + .ant-table-tbody-virtual-scrollbar-horizontal { + display: none !important; + height: 0 !important; + } + /* Make the virtual scrollbar container inert (avoids dead click zone) */ + .ant-table-tbody-virtual-scrollbar { + pointer-events: none !important; + } + + /* (Optional) Some builds also render a sticky helper track – hide it too */ + .ant-table-sticky-scroll, + .ant-table-sticky-scroll-bar { + display: none !important; + height: 0 !important; + } +`; + +export type ResizeableTableProps = Omit< + TableProps, + "components" | "columns" +> & { + columns: CustomColumnType[]; + viewModeResizable: boolean; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + columnsStyle: TableColumnStyleType; + size?: string; + rowAutoHeight?: boolean; + customLoading?: boolean; + onCellClick: (columnName: string, dataIndex: string) => void; + // Virtualization props + containerHeight?: number; + isFixedHeight?: boolean; +}; + +/** + * A table with adjustable column width, width less than 0 means auto column width + */ +function ResizeableTableComp( + props: ResizeableTableProps +) { + const { + columns, + viewModeResizable, + rowColorFn, + rowHeightFn, + columnsStyle, + size, + rowAutoHeight, + customLoading, + onCellClick, + containerHeight, + isFixedHeight, + dataSource, + ...restProps + } = props; + + const [resizeData, setResizeData] = useState({ index: -1, width: -1 }); + const tableRef = useRef(null); + + + const handleResize = useCallback((width: number, index: number) => { + setResizeData({ index, width }); + }, []); + + const handleResizeStop = useCallback( + (width: number, index: number, onWidthResize?: (width: number) => void) => { + setResizeData({ index: -1, width: -1 }); + if (onWidthResize) { + onWidthResize(width); + } + }, + [] + ); + + const createCellHandler = useCallback( + (col: CustomColumnType) => { + return (record: RecordType, index: number) => ({ + record, + title: String(col.dataIndex), + rowColorFn, + rowHeightFn, + cellColorFn: col.cellColorFn, + rowIndex: index, + columnsStyle, + columnStyle: col.style, + linkStyle: col.linkStyle, + tableSize: size, + autoHeight: rowAutoHeight, + onClick: () => onCellClick(col.titleText, String(col.dataIndex)), + loading: customLoading, + customAlign: col.align, + }); + }, + [ + rowColorFn, + rowHeightFn, + columnsStyle, + size, + rowAutoHeight, + onCellClick, + customLoading, + ] + ); + + const createHeaderCellHandler = useCallback( + (col: CustomColumnType, index: number, resizeWidth: number) => { + return () => ({ + width: resizeWidth, + title: col.titleText, + viewModeResizable, + onResize: (width: React.SyntheticEvent) => { + if (width) { + handleResize(Number(width), index); + } + }, + onResizeStop: ( + e: React.SyntheticEvent, + { size }: { size: { width: number } } + ) => { + handleResizeStop(size.width, index, col.onWidthResize); + }, + }); + }, + [viewModeResizable, handleResize, handleResizeStop] + ); + + // AntD Table ignores `minWidth` prop on columns. + // We enforce a *real* minimum by giving auto columns a concrete width = COL_MIN_WIDTH + const memoizedColumns = useMemo(() => { + return columns.map((col: CustomColumnType, index: number) => { + const { + width, + style, + linkStyle, + cellColorFn, + onWidthResize, + ...restCol + } = col; + const resizeWidth = + (resizeData.index === index ? resizeData.width : col.width) ?? 0; + + const column: ColumnType = { + ...restCol, + // If no explicit width, use COL_MIN_WIDTH as a *real* floor + width: + typeof resizeWidth === "number" && resizeWidth > 0 + ? resizeWidth + : COL_MIN_WIDTH, + onCell: (record: RecordType, rowIndex?: number) => + createCellHandler(col)(record, rowIndex ?? 0), + onHeaderCell: () => + createHeaderCellHandler(col, index, Number(resizeWidth))(), + }; + return column; + }); + }, [columns, resizeData, createCellHandler, createHeaderCellHandler]); + + + + // Sum widths (including resized values) to keep horizontal scroll baseline accurate + function getTotalTableWidth( + cols: CustomColumnType[], + rData: { index: number; width: number } + ) { + return cols.reduce((sum, col, i) => { + const liveWidth = + (rData.index === i ? rData.width : (col.width as number | undefined)) ?? + undefined; + const w = + typeof liveWidth === "number" && liveWidth > 0 + ? liveWidth + : COL_MIN_WIDTH; + return sum + w; + }, 0); + } + + const scrollAndVirtualizationSettings = useMemo(() => { + const totalWidth = getTotalTableWidth(memoizedColumns as any, resizeData); + const shouldVirtualize = isFixedHeight && (dataSource?.length ?? 0) >= 50; + + return { + virtual: shouldVirtualize, + scroll: { + x: totalWidth, + // FIX: Set y for ANY fixed height mode, not just virtualization + y: isFixedHeight && containerHeight ? containerHeight : undefined + } + }; + }, [isFixedHeight, containerHeight, dataSource?.length, memoizedColumns, resizeData]); + + return ( + + + components={{ + header: { + cell: ResizeableTitle, + }, + body: { + cell: TableCellView, + }, + }} + {...(restProps as any)} + dataSource={dataSource} + pagination={false} + columns={memoizedColumns} + virtual={scrollAndVirtualizationSettings.virtual} + scroll={scrollAndVirtualizationSettings.scroll} + /> + + ); +} + +const ResizeableTable = React.memo( + ResizeableTableComp +) as typeof ResizeableTableComp; +export default ResizeableTable; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/ResizeableTitle.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/ResizeableTitle.tsx new file mode 100644 index 0000000000..d607f9c709 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/ResizeableTitle.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import _ from "lodash"; +import { Resizable } from "react-resizable"; +import styled from "styled-components"; +import { useUserViewMode } from "util/hooks"; +import { ReactRef, ResizeHandleAxis } from "layout/gridLayoutPropTypes"; + +const TitleResizeHandle = styled.span` + position: absolute; + top: 0; + right: -5px; + width: 10px; + height: 100%; + cursor: col-resize; + z-index: 1; +`; + +const TableTh = styled.th<{ width?: number }>` + overflow: hidden; + + > div { + overflow: hidden; + white-space: pre; + text-overflow: ellipsis; + } + + ${(props) => props.width && `width: ${props.width}px`}; +`; + +const ResizeableTitle = (props: any) => { + const { onResize, onResizeStop, width, viewModeResizable, ...restProps } = props; + const [childWidth, setChildWidth] = useState(0); + const resizeRef = useRef(null); + const isUserViewMode = useUserViewMode(); + + const updateChildWidth = useCallback(() => { + if (resizeRef.current) { + const width = resizeRef.current.getBoundingClientRect().width; + setChildWidth(width); + } + }, []); + + useEffect(() => { + updateChildWidth(); + const resizeObserver = new ResizeObserver(() => { + updateChildWidth(); + }); + + if (resizeRef.current) { + resizeObserver.observe(resizeRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [updateChildWidth]); + + const isNotDataColumn = _.isNil(restProps.title); + if ((isUserViewMode && !restProps.viewModeResizable) || isNotDataColumn) { + return ; + } + + return ( + 0 ? width : childWidth} + height={0} + onResize={(e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + e.stopPropagation(); + onResize(size.width); + }} + onResizeStart={(e: React.SyntheticEvent) => { + updateChildWidth(); + e.stopPropagation(); + e.preventDefault(); + }} + onResizeStop={onResizeStop} + draggableOpts={{ enableUserSelectHack: false }} + handle={(axis: ResizeHandleAxis, ref: ReactRef) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + /> + )} + > + + + ); +}; + +export default ResizeableTitle; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableCellView.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableCellView.tsx new file mode 100644 index 0000000000..70de6dee71 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableCellView.tsx @@ -0,0 +1,212 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; +import Skeleton from "antd/es/skeleton"; +import { SkeletonButtonProps } from "antd/es/skeleton/Button"; +import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; +import { OB_ROW_ORI_INDEX } from "../tableUtils"; +import { TableColumnLinkStyleType, TableColumnStyleType, ThemeDetail } from "comps/controls/styleControlConstants"; +import { RowColorViewType, RowHeightViewType } from "../tableTypes"; +import { CellColorViewType } from "../column/tableColumnComp"; + +interface TableTdProps { + $background: string; + $style: TableColumnStyleType & { rowHeight?: string }; + $defaultThemeDetail: ThemeDetail; + $linkStyle?: TableColumnLinkStyleType; + $tableSize?: string; + $autoHeight?: boolean; + $customAlign?: 'left' | 'center' | 'right'; +} + +const TableTd = styled.td` + .ant-table-row-expand-icon, + .ant-table-row-indent { + display: initial; + } + &.ant-table-row-expand-icon-cell { + background: ${(props) => props.$background}; + border-color: ${(props) => props.$style.border}; + } + background: ${(props) => props.$background} !important; + border-color: ${(props) => props.$style.border} !important; + border-radius: ${(props) => props.$style.radius}; + padding: 0 !important; + text-align: ${(props) => props.$customAlign || 'left'} !important; + + > div { + margin: ${(props) => props.$style.margin}; + color: ${(props) => props.$style.text}; + font-weight: ${(props) => props.$style.textWeight}; + font-family: ${(props) => props.$style.fontFamily}; + overflow: hidden; + display: flex; + justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; + align-items: center; + text-align: ${(props) => props.$customAlign || 'left'}; + box-sizing: border-box; + ${(props) => props.$tableSize === 'small' && ` + padding: 1px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '14px'}; + line-height: 20px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '28px'}; + `}; + `}; + ${(props) => props.$tableSize === 'middle' && ` + padding: 8px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '24px'}; + line-height: 24px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '48px'}; + `}; + `}; + ${(props) => props.$tableSize === 'large' && ` + padding: 16px 16px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '48px'}; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '96px'}; + `}; + `}; + + > .ant-badge > .ant-badge-status-text, + > div > .markdown-body { + color: ${(props) => props.$style.text}; + } + + > div > svg g { + stroke: ${(props) => props.$style.text}; + } + + > a, + > div a { + color: ${(props) => props.$linkStyle?.text}; + + &:hover { + color: ${(props) => props.$linkStyle?.hoverText}; + } + + &:active { + color: ${(props) => props.$linkStyle?.activeText}; + } + } + } +`; + +const TableTdLoading = styled(Skeleton.Button)` + width: 90% !important; + display: table !important; + + .ant-skeleton-button { + min-width: auto !important; + display: block !important; + ${(props) => props.$tableSize === 'small' && ` + height: 20px !important; + `} + ${(props) => props.$tableSize === 'middle' && ` + height: 24px !important; + `} + ${(props) => props.$tableSize === 'large' && ` + height: 28px !important; + `} + } +`; + +const TableCellView = React.memo((props: { + record: any; + title: string; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + cellColorFn: CellColorViewType; + rowIndex: number; + children: any; + columnsStyle: TableColumnStyleType; + columnStyle: TableColumnStyleType; + linkStyle: TableColumnLinkStyleType; + tableSize?: string; + autoHeight?: boolean; + loading?: boolean; + customAlign?: 'left' | 'center' | 'right'; +}) => { + const { + record, + title, + rowIndex, + rowColorFn, + rowHeightFn, + cellColorFn, + children, + columnsStyle, + columnStyle, + linkStyle, + tableSize, + autoHeight, + loading, + customAlign, + ...restProps + } = props; + + const style = useMemo(() => { + if (!record) return null; + const rowColor = rowColorFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const rowHeight = rowHeightFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const cellColor = cellColorFn({ + currentCell: record[title], + currentRow: record, + }); + + return { + background: cellColor || rowColor || columnStyle.background || columnsStyle.background, + margin: columnStyle.margin || columnsStyle.margin, + text: columnStyle.text || columnsStyle.text, + border: columnStyle.border || columnsStyle.border, + radius: columnStyle.radius || columnsStyle.radius, + textSize: columnStyle.textSize || columnsStyle.textSize, + textWeight: columnsStyle.textWeight || columnStyle.textWeight, + fontFamily: columnsStyle.fontFamily || columnStyle.fontFamily, + fontStyle: columnsStyle.fontStyle || columnStyle.fontStyle, + rowHeight: rowHeight, + } as TableColumnStyleType & { rowHeight?: string }; + }, [record, rowIndex, title, rowColorFn, rowHeightFn, cellColorFn, columnStyle, columnsStyle]); + + if (!record) { + return {children} as any; + } + + const { background } = style!; + + return ( + + {loading ? : children} + + ); +}); + +export default TableCellView; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableContainer.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableContainer.tsx new file mode 100644 index 0000000000..1a44cfa7d7 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableContainer.tsx @@ -0,0 +1,162 @@ +// parts/TableContainer.tsx +import React from 'react'; +import styled from 'styled-components'; +import SimpleBar from 'simplebar-react'; +// import 'simplebar-react/dist/simplebar.min.css'; + +const MainContainer = styled.div<{ + $mode: 'AUTO' | 'FIXED'; + $showHorizontalScrollbar: boolean; + $showVerticalScrollbar: boolean; + $virtual: boolean; +}>` + display: flex; + flex-direction: column; + height: 100%; + position: relative; + + /* Critical CSS controls for SimpleBar */ + + + ${props => (!props.$showHorizontalScrollbar || props.$virtual) && ` + div.simplebar-horizontal { + visibility: hidden !important; + } + `} + + ${props => (!props.$showVerticalScrollbar || props.$virtual) && ` + div.simplebar-vertical { + visibility: hidden !important; + } + `} + + /* Ant Table virtualization scrollbar - match SimpleBar colors */ + .ant-table-tbody-virtual-scrollbar-thumb { + background: rgba(0,0,0,0.35) !important; + border-radius: 6px; + transition: background .2s ease; + } + + .ant-table-tbody-virtual-scrollbar-horizontal { + display: ${(props) => (props.$showHorizontalScrollbar) ? 'block' : 'none'} !important; + } + + .ant-table-tbody-virtual-scrollbar-vertical { + display: ${(props) => (props.$showVerticalScrollbar) ? 'block' : 'none'} !important; + } + +`; + +const StickyToolbar = styled.div<{ + $position: 'above' | 'below'; +}>` + position: sticky; + ${props => props.$position === 'above' ? 'top: 0;' : 'bottom: 0;'} + z-index: 10; + background: inherit; + box-shadow: ${props => props.$position === 'above' + ? '0 2px 8px rgba(0,0,0,0.1)' + : '0 -2px 8px rgba(0,0,0,0.1)' + }; + flex-shrink: 0; +`; + +const DefaultToolbar = styled.div` + flex-shrink: 0; + /* Prevent horizontal scrolling while allowing vertical flow */ + position: sticky; + left: 0; + right: 0; + z-index: 1; + background: inherit; +`; + +const TableSection = styled.div<{ + $mode: 'AUTO' | 'FIXED'; +}>` + min-height: 0; + min-width: 0; +`; + +const SimpleBarWrapper = styled(SimpleBar)` + height: 100%; + overflow: auto !important; + + + /* CRITICAL: Transfer scroll control from Ant Design to SimpleBar */ + .simplebar-track { background: transparent; } + + .simplebar-track.simplebar-vertical { width: 10px; } + .simplebar-track.simplebar-horizontal { height: 10px; } + + .simplebar-scrollbar:before { + background: rgba(0,0,0,0.35); + border-radius: 6px; + transition: background .2s ease, inset .2s ease; + } + + .simplebar-track.simplebar-vertical .simplebar-scrollbar:before { left: 2px; right: 2px; } + .simplebar-track.simplebar-horizontal .simplebar-scrollbar:before { top: 2px; bottom: 2px; } + + .simplebar-track:hover .simplebar-scrollbar:before { background: rgba(0,0,0,0.55); } + .simplebar-track.simplebar-vertical:hover .simplebar-scrollbar:before { left: 1px; right: 1px; } + .simplebar-track.simplebar-horizontal:hover .simplebar-scrollbar:before { top: 1px; bottom: 1px; } +`; + +interface TableContainerProps { + mode: 'AUTO' | 'FIXED'; + toolbarPosition: 'above' | 'below' | 'close'; + stickyToolbar: boolean; + showToolbar: boolean; + toolbar: React.ReactNode; + children: React.ReactNode; + containerRef?: React.RefObject; + showVerticalScrollbar: boolean; + showHorizontalScrollbar: boolean; + virtual: boolean; +} + +export const TableContainer: React.FC = ({ + mode, + toolbarPosition, + stickyToolbar, + showToolbar, + toolbar, + children, + containerRef, + showVerticalScrollbar, + showHorizontalScrollbar, + virtual +}) => { + return ( + + {/* Sticky above toolbar - always visible */} + {stickyToolbar && toolbarPosition === 'above' && showToolbar && ( + {toolbar} + )} + + + + {!stickyToolbar && toolbarPosition === 'above' && showToolbar && ( + {toolbar} + )} + {children} + {!stickyToolbar && toolbarPosition === 'below' && showToolbar && ( + {toolbar} + )} + + + + {/* Sticky below toolbar - always visible */} + {stickyToolbar && toolbarPosition === 'below' && showToolbar && ( + {toolbar} + )} + + ); +}; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableRenderer.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableRenderer.tsx new file mode 100644 index 0000000000..48169659e4 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/TableRenderer.tsx @@ -0,0 +1,155 @@ +import React, { useMemo } from "react"; +import AutoModeTable from "./AutoModeTable"; +import FixedModeTable from "./FixedModeTable"; +import VirtualizedTable from "./VirtualizedTable"; +import { BaseTableProps } from "./BaseTable"; +import { COL_MIN_WIDTH } from "../tableUtils"; + +export interface TableRendererProps extends BaseTableProps { + mode: 'AUTO' | 'FIXED'; + heights: { + bodyHeight?: number; + canVirtualize: boolean; + [key: string]: any; + }; + virtualizationConfig: { + enabled: boolean; + itemHeight: number; + threshold: number; + reason?: string; + }; + style?: any; + headerStyle?: any; + rowStyle?: any; + showHeader?: boolean; + fixedHeader?: boolean; + showHRowGridBorder?: boolean; + showVerticalScrollbar?: boolean; + showHorizontalScrollbar?: boolean; +} + +function TableRendererComp(props: TableRendererProps) { + const { + mode, + heights, + virtualizationConfig, + columns, + ...baseTableProps + } = props; + + // Calculate total width for X scroll + const totalWidth = useMemo(() => { + return columns.reduce((sum, col) => { + const width = typeof col.width === "number" && col.width > 0 ? col.width : COL_MIN_WIDTH; + return sum + width; + }, 0); + }, [columns]); + + // AUTO MODE: Natural growth + if (mode === 'AUTO') { + return ( + + ); + } + + // FIXED MODE: Height-constrained with optional virtualization + if (mode === 'FIXED') { + const bodyHeight = heights.bodyHeight ?? 0; + + if (bodyHeight <= 0) { + return ( + + ); + } + + // VIRTUALIZED: High performance for large datasets + if (virtualizationConfig.enabled && heights.canVirtualize) { + const scrollConfig = { x: totalWidth, y: bodyHeight }; + return ( + + ); + } + + // FIXED: Regular height-constrained mode without internal vertical scroll + // Let the outer container handle vertical scrolling so the footer appears right after the table + return ( + + ); + } + + // Fallback + return ( + + ); +} + +const TableRenderer = React.memo(TableRendererComp) as typeof TableRendererComp; +export default TableRenderer; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/VirtualizedTable.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/VirtualizedTable.tsx new file mode 100644 index 0000000000..d98a3ea95f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/parts/VirtualizedTable.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import BaseTable, { BaseTableProps } from "./BaseTable"; + +export interface VirtualizedTableProps extends BaseTableProps { + bodyHeight: number; + virtualizationConfig: { + enabled: boolean; + itemHeight: number; + threshold: number; + reason?: string; + }; +} + +function VirtualizedTableComp(props: VirtualizedTableProps) { + const { bodyHeight, virtualizationConfig, ...baseProps } = props; + + // Virtualized mode: explicit scroll config for performance + return ( + + {...baseProps} + // Props are set by TableRenderer + /> + ); +} + +const VirtualizedTable = React.memo(VirtualizedTableComp) as typeof VirtualizedTableComp; +export default VirtualizedTable; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx new file mode 100644 index 0000000000..af64f4ac6c --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx @@ -0,0 +1,134 @@ +import { TableRowSelection } from "antd/es/table/interface"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { stateComp } from "comps/generators"; +import { trans } from "i18n"; +import { changeChildAction, ConstructorToComp } from "lowcoder-core"; +import { TableOnEventView } from "./tableTypes"; +import { OB_ROW_ORI_INDEX, RecordType } from "./tableUtils"; +import { ControlNodeCompBuilder } from "comps/generators/controlCompBuilder"; + +// double-click detection constants +const DOUBLE_CLICK_THRESHOLD = 300; // ms +let lastClickTime = 0; +let clickTimer: ReturnType; + +const modeOptions = [ + { + label: trans("selectionControl.single"), + value: "single", + }, + { + label: trans("selectionControl.multiple"), + value: "multiple", + }, + { + label: trans("selectionControl.close"), + value: "close", + }, +] as const; + +/** + * Currently use index as key + */ +function getKey(record: RecordType) { + return record[OB_ROW_ORI_INDEX]; +} + +export function getSelectedRowKeys( + selection: ConstructorToComp +): Array { + const mode = selection.children.mode.getView(); + switch (mode) { + case "single": + return [selection.children.selectedRowKey.getView()]; + case "multiple": + return selection.children.selectedRowKeys.getView(); + default: + return []; + } +} + +export const SelectionControl = (function () { + const childrenMap = { + mode: dropdownControl(modeOptions, "single"), + selectedRowKey: stateComp("0"), + selectedRowKeys: stateComp>([]), + }; + return new ControlNodeCompBuilder(childrenMap, (props, dispatch) => { + const changeSelectedRowKey = (record: RecordType) => { + const key = getKey(record); + if (key !== props.selectedRowKey) { + dispatch(changeChildAction("selectedRowKey", key, false)); + } + }; + + return (onEvent: TableOnEventView) => { + const handleClick = (record: RecordType) => { + return () => { + const now = Date.now(); + clearTimeout(clickTimer); + if (now - lastClickTime < DOUBLE_CLICK_THRESHOLD) { + + changeSelectedRowKey(record); + onEvent("doubleClick"); + if (getKey(record) !== props.selectedRowKey) { + onEvent("rowSelectChange"); + } + } else { + clickTimer = setTimeout(() => { + changeSelectedRowKey(record); + onEvent("rowClick"); + if (getKey(record) !== props.selectedRowKey) { + onEvent("rowSelectChange"); + } + }, DOUBLE_CLICK_THRESHOLD); + } + lastClickTime = now; + }; + }; + + if (props.mode === "single" || props.mode === "close") { + return { + rowKey: getKey, + rowClassName: (record: RecordType, index: number, indent: number) => { + if (props.mode === "close") { + return ""; + } + return getKey(record) === props.selectedRowKey ? "ant-table-row-selected" : ""; + }, + onRow: (record: RecordType, index: number | undefined) => ({ + onClick: handleClick(record), + }), + }; + } + + const result: TableRowSelection = { + type: "checkbox", + selectedRowKeys: props.selectedRowKeys, + preserveSelectedRowKeys: true, + onChange: (selectedRowKeys) => { + dispatch(changeChildAction("selectedRowKeys", selectedRowKeys as string[], false)); + onEvent("rowSelectChange"); + }, + onSelect: (record: RecordType) => { + changeSelectedRowKey(record); + onEvent("rowClick"); + }, + }; + return { + rowKey: getKey, + rowSelection: result, + onRow: (record: RecordType) => ({ + onClick: handleClick(record), + }), + }; + }; + }) + .setPropertyViewFn((children) => + children.mode.propertyView({ + label: trans("selectionControl.mode"), + radioButton: true, + }) + ) + .build(); +})(); \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/CellStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/CellStyles.ts new file mode 100644 index 0000000000..2ef7329e8d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/CellStyles.ts @@ -0,0 +1,19 @@ +import styled from "styled-components"; + +export const CellStyleProvider = styled.div<{ + $rowStyle: any; + $columnsStyle: any; +}>` + .ant-table-tbody > tr > td { + background: ${props => props.$rowStyle?.background || '#ffffff'}; + color: ${props => props.$rowStyle?.color || 'rgba(0, 0, 0, 0.85)'}; + border-color: ${props => props.$rowStyle?.borderColor || '#f0f0f0'}; + /* padding: ${props => props.$rowStyle?.padding || '12px 16px'}; */ + ${props => props.$rowStyle?.customCSS || ''} + } + + .ant-table-tbody > tr > td { + ${props => props.$columnsStyle?.textAlign && `text-align: ${props.$columnsStyle.textAlign};`} + ${props => props.$columnsStyle?.customCSS || ''} + } +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/HeaderStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/HeaderStyles.ts new file mode 100644 index 0000000000..66ea2aa414 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/HeaderStyles.ts @@ -0,0 +1,37 @@ +import styled from "styled-components"; + +export const HeaderStyleProvider = styled.div<{ + $headerStyle: any; + $isSticky: boolean; + $isHidden: boolean; +}>` + + ${props => props.$isHidden && ` + .ant-table-thead { + display: none; + } + `} + + .ant-table-thead > tr > th { + background: ${props => props.$headerStyle?.headerBackground}; + color: ${props => props.$headerStyle?.headerText}; + border-color: ${props => props.$headerStyle?.border}; + border-width: ${props => props.$headerStyle?.borderWidth}; + padding: ${props => props.$headerStyle?.padding}; + font-size: ${props => props.$headerStyle?.textSize}; + + ${props => props.$headerStyle?.customCSS || ''} + } + + .ant-table-thead > tr > th > div { + margin: ${props => props.$headerStyle?.margin}; + } + + ${props => props.$isSticky && ` + &&& .ant-table-thead > tr > th { + position: sticky; + top: 0; + z-index: 3; + } + `} +`; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/RowStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/RowStyles.ts new file mode 100644 index 0000000000..69f4fde15e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/RowStyles.ts @@ -0,0 +1,51 @@ +import styled from "styled-components"; + +export const RowStyleProvider = styled.div<{ + $rowStyle: any; + $showHRowGridBorder: boolean; +}>` + + /* Hide the measure row to avoid the extra space */ + tr.ant-table-measure-row{ + visibility: collapse; + } + + /* Only apply row styles if explicitly set by user */ + .ant-table-tbody > tr { + ${props => props.$rowStyle?.background && `background: ${props.$rowStyle.background};`} + ${props => props.$rowStyle?.borderColor && `border-color: ${props.$rowStyle.borderColor};`} + ${props => props.$rowStyle?.height && `height: ${props.$rowStyle.height};`} + ${props => props.$rowStyle?.minHeight && `min-height: ${props.$rowStyle.minHeight};`} + } + + /* Row hover effects - only if explicitly set */ + ${props => props.$rowStyle?.hoverBackground && ` + .ant-table-tbody > tr:hover { + background: ${props.$rowStyle.hoverBackground}; + } + `} + + /* Alternating row colors - only if explicitly set */ + ${props => props.$rowStyle?.alternatingBackground && ` + .ant-table-tbody > tr:nth-child(even) { + background: ${props.$rowStyle.alternatingBackground}; + } + `} + + /* Selected row styling - only if explicitly set */ + ${props => props.$rowStyle?.selectedBackground && ` + .ant-table-tbody > tr.ant-table-row-selected { + background: ${props.$rowStyle.selectedBackground}; + } + `} + + /* Horizontal grid borders */ + ${props => props.$showHRowGridBorder && ` + .ant-table-tbody > tr > td { + border-bottom: 1px solid ${props.$rowStyle?.borderColor || '#f0f0f0'}; + } + `} + + /* Custom row CSS */ + ${props => props.$rowStyle?.customCSS || ''} +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/ScrollbarStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/ScrollbarStyles.ts new file mode 100644 index 0000000000..f090bbca88 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/ScrollbarStyles.ts @@ -0,0 +1,39 @@ +import styled from "styled-components"; + +export const ScrollbarStyleProvider = styled.div<{ + $showVerticalScrollbar: boolean; + $showHorizontalScrollbar: boolean; +}>` + ${props => !props.$showHorizontalScrollbar && ` + /* Target horizontal scrollbars - focus on .ant-table-body since it's common to all modes */ + .ant-table-body::-webkit-scrollbar:horizontal, + .ant-table-body::-webkit-scrollbar { + display: none; + } + .ant-table-content::-webkit-scrollbar:horizontal, + .ant-table-content::-webkit-scrollbar { + display: none; + } + + .ant-table-body, + .ant-table-content { + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE */ + } + `} + + /* Hide ANTD's virtual scrollbars */ + ${props => !props.$showHorizontalScrollbar && ` + .ant-table-tbody-virtual-scrollbar-horizontal { + display: none !important; + height: 0 !important; + } + `} + + ${props => !props.$showVerticalScrollbar && ` + .ant-table-tbody-virtual-scrollbar { + display: none !important; + width: 0 !important; + } + `} +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/TableContainerStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/TableContainerStyles.ts new file mode 100644 index 0000000000..11d4ac27a4 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/TableContainerStyles.ts @@ -0,0 +1,91 @@ +import styled from "styled-components"; + +export const TableContainer = styled.div<{ + $style: any; +}>` + /* SMALL SIZE */ + .ant-table-small { + /* Body cell padding */ + .ant-table-tbody > tr > td { + padding: 8px 8px !important; + font-size: 12px; /* Smaller font */ + line-height: 1.4; + } + /* Virtualized body cell padding */ + .ant-table-tbody-virtual .ant-table-cell { + padding: 8px 8px !important; + font-size: 12px; + line-height: 1.4; + } + + /* Header cell padding and font */ + .ant-table-thead > tr > th { + padding: 8px 8px !important; + font-size: 12px; + } + + /* Row height adjustments */ + .ant-table-tbody > tr { + height: auto; + min-height: 32px; /* Smaller minimum row height */ + } + } + + /* MIDDLE SIZE */ + .ant-table-middle { + /* Body cell padding */ + .ant-table-tbody > tr > td { + padding: 12px 12px !important; + font-size: 14px; + line-height: 1.5; + } + /* Virtualized body cell padding */ + .ant-table-tbody-virtual .ant-table-cell { + padding: 12px 12px !important; + font-size: 14px; + line-height: 1.5; + } + + /* Header cell padding and font */ + .ant-table-thead > tr > th { + padding: 12px 12px !important; + font-size: 14px; + } + + /* Row height adjustments */ + .ant-table-tbody > tr { + min-height: 40px; + } + } + + /* DEFAULT/LARGE SIZE */ + .ant-table:not(.ant-table-small):not(.ant-table-middle) { + /* Body cell padding */ + .ant-table-tbody > tr > td { + padding: 16px 16px !important; + font-size: 14px; + line-height: 1.6; + } + /* Virtualized body cell padding */ + .ant-table-tbody-virtual .ant-table-cell { + padding: 16px 16px !important; + font-size: 14px; + line-height: 1.6; + } + + /* Header cell padding and font */ + .ant-table-thead > tr > th { + padding: 16px 16px !important; + font-size: 14px; + font-weight: 600; + } + + /* Row height adjustments */ + .ant-table-tbody > tr { + min-height: 48px; + } + } + + /* Your existing custom styles */ + ${props => props.$style?.customCSS || ''} +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/TableWrapper.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/TableWrapper.tsx new file mode 100644 index 0000000000..801ec56b80 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/TableWrapper.tsx @@ -0,0 +1,38 @@ +import styled from "styled-components"; +import { + TableStyleType, + TableHeaderStyleType, + TableRowStyleType, +} from "comps/controls/styleControlConstants"; + +import { getTableBaseStyles } from "./tableBaseStyles"; +import { getTableHeaderStyles } from "./tableHeaderStyles"; +import { getTableRowStyles } from "./tableRowStyles"; + +interface TableWrapperProps { + $style: TableStyleType; + $headerStyle: TableHeaderStyleType; + $rowStyle: TableRowStyleType; + $visibleResizables: boolean; + $showHRowGridBorder?: boolean; +} + +export const TableWrapper = styled.div` + /* Base table styles */ + ${(props) => getTableBaseStyles(props.$style)} + + /* Header styles */ + ${(props) => getTableHeaderStyles(props.$headerStyle, props.$visibleResizables)} + + /* Row styles */ + ${(props) => getTableRowStyles(props.$rowStyle)} + + + /* Additional table specific styles */ + ${(props) => !props.$showHRowGridBorder && ` + .ant-table thead > tr > th, + .ant-table tbody > tr > td { + border-bottom: 0px; + } + `} +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/ToolbarStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/ToolbarStyles.ts new file mode 100644 index 0000000000..5d12d21623 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/ToolbarStyles.ts @@ -0,0 +1,12 @@ +import styled from "styled-components"; +import { TableToolbarStyleType } from "comps/controls/styleControlConstants"; + +export const ToolbarStyleProvider = styled.div<{ + $toolbarStyle: TableToolbarStyleType; +}>` + background: ${props => props.$toolbarStyle?.background}; + border: 1px solid ${props => props.$toolbarStyle?.border}; + margin: ${props => props.$toolbarStyle?.margin}; + color: ${props => props.$toolbarStyle?.toolbarText}; + +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/index.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/index.ts new file mode 100644 index 0000000000..b3d50b4255 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/index.ts @@ -0,0 +1,5 @@ +export { TableContainer } from './TableContainerStyles'; +export { HeaderStyleProvider } from './HeaderStyles'; +export { CellStyleProvider } from './CellStyles'; +export { ScrollbarStyleProvider } from './ScrollbarStyles'; +export { RowStyleProvider } from './RowStyles'; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableBaseStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableBaseStyles.ts new file mode 100644 index 0000000000..8aef2d3ccd --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableBaseStyles.ts @@ -0,0 +1,69 @@ +import { css } from "styled-components"; +import { TableStyleType } from "comps/controls/styleControlConstants"; +import { PrimaryColor } from "constants/style"; + +export const getTableBaseStyles = ( + style: TableStyleType +) => css` + .ant-table-wrapper { + border-top: unset; + border-color: inherit; + } + + .ant-table-row-expand-icon { + color: ${PrimaryColor}; + } + + .ant-table .ant-table-cell-with-append .ant-table-row-expand-icon { + margin: 0; + top: 18px; + left: 4px; + } + + .ant-table.ant-table-small .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 10px; + } + + .ant-table.ant-table-middle .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 14px; + margin-right: 5px; + } + + .ant-table { + background: ${style.background}; + + .ant-table-container { + border-left: unset; + border-top: none !important; + border-inline-start: none !important; + + &::after { + box-shadow: none !important; + } + + .ant-table-content { + overflow: unset !important; + } + + .ant-table-tbody .ant-table-wrapper:only-child .ant-table { + margin: 0; + } + + table { + border-top: unset; + + tbody > tr > td:last-child { + border-right: unset !important; + } + + .ant-empty-img-simple-g { + fill: #fff; + } + } + + .ant-table-expanded-row-fixed:after { + border-right: unset !important; + } + } + } +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableHeaderStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableHeaderStyles.ts new file mode 100644 index 0000000000..e72dcbb918 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableHeaderStyles.ts @@ -0,0 +1,83 @@ +import { css } from "styled-components"; +import { darkenColor } from "lowcoder-design"; +import { TableHeaderStyleType } from "comps/controls/styleControlConstants"; +import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; + +export const getTableHeaderStyles = ( + headerStyle: TableHeaderStyleType, + visibleResizables: boolean +) => css` + .ant-table { + > .ant-table-container { + table { + > .ant-table-thead { + > tr { + background: ${headerStyle.headerBackground}; + } + > tr > th { + background: transparent; + border-color: ${headerStyle.border}; + border-width: ${headerStyle.borderWidth}; + color: ${headerStyle.headerText}; + + /* Proper styling for fixed header cells */ + &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { + z-index: 1; + background: ${headerStyle.headerBackground}; + } + + > div { + margin: ${headerStyle.margin}; + + &, .ant-table-column-title > div { + font-size: ${headerStyle.textSize}; + font-weight: ${headerStyle.textWeight}; + font-family: ${headerStyle.fontFamily}; + font-style: ${headerStyle.fontStyle}; + color: ${headerStyle.headerText}; + } + } + + &:last-child { + border-inline-end: none !important; + } + + &.ant-table-column-has-sorters:hover { + background-color: ${darkenColor(headerStyle.headerBackground, 0.05)}; + } + + > .ant-table-column-sorters > .ant-table-column-sorter { + color: ${headerStyle.headerText === defaultTheme.textDark ? "#bfbfbf" : headerStyle.headerText}; + } + + &::before { + background-color: ${headerStyle.border}; + width: ${visibleResizables ? "1px" : "0px"} !important; + } + } + } + + > thead > tr > th, + > tbody > tr > td { + border-color: ${headerStyle.border}; + } + + thead > tr:first-child { + th:last-child { + border-right: unset; + } + } + + > thead > tr:first-child { + th:first-child { + border-top-left-radius: 0px; + } + + th:last-child { + border-top-right-radius: 0px; + } + } + } + } + } +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableRowStyles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableRowStyles.ts new file mode 100644 index 0000000000..f145d5216d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/tableRowStyles.ts @@ -0,0 +1,116 @@ +import { css } from "styled-components"; +import { TableRowStyleType } from "comps/controls/styleControlConstants"; +import { darkenColor } from "lowcoder-design"; + +export const getTableRowStyles = ( + rowStyle: TableRowStyleType +) => css` + .ant-table { + > .ant-table-container { + .ant-table-body { + background: ${rowStyle.background}; + } + + .ant-table-tbody { + /* Regular row (odd rows - 1st, 3rd, 5th, etc.) */ + > tr:nth-of-type(2n + 1) { + background: ${rowStyle.background}; + + > td.ant-table-cell { + background: transparent; + border-color: ${rowStyle.border}; + border-width: ${rowStyle.borderWidth}; + border-style: ${rowStyle.borderStyle}; + border-radius: ${rowStyle.radius}; + } + } + + /* Alternate row (even rows - 2nd, 4th, 6th, etc.) */ + > tr:nth-of-type(2n) { + background: ${rowStyle.alternateBackground}; + + > td.ant-table-cell { + background: transparent; + border-color: ${rowStyle.border}; + border-width: ${rowStyle.borderWidth}; + border-style: ${rowStyle.borderStyle}; + border-radius: ${rowStyle.radius}; + } + } + + /* Selected row states */ + > tr:nth-of-type(2n + 1).ant-table-row-selected { + background: ${rowStyle.selectedRowBackground} !important; + + > td.ant-table-cell { + background: transparent !important; + } + + &:hover { + background: ${darkenColor(rowStyle.selectedRowBackground, 0.05)} !important; + } + } + + > tr:nth-of-type(2n).ant-table-row-selected { + background: ${rowStyle.selectedRowBackground} !important; + + > td.ant-table-cell { + background: transparent !important; + } + + &:hover { + background: ${darkenColor(rowStyle.selectedRowBackground, 0.05)} !important; + } + } + + /* Hover row states for non-selected rows */ + > tr:nth-of-type(2n + 1):hover:not(.ant-table-row-selected) { + background: ${rowStyle.hoverRowBackground} !important; + + > td.ant-table-cell-row-hover { + background: transparent; + } + } + + > tr:nth-of-type(2n):hover:not(.ant-table-row-selected) { + background: ${rowStyle.hoverRowBackground} !important; + + > td.ant-table-cell-row-hover { + background: transparent; + } + } + + /* Fixed column support for row backgrounds */ + tr td.ant-table-cell-fix-left, + tr td.ant-table-cell-fix-right { + z-index: 1; + background: inherit; + transition: background-color 0.3s; + } + + /* Selected row fixed columns */ + tr.ant-table-row-selected td.ant-table-cell-fix-left, + tr.ant-table-row-selected td.ant-table-cell-fix-right { + background-color: ${rowStyle.selectedRowBackground} !important; + } + + /* Hover row fixed columns */ + tr:hover:not(.ant-table-row-selected) td.ant-table-cell-fix-left, + tr:hover:not(.ant-table-row-selected) td.ant-table-cell-fix-right { + background-color: ${rowStyle.hoverRowBackground} !important; + } + + /* Selected and hovered row fixed columns */ + tr.ant-table-row-selected:hover td.ant-table-cell-fix-left, + tr.ant-table-row-selected:hover td.ant-table-cell-fix-right { + background-color: ${darkenColor(rowStyle.selectedRowBackground, 0.05)} !important; + } + } + + /* Last cell border removal */ + table tbody > tr > td:last-child { + border-right: unset !important; + } + } + } +`; \ No newline at end of file diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/toolbar.styles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/toolbar.styles.ts new file mode 100644 index 0000000000..83fca93824 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/toolbar.styles.ts @@ -0,0 +1,22 @@ +import styled from "styled-components"; + +export const ToolbarContainer = styled.div` + padding: 12px; + width: 100%; +`; + +export const ToolbarRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + min-width: max-content; + width: 100%; +`; + +export const ToolbarIcons = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + + diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx new file mode 100644 index 0000000000..96121e4f59 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx @@ -0,0 +1,213 @@ +import { ColumnComp } from "comps/comps/tableComp/column/tableColumnComp"; +import { evalAndReduce } from "comps/utils"; +import _ from "lodash"; +import { fromValue } from "lowcoder-core"; +import { MemoryRouter } from "react-router-dom"; +import { MockTableComp } from "./mockTableComp"; +import { TableLiteComp } from "./tableComp"; +import { OB_ROW_ORI_INDEX } from "./tableUtils"; + +test("test column", () => { + const columnData = { + title: "name", + // editable: true, // TODO: change to boolean + }; + let comp = new ColumnComp({ value: columnData }); + comp = evalAndReduce(comp); + const columnOutput = comp.getView(); + expect(columnOutput.title).toEqual(columnData.title); + // expect(columnOutput.editable).toEqual(columnData.editable); +}); + +test("test column render", () => { + const columnData = { + render: { + compType: "text" as const, + comp: { + text: "{{currentRow.id}}", + }, + }, + // editable: true, // TODO: change to boolean + }; + let comp = new ColumnComp({ value: columnData }); + comp = evalAndReduce(comp); + const columnOutput = comp.getView(); + expect( + ( + columnOutput + .render( + { + currentCell: null, + currentIndex: null, + currentRow: { id: "hello" }, + currentOriginalIndex: null, + }, + "0" + ) + .getView() + .view({}) as any + ).props.normalView + ).toEqual("hello"); + // FIXME: see what should be output if the input is wrong + // expect(columnOutput.render()).toEqual(""); + // expect(columnOutput.render(null, "def")).toEqual(""); +}); + +test("test table", async () => { + // jest.setTimeout(1000); + const tableData = { + data: JSON.stringify([{ a: 1 }]), + columns: [ + { + dataIndex: "a", + hide: true, + }, + { + title: "custom", + dataIndex: "", + isCustom: true, + }, + ], + }; + const exposingInfo: any = { + query1: fromValue({ data: [{ q: 1 }] }), + query2: fromValue({ data: [{ q2: 2 }] }), + }; + let comp = new TableLiteComp({ + dispatch: (action: any) => { + comp = evalAndReduce(comp.reduce(action), exposingInfo); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + let columns = comp.children.columns.getView(); + expect(columns.length).toEqual(2); + comp = evalAndReduce(comp.reduce(comp.changeChildAction("data", '[{"a":1, "c":2, "d":3}]'))); + await new Promise((r) => setTimeout(r, 20)); + columns = comp.children.columns.getView(); + expect(columns.length).toEqual(4); + expect(columns[0].getView().dataIndex).toEqual("a"); + expect(columns[0].getView().hide).toBe(true); + expect(columns[1].getView().title).toEqual("custom"); + expect(columns[2].getView().title).toEqual("c"); + expect(columns[3].getView().title).toEqual("d"); +}, 1000); + +// FIXME: add a single test for the click action of the table + +function DebugContainer(props: any) { + return ( + + {props.comp.getView()} + + ); +} + +test("test mock table render", () => { + let comp = new MockTableComp({}); + comp = evalAndReduce(comp); + // render(); + // screen.getByText(/Date/i); +}); + +test("test table data transform", () => { + function getAndExpectTableData(expectDisplayDataLen: number, comp: any) { + const exposingValues = comp.exposingValues; + const displayData = exposingValues["displayData"]; + const { data } = comp.getProps(); + const filteredData = comp.filterData; + // Transform, sort, filter the raw data. + expect(data.length).toEqual(3); + expect(displayData.length).toEqual(expectDisplayDataLen); + // Remove the custom column, displayData is the same as tranFormData, if title is not defined + expect(displayData.map((d: any) => _.omit(d, "custom"))).toEqual( + _.map(filteredData, (row) => _.omit(row, OB_ROW_ORI_INDEX)) + ); + return { transformedData: filteredData, data, displayData }; + } + + const tableData = { + data: JSON.stringify([ + { id: 1, name: "gg" }, + { id: 5, name: "gg2" }, + { id: 3, name: "jjj" }, + ]), + columns: [ + { + dataIndex: "id", + isCustom: false, + sortable: true, + render: { compType: "text" as const, comp: { text: "{{currentCell}}" } }, + }, + { + dataIndex: "name", + isCustom: false, + render: { compType: "text" as const, comp: { text: "{{currentCell}}" } }, + }, + { + title: "custom", + dataIndex: "ealekfg", + isCustom: true, + render: { + compType: "image" as const, + comp: { + src: "{{currentRow.id}}", + }, + }, + }, + ], + }; + let comp = new TableLiteComp({ + dispatch: (action: any) => { + comp = evalAndReduce(comp.reduce(action)); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + // id sort + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("sort", [ + { + column: "id", + desc: true, + }, + ]) + ) + ); + let { transformedData, data, displayData } = getAndExpectTableData(3, comp); + expect(transformedData.map((d: any) => d["id"])).toEqual([5, 3, 1]); + // search + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("toolbar", { + searchText: "gg", + }) + ) + ); + getAndExpectTableData(2, comp); + // filter + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("toolbar", { + showFilter: true, + filter: { + stackType: "and", + filters: [ + { + columnKey: "id", + filterValue: "4", + operator: "gt", + }, + { + columnKey: "id", + filterValue: "5", + operator: "lte", + }, + ], + }, + }) + ) + ); + getAndExpectTableData(1, comp); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx new file mode 100644 index 0000000000..652867aa9d --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx @@ -0,0 +1,472 @@ +import { tableDataRowExample } from "./column/tableColumnListComp"; +import { getPageSize } from "./paginationControl"; +import { TableCompView } from "./tableCompView"; +import { + columnHide, + ColumnsAggrData, + RecordType, + transformDispalyData, +} from "./tableUtils"; +import { isTriggerAction } from "comps/controls/actionSelector/actionSelectorControl"; +import { withPropertyViewFn, withViewFn } from "comps/generators"; +import { childrenToProps } from "comps/generators/multi"; +import { HidableView } from "comps/generators/uiCompBuilder"; +import { withDispatchHook } from "comps/generators/withDispatchHook"; +import { + CompDepsConfig, + depsConfig, + DepsConfig, + NameConfig, + withExposingConfigs, +} from "comps/generators/withExposing"; +import { withMethodExposing } from "comps/generators/withMethodExposing"; +import { trans } from "i18n"; +import _ from "lodash"; +import { + changeChildAction, + CompAction, + CompActionTypes, + deferAction, + executeQueryAction, + onlyEvalAction, + routeByNameAction, + wrapChildAction, +} from "lowcoder-core"; +import { saveDataAsFile } from "util/fileUtils"; +import { JSONObject } from "util/jsonTypes"; +import { indexKeyToRecord, toDisplayIndex } from "./utils/selectionUtils"; + +import { getSelectedRowKeys } from "./selectionControl"; +import { compTablePropertyView } from "./tablePropertyView"; +import { RowColorComp, RowHeightComp, TableChildrenView, TableInitComp } from "./tableTypes"; + +import { useContext } from "react"; +import { EditorContext } from "comps/editorState"; +import { tableMethodExposings } from "./methods/tableMethodExposings"; +import { buildSortedDataNode, buildFilteredDataNode, buildOriDisplayDataNode, buildColumnAggrNode } from "./nodes/dataNodes"; + +export class TableImplComp extends TableInitComp { + private prevUnevaledValue?: string; + readonly filterData: RecordType[] = []; + readonly columnAggrData: ColumnsAggrData = {}; + + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } + + downloadData(fileName: string) { + saveDataAsFile({ + data: (this as any).exposingValues["displayData"], + filename: fileName, + fileType: "csv", + delimiter: this.children.toolbar.children.columnSeparator.getView(), + }); + } + + refreshData(allQueryNames: Array, setLoading: (loading: boolean) => void) { + const deps: Array = this.children.data.exposingNode().dependNames(); + const depsQueryNames = deps.map((d) => d.split(".")[0]); + if (_.isEmpty(depsQueryNames)) { + setLoading(true); + setTimeout(() => setLoading(false), 200); + return; + } + const queryNameSet = new Set(allQueryNames); + depsQueryNames.forEach((name) => { + if (queryNameSet.has(name)) { + this.dispatch(deferAction(routeByNameAction(name, executeQueryAction({})))); + } + }); + } + + getProps() { + return childrenToProps(_.omit(this.children, "style")) as TableChildrenView; + } + + shouldGenerateColumn(comp: this, nextRowExample?: JSONObject) { + const columnKeys = comp.children.columns + .getView() + .map((col) => { + const colView = col.getView(); + if (colView.isCustom) { + return ""; + } else { + return colView.dataIndex; + } + }) + .filter((t) => !!t); + const nextUnevaledVal = comp.children.data.unevaledValue; + const prevUnevaledVal = this.prevUnevaledValue; + if (!nextRowExample) { + this.prevUnevaledValue = nextUnevaledVal; + return false; + } + let doGenColumn = false; + const nextRowKeys = Object.keys(nextRowExample); + const dynamicColumn = comp.children.dynamicColumn.getView(); + if (!prevUnevaledVal && columnKeys.length === 0) { + doGenColumn = true; + } else if (prevUnevaledVal && nextUnevaledVal !== prevUnevaledVal) { + doGenColumn = true; + } else if (dynamicColumn) { + doGenColumn = true; + } else if ( + columnKeys.length < nextRowKeys.length && + columnKeys.every((key) => nextRowKeys.includes(key)) + ) { + doGenColumn = true; + } + if (!doGenColumn) { + const toBeGenRow = comp.children.dataRowExample.getView(); + const columnKeyChanged = + columnKeys.length !== nextRowKeys.length || + !_.isEqual(_.sortBy(columnKeys), _.sortBy(nextRowKeys)); + if (columnKeyChanged && !_.isEqual(toBeGenRow, nextRowExample)) { + setTimeout(() => { + comp.children.dataRowExample.dispatchChangeValueAction(nextRowExample); + }); + } else if (!columnKeyChanged && toBeGenRow) { + setTimeout(() => { + comp.children.dataRowExample.dispatchChangeValueAction(null); + }); + } + } + this.prevUnevaledValue = nextUnevaledVal; + return doGenColumn; + } + + override reduce(action: CompAction): this { + let comp = super.reduce(action); + let dataChanged = false; + if (action.type === CompActionTypes.UPDATE_NODES_V2) { + const nextRowExample = tableDataRowExample(comp.children.data.getView()); + dataChanged = + comp.children.data !== this.children.data && + !_.isEqual(this.children.data.getView(), comp.children.data.getView()); + if (dataChanged) { + comp = comp.setChild( + "rowColor", + comp.children.rowColor.reduce( + RowColorComp.changeContextDataAction({ + currentRow: nextRowExample, + currentIndex: 0, + currentOriginalIndex: 0, + columnTitle: nextRowExample ? Object.keys(nextRowExample)[0] : undefined, + }) + ) + ); + comp = comp.setChild( + "rowHeight", + comp.children.rowHeight.reduce( + RowHeightComp.changeContextDataAction({ + currentRow: nextRowExample, + currentIndex: 0, + currentOriginalIndex: 0, + columnTitle: nextRowExample ? Object.keys(nextRowExample)[0] : undefined, + }) + ) + ); + } + + if (dataChanged) { + const doGene = comp.shouldGenerateColumn(comp, nextRowExample); + const actions: CompAction[] = []; + actions.push( + wrapChildAction( + "columns", + comp.children.columns.dataChangedAction({ + rowExample: nextRowExample || {}, + doGeneColumn: doGene, + dynamicColumn: comp.children.dynamicColumn.getView(), + data: comp.children.data.getView(), + }) + ) + ); + doGene && actions.push(comp.changeChildAction("dataRowExample", null)); + setTimeout(() => { + actions.forEach((action) => comp.dispatch(deferAction(action))); + }, 0); + } + } + + let needMoreEval = false; + + const thisSelection = getSelectedRowKeys(this.children.selection)[0] ?? "0"; + const newSelection = getSelectedRowKeys(comp.children.selection)[0] ?? "0"; + const selectionChanged = + this.children.selection !== comp.children.selection && thisSelection !== newSelection; + if ( + (action.type === CompActionTypes.CUSTOM && + comp.children.columns.getView().length !== this.children.columns.getView().length) || + selectionChanged + ) { + comp = comp.setChild( + "columns", + comp.children.columns.reduce(comp.children.columns.setSelectionAction(newSelection)) + ); + needMoreEval = true; + } + if (action.type === CompActionTypes.UPDATE_NODES_V2 && needMoreEval) { + setTimeout(() => comp.dispatch(onlyEvalAction())); + } + return comp; + } + + override extraNode() { + const extra = { + sortedData: buildSortedDataNode(this), + filterData: buildFilteredDataNode(this), + oriDisplayData: buildOriDisplayDataNode(this), + columnAggrData: buildColumnAggrNode(this), + }; + return { + node: extra, + updateNodeFields: (value: any) => ({ + filterData: value.filterData, + columnAggrData: value.columnAggrData, + }), + }; + } +} + +let TableTmpComp = withViewFn(TableImplComp, (comp) => { + return ( + + ); +}); + +const withEditorModeStatus = (Component:any) => (props:any) => { + const editorModeStatus = useContext(EditorContext).editorModeStatus; + const {ref, ...otherProps} = props; + return ; +}; + + +TableTmpComp = withPropertyViewFn(TableTmpComp, (comp) => withEditorModeStatus(compTablePropertyView)(comp)); + +TableTmpComp = withDispatchHook(TableTmpComp, (dispatch) => (action) => { + if (!dispatch) { + return; + } + if (isTriggerAction(action)) { + const context = action.value.context; + if (context && !_.isNil(context["currentOriginalIndex"])) { + const key = context["currentOriginalIndex"] + ""; + dispatch(wrapChildAction("selection", changeChildAction("selectedRowKey", key, false))); + } + } + return dispatch(action); +}); + +TableTmpComp = withMethodExposing(TableTmpComp, tableMethodExposings); + +export const TableLiteComp = withExposingConfigs(TableTmpComp, [ + new DepsConfig( + "selectedRow", + (children) => { + return { + selectedRowKey: children.selection.children.selectedRowKey.node(), + data: children.data.exposingNode(), + }; + }, + (input) => { + if (!input.data) { + return undefined; + } + return indexKeyToRecord(input.data, input.selectedRowKey); + }, + trans("table.selectedRowDesc") + ), + new DepsConfig( + "selectedRows", + (children) => { + return { + selectedRowKeys: children.selection.children.selectedRowKeys.node(), + data: children.data.exposingNode(), + }; + }, + (input) => { + if (!input.data) { + return undefined; + } + return input.selectedRowKeys.flatMap((key: string) => { + const result = indexKeyToRecord(input.data, key); + return result === undefined ? [] : [result]; + }); + }, + trans("table.selectedRowsDesc") + ), + new CompDepsConfig( + "selectedIndex", + (comp) => { + return { + oriDisplayData: buildOriDisplayDataNode(comp), + selectedRowKey: comp.children.selection.children.selectedRowKey.node(), + }; + }, + (input) => { + return toDisplayIndex(input.oriDisplayData, input.selectedRowKey); + }, + trans("table.selectedIndexDesc") + ), + new CompDepsConfig( + "selectedIndexes", + (comp) => { + return { + oriDisplayData: buildOriDisplayDataNode(comp), + selectedRowKeys: comp.children.selection.children.selectedRowKeys.node(), + }; + }, + (input) => { + return input.selectedRowKeys.flatMap((key: string) => { + const result = toDisplayIndex(input.oriDisplayData, key); + return result === undefined ? [] : [result]; + }); + }, + trans("table.selectedIndexDesc") + ), + + new DepsConfig( + "pageNo", + (children) => { + return { + pageNo: children.pagination.children.pageNo.exposingNode(), + }; + }, + (input) => input.pageNo, + trans("table.pageNoDesc") + ), + new DepsConfig( + "pageSize", + (children) => { + return { + showSizeChanger: children.pagination.children.showSizeChanger.node(), + changeablePageSize: children.pagination.children.changeablePageSize.node(), + pageSize: children.pagination.children.pageSize.node(), + pageSizeOptions: children.pagination.children.pageSizeOptions.node(), + }; + }, + (input) => { + return getPageSize( + input.showSizeChanger.value, + input.pageSize.value, + input.pageSizeOptions.value, + input.changeablePageSize + ); + }, + trans("table.pageSizeDesc") + ), + new DepsConfig( + "sortColumn", + (children) => { + return { + sort: children.sort.node(), + columns: children.columns.node()!, + }; + }, + (input) => { + const sortIndex = input.sort[0]?.column; + const column = Object.values(input.columns as any).find( + (c: any) => c.dataIndex === sortIndex + ) as any; + if (column?.isCustom && column?.title.value) { + return column.title.value; + } else { + return sortIndex; + } + }, + trans("table.sortColumnDesc") + ), + new DepsConfig( + "sortColumns", + (children) => { + return { + sort: children.sort.node(), + }; + }, + (input) => { + return input.sort; + }, + trans("table.sortColumnDesc") + ), + depsConfig({ + name: "sortDesc", + desc: trans("table.sortDesc"), + depKeys: ["sort"], + func: (input) => { + return input.sort[0]?.desc || false; + }, + }), + new DepsConfig( + "pageOffset", + (children) => { + return { + showSizeChanger: children.pagination.children.showSizeChanger.node(), + changeablePageSize: children.pagination.children.changeablePageSize.node(), + pageSize: children.pagination.children.pageSize.node(), + pageSizeOptions: children.pagination.children.pageSizeOptions.node(), + pageNo: children.pagination.children.pageNo.node(), + }; + }, + (input) => { + return ( + getPageSize( + input.showSizeChanger.value, + input.pageSize.value, + input.pageSizeOptions.value, + input.changeablePageSize + ) * + (input.pageNo - 1) + ); + }, + trans("table.pageOffsetDesc") + ), + new CompDepsConfig( + "displayData", + (comp) => { + return { + oriDisplayData: buildOriDisplayDataNode(comp), + dataIndexes: comp.children.columns.getColumnsNode("dataIndex"), + titles: comp.children.columns.getColumnsNode("title"), + hides: comp.children.columns.getColumnsNode("hide"), + tempHides: comp.children.columns.getColumnsNode("tempHide"), + columnSetting: comp.children.toolbar.children.columnSetting.node(), + }; + }, + (input) => { + const dataIndexTitleDict = _(input.dataIndexes) + .pickBy( + (_1, idx) => + !columnHide({ + hide: input.hides[idx].value, + tempHide: input.tempHides[idx], + enableColumnSetting: input.columnSetting.value, + }) + ) + .mapValues((_dataIndex, idx) => input.titles[idx]?.value) + .mapKeys((_title, idx) => input.dataIndexes[idx]) + .value(); + return transformDispalyData(input.oriDisplayData, dataIndexTitleDict); + }, + trans("table.displayDataDesc") + ), + new DepsConfig( + "selectedCell", + (children) => { + return { + selectedCell: children.selectedCell.node(), + }; + }, + (input) => { + return input.selectedCell; + }, + trans("table.selectedCellDesc") + ), + new NameConfig("data", trans("table.dataDesc")), +]); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx new file mode 100644 index 0000000000..c35fcc0ba3 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx @@ -0,0 +1,251 @@ +import { TableToolbar } from "./tableToolbarComp"; +import { TableEventOptionValues } from "./tableTypes"; +import { COL_MIN_WIDTH, columnsToAntdFormat, onTableChange } from "./tableUtils"; +import { CompNameContext, EditorContext } from "comps/editorState"; +import { trans } from "i18n"; +import React, { useCallback, useContext, useMemo, useState, useRef, useEffect } from "react"; +import { TableImplComp } from "./tableComp"; +import { EmptyContent } from "pages/common/styledComponent"; +import { TableSummary } from "./tableSummaryComp"; +import { ThemeContext } from "@lowcoder-ee/comps/utils/themeContext"; +import TableRenderer from "./parts/TableRenderer"; +import { useContainerHeight, useTableMode, useTableHeights, useVirtualization } from "./hooks/useTableConfiguration"; +import { ToolbarStyleProvider } from "./styles/ToolbarStyles"; +import { TableContainer } from "./parts/TableContainer"; + +export const TableCompView = React.memo((props: { + comp: InstanceType; + onRefresh: (allQueryNames: Array, setLoading: (loading: boolean) => void) => void; + onDownload: (fileName: string) => void; +}) => { + const editorState = useContext(EditorContext); + const currentTheme = useContext(ThemeContext)?.theme; + const showDataLoadingIndicators = currentTheme?.showDataLoadingIndicators; + + const compName = useContext(CompNameContext); + + const { comp, onDownload, onRefresh } = props; + const compChildren = comp.children; + const hideToolbar = compChildren.hideToolbar.getView(); + const columnsStyle = compChildren.columnsStyle.getView(); + const summaryRowStyle = compChildren.summaryRowStyle.getView(); + const style = compChildren.style.getView(); + const rowStyle = compChildren.rowStyle.getView(); + const headerStyle = compChildren.headerStyle.getView(); + const toolbarStyle = compChildren.toolbarStyle.getView(); + const showHRowGridBorder = compChildren.showHRowGridBorder.getView(); + const columns = useMemo(() => compChildren.columns.getView(), [compChildren.columns]); + const columnViews = useMemo(() => columns.map((c) => c.getView()), [columns]); + const data = comp.filterData; + const sort = useMemo(() => compChildren.sort.getView(), [compChildren.sort]); + const toolbar = useMemo(() => compChildren.toolbar.getView(), [compChildren.toolbar]); + const showSummary = useMemo(() => compChildren.showSummary.getView(), [compChildren.showSummary]); + const summaryRows = useMemo(() => compChildren.summaryRows.getView(), [compChildren.summaryRows]); + const pagination = useMemo(() => compChildren.pagination.getView(), [compChildren.pagination]); + const size = useMemo(() => compChildren.size.getView(), [compChildren.size]); + const onEvent = useMemo(() => compChildren.onEvent.getView(), [compChildren.onEvent]); + const dynamicColumn = compChildren.dynamicColumn.getView(); + const [loading, setLoading] = useState(false); + const autoHeight = compChildren.autoHeight.getView(); + const rowAutoHeight = compChildren.rowAutoHeight.getView(); + const showHeader = !compChildren.hideHeader.getView(); + const stickyToolbar = !!toolbar.fixedToolbar; // use toolbar setting + + + // NEW: Use hooks for clean logic + const { mode, isFixedMode } = useTableMode(autoHeight); + const { containerHeight, containerRef } = useContainerHeight(isFixedMode); + const heights = useTableHeights(mode as 'AUTO' | 'FIXED', containerHeight, { + showToolbar: !hideToolbar, + showHeader: showHeader, + toolbarHeight: 48, + headerHeight: 40, + stickyToolbar, + }); + const virtualization = useVirtualization( + heights.canVirtualize, + data?.length ?? 0, + 50 + ); + + + const dynamicColumnConfig = useMemo( + () => compChildren.dynamicColumnConfig.getView(), + [compChildren.dynamicColumnConfig] + ); + const columnsAggrData = comp.columnAggrData; + const headerFilters = useMemo(() => compChildren.headerFilters.getView(), [compChildren.headerFilters]); + + + + const antdColumns = useMemo( + () => + columnsToAntdFormat( + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + onEvent, + headerFilters, + ), + [ + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + headerFilters, + ] + ); + + const pageDataInfo = useMemo(() => { + let pagedData = data; + let current = pagination.current; + const total = pagination.total || data.length; + if (data.length > pagination.pageSize) { + let offset = (current - 1) * pagination.pageSize; + if (offset >= total) { + current = 1; + offset = 0; + } + pagedData = pagedData.slice(offset, offset + pagination.pageSize); + } + + return { + total: total, + current: current, + data: pagedData, + }; + }, [pagination, data]); + + const handleChangeEvent = useCallback( + (eventName: TableEventOptionValues) => { + compChildren.onEvent.getView()(eventName); + }, + [compChildren.onEvent] + ); + + + + const toolbarView = !hideToolbar && ( + + + onRefresh( + editorState.queryCompInfoList().map((info) => info.name), + setLoading + ) + } + onDownload={() => { + handleChangeEvent("download"); + onDownload(`${compName}-data`) + }} + onEvent={onEvent} + /> + + ); + + const summaryView = () => { + if (!showSummary) return undefined; + return ( + + ); + }; + + if (antdColumns.length === 0) { + return ( + + + + ); + } + const showTableLoading = + loading || + ((showDataLoadingIndicators) && (compChildren.data as any).isLoading()) || + compChildren.loading.getView(); + + return ( + + + + {...compChildren.selection.getView()(onEvent)} + bordered={compChildren.showRowGridBorder.getView()} + onChange={(pagination: any, filters: any, sorter: any, extra: any) => { + onTableChange(pagination, filters, sorter, extra, comp.dispatch, onEvent); + }} + showHeader={showHeader} + columns={antdColumns} + dataSource={pageDataInfo.data} + size={size} + tableLayout="fixed" + pagination={false} + summary={summaryView} + viewModeResizable={compChildren.viewModeResizable.getView()} + rowColorFn={compChildren.rowColor.getView() as any} + rowHeightFn={compChildren.rowHeight.getView() as any} + columnsStyle={columnsStyle} + rowAutoHeight={rowAutoHeight} + customLoading={showTableLoading} + onCellClick={(columnName: string, dataIndex: string) => { + comp.children.selectedCell.dispatchChangeValueAction({ + name: columnName, + dataIndex: dataIndex, + }); + }} + mode={mode as 'AUTO' | 'FIXED'} + heights={heights} + virtualizationConfig={virtualization} + style={style} + toolbarStyle={toolbarStyle} + headerStyle={headerStyle} + rowStyle={rowStyle} + fixedHeader={compChildren.fixedHeader.getView()} + showHRowGridBorder={showHRowGridBorder} + showVerticalScrollbar={compChildren.showVerticalScrollbar.getView()} + showHorizontalScrollbar={compChildren.showHorizontalScrollbar.getView()} + /> + + + ); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx new file mode 100644 index 0000000000..68c5a56fab --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx @@ -0,0 +1,7 @@ +import React from "react"; +import _ from "lodash"; + +export const TableRowContext = React.createContext<{ + hover: boolean; + selected: boolean; +}>({ hover: false, selected: false }); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx new file mode 100644 index 0000000000..d38cdb9877 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx @@ -0,0 +1,241 @@ +import { TableComp } from "comps/comps/tableComp/tableComp"; +import { columnsToAntdFormat } from "comps/comps/tableComp/tableUtils"; +import { evalAndReduce } from "comps/utils"; +import { reduceInContext } from "comps/utils/reduceContext"; +import _ from "lodash"; +import { changeChildAction, fromValue, SimpleNode } from "lowcoder-core"; +import { JSONObject } from "util/jsonTypes"; + +const expectColumn = ( + comp: InstanceType, + expectValues: Array<{ + dataIndex: string; + title: string; + hide?: boolean; + isCustom?: boolean; + }> +) => { + const columns = comp.children.columns.getView(); + const columnViews = columns.map((c) => c.getView()); + expect(expectValues.length).toEqual(columnViews.length); + expectValues.forEach((val) => { + const column = columnViews.find((c) => c.dataIndex === val.dataIndex); + if (!column) { + throw new Error(`expect column: ${JSON.stringify(val)}, but not found.`); + } + Object.keys(val).forEach((key) => { + const colVal = (column as any)[key]; + const expectVal = (val as any)[key]; + if (expectVal !== undefined) { + if (!_.isEqual(colVal, expectVal)) { + throw new Error(`ColumnKey:${key}, expect: "${expectVal}", but found: "${colVal}"`); + } + } + }); + }); + // with dynamic config + const dynamicColumnConfig = comp.children.dynamicColumnConfig.getView(); + if (dynamicColumnConfig?.length > 0) { + const onEvent = (eventName: any) => {}; + const antdColumns = columnsToAntdFormat( + columnViews, + comp.children.sort.getView(), + comp.children.toolbar.getView().columnSetting, + comp.children.size.getView(), + comp.children.dynamicColumn.getView(), + dynamicColumnConfig, + comp.columnAggrData, + comp.children.editModeClicks.getView(), + onEvent, + ); + expect(columnViews.length).toBeGreaterThanOrEqual(antdColumns.length); + antdColumns.forEach((column) => { + const dataIndex = (column as any).dataIndex; + const colView = columnViews.find((c) => c.dataIndex === dataIndex); + if (!colView) { + throw new Error(`Error, column should not be undefined, column: ${JSON.stringify(column)}`); + } + const configName = colView.isCustom ? colView.title : colView.dataIndex; + if (!dynamicColumnConfig.includes(configName)) { + throw new Error(`dynamic config test fail: unexpect column: ${configName}`); + } + }); + } +}; + +function getTableInitData() { + const exposingInfo: Record> = { + query1: fromValue({ data: [{ q1: 1 }] }), + query2: fromValue({ data: [{ q2: 2 }] }), + }; + return { + tableData: { + data: JSON.stringify([{ a: 1 }]), + columns: [ + { + dataIndex: "a", + title: "a", + hide: true, + }, + { + title: "custom", + dataIndex: "custom1", + isCustom: true, + }, + ], + }, + exposingInfo: exposingInfo, + initColumns: [ + { + dataIndex: "a", + hide: true, + title: "a", + }, + { + dataIndex: "custom1", + hide: false, + title: "custom", + isCustom: true, + }, + ], + }; +} + +async function sleep() { + await new Promise((r) => setTimeout(r, 20)); +} + +test("test table dynamic columns: Change unEvalValue", async () => { + // 0. Init check + const { initColumns, tableData, exposingInfo } = getTableInitData(); + let comp = new TableComp({ + dispatch: (action) => { + comp = evalAndReduce(comp.reduce(action), exposingInfo); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + expectColumn(comp, initColumns); + /** 1. Change unEvalValue data, change column whatever **/ + // 1.1 add column c & d + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", JSON.stringify([{ a: 1, c: 2, d: 3 }]))) + ); + await sleep(); + const columnsAfterAdd = [ + ...initColumns, + { + dataIndex: "c", + hide: false, + title: "c", + }, + { + dataIndex: "d", + title: "d", + }, + ]; + expectColumn(comp, columnsAfterAdd); + // 1.2 del column a + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", JSON.stringify([{ c: 2, d: 3 }]))) + ); + await sleep(); + expectColumn( + comp, + columnsAfterAdd.filter((c) => c.dataIndex !== "a") + ); +}, 1000); + +async function dynamicColumnsTest( + dynamicColumn: boolean, + isViewMode: boolean, + dynamicConfigs?: Array +) { + const { initColumns, tableData, exposingInfo } = getTableInitData(); + // init comp + let comp = new TableComp({ + dispatch: (action) => { + let tmpComp; + if (isViewMode) { + tmpComp = reduceInContext({ readOnly: isViewMode }, () => comp.reduce(action)); + } else { + tmpComp = comp.reduce(action); + } + comp = evalAndReduce(tmpComp, exposingInfo); + }, + value: { + ...tableData, + dynamicColumn: dynamicColumn, + ...(dynamicColumn && + dynamicConfigs && { dynamicColumnConfig: JSON.stringify(dynamicConfigs) }), + }, + }); + comp = evalAndReduce(comp); + + const updateTableComp = async () => { + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", "{{query1.data}}")), + exposingInfo + ); + await sleep(); + }; + // change data to query1 + const query1Columns = [ + { + dataIndex: "q1", + title: "q1", + }, + { + dataIndex: "custom1", + title: "custom", + isCustom: true, + }, + ]; + await updateTableComp(); + if (!dynamicColumn && isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, query1Columns); + } + // change query data, add column: a + const addData: Array = [{ q1: 1, a: 2 }]; + exposingInfo.query1 = fromValue({ data: addData }); + await updateTableComp(); + const columnsAfterAdd = [ + ...query1Columns, + { + dataIndex: "a", + title: "a", + }, + ]; + expect(comp.children.data.getView()).toEqual(addData); + if (!dynamicColumn && isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, columnsAfterAdd); + } + // change query data, del column: q1 + const delData = [{ a: 2 }]; + exposingInfo.query1 = fromValue({ data: delData }); + await updateTableComp(); + expect(comp.children.data.getView()).toEqual(delData); + if (dynamicColumn) { + expectColumn( + comp, + columnsAfterAdd.filter((c) => c.dataIndex !== "q1") + ); + } else if (isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, columnsAfterAdd); + } +} + +test("test table dynamic columns", async () => { + await dynamicColumnsTest(false, false); + await dynamicColumnsTest(false, true); + await dynamicColumnsTest(true, false); + await dynamicColumnsTest(true, true); + await dynamicColumnsTest(true, false, ["custom", "q1"]); + await dynamicColumnsTest(true, true, ["custom", "q1"]); +}, 2000); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx new file mode 100644 index 0000000000..62f3f1ffbc --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx @@ -0,0 +1,624 @@ +import { + ColumnCompType, + newCustomColumn, + RawColumnType, +} from "./column/tableColumnComp"; +import { hiddenPropertyView, loadingPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { changeValueAction, deferAction, MultiBaseComp, wrapChildAction } from "lowcoder-core"; +import { + BluePlusIcon, + CheckBox, + CloseEyeIcon, + controlItem, + CustomModal, + Dropdown, + labelCss, + LinkButton, + OpenEyeIcon, + Option, + OptionItem, + RedButton, + RefreshIcon, + Section, + sectionNames, + TextLabel, + ToolTipLabel, +} from "lowcoder-design"; +import { tableDataDivClassName } from "pages/tutorials/tutorialsConstant"; +import styled, { css } from "styled-components"; +import { getSelectedRowKeys } from "./selectionControl"; +import { TableChildrenType } from "./tableTypes"; +import React, { useMemo, useState, useCallback } from "react"; +import { GreyTextColor } from "constants/style"; +import { alignOptions } from "comps/controls/dropdownControl"; +import { ColumnTypeCompMap } from "./column/columnTypeComp"; +import Segmented from "antd/es/segmented"; +import { CheckboxChangeEvent } from "antd/es/checkbox"; + +const InsertDiv = styled.div` + display: flex; + justify-content: end; + width: 100%; + gap: 8px; + align-items: center; +`; +const Graylabel = styled.span` + ${labelCss}; + color: #8b8fa3; +`; + +const StyledRefreshIcon = styled(RefreshIcon)` + width: 16px; + height: 16px; + cursor: pointer; + + &:hover { + g g { + stroke: #4965f2; + } + } +`; + +const eyeIconCss = css` + height: 16px; + width: 16px; + display: inline-block; + + &:hover { + cursor: pointer; + } + + &:hover path { + fill: #315efb; + } +`; + +const CloseEye = styled(CloseEyeIcon)` + ${eyeIconCss} +`; +const OpenEye = styled(OpenEyeIcon)` + ${eyeIconCss} +`; + +const ColumnDropdown = styled(Dropdown)` + width: 100px; + + &, + > div { + height: 22px; + } + + .ant-segmented-item-label { + height: 18px; + min-height: 18px; + line-height: 18px; + padding: 0; + } +`; + +const ColumnBatchOptionWrapper = styled.div` + display: flex; + align-items: center; + color: ${GreyTextColor}; + line-height: 16px; + font-size: 13px; +`; + +type ViewOptionType = "normal" | "summary"; + +const summaryRowOptions = [ + { + label: "Row 1", + value: 0, + }, + { + label: "Row 2", + value: 1, + }, + { + label: "Row 3", + value: 2, + }, +]; + +const columnViewOptions = [ + { + label: "Normal", + value: "normal", + }, + { + label: "Summary", + value: "summary", + }, +]; + +const columnFilterOptions = [ + { label: trans("table.allColumn"), value: "all" }, + { label: trans("table.visibleColumn"), value: "visible" }, +]; +type ColumnFilterOptionValueType = typeof columnFilterOptions[number]["value"]; + +const columnBatchOptions = [ + { + label: trans("prop.hide"), + value: "hide", + }, + { + label: trans("table.autoWidth"), + value: "autoWidth", + }, + { + label: trans("table.sortable"), + value: "sortable", + }, + { + label: trans("table.align"), + value: "align", + }, +] as const; + +type ColumnBatchOptionValueType = typeof columnBatchOptions[number]["value"]; + +const HideIcon = React.memo((props: { hide: boolean; setHide: (hide: boolean) => void }) => { + const { hide, setHide } = props; + const Eye = hide ? CloseEye : OpenEye; + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setHide(!hide); + }, [hide, setHide]); + + return ; +}); + +HideIcon.displayName = 'HideIcon'; + +function ColumnBatchCheckBox({ + childrenKey, + column, + convertValueFunc, +}: { + childrenKey: T; + column: ColumnCompType | Array; + convertValueFunc?: (checked: boolean) => RawColumnType[T]; +}) { + const isChecked = useCallback((column: ColumnCompType) => { + if (childrenKey === "autoWidth") { + return column.children.autoWidth.getView() === "auto"; + } else { + return column.children[childrenKey].getView(); + } + }, [childrenKey]); + + const convertValue = useCallback((checked: boolean) => + convertValueFunc ? convertValueFunc(checked) : checked, + [convertValueFunc] + ); + + const isBatch = Array.isArray(column); + const columns = isBatch ? column : [column]; + + const disabledStatus = useMemo(() => columns.map((c) => { + return false; + }), [columns, childrenKey]); + + const { allChecked, allNotChecked } = useMemo(() => { + let allChecked = true; + let allNotChecked = true; + + columns.forEach((c, index) => { + if (disabledStatus[index]) { + if (!isBatch) { + allChecked = false; + } + return; + } + if (isChecked(c)) { + allNotChecked = false; + } else { + allChecked = false; + } + }); + + return { allChecked, allNotChecked }; + }, [columns, disabledStatus, isBatch, isChecked]); + + const onCheckChange = useCallback((checked: boolean) => { + columns.forEach( + (c, index) => + !disabledStatus[index] && + c.children[childrenKey].dispatch( + deferAction(changeValueAction(convertValue(checked) as any, true)) + ) + ); + }, [columns, disabledStatus, childrenKey, convertValue]); + + if (childrenKey === "hide") { + return ; + } + + return ( + { + onCheckChange(e.target.checked); + }} + /> + ); +} + +const ColumnBatchView: Record< + ColumnBatchOptionValueType, + (column: ColumnCompType | Array) => JSX.Element +> = { + hide: (column) => , + sortable: (column) => , + autoWidth: (column) => ( + (checked ? "auto" : "fixed")} + /> + ), + align: (column) => { + const columns = Array.isArray(column) ? column : [column]; + const value = Array.isArray(column) ? undefined : column.children.align.getView(); + return ( + { + columns.forEach((c) => + c.children.align.dispatch(deferAction(changeValueAction(value, true))) + ); + }} + /> + ); + }, +}; + +function ColumnPropertyView>(props: { + comp: T; + columnLabel: string; +}) { + const { comp } = props; + const [viewMode, setViewMode] = useState('normal'); + const [summaryRow, setSummaryRow] = useState(0); + const [columnFilterType, setColumnFilterType] = useState("all"); + const [columnBatchType, setColumnBatchType] = useState("hide"); + + const selection = useMemo(() => getSelectedRowKeys(comp.children.selection)[0] ?? "0", [comp.children.selection]); + const columns = useMemo(() => comp.children.columns.getView(), [comp.children.columns]); + const rowExample = useMemo(() => comp.children.dataRowExample.getView(), [comp.children.dataRowExample]); + const dynamicColumn = useMemo(() => comp.children.dynamicColumn.getView(), [comp.children.dynamicColumn]); + const data = useMemo(() => comp.children.data.getView(), [comp.children.data]); + const columnOptionItems = useMemo( + () => columns.filter((c) => columnFilterType === "all" || !c.children.hide.getView()), + [columnFilterType, columns] + ); + const summaryRows = parseInt(comp.children.summaryRows.getView()); + + const handleViewModeChange = useCallback((value: string) => { + setViewMode(value as ViewOptionType); + }, []); + + const handleSummaryRowChange = useCallback((value: number) => { + setSummaryRow(value); + }, []); + + const handleColumnFilterChange = useCallback((value: ColumnFilterOptionValueType) => { + setColumnFilterType(value); + }, []); + + const handleColumnBatchTypeChange = useCallback((value: ColumnBatchOptionValueType) => { + setColumnBatchType(value); + }, []); + + const columnOptionToolbar = ( + +
+ + {" (" + columns.length + ")"} +
+ {rowExample && ( + + { + // console.log("comp", comp); + comp.dispatch( + wrapChildAction( + "columns", + comp.children.columns.dataChangedAction({ + rowExample, + doGeneColumn: true, + dynamicColumn: dynamicColumn, + data: data, + }) + ) + ); + // the function below is not working + // comp.dispatch(comp.changeChildAction("dataRowExample", null)); + }} + /> + + )} + } + text={trans("addItem")} + onClick={() => { + comp.children.columns.dispatch(comp.children.columns.pushAction(newCustomColumn())); + }} + /> +
+ ); + + return ( + <> +