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 000000000..ff3df44c4 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx @@ -0,0 +1,170 @@ +import { CellProps } from "components/table/EditableCell"; +import { DateTimeComp } from "comps/comps/tableComp/column/columnTypeComps/columnDateTimeComp"; +import { TimeComp } from "./columnTypeComps/columnTimeComp"; +import { ButtonComp } from "comps/comps/tableComp/column/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"; + +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 000000000..b401761d2 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx @@ -0,0 +1,212 @@ +import { + CellViewReturn, + EditableCell, + EditViewFn, + TABLE_EDITABLE_SWITCH_ON, +} 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; + +type NewChildrenCtorMap = ChildrenCtorMap & { + changeValue: ReturnType>; +}; + +export type ColumnTypeViewFn = ViewFnTypeForComp< + ViewReturn, + RecordConstructorToComp> +>; + +export class ColumnTypeCompBuilder< + ChildrenCtorMap extends Record>, + T extends JSONValue = JSONValue +> { + private childrenMap: NewChildrenCtorMap; + private propertyViewFn?: PropertyViewFnTypeForComp< + RecordConstructorToComp> + >; + private stylePropertyViewFn?: PropertyViewFnTypeForComp< + RecordConstructorToComp> + >; + private editViewFn?: EditViewFn; + private cleanupFunctions: (() => void)[] = []; + + constructor( + childrenMap: ChildrenCtorMap, + private viewFn: ColumnTypeViewFn, + private displayValueFn: ViewValueFnType, + private baseValueFn?: ColumnTypeViewFn + ) { + this.childrenMap = { ...childrenMap, changeValue: stateComp(null) }; + } + + setEditViewFn(editViewFn: NonNullable) { + if (TABLE_EDITABLE_SWITCH_ON) { + this.editViewFn = editViewFn; + } + 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 ( + + {...props} + normalView={normalView} + dispatch={dispatch} + baseValue={baseValue} + changeValue={props.changeValue as any} + editViewFn={this.editViewFn} + /> + ); + }, + (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 ToConstructor< + RecordConstructorToComp> + >, + viewFn + ) + .setPropertyViewFn(this.propertyViewFn) + .build(); + + const displayValueFn = this.displayValueFn; + const editViewFn = this.editViewFn; + + 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; + } + + static canBeEditable() { + return !_.isNil(editViewFn); + } + + 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 000000000..619b42674 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx @@ -0,0 +1,224 @@ +import React, { useState, useRef, useEffect, 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 InputNumberWrapper = styled.div` + .ant-input-number { + width: 100%; + border-radius: 0; + background: transparent !important; + // padding: 0 !important; + box-shadow: none; + + input { + padding: 0; + border-radius: 0; + } + } +`; + +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; +}; + +type NumberEditProps = { + value: number; + onChange: (value: number) => void; + onChangeEnd: () => void; + step: number; + precision: number; + float: boolean; +}; + +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'; + + +const ColumnNumberEdit = React.memo((props: NumberEditProps) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(0); + }; + }, []); + + const handleChange = useCallback((value: string | number | null) => { + if (!mountedRef.current) return; + const newValue = typeof value === 'number' ? value : 0; + const finalValue = !props.float ? Math.floor(newValue) : newValue; + props.onChange(finalValue); + setCurrentValue(finalValue); + }, [props.onChange, props.float]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + return ( + + + + ); +}); + +ColumnNumberEdit.displayName = 'NumberEdit'; + +export const ColumnNumberComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + const { value, onChange, onChangeEnd, otherProps } = props; + return ( + + ); + }) + .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 000000000..f02ee1994 --- /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 000000000..d1d530eb6 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx @@ -0,0 +1,201 @@ +import React, { useCallback, useRef, useEffect, useMemo } from "react"; +import { BoolCodeControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { default as Checkbox, CheckboxChangeEvent } from "antd/es/checkbox"; +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 CheckboxStyled = styled(Checkbox)<{ $style: CheckboxStyleType }>` + ${(props) => props.$style && getStyle(props.$style)} +`; + +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; + +type CheckBoxEditPropsType = { + value: boolean; + onChange: (value: boolean) => void; + onChangeEnd: () => void; +}; + +// Memoized checkbox edit component +const CheckBoxEdit = React.memo((props: CheckBoxEditPropsType) => { + const mountedRef = useRef(true); + const style = useStyle(CheckboxStyle); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!mountedRef.current) return; + if (e.key === "Enter") { + props.onChangeEnd(); + } + }, [props.onChangeEnd]); + + const handleChange = useCallback((e: CheckboxChangeEvent) => { + if (!mountedRef.current) return; + props.onChange(e.target.checked); + }, [props.onChange]); + + return ( + + + + ); +}); + +CheckBoxEdit.displayName = 'CheckBoxEdit'; + +// 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 = props.changeValue ?? getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + + ); + }) + .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 000000000..55168b151 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx @@ -0,0 +1,283 @@ +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, { Dayjs } from "dayjs"; +import utc from "dayjs/plugin/utc"; +import { CalendarCompIconSmall, PrevIcon, SuperPrevIcon } from "lowcoder-design"; +import React, { useCallback, useEffect, useRef, useState } 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; +`; + +const Wrapper = styled.div` + background: transparent !important; +`; + +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; + +type DateEditProps = { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + showTime: boolean; + inputFormat: string; +}; + +// Memoized DateEdit component +export const DateEdit = React.memo((props: DateEditProps) => { + const pickerRef = useRef(); + const mountedRef = useRef(true); + const [panelOpen, setPanelOpen] = useState(true); + + // Initialize tempValue with proper validation + const [tempValue, setTempValue] = useState(() => { + const initialValue = dayjs(props.value, DateParser); + return initialValue.isValid() ? initialValue : dayjs(0, DateParser); + }); + + // Memoize event handlers + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!mountedRef.current) return; + if (e.key === "Enter" && !panelOpen) { + props.onChangeEnd(); + } + }, [panelOpen, props.onChangeEnd]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (!mountedRef.current) return; + e.stopPropagation(); + e.preventDefault(); + }, []); + + const handleOpenChange = useCallback((open: boolean) => { + if (!mountedRef.current) return; + setPanelOpen(open); + }, []); + + const handleChange = useCallback((value: dayjs.Dayjs | null, dateString: string | string[]) => { + if (!mountedRef.current) return; + props.onChange(dateString as string); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + // Update tempValue when props.value changes + useEffect(() => { + if (!mountedRef.current) return; + + const newValue = props.value ? dayjs(props.value, DateParser) : null; + if (newValue?.isValid()) { + setTempValue(newValue); + } + }, [props.value]); + + // Cleanup event listeners and state + useEffect(() => { + return () => { + mountedRef.current = false; + setTempValue(null); + if (pickerRef.current) { + pickerRef.current = null; + } + }; + }, []); + + return ( + + } + prevIcon={} + nextIcon={} + superNextIcon={} + superPrevIcon={} + format={props.inputFormat} + allowClear={true} + variant="borderless" + autoFocus + value={tempValue} + showTime={props.showTime} + showNow={true} + defaultOpen={true} + panelRender={(panelNode) => ( + + {panelNode} + + )} + onOpenChange={handleOpenChange} + onChange={(date: unknown, dateString: string | string[]) => handleChange(date as Dayjs | null, dateString)} + onBlur={handleBlur} + /> + + ); +}); + +DateEdit.displayName = 'DateEdit'; + +export const DateComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return formatDate(value, props.format); + }, + (nodeValue) => formatDate(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .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 000000000..181fccba7 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx @@ -0,0 +1,90 @@ +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 { DateEdit, formatDate } from "./columnDateComp"; +import React, { useCallback, useEffect, useRef } from "react"; + +const childrenMap = { + text: StringControl, + format: withDefault(StringControl, DATE_TIME_FORMAT), + inputFormat: withDefault(StringControl, DATE_TIME_FORMAT), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +// Memoized DateTimeEdit component +const DateTimeEdit = React.memo((props: { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + inputFormat: string; +}) => { + const mountedRef = useRef(true); + + // Memoize event handlers + const handleChange = useCallback((value: string) => { + if (!mountedRef.current) return; + props.onChange(value); + }, [props.onChange]); + + const handleChangeEnd = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + return ( + + ); +}); + +DateTimeEdit.displayName = 'DateTimeEdit'; + +export const DateTimeComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return formatDate(value, props.format); + }, + (nodeValue) => formatDate(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .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 000000000..b78601a5f --- /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 000000000..d3d204101 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx @@ -0,0 +1,143 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Input } from "antd/es/input"; +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'; + +// Memoized edit component +const ImageEdit = React.memo(({ value, onChange, onChangeEnd }: { value: string; onChange: (value: string) => void; onChangeEnd: () => void }) => { + const mountedRef = useRef(true); + const [currentValue, setCurrentValue] = useState(value); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((e: React.ChangeEvent) => { + if (mountedRef.current) { + const newValue = e.target.value; + setCurrentValue(newValue); + onChange(newValue); + } + }, [onChange]); + + const handleBlur = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + return ( + + ); +}); + +ImageEdit.displayName = 'ImageEdit'; + +const getBaseValue: ColumnTypeViewFn = (props) => props.src; + +export const ImageComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.src.value, + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .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 000000000..e93b3082a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx @@ -0,0 +1,145 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Input } from "antd/es/input"; +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'; + +// Memoized edit component +const LinkEdit = React.memo(({ value, onChange, onChangeEnd }: { value: string; onChange: (value: string) => void; onChangeEnd: () => void }) => { + const mountedRef = useRef(true); + const [currentValue, setCurrentValue] = useState(value); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((e: React.ChangeEvent) => { + if (mountedRef.current) { + const newValue = e.target.value; + setCurrentValue(newValue); + onChange(newValue); + } + }, [onChange]); + + const handleBlur = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (mountedRef.current) { + onChangeEnd(); + } + }, [onChangeEnd]); + + return ( + + ); +}); + +LinkEdit.displayName = 'LinkEdit'; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +const LinkCompTmp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .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 000000000..5a7fae3d3 --- /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 000000000..17ad78efd --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx @@ -0,0 +1,128 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Input } from "antd/es/input"; +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'; + +// Memoized edit component with proper cleanup +const MarkdownEdit = React.memo((props: { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; +}) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(''); + }; + }, []); + + const handleChange = useCallback((e: React.ChangeEvent) => { + if (!mountedRef.current) return; + const value = e.target.value; + props.onChange(value); + setCurrentValue(value); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + return ( + + ); +}); + +MarkdownEdit.displayName = 'MarkdownEdit'; + +export const ColumnMarkdownComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .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 000000000..7e06f4c8e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx @@ -0,0 +1,166 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { NumberControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { default as InputNumber } from "antd/es/input-number"; +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 { TableMinusIcon, TablePlusIcon } from "lowcoder-design"; +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 InputNumberStyled = styled(InputNumber)` + background: transparent !important; + width: 100%; + height: 100%; + position: absolute; + top: 0; + .ant-input-number-input-wrap { + height: 100%; + display: flex; + align-items: center; + } + .ant-input-number-handler-wrap { + top: 1.5px; + right: 1.5px; + height: calc(100% - 3px); + border-radius: 0; + } + .ant-input-number-handler-up { + border-bottom: 1px solid #d7d9e0; + } + .ant-input-number-handler-up, + .ant-input-number-handler-down { + display: flex; + align-items: center; + justify-content: center; + > span { + width: 16px; + height: 16px; + margin-top: 0; + position: unset; + transform: none; + } + &:hover { + &:not(.ant-input-number-handler-up-disabled):not(.ant-input-number-handler-down-disabled) + path { + fill: #315efb; + } + } + } +`; + +const childrenMap = { + text: NumberControl, + showValue: BoolControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type ProgressEditProps = { + value: number; + onChange: (value: number) => void; + onChangeEnd: () => void; +}; + +const ProgressEdit = React.memo((props: ProgressEditProps) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(0); + }; + }, []); + + const handleChange = useCallback((value: string | number | null) => { + if (!mountedRef.current) return; + const newValue = typeof value === 'number' ? value : 0; + props.onChange(newValue); + setCurrentValue(newValue); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + const handlePressEnter = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + }, [props.onChangeEnd]); + + return ( + , downIcon: }} + onChange={handleChange} + onBlur={handleBlur} + onPressEnter={handlePressEnter} + /> + ); +}); + +ProgressEdit.displayName = 'ProgressEdit'; + +export const ProgressComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + const Progress = () => { + const style = useStyle(ProgressStyle); + return ( + + ); + }; + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + + ); + }) + .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 000000000..fc44cd936 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx @@ -0,0 +1,139 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } 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 Wrapper = styled.div` + background: transparent !important; + padding: 0 8px; +`; + +const childrenMap = { + text: NumberControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.text; + +type RatingEditProps = { + value: number; + onChange: (value: number) => void; + onChangeEnd: () => void; +}; + +const RatingEdit = React.memo((props: RatingEditProps) => { + const [currentValue, setCurrentValue] = useState(props.value); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(0); + }; + }, []); + + const handleChange = useCallback((value: number) => { + if (!mountedRef.current) return; + props.onChange(value); + setCurrentValue(value); + }, [props.onChange]); + + const handleBlur = useCallback((e: React.FocusEvent) => { + if (!mountedRef.current) return; + if (!e.currentTarget?.contains(e.relatedTarget)) { + props.onChangeEnd(); + } + }, [props.onChangeEnd]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!mountedRef.current) return; + if (e.key === "Enter") { + props.onChangeEnd(); + } + }, [props.onChangeEnd]); + + return ( + + + + ); +}); + +RatingEdit.displayName = 'RatingEdit'; + +export const RatingComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ; + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + + ); + }) + .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 000000000..b54be8799 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx @@ -0,0 +1,232 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } 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; + +type SelectEditProps = { + initialValue: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + options: any[]; + onMainEvent?: (eventName: string) => void; +}; + +const SelectEdit = React.memo((props: SelectEditProps) => { + const [currentValue, setCurrentValue] = useState(props.initialValue); + const mountedRef = useRef(true); + const defaultProps: any = {}; + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setCurrentValue(''); + }; + }, []); + + const handleChange = useCallback((val: string) => { + if (!mountedRef.current) return; + props.onChange(val); + setCurrentValue(val); + + // Trigger the specific option's event handler + const selectedOption = props.options.find(option => option.value === val); + if (selectedOption?.onEvent) { + selectedOption.onEvent("click"); + } + + // Also trigger the main component's event handler + if (props.onMainEvent) { + props.onMainEvent("click"); + } + }, [props.onChange, props.options, props.onMainEvent]); + + const handleEvent = useCallback(async (eventName: string) => { + if (!mountedRef.current) return [] as unknown[]; + if (eventName === "blur") { + props.onChangeEnd(); + } + return [] as unknown[]; + }, [props.onChangeEnd]); + + const memoizedOptions = useMemo(() => props.options, [props.options]); + + return ( + + ); +}); + +SelectEdit.displayName = 'SelectEdit'; + +export const ColumnSelectComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + const option = props.options.find(x => x.value === value); + return ( + <> + {option?.prefixIcon} + {option?.label} + > + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue, + ) + .setEditViewFn((props) => { + return ( + + + + ) + }) + .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 000000000..61a8fbc6f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx @@ -0,0 +1,192 @@ +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, { ReactNode, useContext, useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { StatusContext } from "components/table/EditableCell"; +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, +}); + +type StatusEditPropsType = { + value: { value: string; status: StatusType }; + onChange: (value: { value: string; status: StatusType }) => void; + onChangeEnd: () => void; +}; + +const StatusEdit = React.memo((props: StatusEditPropsType) => { + const defaultStatus = useContext(StatusContext); + const [status, setStatus] = useState>(() => { + const result: Array<{ text: string; status: StatusType }> = []; + defaultStatus.forEach((item) => { + if (item.text.includes(",")) { + item.text.split(",").forEach((tag) => result.push({ text: tag, status: "none" })); + } + result.push({ text: item.text, status: item.status }); + }); + return result; + }); + const [open, setOpen] = useState(false); + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setStatus([]); + setOpen(false); + }; + }, []); + + // Update status when defaultStatus changes + useEffect(() => { + if (!mountedRef.current) return; + + const result: Array<{ text: string; status: StatusType }> = []; + defaultStatus.forEach((item) => { + if (item.text.includes(",")) { + item.text.split(",").forEach((tag) => result.push({ text: tag, status: "none" })); + } + result.push({ text: item.text, status: item.status }); + }); + setStatus(result); + }, [defaultStatus]); + + const handleSearch = useCallback((value: string) => { + if (!mountedRef.current) return; + + if (defaultStatus.findIndex((item) => item.text.includes(value)) < 0) { + setStatus([...defaultStatus, { text: value, status: "none" }]); + } else { + setStatus(defaultStatus); + } + props.onChange({ + value, + status: status.find((item) => item.text === value)?.status || "none", + }); + }, [defaultStatus, status, props.onChange]); + + const handleChange = useCallback((value: string) => { + if (!mountedRef.current) return; + props.onChange({ + value, + status: status.find((item) => item.text === value)?.status || "none", + }); + setOpen(false); + }, [status, props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + setOpen(false); + }, [props.onChangeEnd]); + + const handleFocus = useCallback(() => { + if (!mountedRef.current) return; + setOpen(true); + }, []); + + const handleClick = useCallback(() => { + if (!mountedRef.current) return; + setOpen(!open); + }, [open]); + + const memoizedOptions = useMemo(() => + BadgeStatusOptions.map((value, index) => ( + + {value === "none" ? value : } + + )), + [] + ); + + return ( + + } + showSearch + onSearch={handleSearch} + onChange={handleChange} + popupRender={(originNode: ReactNode) => ( + + {originNode} + + )} + styles={{ popup: { root: { marginTop: "7px", padding: "8px 0 6px 0" }}}} + onBlur={handleBlur} + onFocus={handleFocus} + onClick={handleClick} + > + {memoizedOptions} + + + ); +}); + +StatusEdit.displayName = 'StatusEdit'; + +export const BadgeStatusComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const text = props.changeValue?.value ?? getBaseValue(props, dispatch).value; + const status = props.changeValue?.status ?? getBaseValue(props, dispatch).status; + return status === "none" ? text : ; + }, + (nodeValue) => [nodeValue.status.value, nodeValue.text.value].filter((t) => t).join(" "), + getBaseValue + ) + .setEditViewFn((props) => ( + + )) + .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 000000000..0cdeee48a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx @@ -0,0 +1,168 @@ +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 { RefControl } from "comps/controls/refControl"; +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 Wrapper = styled.div` + background: transparent !important; + padding: 0 8px; +` + +const childrenMap = { + value: booleanExposingStateControl("value"), + switchState: BoolCodeControl, + onEvent: eventHandlerControl(EventOptions), + disabled: BoolCodeControl, + style: styleControl(InputFieldStyle), + // viewRef: RefControl, +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.switchState; + +const SwitchView = React.memo(({ value, disabled, onEvent, valueControl }: { + value: boolean; + disabled: boolean; + // viewRef: (viewRef: HTMLButtonElement | null) => void; + 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'; + +const SwitchEdit = React.memo(({ value, onChange, onChangeEnd }: { + value: boolean; + onChange: (value: boolean) => void; + onChangeEnd: () => void; +}) => { + const mountedRef = useRef(true); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleChange = useCallback((checked: boolean) => { + if (!mountedRef.current) return; + onChange(checked); + }, [onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + onChangeEnd(); + }, [onChangeEnd]); + + return ( + + + + ); +}); + +SwitchEdit.displayName = 'SwitchEdit'; + +export const SwitchComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.switchState.value, + getBaseValue + ) + .setEditViewFn((props) => { + return ( + { + props.onChangeEnd() + }} + > + { + props.onChange(checked); + props.otherProps?.onEvent?.("change"); + props.otherProps?.onEvent?.(checked ? "true" : "false"); + }} + /> + + ); + }) + .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 000000000..1e6a6e1a8 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx @@ -0,0 +1,465 @@ +import { default as Tag } from "antd/es/tag"; +import { PresetStatusColorTypes } from "antd/es/_util/colors"; +import { TagsContext } from "components/table/EditableCell"; +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, useContext, useState, useRef, useEffect, 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; + +type TagEditPropsType = { + value: string | string[]; + onChange: (value: string | string[]) => void; + onChangeEnd: () => void; + tagOptions: any[]; +}; + +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; + } +`; + +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 [open, setOpen] = useState(false); + const mountedRef = useRef(true); + + // Memoize tag options to prevent unnecessary re-renders + const memoizedTagOptions = useMemo(() => props.tagOptions || [], [props.tagOptions]); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + setTags([]); + setOpen(false); + }; + }, []); + + // 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); + }, [defaultTags]); + + const handleSearch = useCallback((value: string) => { + if (!mountedRef.current) return; + + if (defaultTags.findIndex((item) => item.includes(value)) < 0) { + setTags([...defaultTags, value]); + } else { + setTags(defaultTags); + } + props.onChange(value); + }, [defaultTags, props.onChange]); + + const handleChange = useCallback((value: string | string[]) => { + if (!mountedRef.current) return; + props.onChange(value); + setOpen(false); + }, [props.onChange]); + + const handleBlur = useCallback(() => { + if (!mountedRef.current) return; + props.onChangeEnd(); + setOpen(false); + }, [props.onChangeEnd]); + + const handleTagClick = useCallback((tagText: string, e: React.MouseEvent) => { + e.stopPropagation(); + const foundOption = memoizedTagOptions.find(option => option.label === tagText); + if (foundOption && foundOption.onEvent) { + foundOption.onEvent("click"); + } + }, [memoizedTagOptions]); + + return ( + + } + onSearch={handleSearch} + onChange={handleChange} + popupRender={(originNode: ReactNode) => ( + + {originNode} + + )} + styles={{ popup: { root: { marginTop: "7px", padding: "8px 0 6px 0" }}}} + onFocus={() => { + if (mountedRef.current) { + setOpen(true); + } + }} + onBlur={handleBlur} + onClick={() => { + if (mountedRef.current) { + setOpen(!open); + } + }} + > + {tags.map((value, index) => ( + + {value.split(",")[1] ? ( + value.split(",").map((item, i) => { + const tagColor = getTagColor(item, memoizedTagOptions); + const tagIcon = getTagIcon(item, memoizedTagOptions); + const tagStyle = getTagStyle(item, memoizedTagOptions); + + return ( + handleTagClick(item, e)} + > + {item} + + ); + }) + ) : ( + handleTagClick(value, e)} + > + {value} + + )} + + ))} + + + ); +}); + +TagEdit.displayName = 'TagEdit'; + +export const ColumnTagsComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const tagOptions = props.tagColors; + let value = props.changeValue ?? 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 + ) + .setEditViewFn((props) => { + const text = props.value; + const value = _.isArray(text) ? text.join(",") : text; + return ; + }) + .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 000000000..f338f0f64 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx @@ -0,0 +1,193 @@ +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, { useEffect, useRef, useState, useCallback } 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; + } +`; + +const Wrapper = styled.div` + background: transparent !important; +`; + +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; + +type TimeEditProps = { + value: string; + onChange: (value: string) => void; + onChangeEnd: () => void; + inputFormat: string; +}; + +export const TimeEdit = React.memo((props: TimeEditProps) => { + const pickerRef = useRef(); + const [panelOpen, setPanelOpen] = useState(true); + const mountedRef = useRef(true); + + // Initialize tempValue with proper validation + const [tempValue, setTempValue] = useState(() => { + const initialValue = dayjs(props.value, TIME_FORMAT); + return initialValue.isValid() ? initialValue : dayjs("00:00:00", TIME_FORMAT); + }); + + // Memoize event handlers + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" && !panelOpen) { + props.onChangeEnd(); + } + }, [panelOpen, props.onChangeEnd]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }, []); + + const handleOpenChange = useCallback((open: boolean) => { + if (mountedRef.current) { + setPanelOpen(open); + } + }, []); + + const handleChange = useCallback((value: dayjs.Dayjs | null, dateString: string | string[]) => { + props.onChange(dateString as string); + }, [props.onChange]); + + // Update tempValue when props.value changes + useEffect(() => { + if (!mountedRef.current) return; + + const newValue = props.value ? dayjs(props.value, TIME_FORMAT) : null; + if (newValue?.isValid()) { + setTempValue(newValue); + } + }, [props.value]); + + // Cleanup event listeners and state + useEffect(() => { + return () => { + mountedRef.current = false; + setTempValue(null); + if (pickerRef.current) { + pickerRef.current = null; + } + }; + }, []); + + return ( + + + + ); +}); + +TimeEdit.displayName = 'TimeEdit'; + +export const TimeComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + const value = props.changeValue ?? getBaseValue(props, dispatch); + return ( + <> + {hasIcon(props.prefixIcon) && ( + {props.prefixIcon} + )} + {value} + {hasIcon(props.suffixIcon) && ( + {props.suffixIcon} + )} + > + ); + }, + (nodeValue) => formatTime(nodeValue.text.value, nodeValue.format.value), + getBaseValue + ) + .setEditViewFn(({value, onChange, onChangeEnd, otherProps}) => ( + + )) + .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 000000000..dcdffe390 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx @@ -0,0 +1,121 @@ +import { default as Input } from "antd/es/input"; +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; +} + +interface SimpleTextEditViewProps { + value: string | number; + onChange: (value: string | number) => void; + onChangeEnd: () => 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 SimpleTextEditView = React.memo(({ value, onChange, onChangeEnd }: SimpleTextEditViewProps) => { + const handleChange = useCallback((e: React.ChangeEvent) => { + onChange(e.target.value); + }, [onChange]); + + return ( + + ); +}); + +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 = props.changeValue ?? getBaseValue(props, dispatch); + return ( + + ); + }, + (nodeValue) => nodeValue.text.value, + getBaseValue + ) + .setEditViewFn((props) => ) + .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 000000000..f9bedc754 --- /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 000000000..f82a1a981 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx @@ -0,0 +1,486 @@ +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, + width: NumberControl, + autoWidth: dropdownControl(columnWidthOptions, "auto"), + render: RenderComp, + align: HorizontalAlignmentControl, + tempHide: stateComp(false), + fixed: dropdownControl(columnFixOptions, "close"), + editable: BoolControl, + 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"), + })} + {ColumnTypeCompMap[columnType].canBeEditable() && + comp.children.editable.propertyView({ label: trans("table.editable") })} + {comp.children.sortable.propertyView({ + label: trans("table.sortable"), + })} + {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, + editable: ColumnTypeCompMap[columnType].canBeEditable() && superView.editable, + }; + } + + 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() { + const dataIndex = this.children.dataIndex.getView(); + const changeSet = _.mapValues(this.children.render.getMap(), (value) =>{ + return value.getComp().children.comp.children.changeValue.getView() + }); + return { [dataIndex]: changeSet }; + } + + dispatchClearChangeSet() { + this.children.render.dispatch( + deferAction( + RenderComp.forEachAction( + wrapChildAction( + "comp", + wrapChildAction("comp", changeChildAction("changeValue", null, false)) + ) + ) + ) + ); + // clear render comp cache when change set is cleared + this.children.render.dispatch(RenderComp.clearAction()); + } + + dispatchClearInsertSet() { + const renderMap = this.children.render.getMap(); + Object.keys(renderMap).forEach(key => { + const render = renderMap[key]; + render.getComp().children.comp.children.changeValue.dispatchChangeValueAction(null); + }); + } + + 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 000000000..6e48e1f21 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx @@ -0,0 +1,198 @@ +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, +} 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 ActionDataType = { + type: "dataChanged"; + rowExample: RowExampleType; + doGeneColumn: boolean; + dynamicColumn: boolean; + data: Array; +}; + +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; + } + return super.reduce(action); + } + + 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 000000000..6b0e2c406 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx @@ -0,0 +1,205 @@ +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() { + const dataIndex = this.children.dataIndex.getView(); + const changeSet = _.mapValues(this.children.render.getMap(), (value) => + value.getComp().children.comp.children.changeValue.getView() + ); + return { [dataIndex]: changeSet }; + } + + dispatchClearChangeSet() { + this.children.render.dispatch( + deferAction( + RenderComp.forEachAction( + wrapChildAction( + "comp", + wrapChildAction("comp", changeChildAction("changeValue", null, false)) + ) + ) + ) + ); + } + + static setSelectionAction(key: string) { + return wrapChildAction("render", RenderComp.setSelectionAction(key)); + } +} 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 000000000..3caa488f3 --- /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/mockTableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx new file mode 100644 index 000000000..65e1660f3 --- /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/paginationControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx new file mode 100644 index 000000000..eb5b4a0f8 --- /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/selectionControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx new file mode 100644 index 000000000..af64f4ac6 --- /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/toolbar.styles.ts b/client/packages/lowcoder/src/comps/comps/tableLiteComp/styles/toolbar.styles.ts new file mode 100644 index 000000000..83fca9382 --- /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 000000000..96121e4f5 --- /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 000000000..c3d39e1f7 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx @@ -0,0 +1,931 @@ +import { tableDataRowExample } from "./column/tableColumnListComp"; +import { getPageSize } from "./paginationControl"; +import { TableCompView } from "./tableCompView"; +import { + columnHide, + ColumnsAggrData, + COLUMN_CHILDREN_KEY, + filterData, + genSelectionParams, + getColumnsAggr, + getOriDisplayData, + OB_ROW_ORI_INDEX, + RecordType, + sortData, + transformDispalyData, + tranToTableRecord, +} 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 { MAP_KEY } from "comps/generators/withMultiContext"; +import { NameGenerator } from "comps/utils"; +import { trans } from "i18n"; +import _, { isArray } from "lodash"; +import { + changeChildAction, + CompAction, + CompActionTypes, + deferAction, + executeQueryAction, + fromRecord, + FunctionNode, + Node, + onlyEvalAction, + RecordNode, + RecordNodeToValue, + routeByNameAction, + ValueAndMsg, + withFunction, + wrapChildAction, +} from "lowcoder-core"; +import { saveDataAsFile } from "util/fileUtils"; +import { JSONObject, JSONValue } from "util/jsonTypes"; +import { lastValueIfEqual, shallowEqual } from "util/objectUtils"; + +import { getSelectedRowKeys } from "./selectionControl"; +import { compTablePropertyView } from "./tablePropertyView"; +import { RowColorComp, RowHeightComp, SortValue, TableChildrenView, TableInitComp } from "./tableTypes"; + +import { useContext, useState } from "react"; +import { EditorContext } from "comps/editorState"; + +export class TableImplComp extends TableInitComp { + private prevUnevaledValue?: string; + readonly filterData: RecordType[] = []; + readonly columnAggrData: ColumnsAggrData = {}; + + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } + + getTableAutoHeight() { + 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)) { + // Independent query, using local data, giving a fake loading effect + 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({})))); + } + }); + } + + // only for test? + 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) { + // the first time + doGenColumn = true; + } else if (prevUnevaledVal && nextUnevaledVal !== prevUnevaledVal) { + // modify later + doGenColumn = true; + } else if (dynamicColumn) { + doGenColumn = true; + } else if ( + columnKeys.length < nextRowKeys.length && + columnKeys.every((key) => nextRowKeys.includes(key)) + ) { + // new column is automatically generated + doGenColumn = true; + } + if (!doGenColumn) { + const toBeGenRow = comp.children.dataRowExample.getView(); + const columnKeyChanged = + columnKeys.length !== nextRowKeys.length || + !_.isEqual(_.sortBy(columnKeys), _.sortBy(nextRowKeys)); + // The data has changed, but can't judge the auto generation + 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) { + // update rowColor context + 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())); + } + // console.info("exit tableComp reduce. action: ", action, "\nthis: ", this, "\ncomp: ", comp); + return comp; + } + + override extraNode() { + const extra = { + sortedData: this.sortDataNode(), + filterData: this.filterNode(), + oriDisplayData: this.oriDisplayDataNode(), + columnAggrData: this.columnAggrNode(), + }; + return { + node: extra, + updateNodeFields: (value: any) => ({ + filterData: value.filterData, + columnAggrData: value.columnAggrData, + }), + }; + } + + // handle sort: data -> sortedData + sortDataNode() { + const nodes: { + data: Node; + sort: Node; + dataIndexes: RecordNode>>; + sortables: RecordNode>>>; + withParams: RecordNode<_.Dictionary>, + } = { + data: this.children.data.exposingNode(), + sort: this.children.sort.node(), + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + sortables: this.children.columns.getColumnsNode("sortable"), + withParams: this.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 sortedData = sortData(originalData, sortColumns, sort); + + // console.info( "sortNode. data: ", data, " sort: ", sort, " columns: ", columns, " sortedData: ", sortedData); + const newData = sortedData.map(row => { + return { + ...row, + ...updatedDataMap[row[OB_ROW_ORI_INDEX]], + } + }); + return newData; + }); + return lastValueIfEqual(this, "sortedDataNode", [sortedDataNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + // handle hide/search/filter: sortedData->filteredData + filterNode() { + const nodes = { + data: this.sortDataNode(), + searchValue: this.children.searchText.node(), + }; + let context = this; + + const filteredDataNode = withFunction(fromRecord(nodes), (input) => { + const { data, searchValue } = input; + const filteredData = filterData(data, searchValue.value, { filters: [], stackType: "and" }, false); + + if (Boolean(searchValue.value) && data.length !== filteredData.length) { + const onEvent = context.children.onEvent.getView(); + setTimeout(() => { + onEvent("dataSearch"); + }); + } + + return filteredData.map((row) => tranToTableRecord(row, row[OB_ROW_ORI_INDEX])); + }); + + return lastValueIfEqual(this, "filteredDataNode", [filteredDataNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + + oriDisplayDataNode() { + const nodes = { + data: this.filterNode(), + // --> pageSize + showSizeChanger: this.children.pagination.children.showSizeChanger.node(), + pageSize: this.children.pagination.children.pageSize.node(), + pageSizeOptions: this.children.pagination.children.pageSizeOptions.node(), + changablePageSize: this.children.pagination.children.changeablePageSize.node(), + // <-- pageSize + withParams: this.children.columns.withParamsNode(), + dataIndexes: this.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(this, "oriDisplayDataNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + displayDataIndexesNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + }; + const resNode = withFunction(fromRecord(nodes), (input) => { + return _(input.oriDisplayData) + .map((row, idx) => [row[OB_ROW_ORI_INDEX], idx] as [string, number]) + .fromPairs() + .value(); + }); + return lastValueIfEqual(this, "displayDataIndexesNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + private getUpsertSetResNode( + nodes: Record>>>, + filterNewRows?: boolean, + ) { + return withFunction(fromRecord(nodes), (input) => { + // merge input.dataIndexes and input.withParams into one structure + const dataIndexRenderDict = _(input.dataIndexes) + .mapValues((dataIndex, idx) => input.renders[idx]) + .mapKeys((render, idx) => input.dataIndexes[idx]) + .value(); + const record: Record> = {}; + _.forEach(dataIndexRenderDict, (render, dataIndex) => { + _.forEach(render[MAP_KEY], (value, key) => { + const changeValue = (value.comp as any).comp.changeValue; + if (!_.isNil(changeValue)) { + if (!record[key]) record[key] = {}; + record[key][dataIndex] = changeValue; + } + }); + }); + return record; + }); + } + + changeSetNode() { + const nodes = { + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + renders: this.children.columns.getColumnsNode("render"), + }; + + const resNode = this.getUpsertSetResNode(nodes); + return lastValueIfEqual(this, "changeSetNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + insertSetNode() { + const nodes = { + dataIndexes: this.children.columns.getColumnsNode("dataIndex"), + renders: this.children.columns.getColumnsNode("render"), + }; + + const resNode = this.getUpsertSetResNode(nodes, true); + return lastValueIfEqual(this, "insertSetNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + private getToUpsertRowsResNodes( + nodes: Record> + ) { + return withFunction(fromRecord(nodes), (input) => { + const res = _(input.changeSet) + .map((changeValues, oriIndex) => { + const idx = input.indexes[oriIndex]; + const oriRow = _.omit(input.oriDisplayData[idx], OB_ROW_ORI_INDEX); + return { ...oriRow, ...changeValues }; + }) + .value(); + // console.info("toUpdateRowsNode. input: ", input, " res: ", res); + return res; + }); + } + + toUpdateRowsNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + indexes: this.displayDataIndexesNode(), + changeSet: this.changeSetNode(), + }; + + const resNode = this.getToUpsertRowsResNodes(nodes); + return lastValueIfEqual(this, "toUpdateRowsNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + toInsertRowsNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + indexes: this.displayDataIndexesNode(), + changeSet: this.insertSetNode(), + }; + + const resNode = this.getToUpsertRowsResNodes(nodes); + return lastValueIfEqual(this, "toInsertRowsNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } + + columnAggrNode() { + const nodes = { + oriDisplayData: this.oriDisplayDataNode(), + withParams: this.children.columns.withParamsNode(), + dataIndexes: this.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); + // console.info("columnAggrNode: ", res); + return res; + }); + return lastValueIfEqual(this, "columnAggrNode", [resNode, nodes] as const, (a, b) => + shallowEqual(a[1], b[1]) + )[0]; + } +} + +let TableTmpComp = withViewFn(TableImplComp, (comp) => { + return ( + + comp.refreshData(allQueryNames, setLoading)} + onDownload={(fileName) => comp.downloadData(fileName)} + /> + + ); +}); + + +const withEditorModeStatus = (Component:any) => (props:any) => { + const editorModeStatus = useContext(EditorContext).editorModeStatus; + const {ref, ...otherProps} = props; + return ; +}; + +// Use this HOC when defining TableTmpComp +TableTmpComp = withPropertyViewFn(TableTmpComp, (comp) => withEditorModeStatus(compTablePropertyView)(comp)); + +// TableTmpComp = withPropertyViewFn(TableTmpComp, compTablePropertyView); + + + + + +/** + * Hijack children's execution events and ensure that selectedRow is modified first (you can also add a triggeredRow field). + */ +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))); + } + // action.context; + } + return dispatch(action); +}); + +function _indexKeyToRecord(data: JSONObject[], key: string) { + const keyPath = (key + "").split("-"); + let currentData = data; + let res = undefined; + for (let k of keyPath) { + const index = Number(k); + if (index >= 0 && Array.isArray(currentData) && index < currentData.length) { + res = currentData[index]; + currentData = res[COLUMN_CHILDREN_KEY] as JSONObject[]; + } + } + return res; +} + +function toDisplayIndex(displayData: JSONObject[], selectRowKey: string) { + const keyPath = selectRowKey.split("-"); + const originSelectKey = keyPath[0]; + if (!originSelectKey) { + return ""; + } + let displayIndex; + displayData.forEach((data, index) => { + if (data[OB_ROW_ORI_INDEX] === originSelectKey) { + displayIndex = index; + } + }); + if (displayIndex && keyPath.length > 1) { + return [displayIndex, ...keyPath.slice(1)].join("-"); + } + return displayIndex; +} + +TableTmpComp = withMethodExposing(TableTmpComp, [ + { + method: { + name: "setFilter", + description: "", + params: [{ name: "filter", type: "JSON" }], + }, + execute: (comp, values) => { + //TODO: add filter maybe + if (values[0]) { + // const param = values[0] as TableFilter; + // const currentVal = comp.children.toolbar.children.filter.getView(); + // comp.children.toolbar.children.filter.dispatchChangeValueAction({ + // ...currentVal, + // ...param, + // }); + } + }, + }, + { + method: { + name: "setPage", + description: "", + params: [{ name: "page", type: "number" }], + }, + execute: (comp, values) => { + 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" }, + { name: "sortDesc", type: "boolean" }, + ], + }, + execute: (comp, values) => { + 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"}, + ], + }, + execute: (comp, values) => { + const sortColumns = values[0]; + if (!isArray(sortColumns)) { + return Promise.reject("setMultiSort function only accepts array of sort objects i.e. [{column: column_name, desc: boolean}]") + } + if (sortColumns && isArray(sortColumns)) { + comp.children.sort.dispatchChangeValueAction(sortColumns as SortValue[]); + } + }, + }, + { + method: { + name: "resetSelections", + description: "", + params: [], + }, + execute: (comp) => { + 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) => { + const displayData = comp.filterData ?? []; + const allKeys = displayData.map((row) => row[OB_ROW_ORI_INDEX] + ""); + comp.children.selection.children.selectedRowKey.dispatchChangeValueAction(allKeys[0] || "0"); + comp.children.selection.children.selectedRowKeys.dispatchChangeValueAction(allKeys); + }, + }, + { + method: { + name: "cancelChanges", + description: "", + params: [], + }, + execute: (comp, values) => { + comp.children.columns.dispatchClearChangeSet(); + }, + }, + { + method: { + name: "cancelInsertChanges", + description: "", + params: [], + }, + execute: (comp, values) => { + comp.children.columns.dispatchClearInsertSet(); + }, + }, +]); + +// exposing data +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: comp.oriDisplayDataNode(), + selectedRowKey: comp.children.selection.children.selectedRowKey.node(), + }; + }, + (input) => { + return toDisplayIndex(input.oriDisplayData, input.selectedRowKey); + }, + trans("table.selectedIndexDesc") + ), + new CompDepsConfig( + "selectedIndexes", + (comp) => { + return { + oriDisplayData: comp.oriDisplayDataNode(), + 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 CompDepsConfig( + "changeSet", + (comp) => ({ + changeSet: comp.changeSetNode(), + }), + (input) => input.changeSet, + trans("table.changeSetDesc") + ), + new CompDepsConfig( + "insertSet", + (comp) => ({ + insertSet: comp.insertSetNode(), + }), + (input) => input.insertSet, + trans("table.changeSetDesc") + ), + new CompDepsConfig( + "toUpdateRows", + (comp) => ({ + toUpdateRows: comp.toUpdateRowsNode(), + }), + (input) => { + return input.toUpdateRows; + }, + trans("table.toUpdateRowsDesc") + ), + new CompDepsConfig( + "toInsertRows", + (comp) => ({ + toInsertRows: comp.toInsertRowsNode(), + }), + (input) => { + return input.toInsertRows; + }, + trans("table.toUpdateRowsDesc") + ), + 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: comp.oriDisplayDataNode(), + dataIndexes: comp.children.columns.getColumnsNode("dataIndex"), + titles: comp.children.columns.getColumnsNode("title"), + // --> hide + hides: comp.children.columns.getColumnsNode("hide"), + tempHides: comp.children.columns.getColumnsNode("tempHide"), + columnSetting: comp.children.toolbar.children.columnSetting.node(), + // <-- hide + }; + }, + (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 000000000..4d9a6d92e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx @@ -0,0 +1,1039 @@ +import { default as Table, TableProps, ColumnType } from "antd/es/table"; +import { TableRowContext } from "./tableContext"; +import { TableToolbar } from "./tableToolbarComp"; +import { RowColorViewType, RowHeightViewType, TableEventOptionValues } from "./tableTypes"; +import { + COL_MIN_WIDTH, + COLUMN_CHILDREN_KEY, + ColumnsAggrData, + columnsToAntdFormat, + CustomColumnType, + OB_ROW_ORI_INDEX, + onTableChange, + RecordType, + supportChildrenTree, +} from "./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 { 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"; + +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; + $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}; + } + + // 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 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; + + + + +export const TableCompView = React.memo((props: { + comp: InstanceType; + onRefresh: (allQueryNames: Array, setLoading: (loading: boolean) => void) => void; + onDownload: (fileName: string) => void; +}) => { + + console.log("TableCompView"); + + const editorState = useContext(EditorContext); + const currentTheme = useContext(ThemeContext)?.theme; + const showDataLoadingIndicators = currentTheme?.showDataLoadingIndicators; + const { width, ref } = useResizeDetector({ + refreshMode: "debounce", + refreshRate: 600, + handleHeight: false, + }); + const viewMode = useUserViewMode(); + const compName = useContext(CompNameContext); + const [loading, setLoading] = useState(false); + const { comp, onDownload, onRefresh } = props; + const compChildren = comp.children; + const style = compChildren.style.getView(); + const rowStyle = compChildren.rowStyle.getView(); + const headerStyle = compChildren.headerStyle.getView(); + 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(); + const showHRowGridBorder = compChildren.showHRowGridBorder.getView(); + const columnsStyle = compChildren.columnsStyle.getView(); + const summaryRowStyle = compChildren.summaryRowStyle.getView(); + const changeSet = useMemo(() => compChildren.columns.getChangeSet(), [compChildren.columns]); + const insertSet = useMemo(() => compChildren.columns.getChangeSet(true), [compChildren.columns]); + const hasChange = useMemo(() => !_.isEmpty(changeSet) || !_.isEmpty(insertSet), [changeSet, insertSet]); + 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 dynamicColumnConfig = useMemo( + () => compChildren.dynamicColumnConfig.getView(), + [compChildren.dynamicColumnConfig] + ); + const columnsAggrData = comp.columnAggrData; + const antdColumns = useMemo( + () => + columnsToAntdFormat( + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + onEvent, + ), + [ + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + ] + ); + + const supportChildren = useMemo( + () => supportChildrenTree(compChildren.data.getView()), + [compChildren.data] + ); + + + const pageDataInfo = useMemo(() => { + // Data pagination + let pagedData = data; + let current = pagination.current; + const total = pagination.total || data.length; + if (data.length > pagination.pageSize) { + // Local pagination + 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 childrenProps = childrenToProps(comp.children); + + useMergeCompStyles( + childrenProps as Record, + comp.dispatch + ); + + const handleChangeEvent = useCallback( + (eventName: TableEventOptionValues) => { + compChildren.onEvent.getView()(eventName); + setTimeout(() => compChildren.columns.dispatchClearChangeSet()); + }, + [viewMode, compChildren.onEvent, compChildren.columns] + ); + + 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 ( + + {toolbar.position === "above" && !hideToolbar && toolbarView} + + {toolbar.position === "below" && !hideToolbar && toolbarView} + + ); + } + + const hideScrollbar = !showHorizontalScrollbar && !showVerticalScrollbar; + const showTableLoading = loading || + // fixme isLoading type + ((showDataLoadingIndicators) && + (compChildren.data as any).isLoading()) || + compChildren.loading.getView(); + + return ( + + + {toolbar.position === "above" && !hideToolbar && (toolbar.fixedToolbar || (tableAutoHeight && showHorizontalScrollbar)) && toolbarView} + + + + rowColorFn={compChildren.rowColor.getView()} + rowHeightFn={compChildren.rowHeight.getView()} + {...compChildren.selection.getView()(onEvent)} + bordered={compChildren.showRowGridBorder.getView()} + onChange={(pagination, filters, sorter, extra) => { + onTableChange(pagination, filters, sorter, extra, comp.dispatch, onEvent); + }} + showHeader={!compChildren.hideHeader.getView()} + columns={antdColumns} + columnsStyle={columnsStyle} + viewModeResizable={compChildren.viewModeResizable.getView()} + visibleResizables={compChildren.visibleResizables.getView()} + dataSource={pageDataInfo.data} + size={compChildren.size.getView()} + rowAutoHeight={rowAutoHeight} + tableLayout="fixed" + customLoading={showTableLoading} + onCellClick={(columnName: string, dataIndex: string) => { + comp.children.selectedCell.dispatchChangeValueAction({ + name: columnName, + dataIndex: dataIndex, + }); + }} + summary={summaryView} + /> + + + {toolbar.position === "below" && !hideToolbar && (toolbar.fixedToolbar || (tableAutoHeight && showHorizontalScrollbar)) && toolbarView} + + + + ); +}); 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 000000000..68c5a56fa --- /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 000000000..d38cdb987 --- /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 000000000..2b837d489 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx @@ -0,0 +1,629 @@ +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 ( + <> + { + handleColumnFilterChange(value); + }} + /> + } + config={{ dataIndex: "header" }} + draggable={false} + optionExtra={ + + { + handleColumnBatchTypeChange(value); + }} + /> + {ColumnBatchView[columnBatchType](columns)} + + } + /> + } + itemExtra={(column) => { + return ( + e.stopPropagation()} + > + {ColumnBatchView[columnBatchType](column)} + + ); + }} + items={columnOptionItems} + optionToolbar={columnOptionToolbar} + itemTitle={(column) => { + const columnView = column.getView(); + if (columnView.hide) { + return {columnView.title}; + } + return columnView.title; + }} + popoverTitle={(column) => { + const columnView = column.getView(); + return columnView.isCustom ? trans("table.customColumn") : columnView.dataIndex; + }} + content={(column, index) => ( + <> + handleViewModeChange(k as ViewOptionType)} + /> + {viewMode === 'summary' && ( + handleSummaryRowChange(k)} + /> + )} + {column.propertyView(selection, viewMode, summaryRow)} + {column.getView().isCustom && ( + { + CustomModal.confirm({ + title: trans("table.deleteColumn"), + content: trans("table.confirmDeleteColumn") + `${column.getView().title}?`, + onConfirm: () => + comp.children.columns.dispatch(comp.children.columns.deleteAction(index)), + confirmBtnType: "delete", + okText: trans("delete"), + }); + }} + > + {trans("delete")} + + )} + > + )} + onAdd={() => { + comp.children.columns.dispatch(comp.children.columns.pushAction(newCustomColumn())); + }} + onMove={(fromIndex, toIndex) => { + const action = comp.children.columns.arrayMoveAction(fromIndex, toIndex); + comp.children.columns.dispatch(action); + }} + dataIndex={(column) => column.getView().dataIndex} + scrollable={true} + /> + > + ); +} + +function columnPropertyView>(comp: T) { + const columnLabel = trans("table.columnNum"); + // const dynamicColumn = comp.children.dynamicColumn.getView(); + return [ + controlItem( + { filterText: columnLabel }, + + ), + /* comp.children.dynamicColumn.propertyView({ label: trans("table.dynamicColumn") }), + dynamicColumn && + comp.children.dynamicColumnConfig.propertyView({ + label: trans("table.dynamicColumnConfig"), + tooltip: trans("table.dynamicColumnConfigDesc"), + }), */ + ]; +} + +export function compTablePropertyView & { editorModeStatus: string }>(comp: T) { + const editorModeStatus = comp.editorModeStatus; + const dataLabel = trans("data"); + + return ( + <> + {["logic", "both"].includes(editorModeStatus) && ( + + {controlItem( + { filterText: dataLabel }, + + {comp.children.data.propertyView({ + label: dataLabel, + })} + + )} + + )} + + {["layout", "both"].includes(editorModeStatus) && ( + + {columnPropertyView(comp)} + + )} + + {["logic", "both"].includes(editorModeStatus) && ( + <> + + {comp.children.onEvent.getPropertyView()} + {hiddenPropertyView(comp.children)} + {loadingPropertyView(comp.children)} + {comp.children.selection.getPropertyView()} + {comp.children.searchText.propertyView({ + label: trans("table.searchText"), + tooltip: trans("table.searchTextTooltip"), + placeholder: "{{input1.value}}", + })} + + + + {comp.children.showSummary.propertyView({ + label: trans("table.showSummary") + })} + {comp.children.showSummary.getView() && + comp.children.summaryRows.propertyView({ + label: trans("table.totalSummaryRows"), + radioButton: true, + })} + + + + + {comp.children.toolbar.getPropertyView()} + + > + )} + + {["layout", "both"].includes(editorModeStatus) && ( + <> + + {comp.children.size.propertyView({ + label: trans("table.tableSize"), + radioButton: true, + })} + {comp.children.autoHeight.getPropertyView()} + {comp.children.showHorizontalScrollbar.propertyView({ + label: trans("prop.showHorizontalScrollbar"), + })} + {!comp.children.autoHeight.getView() && comp.children.showVerticalScrollbar.propertyView({ + label: trans("prop.showVerticalScrollbar"), + })} + {comp.children.fixedHeader.propertyView({ + label: trans("table.fixedHeader"), + tooltip: trans("table.fixedHeaderTooltip") + })} + {comp.children.hideHeader.propertyView({ + label: trans("table.hideHeader"), + })} + {comp.children.hideToolbar.propertyView({ + label: trans("table.hideToolbar"), + })} + {comp.children.viewModeResizable.propertyView({ + label: trans("table.viewModeResizable"), + tooltip: trans("table.viewModeResizableTooltip"), + })} + {comp.children.visibleResizables.propertyView({ + label: trans("table.visibleResizables"), + tooltip: trans("table.visibleResizablesTooltip"), + })} + + + {comp.children.pagination.getPropertyView()} + + > + )} + + {["logic", "both"].includes(editorModeStatus) && ( + <> + + {comp.children.dynamicColumn.propertyView({ label: trans("table.dynamicColumn") })} + {comp.children.dynamicColumn.getView() && + comp.children.dynamicColumnConfig.propertyView({ + label: trans("table.dynamicColumnConfig"), + tooltip: trans("table.dynamicColumnConfigDesc"), + })} + + > + )} + + {["layout", "both"].includes(editorModeStatus) && ( + <> + {comp.children.style.getPropertyView()} + + + {comp.children.headerStyle.getPropertyView()} + + + {comp.children.toolbarStyle.getPropertyView()} + + + {comp.children.showRowGridBorder.propertyView({ + label: trans("table.showVerticalRowGridBorder"), + })} + {comp.children.showHRowGridBorder.propertyView({ + label: trans("table.showHorizontalRowGridBorder"), + })} + {comp.children.rowStyle.getPropertyView()} + {comp.children.rowAutoHeight.getPropertyView()} + {comp.children.rowHeight.getPropertyView()} + {comp.children.rowColor.getPropertyView()} + + + {comp.children.columnsStyle.getPropertyView()} + + + {comp.children.summaryRowStyle.getPropertyView()} + + > + )} + > + ); +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableSummaryComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableSummaryComp.tsx new file mode 100644 index 000000000..45751c385 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableSummaryComp.tsx @@ -0,0 +1,286 @@ +import { ThemeDetail } from "api/commonSettingApi"; +import { ColumnComp } from "./column/tableColumnComp"; +import { TableColumnLinkStyleType, TableColumnStyleType, TableSummaryRowStyleType } from "comps/controls/styleControlConstants"; +import styled from "styled-components"; +import { defaultTheme } from "@lowcoder-ee/constants/themeConstants"; +import Table from "antd/es/table"; +import { ReactNode, useMemo, memo, useCallback } from "react"; +import Tooltip from "antd/es/tooltip"; + +const CellContainer = styled.div<{ + $textAlign?: 'left' | 'center' | 'right'; +}>` + display: flex; + justify-content: ${(props) => { + switch (props.$textAlign) { + case 'left': + return 'flex-start'; + case 'center': + return 'center'; + case 'right': + return 'flex-end'; + default: + return 'flex-start'; + } + }}; +`; + +const TableSummaryRow = styled(Table.Summary.Row)<{ + $istoolbarPositionBelow: boolean; + $background: string; +}>` + ${props => `background: ${props.$background}`}; + + td:last-child { + border-right: unset !important; + } + + ${props => !props.$istoolbarPositionBelow && ` + &:last-child td { + border-bottom: none !important; + } + `} + +`; + +const TableSummarCell = styled(Table.Summary.Cell)<{ + $style: TableSummaryRowStyleType; + $defaultThemeDetail: ThemeDetail; + $linkStyle?: TableColumnLinkStyleType; + $tableSize?: string; + $autoHeight?: boolean; +}>` + background: ${(props) => props.$style.background} !important; + border-color: ${(props) => props.$style.border} !important; + // border-width: ${(props) => props.$style.borderWidth} !important; + // border-style: ${(props) => props.$style.borderStyle} !important; + border-radius: ${(props) => props.$style.radius}; + padding: 0 !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; + ${(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: 14px; + line-height: 20px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: 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: 24px; + line-height: 24px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: 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: 48px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: 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 CellWrapper = memo(({ + children, + tooltipTitle, +}: { + children: ReactNode, + tooltipTitle?: string, +}) => { + if (tooltipTitle) { + return ( + + {children} + + ) + } + return ( + <>{children}> + ) +}); + +CellWrapper.displayName = 'CellWrapper'; + +const TableSummaryCellView = memo(function TableSummaryCellView(props: { + index: number; + key: string; + children: any; + align?: any; + rowStyle: TableSummaryRowStyleType; + columnStyle: TableColumnStyleType; + linkStyle: TableColumnLinkStyleType; + tableSize?: string; + autoHeight?: boolean; + cellColor: string; + cellTooltip: string; +}) { + const { + children, + rowStyle, + columnStyle, + tableSize, + autoHeight, + cellColor, + cellTooltip, + ...restProps + } = props; + + const style = useMemo(() => ({ + background: cellColor || columnStyle.background, + margin: columnStyle.margin || rowStyle.margin, + text: columnStyle.text || rowStyle.text, + border: columnStyle.border || rowStyle.border, + borderWidth: rowStyle.borderWidth, + borderStyle: rowStyle.borderStyle, + radius: columnStyle.radius || rowStyle.radius, + textSize: columnStyle.textSize || rowStyle.textSize, + textWeight: rowStyle.textWeight || columnStyle.textWeight, + fontFamily: rowStyle.fontFamily || columnStyle.fontFamily, + fontStyle: rowStyle.fontStyle || columnStyle.fontStyle, + }), [cellColor, columnStyle, rowStyle]); + + return ( + + + + {children} + + + + ); +}); + +TableSummaryCellView.displayName = 'TableSummaryCellView'; + +export const TableSummary = memo(function TableSummary(props: { + tableSize: string; + multiSelectEnabled: boolean; + summaryRows: number; + columns: ColumnComp[]; + summaryRowStyle: TableSummaryRowStyleType; + istoolbarPositionBelow: boolean; + dynamicColumn: boolean; + dynamicColumnConfig: string[]; +}) { + const { + columns, + summaryRows, + summaryRowStyle, + tableSize, + multiSelectEnabled, + istoolbarPositionBelow, + dynamicColumn, + dynamicColumnConfig, + } = props; + + const visibleColumns = useMemo(() => { + let cols = columns.filter(col => !col.getView().hide); + if (dynamicColumn && dynamicColumnConfig?.length) { + cols = cols.filter(col => { + const colView = col.getView(); + return dynamicColumnConfig.includes(colView.isCustom ? colView.title : colView.dataIndex) + }) + } + if (multiSelectEnabled) { + cols.unshift(new ColumnComp({})); + } + return cols; + }, [columns, multiSelectEnabled, dynamicColumn, dynamicColumnConfig]); + + const renderSummaryCell = useCallback((column: ColumnComp, rowIndex: number, index: number) => { + const summaryColumn = column.children.summaryColumns.getView()[rowIndex].getView(); + return ( + + {summaryColumn.render({}, '').getView().view({})} + + ); + }, [tableSize, summaryRowStyle]); + + if (!visibleColumns.length) return <>>; + + return ( + + {Array.from(Array(summaryRows)).map((_, rowIndex) => ( + + {visibleColumns.map((column, index) => renderSummaryCell(column, rowIndex, index))} + + ))} + + ); +}); + +TableSummary.displayName = 'TableSummary'; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableToolbarComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableToolbarComp.tsx new file mode 100644 index 000000000..476bffaad --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableToolbarComp.tsx @@ -0,0 +1,248 @@ +import { default as Pagination, PaginationProps } from "antd/es/pagination"; +import { default as Popover } from "antd/es/popover"; +import { ColumnCompType } from "comps/comps/tableLiteComp/column/tableColumnComp"; +import { TableOnEventView } from "comps/comps/tableLiteComp/tableTypes"; +import { BoolControl } from "comps/controls/boolControl"; +import { StringControl } from "comps/controls/codeControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { withDefault } from "comps/generators"; +import { trans } from "i18n"; +import { ConstructorToView } from "lowcoder-core"; +import { + AlignBottom, + AlignClose, + AlignTop, + CheckBox, + CommonTextLabel, + DownloadIcon, + pageItemRender, + RefreshIcon, + TableColumnVisibilityIcon, + SuspensionBox, +} from "lowcoder-design"; +import React, { useMemo, useState, memo, useCallback } from "react"; +import { ToolbarContainer, ToolbarRow, ToolbarIcons } from "./styles/toolbar.styles"; +import { ControlNodeCompBuilder } from "comps/generators/controlCompBuilder"; +import type { CheckboxChangeEvent } from 'antd/es/checkbox'; + +type ToolbarRowType = ConstructorToView; + +const positionOptions = [ + { + label: , + value: "below", + }, + { + label: , + value: "above", + }, + { + label: , + value: "close", + }, +] as const; + +const ColumnSetting = memo(function ColumnSetting(props: { + columns: Array; + setVisible: (v: boolean) => void; +}) { + const { columns, setVisible } = props; + + // Memoize checked states for all columns + const checkedStates = useMemo(() => columns.map((c) => !c.getView().tempHide), [columns]); + + // Memoize allChecked calculation + const allChecked = useMemo(() => checkedStates.every(Boolean), [checkedStates]); + + // Memoize checkViews rendering + const checkViews = useMemo(() => + columns.map((c, idx) => { + const columnView = c.getView(); + const checked = checkedStates[idx]; + return ( + + { + c.children.tempHide.dispatchChangeValueAction(!e.target.checked); + }} + /> + {columnView.title || columnView.dataIndex} + + ); + }), + [columns, checkedStates] + ); + + // Memoize the 'Select All' onChange handler + const handleSelectAll = useCallback((e: CheckboxChangeEvent) => { + const checked = e.target.checked; + columns.forEach((c) => { + const tempHide = c.children.tempHide.getView(); + // fixme batch dispatch + if (checked && tempHide) { + c.children.tempHide.dispatchChangeValueAction(false); + } else if (!checked && !tempHide) { + c.children.tempHide.dispatchChangeValueAction(true); + } + }); + }, [columns]); + + return ( + setVisible(false)} + width={160} + contentMaxHeight={150} + scrollable + content={{checkViews}} + footer={ + + + + {trans("table.selectAll")} + + + } + /> + ); +}); + +ColumnSetting.displayName = 'ColumnSetting'; + +const ToolbarPopover = memo(function ToolbarPopover(props: { + visible: boolean; + setVisible: (v: boolean) => void; + Icon: React.FC>; + iconClassName: string; + content: JSX.Element; +}) { + const { visible, setVisible, Icon, iconClassName, content } = props; + + const handleVisibleChange = useCallback((v: boolean) => { + setVisible(v); + }, [setVisible]); + + return ( + + + + ); +}); + +ToolbarPopover.displayName = 'ToolbarPopover'; + +export const TableToolbar = memo(function TableToolbar(props: { + toolbar: ToolbarRowType; + pagination: PaginationProps; + columns: Array; + onRefresh: () => void; + onDownload: () => void; + onEvent: TableOnEventView; +}) { + const { + toolbar, + pagination, + columns, + onRefresh, + onDownload, + onEvent, + } = props; + const [columnSettingVisible, setColumnSettingVisible] = useState(false); + + + const handleRefresh = useCallback(() => { + onRefresh(); + onEvent("refresh"); + }, [onRefresh, onEvent]); + + const handleDownload = useCallback(() => { + onDownload(); + onEvent("download"); + }, [onDownload, onEvent]); + + return ( + + + + {toolbar.showRefresh && ( + + )} + {toolbar.showDownload && ( + + )} + {toolbar.columnSetting && ( + } + /> + )} + + { + pagination.onChange && pagination.onChange(page, pageSize); + if (page !== pagination.current) { + onEvent("pageChange"); + } + }} + /> + + + ); +}); + +TableToolbar.displayName = 'TableToolbar'; + +export const TableToolbarComp = (function () { + const childrenMap = { + showRefresh: BoolControl, + showDownload: BoolControl, + columnSetting: BoolControl, + fixedToolbar: BoolControl, + // searchText: StringControl, + position: dropdownControl(positionOptions, "below"), + columnSeparator: withDefault(StringControl, ','), + }; + + return new ControlNodeCompBuilder(childrenMap, (props, dispatch) => { + return { + ...props, + + }; + }) + .setPropertyViewFn((children) => [ + children.position.propertyView({ label: trans("table.position"), radioButton: true }), + children.fixedToolbar.propertyView({ + label: trans("table.fixedToolbar"), + tooltip: trans("table.fixedToolbarTooltip") + }), + children.showRefresh.propertyView({ label: trans("table.showRefresh") }), + children.showDownload.propertyView({ label: trans("table.showDownload") }), + children.showDownload.getView() && children.columnSeparator.propertyView({ + label: trans("table.columnSeparator"), + tooltip: trans("table.columnSeparatorTooltip"), + }), + children.columnSetting.propertyView({ label: trans("table.columnSetting") }), + ]) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableTypes.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableTypes.tsx new file mode 100644 index 000000000..023b80433 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableTypes.tsx @@ -0,0 +1,243 @@ +import { ColumnListComp } from "./column/tableColumnListComp"; +import { TableToolbarComp } from "./tableToolbarComp"; +import { BoolControl, BoolPureControl } from "comps/controls/boolControl"; +import { + ArrayStringControl, + BoolCodeControl, + ColorOrBoolCodeControl, + HeightOrBoolCodeControl, + JSONObjectArrayControl, + RadiusControl, + StringControl, +} from "comps/controls/codeControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { styleControl } from "comps/controls/styleControl"; +import { TableColumnStyle, TableRowStyle, TableStyle, TableToolbarStyle, TableHeaderStyle, TableSummaryRowStyle } from "comps/controls/styleControlConstants"; +import { + MultiCompBuilder, + stateComp, + UICompBuilder, + valueComp, + withContext, + withDefault, +} from "comps/generators"; +import { uiChildren } from "comps/generators/uiCompBuilder"; +import { withIsLoadingMethod } from "comps/generators/withIsLoading"; +import { trans } from "i18n"; +import { + ConstructorToView, + RecordConstructorToComp, + RecordConstructorToView, +} from "lowcoder-core"; +import { controlItem } from "lowcoder-design"; +import { JSONArray, JSONObject } from "util/jsonTypes"; +import { PaginationControl } from "./paginationControl"; +import { SelectionControl } from "./selectionControl"; +import { AutoHeightControl } from "comps/controls/autoHeightControl"; + + + +const summarRowsOptions = [ + { + label: "1", + value: "1", + }, + { + label: "2", + value: "2", + }, + { + label: "3", + value: "3", + }, +] as const; + +const sizeOptions = [ + { + label: trans("table.small"), + value: "small", + }, + { + label: trans("table.middle"), + value: "middle", + }, + { + label: trans("table.large"), + value: "large", + }, +] as const; + +export const TableEventOptions = [ + { + label: trans("table.rowSelectChange"), + value: "rowSelectChange", + description: trans("table.rowSelectChange"), + }, + { + label: trans("table.rowClick"), + value: "rowClick", + description: trans("table.rowClick"), + }, + + { + label: trans("table.search"), + value: "dataSearch", + description: trans("table.search"), + }, + { + label: trans("table.download"), + value: "download", + description: trans("table.download"), + }, + { + label: trans("table.filterChange"), + value: "filterChange", + description: trans("table.filterChange"), + }, + { + label: trans("table.sortChange"), + value: "sortChange", + description: trans("table.sortChange"), + }, + { + label: trans("table.pageChange"), + value: "pageChange", + description: trans("table.pageChange"), + }, + { + label: trans("table.refresh"), + value: "refresh", + description: trans("table.refresh"), + }, + { + label: trans("event.doubleClick"), + value: "doubleClick", + description: trans("event.doubleClickDesc"), + } +] as const; + +export type TableEventOptionValues = typeof TableEventOptions[number]['value']; + +export type SortValue = { + column?: string; + desc?: boolean; +}; + +const TableEventControl = eventHandlerControl(TableEventOptions); + +const rowColorLabel = trans("table.rowColor"); +const RowColorTempComp = withContext( + new MultiCompBuilder({ + color: ColorOrBoolCodeControl, + }, (props) => props.color) + .setPropertyViewFn((children) => + children.color.propertyView({ + label: rowColorLabel, + tooltip: trans("table.rowColorDesc"), + }) + ) + .build(), + ["currentRow", "currentIndex", "currentOriginalIndex", "columnTitle"] as const +); + +// @ts-ignore +export class RowColorComp extends RowColorTempComp { + override getPropertyView() { + return controlItem({ filterText: rowColorLabel }, super.getPropertyView()); + } +} + +// fixme, should be infer from RowColorComp, but withContext type incorrect +export type RowColorViewType = (param: { + currentRow: any; + currentIndex: number; + currentOriginalIndex: number | string; + columnTitle: string; +}) => string; + +const rowHeightLabel = trans("table.rowHeight"); +const RowHeightTempComp = withContext( + new MultiCompBuilder({ + height: HeightOrBoolCodeControl, + }, (props) => props.height) + .setPropertyViewFn((children) => + children.height.propertyView({ + label: rowHeightLabel, + tooltip: trans("table.rowHeightDesc"), + }) + ) + .build(), + ["currentRow", "currentIndex", "currentOriginalIndex", "columnTitle"] as const +); + +// @ts-ignore +export class RowHeightComp extends RowHeightTempComp { + override getPropertyView() { + return controlItem({ filterText: rowHeightLabel }, super.getPropertyView()); + } +} + +// fixme, should be infer from RowHeightComp, but withContext type incorrect +export type RowHeightViewType = (param: { + currentRow: any; + currentIndex: number; + currentOriginalIndex: number | string; + columnTitle: string; +}) => string; + +const tableChildrenMap = { + showHeaderGridBorder: BoolControl, + showRowGridBorder: withDefault(BoolControl,true), + showHRowGridBorder: withDefault(BoolControl,true), + hideHeader: BoolControl, + fixedHeader: BoolControl, + autoHeight: withDefault(AutoHeightControl, "auto"), + showVerticalScrollbar: BoolControl, + showHorizontalScrollbar: BoolControl, + data: withIsLoadingMethod(JSONObjectArrayControl), + newData: stateComp([]), + columns: ColumnListComp, + size: dropdownControl(sizeOptions, "middle"), + selection: SelectionControl, + pagination: PaginationControl, + sort: valueComp>([]), + toolbar: TableToolbarComp, + showSummary: BoolControl, + summaryRows: dropdownControl(summarRowsOptions, "1"), + style: styleControl(TableStyle, 'style'), + rowStyle: styleControl(TableRowStyle, 'rowStyle'), + summaryRowStyle: styleControl(TableSummaryRowStyle, 'summaryRowStyle'), + toolbarStyle: styleControl(TableToolbarStyle, 'toolbarStyle'), + hideToolbar: withDefault(BoolControl,false), + headerStyle: styleControl(TableHeaderStyle, 'headerStyle'), + searchText: StringControl, + columnsStyle: styleControl(TableColumnStyle, 'columnsStyle'), + viewModeResizable: BoolControl, + visibleResizables: BoolControl, + // sample data for regenerating columns + dataRowExample: stateComp(null), + onEvent: TableEventControl, + loading: BoolCodeControl, + rowColor: RowColorComp, + rowAutoHeight: withDefault(AutoHeightControl, "auto"), + tableAutoHeight: withDefault(AutoHeightControl, "auto"), + rowHeight: RowHeightComp, + dynamicColumn: BoolPureControl, + // todo: support object config + dynamicColumnConfig: ArrayStringControl, + selectedCell: stateComp({}), +}; + +export const TableInitComp = (function () { + return new UICompBuilder(tableChildrenMap, () => { + return <>>; + }) + .setPropertyViewFn(() => <>>) + .build(); +})(); + +const uiChildrenMap = uiChildren(tableChildrenMap); +export type TableChildrenType = RecordConstructorToComp; +export type TableChildrenView = RecordConstructorToView; +export type TableOnEventView = ConstructorToView; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableUtils.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableUtils.tsx new file mode 100644 index 000000000..af5dce28c --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableUtils.tsx @@ -0,0 +1,484 @@ +import { + ColumnType, + FilterValue, + SorterResult, + TableCurrentDataSource, + TablePaginationConfig, +} from "antd/es/table/interface"; +import type { SortOrder } from "antd/es/table/interface"; +import { __COLUMN_DISPLAY_VALUE_FN } from "./column/columnTypeCompBuilder"; +import { CellColorViewType, RawColumnType, Render } from "./column/tableColumnComp"; +import { SortValue, TableOnEventView } from "./tableTypes"; +import _ from "lodash"; +import { changeChildAction, CompAction, NodeToValue } from "lowcoder-core"; +import { tryToNumber } from "util/convertUtils"; +import { JSONObject, JSONValue } from "util/jsonTypes"; +import { StatusType } from "./column/columnTypeComps/columnStatusComp"; +import { ColumnListComp, tableDataRowExample } from "./column/tableColumnListComp"; +import { TableColumnLinkStyleType, TableColumnStyleType } from "comps/controls/styleControlConstants"; +import Tooltip from "antd/es/tooltip"; +import dayjs from "dayjs"; + +export const COLUMN_CHILDREN_KEY = "children"; +export const OB_ROW_ORI_INDEX = "__ob_origin_index"; +export const OB_ROW_RECORD = "__ob_origin_record"; + +export const COL_MIN_WIDTH = 55; +export const COL_MAX_WIDTH = 500; + +/** + * Add __originIndex__, mainly for the logic of the default key + */ +export type RecordType = JSONObject & { [OB_ROW_ORI_INDEX]: string }; + +export function filterData( + data: Array, + searchValue: string, + filter: { filters: any[], stackType: string }, + showFilter: boolean +) { + let resultData = data; + if (searchValue) { + resultData = resultData.filter((row) => { + let searchLower = searchValue?.toLowerCase(); + if (!searchLower) { + return true; + } else { + return Object.values(row).find((v) => v?.toString().toLowerCase().includes(searchLower)); + } + }); + } + if (showFilter && filter.filters.length > 0) { + resultData = resultData.filter((row) => { + // filter + for (let f of filter.filters) { + const columnValue = row[f.columnKey]; + const result = f.operator.filter(f.filterValue, columnValue); + if (filter.stackType === "or" && result) { + // one condition is met + return true; + } else if (filter.stackType === "and" && !result) { + // one condition is not met + return false; + } + } + if (filter.filters.length === 0) { + return true; + } else if (filter.stackType === "and") { + return true; + } else if (filter.stackType === "or") { + return false; + } + return true; + }); + } + return resultData; +} + +export function sortData( + data: Array, + columns: Record, // key: dataIndex + sorter: Array +): Array { + let resultData: Array = data.map((row, index) => ({ + ...row, + [OB_ROW_ORI_INDEX]: index + "", + })); + if (sorter.length > 0) { + const [sortColumns, sortMethods] = _(sorter) + .filter((s) => { + return !!s.column && columns[s.column]?.sortable; + }) + .map((s) => [s.column, s.desc ? "desc" : "asc"] as const) + .unzip() + .value() as [string[], ("desc" | "asc")[]]; + resultData = _.orderBy( + resultData, + sortColumns.map((colName) => { + return (obj) => { + const val = obj[colName]; + if (typeof val === "string") { + return val.toLowerCase(); + } else { + return val; + } + }; + }), + sortMethods + ); + } + return resultData; +} + +export function columnHide({ + hide, + tempHide, + enableColumnSetting, +}: { + hide: boolean; + tempHide: boolean; + enableColumnSetting: boolean; +}) { + if (enableColumnSetting) { + return tempHide || hide; + } else { + return hide; + } +} + +export function buildOriginIndex(index: string, childIndex: string) { + return index + "-" + childIndex; +} + +export function tranToTableRecord(dataObj: JSONObject, index: string | number): RecordType { + const indexString = index + ""; + if (Array.isArray(dataObj[COLUMN_CHILDREN_KEY])) { + return { + ...dataObj, + [OB_ROW_ORI_INDEX]: indexString, + children: dataObj[COLUMN_CHILDREN_KEY].map((child: any, i: number) => + tranToTableRecord(child, buildOriginIndex(indexString, i + "")) + ), + }; + } + return { + ...dataObj, + [OB_ROW_ORI_INDEX]: indexString, + }; +} + +export function getOriDisplayData( + data: Array, + pageSize: number, + columns: Array<{ dataIndex: string; render: NodeToValue> }> +) { + return data.map((row, idx) => { + const displayData: RecordType = { [OB_ROW_ORI_INDEX]: row[OB_ROW_ORI_INDEX] }; + columns.forEach((col) => { + // if (!row.hasOwnProperty(col.dataIndex)) return; + const node = col.render.wrap({ + currentCell: row[col.dataIndex], + currentRow: _.omit(row, OB_ROW_ORI_INDEX), + currentIndex: idx % pageSize, + currentOriginalIndex: row[OB_ROW_ORI_INDEX], + }) as any; + if (Array.isArray(row[COLUMN_CHILDREN_KEY])) { + displayData[COLUMN_CHILDREN_KEY] = getOriDisplayData( + row[COLUMN_CHILDREN_KEY] as Array, + pageSize, + columns + ); + } + const colValue = node.comp[__COLUMN_DISPLAY_VALUE_FN](node.comp); + if (colValue !== null) { + displayData[col.dataIndex] = colValue; + } + }); + return displayData; + }); +} + +export function transformDispalyData( + oriDisplayData: JSONObject[], + dataIndexTitleDict: _.Dictionary +): JSONObject[] { + return oriDisplayData.map((row) => { + const transData = _(row) + .omit(OB_ROW_ORI_INDEX) + .mapKeys((value, key) => dataIndexTitleDict[key] || key) + .value(); + if (Array.isArray(row[COLUMN_CHILDREN_KEY])) { + return { + ...transData, + [COLUMN_CHILDREN_KEY]: transformDispalyData( + row[COLUMN_CHILDREN_KEY] as JSONObject[], + dataIndexTitleDict + ), + }; + } + return transData; + }); +} + +export type ColumnsAggrData = Record & { compType: string }>; + +export function getColumnsAggr( + oriDisplayData: JSONObject[], + dataIndexWithParamsDict: NodeToValue< + ReturnType["withParamsNode"]> + > +): 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) + .map((row) => row[dataIndex]!) + .filter((tag) => !!tag) + .uniq() + .value(); + } else if (compType === "badgeStatus") { + res.uniqueStatus = _(oriDisplayData) + .map((row) => { + const value = row[dataIndex] as any; + if (value.split(" ")[1]) { + return { + status: value.slice(0, value.indexOf(" ")), + text: value.slice(value.indexOf(" ") + 1), + }; + } else { + return { + status: value, + text: "", + }; + } + }) + .uniqBy("text") + .value(); + } + return res; + }); +} + +function renderTitle(props: { title: string; tooltip: string }) { + const { title, tooltip } = props; + return ( + + + + {title} + + + + ); +} + +function getInitialColumns( + columnsAggrData: ColumnsAggrData, + customColumns: string[], +) { + let initialColumns = []; + Object.keys(columnsAggrData).forEach(column => { + if(customColumns.includes(column)) return; + initialColumns.push({ + label: column, + value: `{{currentRow.${column}}}` + }); + }); + initialColumns.push({ + label: 'Select with handlebars', + value: '{{currentCell}}', + }) + return initialColumns; +} + +export type CustomColumnType = ColumnType & { + onWidthResize?: (width: number) => void; + titleText: string; + style: TableColumnStyleType; + linkStyle: TableColumnLinkStyleType; + cellColorFn: CellColorViewType; +}; + +/** + * convert column in raw format into antd format + */ +export function columnsToAntdFormat( + columns: Array, + sort: SortValue[], + enableColumnSetting: boolean, + size: string, + dynamicColumn: boolean, + dynamicColumnConfig: Array, + columnsAggrData: ColumnsAggrData, + onTableEvent: (eventName: any) => void, +): Array> { + const customColumns = columns.filter(col => col.isCustom).map(col => col.dataIndex); + const initialColumns = getInitialColumns(columnsAggrData, customColumns); + const sortMap: Map = new Map( + sort.map((s) => [s.column, s.desc ? "descend" : "ascend"]) + ); + const sortedColumns = _.sortBy(columns, (c) => { + if (c.fixed === "left") { + return -1; + } else if (c.fixed === "right") { + return Number.MAX_SAFE_INTEGER; + } else if (dynamicColumnConfig.length > 0) { + // sort by dynamic config array + const index = dynamicColumnConfig.indexOf(c.isCustom ? c.title : c.dataIndex); + if (index >= 0) { + return index; + } + } + return 0; + }); + return sortedColumns.flatMap((column, mIndex) => { + if ( + columnHide({ + hide: column.hide, + tempHide: column.tempHide, + enableColumnSetting: enableColumnSetting, + }) + ) { + return []; + } + if ( + dynamicColumn && + dynamicColumnConfig.length > 0 && + !dynamicColumnConfig.includes(column.isCustom ? column.title : column.dataIndex) + ) { + return []; + } + const tags = ((columnsAggrData[column.dataIndex] ?? {}).uniqueTags ?? []) as string[]; + const status = ((columnsAggrData[column.dataIndex] ?? {}).uniqueStatus ?? []) as { + text: string; + status: StatusType; + }[]; + const title = renderTitle({ title: column.title, tooltip: column.titleTooltip }); + + return { + key: `${column.dataIndex}-${mIndex}`, + title: column.showTitle ? title : '', + titleText: column.title, + dataIndex: column.dataIndex, + align: column.align, + width: column.autoWidth === "auto" ? 0 : column.width, + fixed: column.fixed === "close" ? false : column.fixed, + style: { + background: column.background, + margin: column.margin, + text: column.text, + border: column.border, + radius: column.radius, + textSize: column.textSize, + textWeight: column.textWeight, + fontStyle:column.fontStyle, + fontFamily: column.fontFamily, + borderWidth: column.borderWidth, + }, + linkStyle: { + text: column.linkColor, + hoverText: column.linkHoverColor, + activeText: column.linkActiveColor, + }, + cellColorFn: column.cellColor, + onWidthResize: column.onWidthResize, + render: (value: any, record: RecordType, index: number) => { + const row = _.omit(record, OB_ROW_ORI_INDEX); + return column + .render( + { + currentCell: value, + currentRow: row, + currentIndex: index, + currentOriginalIndex: tryToNumber(record[OB_ROW_ORI_INDEX]), + initialColumns, + }, + String(record[OB_ROW_ORI_INDEX]) + ) + .getView() + .view({ + tableSize: size, + candidateTags: tags, + candidateStatus: status, + textOverflow: column.textOverflow, + cellTooltip: column.cellTooltip({ + currentCell: value, + currentRow: row, + currentIndex: index, + }), + onTableEvent, + cellIndex: `${column.dataIndex}-${index}`, + }); + }, + ...(column.sortable + ? { + sorter: { + multiple: (sortedColumns.length - mIndex) + 1, + compare: column.columnType === 'date' || column.columnType === 'dateTime' + ? (a,b) => { + return dayjs(a[column.dataIndex] as string).unix() - dayjs(b[column.dataIndex] as string).unix(); + } + : undefined + }, + sortOrder: sortMap.get(column.dataIndex), + showSorterTooltip: false, + } + : {}), + }; + }); +} + +function getSortValue(sortResult: SorterResult) { + return sortResult.column?.dataIndex + ? { + column: sortResult.column.dataIndex.toString(), + desc: sortResult.order === "descend", + } + : null; +} + +export function onTableChange( + pagination: TablePaginationConfig, + filters: Record, + sorter: SorterResult | SorterResult[], + extra: TableCurrentDataSource, + dispatch: (action: CompAction) => void, + onEvent: TableOnEventView +) { + if (extra.action === "sort") { + let sortValues: SortValue[] = []; + if (Array.isArray(sorter)) { + // multi-column sort + sorter.forEach((s) => { + const v = getSortValue(s); + v && sortValues.push(v); + }); + } else { + const v = getSortValue(sorter); + v && sortValues.push(v); + } + dispatch(changeChildAction("sort", sortValues, true)); + onEvent("sortChange"); + } +} + +export function calcColumnWidth(columnKey: string, data: Array) { + const getWidth = (str: string) => { + const byteLength = new Blob([str]).size; + return str.length === byteLength ? str.length * 10 : str.length * 20; + }; + const cellWidth = + _.max( + data.map((d) => { + const cellValue = d[columnKey]; + if (!cellValue) { + return COL_MIN_WIDTH; + } + return getWidth(cellValue.toString()); + }) + ) || 0; + const titleWidth = getWidth(columnKey); + return Math.max(Math.min(COL_MAX_WIDTH, Math.max(titleWidth, cellWidth) + 10), COL_MIN_WIDTH); +} + +export function genSelectionParams( + filterData: RecordType[], + selection: string +): Record | undefined { + const idx = filterData?.findIndex((row) => row[OB_ROW_ORI_INDEX] === selection); + if (!Boolean(filterData) || idx < 0) { + return undefined; + } + const currentRow = filterData[idx]; + return { + currentRow: _.omit(currentRow, OB_ROW_ORI_INDEX), + currentIndex: idx, + currentOriginalIndex: tryToNumber(currentRow[OB_ROW_ORI_INDEX]), + }; +} + +export function supportChildrenTree(data: Array) { + const rowSample = tableDataRowExample(data) as any; + return rowSample && Array.isArray(rowSample[COLUMN_CHILDREN_KEY]); +} diff --git a/client/packages/lowcoder/src/comps/index.tsx b/client/packages/lowcoder/src/comps/index.tsx index 609ddf5b0..0bbf0b731 100644 --- a/client/packages/lowcoder/src/comps/index.tsx +++ b/client/packages/lowcoder/src/comps/index.tsx @@ -122,6 +122,8 @@ import { import { ModuleComp } from "./comps/moduleComp/moduleComp"; import { TableComp } from "./comps/tableComp/tableComp"; import { defaultTableData } from "./comps/tableComp/mockTableComp"; +import { TableLiteComp } from "./comps/tableLiteComp/tableComp"; +import { defaultTableData as defaultTableLiteData } from "./comps/tableLiteComp/mockTableComp"; import { ContainerComp, defaultContainerData } from "./comps/containerComp/containerComp"; import { ColumnLayoutComp } from "./comps/columnLayout/columnLayout"; import { TabbedContainerComp } from "./comps/tabs/tabbedContainerComp"; @@ -489,6 +491,22 @@ export var uiCompMap: Registry = { defaultDataFn: defaultTableData, }, + tableLite: { + name: trans("uiComp.tableLiteCompName"), + enName: "Table Lite", + description: trans("uiComp.tableLiteCompDesc"), + categories: ["dashboards", "projectmanagement"], + icon: TableCompIcon, + keywords: trans("uiComp.tableLiteCompKeywords"), + comp: TableLiteComp, + layoutInfo: { + w: 12, + h: 40, + }, + withoutLoading: true, + defaultDataFn: defaultTableLiteData, + }, + pivotTable: { name: trans("uiComp.pivotTableCompName"), enName: "pivotTable", diff --git a/client/packages/lowcoder/src/comps/uiCompRegistry.ts b/client/packages/lowcoder/src/comps/uiCompRegistry.ts index 07f0e54b4..f8e09763c 100644 --- a/client/packages/lowcoder/src/comps/uiCompRegistry.ts +++ b/client/packages/lowcoder/src/comps/uiCompRegistry.ts @@ -93,6 +93,7 @@ export type UICompType = | "dropdown" | "text" | "table" + | "tableLite" | "image" | "progress" | "progressCircle" diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 80f667288..1b3ecab91 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -1133,6 +1133,10 @@ export const en = { "tableCompDesc": "A rich table component for displaying data in a structured table format, with options for sorting and filtering, tree Data display and extensible Rows.", "tableCompKeywords": "table, data, sorting, filtering", + "tableLiteCompName": "Table Lite", + "tableLiteCompDesc": "A lightweight, high-performance table component optimized for displaying many rows fast, with essential sorting and filtering capabilities.", + "tableLiteCompKeywords": "table, data, sorting, filtering, performance, lite", + "imageCompName": "Image", "imageCompDesc": "A component for displaying images, supporting various formats based on URI or Base64 Data.", "imageCompKeywords": "image, display, media, Base64", diff --git a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx index beea9cae7..54cd5faaf 100644 --- a/client/packages/lowcoder/src/pages/editor/editorConstants.tsx +++ b/client/packages/lowcoder/src/pages/editor/editorConstants.tsx @@ -237,6 +237,7 @@ export const CompStateIcon: { signature: , step: , table: , + tableLite: , text: , multiTags: , timeline: , pFad - Phonifier reborn Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved. Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages. Alternative Proxies:Alternative ProxypFad ProxypFad v3 ProxypFad v4 Proxy
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy