From 26640829f7d077855af3686010057b10b16a3cc2 Mon Sep 17 00:00:00 2001 From: Faran Javed Date: Tue, 5 Aug 2025 20:10:56 +0500 Subject: [PATCH 1/7] clone original Table Comp --- .../tableLiteComp/column/columnTypeComp.tsx | 170 +++ .../column/columnTypeCompBuilder.tsx | 212 +++ .../columnTypeComps/ColumnNumberComp.tsx | 224 ++++ .../columnTypeComps/columnAvatarsComp.tsx | 251 ++++ .../columnTypeComps/columnBooleanComp.tsx | 201 +++ .../column/columnTypeComps/columnDateComp.tsx | 283 ++++ .../columnTypeComps/columnDateTimeComp.tsx | 90 ++ .../columnTypeComps/columnDropdownComp.tsx | 200 +++ .../column/columnTypeComps/columnImgComp.tsx | 143 ++ .../column/columnTypeComps/columnLinkComp.tsx | 145 ++ .../columnTypeComps/columnLinksComp.tsx | 139 ++ .../columnTypeComps/columnMarkdownComp.tsx | 128 ++ .../columnTypeComps/columnProgressComp.tsx | 166 +++ .../columnTypeComps/columnRatingComp.tsx | 139 ++ .../columnTypeComps/columnSelectComp.tsx | 232 ++++ .../columnTypeComps/columnStatusComp.tsx | 192 +++ .../columnTypeComps/columnSwitchComp.tsx | 168 +++ .../column/columnTypeComps/columnTagsComp.tsx | 465 +++++++ .../column/columnTypeComps/columnTimeComp.tsx | 193 +++ .../column/columnTypeComps/simpleTextComp.tsx | 121 ++ .../column/simpleColumnTypeComps.tsx | 129 ++ .../tableLiteComp/column/tableColumnComp.tsx | 488 +++++++ .../column/tableColumnListComp.tsx | 202 +++ .../column/tableSummaryColumnComp.tsx | 205 +++ .../comps/tableLiteComp/expansionControl.tsx | 116 ++ .../src/comps/comps/tableLiteComp/index.tsx | 1 + .../comps/tableLiteComp/mockTableComp.tsx | 72 + .../comps/tableLiteComp/paginationControl.tsx | 90 ++ .../comps/tableLiteComp/selectionControl.tsx | 134 ++ .../comps/tableLiteComp/tableComp.test.tsx | 213 +++ .../comps/comps/tableLiteComp/tableComp.tsx | 1019 ++++++++++++++ .../comps/tableLiteComp/tableCompView.tsx | 1176 +++++++++++++++++ .../comps/tableLiteComp/tableContext.tsx | 12 + .../tableLiteComp/tableDynamicColumn.test.tsx | 241 ++++ .../comps/tableLiteComp/tablePropertyView.tsx | 650 +++++++++ .../comps/tableLiteComp/tableSummaryComp.tsx | 291 ++++ .../comps/tableLiteComp/tableToolbarComp.tsx | 947 +++++++++++++ .../comps/comps/tableLiteComp/tableTypes.tsx | 282 ++++ .../comps/comps/tableLiteComp/tableUtils.tsx | 492 +++++++ 39 files changed, 10622 insertions(+) create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeCompBuilder.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/ColumnNumberComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDateTimeComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinkComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnProgressComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnRatingComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSelectComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnStatusComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnSwitchComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTagsComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnTimeComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/simpleTextComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/expansionControl.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableSummaryComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableToolbarComp.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableTypes.tsx create mode 100644 client/packages/lowcoder/src/comps/comps/tableLiteComp/tableUtils.tsx diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComp.tsx new file mode 100644 index 0000000000..ff3df44c4e --- /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 0000000000..b401761d26 --- /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 0000000000..619b42674f --- /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 0000000000..f02ee19943 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnAvatarsComp.tsx @@ -0,0 +1,251 @@ +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { StringControl } from "comps/controls/codeControl"; +import { MultiCompBuilder, stateComp, withDefault } from "comps/generators"; +import { trans } from "i18n"; +import styled from "styled-components"; +import { LightActiveTextColor, PrimaryColor } from "constants/style"; +import { styleControl } from "comps/controls/styleControl"; +import { avatarGroupStyle, AvatarGroupStyleType } from "comps/controls/styleControlConstants"; +import { AlignCenter, AlignLeft, AlignRight } from "lowcoder-design"; +import { NumberControl } from "comps/controls/codeControl"; +import { Avatar, Tooltip } from "antd"; +import { clickEvent, eventHandlerControl, refreshEvent, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import React, { ReactElement, useCallback, useEffect, useRef } from "react"; +import { IconControl } from "comps/controls/iconControl"; +import { ColorControl } from "comps/controls/colorControl"; +import { optionsControl } from "comps/controls/optionsControl"; +import { BoolControl } from "comps/controls/boolControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { JSONObject } from "util/jsonTypes"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const MacaroneList = [ + '#fde68a', + '#eecff3', + '#a7f3d0', + '#bfdbfe', + '#bfdbfe', + '#c7d2fe', + '#fecaca', + '#fcd6bb', +] + +const Container = styled.div<{ $style: AvatarGroupStyleType | undefined, alignment: string }>` + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: ${props => props.alignment}; + cursor: pointer; +`; + +const AvatarEventOptions = [clickEvent, refreshEvent] as const; + +const DropdownOption = new MultiCompBuilder( + { + src: StringControl, + AvatarIcon: IconControl, + label: StringControl, + color: ColorControl, + backgroundColor: ColorControl, + Tooltip: StringControl, + onEvent: eventHandlerControl(AvatarEventOptions), + }, + (props) => props +) +.setPropertyViewFn((children) => { + return ( + <> + {children.src.propertyView({ label: trans("avatarComp.src"), placeholder: "", tooltip: trans("avatarComp.avatarCompTooltip") })} + {children.label.propertyView({label: trans("avatarComp.title"), tooltip: trans("avatarComp.avatarCompTooltip"), + })} + {children.AvatarIcon.propertyView({ + label: trans("avatarComp.icon"), + IconType: "All", + tooltip: trans("avatarComp.avatarCompTooltip"), + })} + {children.color.propertyView({ label: trans("style.fill") })} + {children.backgroundColor.propertyView({ label: trans("style.background") })} + {children.Tooltip.propertyView({ label: trans("badge.tooltip") })} + {children.onEvent.propertyView()} + + ); +}) +.build(); + +const EventOptions = [clickEvent, refreshEvent, doubleClickEvent] as const; + +export const alignOptions = [ + { label: , value: "flex-start" }, + { label: , value: "center" }, + { label: , value: "flex-end" }, +] as const; + +// Memoized Avatar component +const MemoizedAvatar = React.memo(({ + item, + index, + style, + autoColor, + avatarSize, + onEvent, + onItemEvent +}: { + item: any; + index: number; + style: any; + autoColor: boolean; + avatarSize: number; + onEvent: (event: string) => void; + onItemEvent?: (event: string) => void; +}) => { + const mountedRef = useRef(true); + const handleClickEvent = useCompClickEventHandler({onEvent}) + + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(() => { + if (!mountedRef.current) return; + + // Trigger individual avatar event first + if (onItemEvent) { + onItemEvent("click"); + } + + // Then trigger main component event + handleClickEvent() + }, [onItemEvent, handleClickEvent]); + + return ( + + + {item.label} + + + ); +}); + +MemoizedAvatar.displayName = 'MemoizedAvatar'; + +// Memoized Avatar Group component +const MemoizedAvatarGroup = React.memo(({ + avatars, + maxCount, + avatarSize, + style, + autoColor, + onEvent +}: { + avatars: any[]; + maxCount: number; + avatarSize: number; + style: any; + autoColor: boolean; + onEvent: (event: string) => void; +}) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + return ( + + {avatars.map((item, index) => ( + + ))} + + ); +}); + +MemoizedAvatarGroup.displayName = 'MemoizedAvatarGroup'; + +export const ColumnAvatarsComp = (function () { + const childrenMap = { + style: styleControl(avatarGroupStyle), + maxCount: withDefault(NumberControl, 3), + avatarSize: withDefault(NumberControl, 40), + alignment: dropdownControl(alignOptions, "center"), + autoColor: BoolControl.DEFAULT_TRUE, + onEvent: eventHandlerControl(EventOptions), + currentAvatar: stateComp({}), + avatars: optionsControl(DropdownOption, { + initOptions: [ + { src: "https://api.dicebear.com/7.x/miniavs/svg?seed=1", label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + { AvatarIcon: "/icon:antd/startwotone" }, + { label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + { label: String.fromCharCode(65 + Math.ceil(Math.random() * 25)) }, + ], + }) + }; + + return new ColumnTypeCompBuilder( + childrenMap, + (props, dispatch) => { + return ( + + + + ); + }, + () => "" + ) + .setPropertyViewFn((children) => ( + <> + {children.avatars.propertyView({})} + {children.maxCount.propertyView({ + label: trans("avatarGroup.maxCount") + })} + {children.avatarSize.propertyView({ + label: trans("avatarGroup.avatarSize") + })} + {children.autoColor.propertyView({ + label: trans("avatarGroup.autoColor") + })} + {children.alignment.propertyView({ + label: trans("table.avatarGroupAlignment"), + radioButton: true, + })} + {children.onEvent.propertyView()} + + )) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnBooleanComp.tsx new file mode 100644 index 0000000000..d1d530eb6c --- /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 0000000000..55168b1515 --- /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 0000000000..181fccba7b --- /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 0000000000..b78601a5fa --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnDropdownComp.tsx @@ -0,0 +1,200 @@ +import React, { ReactNode, useCallback, useRef, useEffect, useMemo, ReactElement } from "react"; +import { DropdownOptionControl } from "comps/controls/optionsControl"; +import { StringControl } from "comps/controls/codeControl"; +import { trans } from "i18n"; +import { ColumnTypeCompBuilder, ColumnTypeViewFn } from "../columnTypeCompBuilder"; +import Menu from "antd/es/menu"; +import Dropdown from "antd/es/dropdown"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { IconControl } from "comps/controls/iconControl"; +import { withDefault } from "comps/generators"; +import { IconWrapper } from "util/bottomResUtils"; +import { ButtonTypeOptions } from "../simpleColumnTypeComps"; +import { useStyle } from "comps/controls/styleControl"; +import { ButtonStyle } from "comps/controls/styleControlConstants"; +import { Button100 } from "comps/comps/buttonComp/buttonCompConstants"; +import styled from "styled-components"; +import { ButtonType } from "antd/es/button"; +import { clickEvent, eventHandlerControl } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; + +const StyledButton = styled(Button100)` + display: flex; + align-items: center; + gap: 0; + min-width: 30px; + width: auto; +`; + +const StyledIconWrapper = styled(IconWrapper)` + margin: 0; +`; + +const DropdownEventOptions = [clickEvent] as const; + +const childrenMap = { + buttonType: dropdownControl(ButtonTypeOptions, "primary"), + label: withDefault(StringControl, 'Menu'), + prefixIcon: IconControl, + suffixIcon: IconControl, + options: DropdownOptionControl, + onEvent: eventHandlerControl(DropdownEventOptions), +}; + +const getBaseValue: ColumnTypeViewFn = (props) => props.label; + +// Memoized dropdown menu component +const DropdownMenu = React.memo(({ items, options, onEvent }: { items: any[]; options: any[]; onEvent: (eventName: string) => void }) => { + const mountedRef = useRef(true); + const handleClickEvent = useCompClickEventHandler({onEvent}) + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const handleClick = useCallback(({ key }: { key: string }) => { + if (!mountedRef.current) return; + const item = items.find((o) => o.key === key); + const itemIndex = options.findIndex(option => option.label === item?.label); + item && options[itemIndex]?.onEvent("click"); + // Also trigger the dropdown's main event handler + handleClickEvent(); + }, [items, options, handleClickEvent]); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }, []); + + return ( + + ); +}); + +DropdownMenu.displayName = 'DropdownMenu'; + +const DropdownView = React.memo((props: { + buttonType: ButtonType; + label: string; + prefixIcon: ReactNode; + suffixIcon: ReactNode; + options: any[]; + onEvent?: (eventName: string) => void; +}) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const hasOptionIcon = useMemo(() => + props.options.findIndex((option) => (option.prefixIcon as ReactElement)?.props.value) > -1, + [props.options] + ); + + const items = useMemo(() => + props.options + .filter((option) => !option.hidden) + .map((option, index) => ({ + title: option.label, + label: option.label, + key: option.label + " - " + index, + disabled: option.disabled, + icon: hasOptionIcon && {option.prefixIcon}, + index, + })), + [props.options, hasOptionIcon] + ); + + const hasPrefixIcon = useMemo(() => + (props.prefixIcon as ReactElement)?.props.value, + [props.prefixIcon] + ); + + const hasSuffixIcon = useMemo(() => + (props.suffixIcon as ReactElement)?.props.value, + [props.suffixIcon] + ); + + const buttonStyle = useStyle(ButtonStyle); + + const menu = useMemo(() => ( + {})} /> + ), [items, props.options, props.onEvent]); + + return ( + menu} + > + + {hasPrefixIcon && ( + + {props.prefixIcon} + + )} + {props.label || (hasPrefixIcon || hasSuffixIcon ? undefined : " ")} + {hasSuffixIcon && ( + + {props.suffixIcon} + + )} + + + ); +}); + +DropdownView.displayName = 'DropdownView'; + +export const ColumnDropdownComp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props) => { + return ; + }, + (nodeValue) => nodeValue.label.value, + getBaseValue, + ) + .setPropertyViewFn((children) => { + return ( + <> + {children.buttonType.propertyView({ + label: trans("table.type"), + radioButton: true, + })} + {children.label.propertyView({ + label: trans("text"), + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.options.propertyView({ + title: trans("optionsControl.optionList"), + })} + {children.onEvent.propertyView()} + + ); + }) + .build(); +})(); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnImgComp.tsx new file mode 100644 index 0000000000..d3d2041016 --- /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 0000000000..e93b3082a6 --- /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 0000000000..5a7fae3d3e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnLinksComp.tsx @@ -0,0 +1,139 @@ +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { default as Menu } from "antd/es/menu"; +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { manualOptionsControl } from "comps/controls/optionsControl"; +import { MultiCompBuilder } from "comps/generators"; +import { disabledPropertyView, hiddenPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import styled from "styled-components"; +import { ColumnLink } from "comps/comps/tableComp/column/columnTypeComps/columnLinkComp"; +import { LightActiveTextColor, PrimaryColor } from "constants/style"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { fixOldActionData } from "comps/comps/tableComp/column/simpleColumnTypeComps"; + +const MenuLinkWrapper = styled.div` + > a { + color: ${PrimaryColor} !important; + + &:hover { + color: ${LightActiveTextColor} !important; + } + } +`; + +const MenuWrapper = styled.div` + ul { + background: transparent !important; + border-bottom: 0; + + li { + padding: 0 10px 0 0 !important; + line-height: normal !important; + + &::after { + content: none !important; + } + } + } +`; + +const LinkEventOptions = [clickEvent, doubleClickEvent] as const; + +// Memoized menu item component +const MenuItem = React.memo(({ option, index }: { option: any; index: number }) => { + return ( + + + + ); +}); + +MenuItem.displayName = 'MenuItem'; + +const OptionItemTmp = new MultiCompBuilder( + { + label: StringControl, + onClick: eventHandlerControl(LinkEventOptions), + hidden: BoolCodeControl, + disabled: BoolCodeControl, + }, + (props) => { + return props; + } +) + .setPropertyViewFn((children) => { + return ( + <> + {children.label.propertyView({ label: trans("label") })} + {hiddenPropertyView(children)} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + ); + }) + .build(); + +const OptionItem = migrateOldData(OptionItemTmp, fixOldActionData); + +// Memoized menu component +const LinksMenu = React.memo(({ options }: { options: any[] }) => { + const mountedRef = useRef(true); + + // Cleanup on unmount + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + const menuItems = useMemo(() => + options + .filter((o) => !o.hidden) + .map((option, index) => ({ + key: index, + label: + })), + [options] + ); + + return ( + + + + ); +}); + +LinksMenu.displayName = 'LinksMenu'; + +const ColumnLinksCompTmp = (function () { + const childrenMap = { + options: manualOptionsControl(OptionItem, { + initOptions: [{ label: trans("table.option1") }], + }), + }; + return new ColumnTypeCompBuilder( + childrenMap, + (props) => { + return ; + }, + () => "" + ) + .setPropertyViewFn((children) => ( + <> + {children.options.propertyView({ + newOptionLabel: trans("table.option"), + title: trans("table.optionList"), + })} + + )) + .build(); +})(); + +export const ColumnLinksComp = migrateOldData(ColumnLinksCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/columnTypeComps/columnMarkdownComp.tsx new file mode 100644 index 0000000000..17ad78efd3 --- /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 0000000000..7e06f4c8ee --- /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 0000000000..fc44cd9367 --- /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 0000000000..b54be87997 --- /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 0000000000..61a8fbc6f8 --- /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 0000000000..0cdeee48a4 --- /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 0000000000..1e6a6e1a8a --- /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 0000000000..f338f0f645 --- /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 0000000000..dcdffe3907 --- /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 0000000000..f9bedc7549 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/simpleColumnTypeComps.tsx @@ -0,0 +1,129 @@ +import { ColumnTypeCompBuilder } from "comps/comps/tableComp/column/columnTypeCompBuilder"; +import { ActionSelectorControlInContext } from "comps/controls/actionSelector/actionSelectorControl"; +import { BoolCodeControl, StringControl } from "comps/controls/codeControl"; +import { dropdownControl } from "comps/controls/dropdownControl"; +import { disabledPropertyView, loadingPropertyView } from "comps/utils/propertyUtils"; +import { trans } from "i18n"; +import { useStyle } from "comps/controls/styleControl"; +import { ButtonStyle } from "comps/controls/styleControlConstants"; +import { Button100 } from "comps/comps/buttonComp/buttonCompConstants"; +import { IconControl } from "comps/controls/iconControl"; +import { hasIcon } from "comps/utils"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { CSSProperties } from "react"; +import { RecordConstructorToComp } from "lowcoder-core"; +import { ToViewReturn } from "@lowcoder-ee/comps/generators/multi"; +import { clickEvent, eventHandlerControl, doubleClickEvent } from "comps/controls/eventHandlerControl"; +import { migrateOldData } from "@lowcoder-ee/comps/generators/simpleGenerators"; +import { useCompClickEventHandler } from "@lowcoder-ee/comps/utils/useCompClickEventHandler"; +import { isArray } from "lodash"; + +export const fixOldActionData = (oldData: any) => { + if (!oldData) return oldData; + if (Boolean(oldData.onClick && !isArray(oldData.onClick))) { + return { + ...oldData, + onClick: [{ + name: "click", + handler: oldData.onClick, + }], + }; + } + return oldData; +} +export const ColumnValueTooltip = trans("table.columnValueTooltip"); + +export const ButtonTypeOptions = [ + { + label: trans("table.primaryButton"), + value: "primary", + }, + { + label: trans("table.defaultButton"), + value: "default", + }, + { + label: trans("table.text"), + value: "text", + }, +] as const; + +const ButtonEventOptions = [clickEvent, doubleClickEvent] as const; + +const childrenMap = { + text: StringControl, + buttonType: dropdownControl(ButtonTypeOptions, "primary"), + onClick: eventHandlerControl(ButtonEventOptions), + loading: BoolCodeControl, + disabled: BoolCodeControl, + prefixIcon: IconControl, + suffixIcon: IconControl, +}; + +const ButtonStyled = React.memo(({ props }: { props: ToViewReturn>}) => { + const style = useStyle(ButtonStyle); + const hasText = !!props.text; + const hasPrefixIcon = hasIcon(props.prefixIcon); + const hasSuffixIcon = hasIcon(props.suffixIcon); + const iconOnly = !hasText && (hasPrefixIcon || hasSuffixIcon); + const handleClickEvent = useCompClickEventHandler({onEvent: props.onClick}) + + const handleClick = useCallback((e: React.MouseEvent) => { + handleClickEvent() + }, [handleClickEvent]); + + const buttonStyle = useMemo(() => ({ + margin: 0, + width: iconOnly ? 'auto' : undefined, + minWidth: iconOnly ? 'auto' : undefined, + padding: iconOnly ? '0 8px' : undefined + } as CSSProperties), [iconOnly]); + + return ( + + {/* prevent the button from disappearing */} + {hasText ? props.text : (iconOnly ? null : " ")} + {hasSuffixIcon && !props.loading && {props.suffixIcon}} + + ); +}); + +const ButtonCompTmp = (function () { + return new ColumnTypeCompBuilder( + childrenMap, + (props) => , + (nodeValue) => nodeValue.text.value + ) + .setPropertyViewFn((children) => ( + <> + {children.text.propertyView({ + label: trans("table.columnValue"), + tooltip: ColumnValueTooltip, + })} + {children.prefixIcon.propertyView({ + label: trans("button.prefixIcon"), + })} + {children.suffixIcon.propertyView({ + label: trans("button.suffixIcon"), + })} + {children.buttonType.propertyView({ + label: trans("table.type"), + radioButton: true, + })} + {loadingPropertyView(children)} + {disabledPropertyView(children)} + {children.onClick.propertyView()} + + )) + .build(); +})(); + +export const ButtonComp = migrateOldData(ButtonCompTmp, fixOldActionData); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx new file mode 100644 index 0000000000..938983ac9e --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnComp.tsx @@ -0,0 +1,488 @@ +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 { EMPTY_ROW_KEY } from "../tableCompView"; +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(); + const insertMapKeys = Object.keys(renderMap).filter(key => key.startsWith(EMPTY_ROW_KEY)); + insertMapKeys.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 0000000000..7ad933cf92 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableColumnListComp.tsx @@ -0,0 +1,202 @@ +import { ColumnComp, newPrimaryColumn } from "comps/comps/tableComp/column/tableColumnComp"; +import { + calcColumnWidth, + COLUMN_CHILDREN_KEY, + supportChildrenTree, +} from "comps/comps/tableComp/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"; +import { EMPTY_ROW_KEY } from "../tableCompView"; + +/** + * 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) => { + const includeChange = filterNewRowsChange + ? key.startsWith(EMPTY_ROW_KEY) + : !key.startsWith(EMPTY_ROW_KEY); + if (!_.isNil(columnChangeSet[dataIndex][key]) && includeChange) { + if (!changeSet[key]) changeSet[key] = {}; + changeSet[key][dataIndex] = columnChangeSet[dataIndex][key]; + } + }); + }); + }); + return changeSet; + } + + dispatchClearChangeSet() { + const columns = this.getView(); + columns.forEach((column) => column.dispatchClearChangeSet()); + } + + dispatchClearInsertSet() { + const columns = this.getView(); + columns.forEach((column) => column.dispatchClearInsertSet()); + } + + /** + * If the table data changes, call this method to trigger the action + */ + dataChangedAction(param: { + rowExample: JSONObject; + doGeneColumn: boolean; + dynamicColumn: boolean; + data: Array; + }) { + return customAction( + { + type: "dataChanged", + ...param, + }, + true + ); + } + + /** + * According to the data, adjust the column + */ + private geneColumnsAction(rowExample: RowExampleType, data: Array) { + // If no data, return directly + if (rowExample === undefined || rowExample === null) { + return []; + } + const dataKeys = Object.keys(rowExample); + if (dataKeys.length === 0) { + return []; + } + const columnsView = this.getView(); + const actions: Array = []; + let deleteCnt = 0; + columnsView.forEach((column, index) => { + if (column.getView().isCustom) { + return; + } + const dataIndex = column.getView().dataIndex; + if (dataIndex === COLUMN_CHILDREN_KEY || !dataKeys.find((key) => dataIndex === key)) { + // to Delete + actions.push(this.deleteAction(index - deleteCnt)); + deleteCnt += 1; + } + }); + // The order should be the same as the data + dataKeys.forEach((key) => { + if (key === COLUMN_CHILDREN_KEY && supportChildrenTree(data)) { + return; + } + if (!columnsView.find((column) => column.getView().dataIndex === key)) { + // to Add + actions.push(this.pushAction(newPrimaryColumn(key, calcColumnWidth(key, data)))); + } + }); + if (actions.length === 0) { + return []; + } + return actions; + } + + withParamsNode() { + const columns = this.getView(); + const nodes = _(columns) + .map((col) => col.children.render.getOriginalComp().node()) + .toPairs() + .fromPairs() + .value(); + const result = lastValueIfEqual( + this, + "withParamsNode", + [fromRecord(nodes), nodes] as const, + (a, b) => shallowEqual(a[1], b[1]) + )[0]; + return result; + } + + getColumnsNode( + field: T + ): RecordNode>> { + const columns = this.getView(); + const nodes = _(columns) + .map((col) => col.children[field].node() as ReturnType) + .toPairs() + .fromPairs() + .value(); + const result = lastValueIfEqual( + this, + "col_nodes_" + field, + [fromRecord(nodes), nodes] as const, + (a, b) => shallowEqual(a[1], b[1]) + )[0]; + return result; + } + + setSelectionAction(key: string) { + return this.forEachAction(ColumnComp.setSelectionAction(key)); + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/column/tableSummaryColumnComp.tsx new file mode 100644 index 0000000000..6b0e2c4068 --- /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/expansionControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/expansionControl.tsx new file mode 100644 index 0000000000..98044cd318 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/expansionControl.tsx @@ -0,0 +1,116 @@ +import { + ContainerBaseProps, + gridItemCompToGridItems, + InnerGrid, +} from "comps/comps/containerComp/containerView"; +import { BoolControl } from "comps/controls/boolControl"; +import { SlotControl } from "comps/controls/slotControl"; +import { withSelectedMultiContext } from "comps/generators"; +import { ControlItemCompBuilder } from "comps/generators/controlCompBuilder"; +import { BackgroundColorContext } from "comps/utils/backgroundColorContext"; +import { trans } from "i18n"; +import _ from "lodash"; +import { ConstructorToView, wrapChildAction } from "lowcoder-core"; +import { createContext, useContext, useCallback } from "react"; +import { tryToNumber } from "util/convertUtils"; +import { SimpleContainerComp } from "../containerBase/simpleContainerComp"; +import { OB_ROW_ORI_INDEX, RecordType } from "./tableUtils"; +import { NameGenerator } from "comps/utils"; +import { JSONValue } from "util/jsonTypes"; + +const ContextSlotControl = withSelectedMultiContext(SlotControl); +export const ExpandViewContext = createContext(false); + +const ContainerView = (props: ContainerBaseProps) => { + return ; +}; + +function ExpandView(props: { containerProps: ConstructorToView }) { + const { containerProps } = props; + const background = useContext(BackgroundColorContext); + return ( + + ); +} + +let ExpansionControlTmp = (function () { + const label = trans("table.expandable"); + return new ControlItemCompBuilder( + { + expandable: BoolControl, + slot: ContextSlotControl, + }, + () => ({ expandableConfig: {}, expandModalView: null }) + ) + .setControlItemData({ filterText: label }) + .setPropertyViewFn((children, dispatch) => { + return ( + <> + {children.expandable.propertyView({ label })} + {children.expandable.getView() && + children.slot + .getSelectedComp() + .getComp() + .propertyView({ buttonText: trans("table.configExpandedView") })} + + ); + }) + .build(); +})(); + +export class ExpansionControl extends ExpansionControlTmp { + getView() { + if (!this.children.expandable.getView()) { + return { expandableConfig: {}, expandModalView: null }; + } + const selectedContainer = this.children.slot.getSelectedComp(); + return { + expandableConfig: { + expandedRowRender: (record: RecordType, index: number) => { + const slotControl = this.children.slot.getView()( + { + currentRow: _.omit(record, OB_ROW_ORI_INDEX), + currentIndex: index, + currentOriginalIndex: tryToNumber(record[OB_ROW_ORI_INDEX]), + }, + String(record[OB_ROW_ORI_INDEX]) + ); + const containerProps = slotControl.children.container.getView(); + return ( + + + + ); + }, + }, + expandModalView: selectedContainer.getView(), + }; + } + + setSelectionAction(selection: string, params?: Record) { + return wrapChildAction("slot", ContextSlotControl.setSelectionAction(selection, params)); + } + + getPasteValue(nameGenerator: NameGenerator): JSONValue { + return { + ...this.toJsonValue(), + slot: this.children.slot.getSelectedComp().getComp().getPasteValue(nameGenerator), + }; + } + + reduce(action: any) { + const comp = super.reduce(action); + // console.info("ExpansionControl reduce. action: ", action, "\nthis: ", this, "\ncomp: ", comp); + return comp; + } +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx new file mode 100644 index 0000000000..3caa488f33 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/index.tsx @@ -0,0 +1 @@ +export { TableLiteComp } from "./tableComp"; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx new file mode 100644 index 0000000000..65e1660f3f --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/mockTableComp.tsx @@ -0,0 +1,72 @@ +import { withDefault } from "comps/generators"; +import { TableLiteComp } from "."; +import { newPrimaryColumn } from "comps/comps/tableComp/column/tableColumnComp"; +import { NameGenerator } from "../../utils"; +import { ConstructorToDataType } from "lowcoder-core"; +import { EditorState } from "../../editorState"; +import { isArrayLikeObject } from "lodash"; +import { i18nObjs } from "i18n"; +import { calcColumnWidth } from "comps/comps/tableComp/tableUtils"; +// for test only +const dataSource = [ + { + key: 0, + date: "2018-02-11", + amount: 120, + type: "income", + note: "transfer", + }, + { + key: 1, + date: "2018-03-11", + amount: 243, + type: "income", + note: "transfer", + }, + { + key: 2, + date: "2018-04-11", + amount: 98, + type: "income", + note: "transfer", + }, +]; +for (let i = 0; i < 53; i += 1) { + dataSource.push({ + key: 3 + i, + date: "2018-04-11", + amount: 98 + i, + type: "income" + (i % 3), + note: "transfer" + (i % 5), + }); +} + +const tableInitValue = { + toolbar: { + showDownload: true, + showFilter: true, + showRefresh: true, + }, +}; + +const tableData = { + ...tableInitValue, + data: JSON.stringify(i18nObjs.table.defaultData, null, " "), + columns: i18nObjs.table.columns.map((t: any) => + newPrimaryColumn(t.key, calcColumnWidth(t.key, i18nObjs.table.defaultData), t.title, t.isTag) + ), +}; +export const MockTableComp = withDefault(TableLiteComp, tableData); + +export function defaultTableData( + compName: string, + nameGenerator: NameGenerator, + editorState?: EditorState +): ConstructorToDataType { + const selectedQueryComp = editorState?.selectedQueryComp(); + const data = selectedQueryComp?.children.data.getView(); + const queryName = selectedQueryComp?.children.name.getView(); + return isArrayLikeObject(data) + ? { ...tableInitValue, data: `{{ ${queryName}.data }}` } + : tableData; +} diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx new file mode 100644 index 0000000000..eb5b4a0f8a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/paginationControl.tsx @@ -0,0 +1,90 @@ +import { BoolControl } from "comps/controls/boolControl"; +import { ArrayNumberControl, NumberControl } from "comps/controls/codeControl"; +import { stateComp, valueComp, withDefault } from "comps/generators"; +import { ControlNodeCompBuilder } from "comps/generators/controlCompBuilder"; +import { migrateOldData } from "comps/generators/simpleGenerators"; +import { trans } from "i18n"; +import { changeChildAction, ConstructorToNodeType } from "lowcoder-core"; + +const DEFAULT_PAGE_SIZE = 5; + +export function getPageSize( + showSizeChanger: boolean, + pageSize: number, + pageSizeOptions: number[], + changeablePageSize: number +) { + if (showSizeChanger) { + return changeablePageSize || pageSizeOptions[0] || DEFAULT_PAGE_SIZE; + } else { + return pageSize || DEFAULT_PAGE_SIZE; + } +} + +export const PaginationTmpControl = (function () { + const childrenMap = { + showQuickJumper: BoolControl, + showSizeChanger: BoolControl, + hideOnSinglePage: BoolControl, + changeablePageSize: migrateOldData(valueComp(5), Number), + pageSize: NumberControl, + total: NumberControl, + pageNo: stateComp(1), + pageSizeOptions: withDefault(ArrayNumberControl, "[5, 10, 20, 50]"), + }; + return new ControlNodeCompBuilder(childrenMap, (props, dispatch) => { + return { + showQuickJumper: props.showQuickJumper, + showSizeChanger: props.showSizeChanger, + total: props.total, + hideOnSinglePage: props.hideOnSinglePage, + pageSize: getPageSize( + props.showSizeChanger, + props.pageSize, + props.pageSizeOptions, + props.changeablePageSize + ), + current: props.pageNo, + pageSizeOptions: props.pageSizeOptions, + onChange: (page: number, pageSize: number) => { + props.showSizeChanger && + pageSize !== props.changeablePageSize && + dispatch(changeChildAction("changeablePageSize", pageSize, true)); + page !== props.pageNo && dispatch(changeChildAction("pageNo", page, false)); + }, + }; + }) + .setPropertyViewFn((children) => [ + children.showQuickJumper.propertyView({ + label: trans("table.showQuickJumper"), + }), + children.hideOnSinglePage.propertyView({ + label: trans("table.hideOnSinglePage"), + }), + children.showSizeChanger.propertyView({ + label: trans("table.showSizeChanger"), + }), + children.showSizeChanger.getView() + ? children.pageSizeOptions.propertyView({ + label: trans("table.pageSizeOptions"), + }) + : children.pageSize.propertyView({ + label: trans("table.pageSize"), + placeholder: String(DEFAULT_PAGE_SIZE), + }), + children.total.propertyView({ + label: trans("table.total"), + tooltip: trans("table.totalTooltip"), + }), + ]) + .build(); +})(); + +export class PaginationControl extends PaginationTmpControl { + getOffset() { + const pagination = this.getView(); + return (pagination.current - 1) * pagination.pageSize; + } +} + +export type PaginationNodeType = ConstructorToNodeType; diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/selectionControl.tsx new file mode 100644 index 0000000000..037516d91b --- /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 "comps/comps/tableComp/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/tableComp.test.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx new file mode 100644 index 0000000000..96121e4f59 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.test.tsx @@ -0,0 +1,213 @@ +import { ColumnComp } from "comps/comps/tableComp/column/tableColumnComp"; +import { evalAndReduce } from "comps/utils"; +import _ from "lodash"; +import { fromValue } from "lowcoder-core"; +import { MemoryRouter } from "react-router-dom"; +import { MockTableComp } from "./mockTableComp"; +import { TableLiteComp } from "./tableComp"; +import { OB_ROW_ORI_INDEX } from "./tableUtils"; + +test("test column", () => { + const columnData = { + title: "name", + // editable: true, // TODO: change to boolean + }; + let comp = new ColumnComp({ value: columnData }); + comp = evalAndReduce(comp); + const columnOutput = comp.getView(); + expect(columnOutput.title).toEqual(columnData.title); + // expect(columnOutput.editable).toEqual(columnData.editable); +}); + +test("test column render", () => { + const columnData = { + render: { + compType: "text" as const, + comp: { + text: "{{currentRow.id}}", + }, + }, + // editable: true, // TODO: change to boolean + }; + let comp = new ColumnComp({ value: columnData }); + comp = evalAndReduce(comp); + const columnOutput = comp.getView(); + expect( + ( + columnOutput + .render( + { + currentCell: null, + currentIndex: null, + currentRow: { id: "hello" }, + currentOriginalIndex: null, + }, + "0" + ) + .getView() + .view({}) as any + ).props.normalView + ).toEqual("hello"); + // FIXME: see what should be output if the input is wrong + // expect(columnOutput.render()).toEqual(""); + // expect(columnOutput.render(null, "def")).toEqual(""); +}); + +test("test table", async () => { + // jest.setTimeout(1000); + const tableData = { + data: JSON.stringify([{ a: 1 }]), + columns: [ + { + dataIndex: "a", + hide: true, + }, + { + title: "custom", + dataIndex: "", + isCustom: true, + }, + ], + }; + const exposingInfo: any = { + query1: fromValue({ data: [{ q: 1 }] }), + query2: fromValue({ data: [{ q2: 2 }] }), + }; + let comp = new TableLiteComp({ + dispatch: (action: any) => { + comp = evalAndReduce(comp.reduce(action), exposingInfo); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + let columns = comp.children.columns.getView(); + expect(columns.length).toEqual(2); + comp = evalAndReduce(comp.reduce(comp.changeChildAction("data", '[{"a":1, "c":2, "d":3}]'))); + await new Promise((r) => setTimeout(r, 20)); + columns = comp.children.columns.getView(); + expect(columns.length).toEqual(4); + expect(columns[0].getView().dataIndex).toEqual("a"); + expect(columns[0].getView().hide).toBe(true); + expect(columns[1].getView().title).toEqual("custom"); + expect(columns[2].getView().title).toEqual("c"); + expect(columns[3].getView().title).toEqual("d"); +}, 1000); + +// FIXME: add a single test for the click action of the table + +function DebugContainer(props: any) { + return ( + + {props.comp.getView()} + + ); +} + +test("test mock table render", () => { + let comp = new MockTableComp({}); + comp = evalAndReduce(comp); + // render(); + // screen.getByText(/Date/i); +}); + +test("test table data transform", () => { + function getAndExpectTableData(expectDisplayDataLen: number, comp: any) { + const exposingValues = comp.exposingValues; + const displayData = exposingValues["displayData"]; + const { data } = comp.getProps(); + const filteredData = comp.filterData; + // Transform, sort, filter the raw data. + expect(data.length).toEqual(3); + expect(displayData.length).toEqual(expectDisplayDataLen); + // Remove the custom column, displayData is the same as tranFormData, if title is not defined + expect(displayData.map((d: any) => _.omit(d, "custom"))).toEqual( + _.map(filteredData, (row) => _.omit(row, OB_ROW_ORI_INDEX)) + ); + return { transformedData: filteredData, data, displayData }; + } + + const tableData = { + data: JSON.stringify([ + { id: 1, name: "gg" }, + { id: 5, name: "gg2" }, + { id: 3, name: "jjj" }, + ]), + columns: [ + { + dataIndex: "id", + isCustom: false, + sortable: true, + render: { compType: "text" as const, comp: { text: "{{currentCell}}" } }, + }, + { + dataIndex: "name", + isCustom: false, + render: { compType: "text" as const, comp: { text: "{{currentCell}}" } }, + }, + { + title: "custom", + dataIndex: "ealekfg", + isCustom: true, + render: { + compType: "image" as const, + comp: { + src: "{{currentRow.id}}", + }, + }, + }, + ], + }; + let comp = new TableLiteComp({ + dispatch: (action: any) => { + comp = evalAndReduce(comp.reduce(action)); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + // id sort + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("sort", [ + { + column: "id", + desc: true, + }, + ]) + ) + ); + let { transformedData, data, displayData } = getAndExpectTableData(3, comp); + expect(transformedData.map((d: any) => d["id"])).toEqual([5, 3, 1]); + // search + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("toolbar", { + searchText: "gg", + }) + ) + ); + getAndExpectTableData(2, comp); + // filter + comp = evalAndReduce( + comp.reduce( + comp.changeChildAction("toolbar", { + showFilter: true, + filter: { + stackType: "and", + filters: [ + { + columnKey: "id", + filterValue: "4", + operator: "gt", + }, + { + columnKey: "id", + filterValue: "5", + operator: "lte", + }, + ], + }, + }) + ) + ); + getAndExpectTableData(1, comp); +}); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx new file mode 100644 index 0000000000..3f3b324115 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableComp.tsx @@ -0,0 +1,1019 @@ +import { tableDataRowExample } from "comps/comps/tableComp/column/tableColumnListComp"; +import { getPageSize } from "comps/comps/tableComp/paginationControl"; +import { EMPTY_ROW_KEY, TableCompView } from "comps/comps/tableLiteComp/tableCompView"; +import { TableFilter } from "comps/comps/tableComp/tableToolbarComp"; +import { + columnHide, + ColumnsAggrData, + COLUMN_CHILDREN_KEY, + filterData, + genSelectionParams, + getColumnsAggr, + getOriDisplayData, + OB_ROW_ORI_INDEX, + RecordType, + sortData, + transformDispalyData, + tranToTableRecord, +} from "comps/comps/tableComp/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 { IContainer } from "../containerBase"; +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 implements IContainer { + private prevUnevaledValue?: string; + readonly filterData: RecordType[] = []; + readonly columnAggrData: ColumnsAggrData = {}; + + override autoHeight(): boolean { + return this.children.autoHeight.getView(); + } + + getTableAutoHeight() { + return this.children.autoHeight.getView(); + } + + private getSlotContainer() { + return this.children.expansion.children.slot.getSelectedComp().getComp().children.container; + } + + findContainer(key: string) { + return this.getSlotContainer().findContainer(key); + } + + getCompTree() { + return this.getSlotContainer().getCompTree(); + } + + getPasteValue(nameGenerator: NameGenerator) { + return { + ...this.toJsonValue(), + expansion: this.children.expansion.getPasteValue(nameGenerator), + }; + } + + realSimpleContainer(key?: string) { + return this.getSlotContainer().realSimpleContainer(key); + } + + 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; + } + + let params = comp.children.expansion.children.slot.getCachedParams(newSelection); + if (selectionChanged || _.isNil(params) || dataChanged) { + params = + _.isNil(params) || dataChanged + ? genSelectionParams(comp.filterData, newSelection) + : undefined; + comp = comp.setChild( + "expansion", + comp.children.expansion.reduce( + comp.children.expansion.setSelectionAction(newSelection, params) + ) + ); + 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(), + filter: this.children.toolbar.children.filter.node(), + showFilter: this.children.toolbar.children.showFilter.node(), + }; + let context = this; + const filteredDataNode = withFunction(fromRecord(nodes), (input) => { + const { data, searchValue, filter, showFilter } = input; + const filteredData = filterData(data, searchValue.value, filter, showFilter.value); + // console.info("filterNode. data: ", data, " filter: ", filter, " filteredData: ", filteredData); + // if data is changed on search then trigger event + 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; + const includeRecord = (filterNewRows && key.startsWith(EMPTY_ROW_KEY)) || (!filterNewRows && !key.startsWith(EMPTY_ROW_KEY)); + if (!_.isNil(changeValue) && includeRecord) { + 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) => { + const [emptyRows, setEmptyRows] = useState([]); + return ( + + ); +}); + + +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) => { + 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(); + }, + }, + { + method: { + name: "setExpandedRows", + description: "", + params: [ + { name: "expandedRows", type: "arrayString"}, + ], + }, + execute: (comp, values) => { + const expandedRows = values[0]; + if (!isArray(expandedRows)) { + return Promise.reject("setExpandedRows function only accepts array of string i.e. ['1', '2', '3']") + } + if (expandedRows && isArray(expandedRows)) { + comp.children.currentExpandedRows.dispatchChangeValueAction(expandedRows as string[]); + } + }, + } +]); + +// 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( + "filter", + (children) => { + return { + filter: children.toolbar.children.filter.node(), + }; + }, + (input) => { + return input.filter; + }, + trans("table.filterDesc") + ), + new DepsConfig( + "selectedCell", + (children) => { + return { + selectedCell: children.selectedCell.node(), + }; + }, + (input) => { + return input.selectedCell; + }, + trans("table.selectedCellDesc") + ), + depsConfig({ + name: "currentExpandedRow", + desc: trans("table.sortDesc"), + depKeys: ["currentExpandedRows"], + func: (input) => { + if (input.currentExpandedRows.length > 0) { + return input.currentExpandedRows[input.currentExpandedRows.length - 1]; + } + return ""; + }, + }), + depsConfig({ + name: "currentExpandedRows", + desc: trans("table.sortDesc"), + depKeys: ["currentExpandedRows"], + func: (input) => { + return input.currentExpandedRows; + }, + }), + new NameConfig("data", trans("table.dataDesc")), +]); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx new file mode 100644 index 0000000000..dc6c88b0d1 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableCompView.tsx @@ -0,0 +1,1176 @@ +import { default as Table, TableProps, ColumnType } from "antd/es/table"; +import { TableCellContext, TableRowContext } from "comps/comps/tableComp/tableContext"; +import { TableToolbar } from "comps/comps/tableComp/tableToolbarComp"; +import { RowColorViewType, RowHeightViewType, TableEventOptionValues } from "comps/comps/tableComp/tableTypes"; +import { + COL_MIN_WIDTH, + COLUMN_CHILDREN_KEY, + ColumnsAggrData, + columnsToAntdFormat, + CustomColumnType, + OB_ROW_ORI_INDEX, + onTableChange, + RecordType, + supportChildrenTree, +} from "comps/comps/tableComp/tableUtils"; +import { + handleToHoverRow, + handleToSelectedRow, + TableColumnLinkStyleType, + TableColumnStyleType, + TableHeaderStyleType, + TableRowStyleType, + TableStyleType, + ThemeDetail, + TableToolbarStyleType, +} from "comps/controls/styleControlConstants"; +import { CompNameContext, EditorContext } from "comps/editorState"; +import { BackgroundColorContext } from "comps/utils/backgroundColorContext"; +import { PrimaryColor } from "constants/style"; +import { trans } from "i18n"; +import _, { isEqual } from "lodash"; +import { darkenColor, isDarkColor, isValidColor, ScrollBar } from "lowcoder-design"; +import React, { Children, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Resizable } from "react-resizable"; +import styled, { css } from "styled-components"; +import { 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"; + +export const EMPTY_ROW_KEY = 'empty_row'; + +function genLinerGradient(color: string) { + return isValidColor(color) ? `linear-gradient(${color}, ${color})` : color; +} + +const getStyle = ( + style: TableStyleType, + rowStyle: TableRowStyleType, + headerStyle: TableHeaderStyleType, + toolbarStyle: TableToolbarStyleType, +) => { + const background = genLinerGradient(style.background); + const selectedRowBackground = genLinerGradient(rowStyle.selectedRowBackground); + const hoverRowBackground = genLinerGradient(rowStyle.hoverRowBackground); + const alternateBackground = genLinerGradient(rowStyle.alternateBackground); + + return css` + .ant-table-body { + background: ${genLinerGradient(style.background)}; + } + .ant-table-tbody { + > tr:nth-of-type(2n + 1) { + background: ${genLinerGradient(rowStyle.background)}; + } + + > tr:nth-of-type(2n) { + background: ${alternateBackground}; + } + + // selected row + > tr:nth-of-type(2n + 1).ant-table-row-selected { + background: ${selectedRowBackground}, ${rowStyle.background} !important; + > td.ant-table-cell { + background: transparent !important; + } + + // > td.ant-table-cell-row-hover, + &:hover { + background: ${hoverRowBackground}, ${selectedRowBackground}, ${rowStyle.background} !important; + } + } + + > tr:nth-of-type(2n).ant-table-row-selected { + background: ${selectedRowBackground}, ${alternateBackground} !important; + > td.ant-table-cell { + background: transparent !important; + } + + // > td.ant-table-cell-row-hover, + &:hover { + background: ${hoverRowBackground}, ${selectedRowBackground}, ${alternateBackground} !important; + } + } + + // hover row + > tr:nth-of-type(2n + 1):hover { + background: ${hoverRowBackground}, ${rowStyle.background} !important; + > td.ant-table-cell-row-hover { + background: transparent; + } + } + > tr:nth-of-type(2n):hover { + background: ${hoverRowBackground}, ${alternateBackground} !important; + > td.ant-table-cell-row-hover { + background: transparent; + } + } + + > tr.ant-table-expanded-row { + background: ${background}; + } + } + `; +}; + +const TitleResizeHandle = styled.span` + position: absolute; + top: 0; + right: -5px; + width: 10px; + height: 100%; + cursor: col-resize; + z-index: 1; +`; + +const BackgroundWrapper = styled.div<{ + $style: TableStyleType; + $tableAutoHeight: boolean; + $showHorizontalScrollbar: boolean; + $showVerticalScrollbar: boolean; + $fixedToolbar: boolean; +}>` + display: flex; + flex-direction: column; + background: ${(props) => props.$style.background} !important; + border-radius: ${(props) => props.$style.radius} !important; + padding: ${(props) => props.$style.padding} !important; + margin: ${(props) => props.$style.margin} !important; + border-style: ${(props) => props.$style.borderStyle} !important; + border-width: ${(props) => `${props.$style.borderWidth} !important`}; + border-color: ${(props) => `${props.$style.border} !important`}; + height: calc(100% - ${(props) => props.$style.margin && getVerticalMargin(props.$style.margin.split(' '))}); + overflow: hidden; + + > div.table-scrollbar-wrapper { + overflow: auto; + ${(props) => props.$fixedToolbar && `height: auto`}; + + ${(props) => (props.$showHorizontalScrollbar || props.$showVerticalScrollbar) && ` + .simplebar-content-wrapper { + overflow: auto !important; + } + `} + + ${(props) => !props.$showHorizontalScrollbar && ` + div.simplebar-horizontal { + visibility: hidden !important; + } + `} + ${(props) => !props.$showVerticalScrollbar && ` + div.simplebar-vertical { + visibility: hidden !important; + } + `} + } +`; + +// TODO: find a way to limit the calc function for max-height only to first Margin value +const TableWrapper = styled.div<{ + $style: TableStyleType; + $headerStyle: TableHeaderStyleType; + $toolbarStyle: TableToolbarStyleType; + $rowStyle: TableRowStyleType; + $toolbarPosition: "above" | "below" | "close"; + $fixedHeader: boolean; + $fixedToolbar: boolean; + $visibleResizables: boolean; + $showHRowGridBorder?: boolean; +}>` + .ant-table-wrapper { + border-top: unset; + border-color: inherit; + } + + .ant-table-row-expand-icon { + color: ${PrimaryColor}; + } + + .ant-table .ant-table-cell-with-append .ant-table-row-expand-icon { + margin: 0; + top: 18px; + left: 4px; + } + + .ant-table.ant-table-small .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 10px; + } + + .ant-table.ant-table-middle .ant-table-cell-with-append .ant-table-row-expand-icon { + top: 14px; + margin-right:5px; + } + + .ant-table { + background: ${(props) =>props.$style.background}; + .ant-table-container { + border-left: unset; + border-top: none !important; + border-inline-start: none !important; + + &::after { + box-shadow: none !important; + } + + .ant-table-content { + overflow: unset !important + } + + // A table expand row contains table + .ant-table-tbody .ant-table-wrapper:only-child .ant-table { + margin: 0; + } + + table { + border-top: unset; + + > .ant-table-thead { + ${(props) => + props.$fixedHeader && ` + position: sticky; + position: -webkit-sticky; + // top: ${props.$fixedToolbar ? '47px' : '0'}; + top: 0; + z-index: 2; + ` + } + > tr { + background: ${(props) => props.$headerStyle.headerBackground}; + } + > tr > th { + background: transparent; + border-color: ${(props) => props.$headerStyle.border}; + border-width: ${(props) => props.$headerStyle.borderWidth}; + color: ${(props) => props.$headerStyle.headerText}; + // border-inline-end: ${(props) => `${props.$headerStyle.borderWidth} solid ${props.$headerStyle.border}`} !important; + + /* Proper styling for fixed header cells */ + &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { + z-index: 1; + background: ${(props) => props.$headerStyle.headerBackground}; + } + + + + > div { + margin: ${(props) => props.$headerStyle.margin}; + + &, .ant-table-column-title > div { + font-size: ${(props) => props.$headerStyle.textSize}; + font-weight: ${(props) => props.$headerStyle.textWeight}; + font-family: ${(props) => props.$headerStyle.fontFamily}; + font-style: ${(props) => props.$headerStyle.fontStyle}; + color:${(props) => props.$headerStyle.text} + } + } + + &:last-child { + border-inline-end: none !important; + } + &.ant-table-column-has-sorters:hover { + background-color: ${(props) => darkenColor(props.$headerStyle.headerBackground, 0.05)}; + } + + > .ant-table-column-sorters > .ant-table-column-sorter { + color: ${(props) => props.$headerStyle.headerText === defaultTheme.textDark ? "#bfbfbf" : props.$headerStyle.headerText}; + } + + &::before { + background-color: ${(props) => props.$headerStyle.border}; + width: ${(props) => (props.$visibleResizables ? "1px" : "0px")} !important; + } + } + } + + > thead > tr > th, + > tbody > tr > td { + border-color: ${(props) => props.$headerStyle.border}; + ${(props) => !props.$showHRowGridBorder && `border-bottom: 0px;`} + } + + td { + padding: 0px 0px; + // ${(props) => props.$showHRowGridBorder ? 'border-bottom: 1px solid #D7D9E0 !important;': `border-bottom: 0px;`} + + /* Proper styling for Fixed columns in the table body */ + &.ant-table-cell-fix-left, &.ant-table-cell-fix-right { + z-index: 1; + background: inherit; + background-color: ${(props) => props.$style.background}; + transition: background-color 0.3s; + } + + } + + /* Fix for selected and hovered rows */ + tr.ant-table-row-selected td.ant-table-cell-fix-left, + tr.ant-table-row-selected td.ant-table-cell-fix-right { + background-color: ${(props) => props.$rowStyle?.selectedRowBackground || '#e6f7ff'} !important; + } + + tr.ant-table-row:hover td.ant-table-cell-fix-left, + tr.ant-table-row:hover td.ant-table-cell-fix-right { + background-color: ${(props) => props.$rowStyle?.hoverRowBackground || '#f5f5f5'} !important; + } + + thead > tr:first-child { + th:last-child { + border-right: unset; + } + } + + tbody > tr > td:last-child { + border-right: unset !important; + } + + .ant-empty-img-simple-g { + fill: #fff; + } + + > thead > tr:first-child { + th:first-child { + border-top-left-radius: 0px; + } + + th:last-child { + border-top-right-radius: 0px; + } + } + } + + .ant-table-expanded-row-fixed:after { + border-right: unset !important; + } + } + } + + ${(props) => + props.$style && getStyle(props.$style, props.$rowStyle, props.$headerStyle, props.$toolbarStyle)} +`; + +const TableTh = styled.th<{ width?: number }>` + overflow: hidden; + + > div { + overflow: hidden; + white-space: pre; + text-overflow: ellipsis; + } + + ${(props) => props.width && `width: ${props.width}px`}; +`; + +interface TableTdProps { + $background: string; + $style: TableColumnStyleType & { rowHeight?: string }; + $defaultThemeDetail: ThemeDetail; + $linkStyle?: TableColumnLinkStyleType; + $isEditing: boolean; + $tableSize?: string; + $autoHeight?: boolean; + $customAlign?: 'left' | 'center' | 'right'; +} +const TableTd = styled.td` + .ant-table-row-expand-icon, + .ant-table-row-indent { + display: ${(props) => (props.$isEditing ? "none" : "initial")}; + } + &.ant-table-row-expand-icon-cell { + background: ${(props) => props.$background}; + border-color: ${(props) => props.$style.border}; + } + background: ${(props) => props.$background} !important; + border-color: ${(props) => props.$style.border} !important; + border-radius: ${(props) => props.$style.radius}; + padding: 0 !important; + text-align: ${(props) => props.$customAlign || 'left'} !important; + + > div:not(.editing-border, .editing-wrapper), + .editing-wrapper .ant-input, + .editing-wrapper .ant-input-number, + .editing-wrapper .ant-picker { + margin: ${(props) => props.$isEditing ? '0px' : props.$style.margin}; + color: ${(props) => props.$style.text}; + font-weight: ${(props) => props.$style.textWeight}; + font-family: ${(props) => props.$style.fontFamily}; + overflow: hidden; + display: flex; + justify-content: ${(props) => props.$customAlign === 'center' ? 'center' : props.$customAlign === 'right' ? 'flex-end' : 'flex-start'}; + align-items: center; + text-align: ${(props) => props.$customAlign || 'left'}; + box-sizing: border-box; + ${(props) => props.$tableSize === 'small' && ` + padding: 1px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '14px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '14px'}; + line-height: 20px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '28px'}; + `}; + `}; + ${(props) => props.$tableSize === 'middle' && ` + padding: 8px 8px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '16px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '24px'}; + line-height: 24px; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '48px'}; + `}; + `}; + ${(props) => props.$tableSize === 'large' && ` + padding: 16px 16px; + font-size: ${props.$defaultThemeDetail.textSize == props.$style.textSize ? '18px !important' : props.$style.textSize + ' !important'}; + font-style:${props.$style.fontStyle} !important; + min-height: ${props.$style.rowHeight || '48px'}; + ${!props.$autoHeight && ` + overflow-y: auto; + max-height: ${props.$style.rowHeight || '96px'}; + `}; + `}; + + > .ant-badge > .ant-badge-status-text, + > div > .markdown-body { + color: ${(props) => props.$style.text}; + } + + > div > svg g { + stroke: ${(props) => props.$style.text}; + } + + // dark link|links color + > a, + > div a { + color: ${(props) => props.$linkStyle?.text}; + + &:hover { + color: ${(props) => props.$linkStyle?.hoverText}; + } + + &:active { + color: ${(props) => props.$linkStyle?.activeText}; + } + } + } +`; + +const TableTdLoading = styled(Skeleton.Button)` + width: 90% !important; + display: table !important; + + .ant-skeleton-button { + min-width: auto !important; + display: block !important; + ${(props) => props.$tableSize === 'small' && ` + height: 20px !important; + `} + ${(props) => props.$tableSize === 'middle' && ` + height: 24px !important; + `} + ${(props) => props.$tableSize === 'large' && ` + height: 28px !important; + `} + } +`; + +const ResizeableTitle = (props: any) => { + const { onResize, onResizeStop, width, viewModeResizable, ...restProps } = props; + const [childWidth, setChildWidth] = useState(0); + const resizeRef = useRef(null); + const isUserViewMode = useUserViewMode(); + + const updateChildWidth = useCallback(() => { + if (resizeRef.current) { + const width = resizeRef.current.getBoundingClientRect().width; + setChildWidth(width); + } + }, []); + + useEffect(() => { + updateChildWidth(); + const resizeObserver = new ResizeObserver(() => { + updateChildWidth(); + }); + + if (resizeRef.current) { + resizeObserver.observe(resizeRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [updateChildWidth]); + + const isNotDataColumn = _.isNil(restProps.title); + if ((isUserViewMode && !restProps.viewModeResizable) || isNotDataColumn) { + return ; + } + + return ( + 0 ? width : childWidth} + height={0} + onResize={(e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + e.stopPropagation(); + onResize(size.width); + }} + onResizeStart={(e: React.SyntheticEvent) => { + updateChildWidth(); + e.stopPropagation(); + e.preventDefault(); + }} + onResizeStop={onResizeStop} + draggableOpts={{ enableUserSelectHack: false }} + handle={(axis: ResizeHandleAxis, ref: ReactRef) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + /> + )} + > + + + ); +}; + +type CustomTableProps = Omit, "components" | "columns"> & { + columns: CustomColumnType[]; + viewModeResizable: boolean; + visibleResizables: boolean; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + columnsStyle: TableColumnStyleType; + size?: string; + rowAutoHeight?: boolean; + customLoading?: boolean; + onCellClick: (columnName: string, dataIndex: string) => void; +}; + +const TableCellView = React.memo((props: { + record: RecordType; + title: string; + rowColorFn: RowColorViewType; + rowHeightFn: RowHeightViewType; + cellColorFn: CellColorViewType; + rowIndex: number; + children: any; + columnsStyle: TableColumnStyleType; + columnStyle: TableColumnStyleType; + linkStyle: TableColumnLinkStyleType; + tableSize?: string; + autoHeight?: boolean; + loading?: boolean; + customAlign?: 'left' | 'center' | 'right'; +}) => { + const { + record, + title, + rowIndex, + rowColorFn, + rowHeightFn, + cellColorFn, + children, + columnsStyle, + columnStyle, + linkStyle, + tableSize, + autoHeight, + loading, + customAlign, + ...restProps + } = props; + + const [editing, setEditing] = useState(false); + const rowContext = useContext(TableRowContext); + + // Memoize style calculations + const style = useMemo(() => { + if (!record) return null; + const rowColor = rowColorFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const rowHeight = rowHeightFn({ + currentRow: record, + currentIndex: rowIndex, + currentOriginalIndex: record[OB_ROW_ORI_INDEX], + columnTitle: title, + }); + const cellColor = cellColorFn({ + currentCell: record[title], + currentRow: record, + }); + + return { + background: cellColor || rowColor || columnStyle.background || columnsStyle.background, + margin: columnStyle.margin || columnsStyle.margin, + text: columnStyle.text || columnsStyle.text, + border: columnStyle.border || columnsStyle.border, + radius: columnStyle.radius || columnsStyle.radius, + // borderWidth: columnStyle.borderWidth || columnsStyle.borderWidth, + textSize: columnStyle.textSize || columnsStyle.textSize, + textWeight: columnsStyle.textWeight || columnStyle.textWeight, + fontFamily: columnsStyle.fontFamily || columnStyle.fontFamily, + fontStyle: columnsStyle.fontStyle || columnStyle.fontStyle, + rowHeight: rowHeight, + }; + }, [record, rowIndex, title, rowColorFn, rowHeightFn, cellColorFn, columnStyle, columnsStyle]); + + let tdView; + if (!record) { + tdView = {children}; + } else { + let { background } = style!; + if (rowContext.hover) { + background = 'transparent'; + } + + tdView = ( + + {loading + ? + : children + } + + ); + } + + return ( + + {tdView} + + ); +}); + +const TableRowView = React.memo((props: any) => { + const [hover, setHover] = useState(false); + const [selected, setSelected] = useState(false); + + // Memoize event handlers + const handleMouseEnter = useCallback(() => setHover(true), []); + const handleMouseLeave = useCallback(() => setHover(false), []); + const handleFocus = useCallback(() => setSelected(true), []); + const handleBlur = useCallback(() => setSelected(false), []); + + return ( + + + + ); +}); + +/** + * A table with adjustable column width, width less than 0 means auto column width + */ +function ResizeableTableComp(props: CustomTableProps) { + const { + columns, + viewModeResizable, + visibleResizables, + rowColorFn, + rowHeightFn, + columnsStyle, + size, + rowAutoHeight, + customLoading, + onCellClick, + ...restProps + } = props; + const [resizeData, setResizeData] = useState({ index: -1, width: -1 }); + + // Memoize resize handlers + const handleResize = useCallback((width: number, index: number) => { + setResizeData({ index, width }); + }, []); + + const handleResizeStop = useCallback((width: number, index: number, onWidthResize?: (width: number) => void) => { + setResizeData({ index: -1, width: -1 }); + if (onWidthResize) { + onWidthResize(width); + } + }, []); + + // Memoize cell handlers + const createCellHandler = useCallback((col: CustomColumnType) => { + return (record: RecordType, index: number) => ({ + record, + title: String(col.dataIndex), + rowColorFn, + rowHeightFn, + cellColorFn: col.cellColorFn, + rowIndex: index, + columnsStyle, + columnStyle: col.style, + linkStyle: col.linkStyle, + tableSize: size, + autoHeight: rowAutoHeight, + onClick: () => onCellClick(col.titleText, String(col.dataIndex)), + loading: customLoading, + customAlign: col.align, + }); + }, [rowColorFn, rowHeightFn, columnsStyle, size, rowAutoHeight, onCellClick, customLoading]); + + // Memoize header cell handlers + const createHeaderCellHandler = useCallback((col: CustomColumnType, index: number, resizeWidth: number) => { + return () => ({ + width: resizeWidth, + title: col.titleText, + viewModeResizable, + onResize: (width: React.SyntheticEvent) => { + if (width) { + handleResize(Number(width), index); + } + }, + onResizeStop: (e: React.SyntheticEvent, { size }: { size: { width: number } }) => { + handleResizeStop(size.width, index, col.onWidthResize); + }, + }); + }, [viewModeResizable, handleResize, handleResizeStop]); + + // Memoize columns to prevent unnecessary re-renders + const memoizedColumns = useMemo(() => { + return columns.map((col, index) => { + const { width, style, linkStyle, cellColorFn, onWidthResize, ...restCol } = col; + const resizeWidth = (resizeData.index === index ? resizeData.width : col.width) ?? 0; + + const column: ColumnType = { + ...restCol, + width: typeof resizeWidth === "number" && resizeWidth > 0 ? resizeWidth : undefined, + minWidth: typeof resizeWidth === "number" && resizeWidth > 0 ? undefined : COL_MIN_WIDTH, + onCell: (record: RecordType, index?: number) => createCellHandler(col)(record, index ?? 0), + onHeaderCell: () => createHeaderCellHandler(col, index, Number(resizeWidth))(), + }; + return column; + }); + }, [columns, resizeData, createCellHandler, createHeaderCellHandler]); + + return ( + + components={{ + header: { + cell: ResizeableTitle, + }, + body: { + cell: TableCellView, + row: TableRowView, + }, + }} + {...restProps} + pagination={false} + columns={memoizedColumns} + scroll={{ + x: COL_MIN_WIDTH * columns.length, + }} + /> + ); +} +ResizeableTableComp.whyDidYouRender = true; + +const ResizeableTable = React.memo(ResizeableTableComp) as typeof ResizeableTableComp; + + +const createNewEmptyRow = ( + rowIndex: number, + columnsAggrData: ColumnsAggrData, +) => { + const emptyRowData: RecordType = { + [OB_ROW_ORI_INDEX]: `${EMPTY_ROW_KEY}_${rowIndex}`, + }; + Object.keys(columnsAggrData).forEach(columnKey => { + emptyRowData[columnKey] = ''; + }); + return emptyRowData; +} + +export const TableCompView = React.memo((props: { + comp: InstanceType; + onRefresh: (allQueryNames: Array, setLoading: (loading: boolean) => void) => void; + onDownload: (fileName: string) => void; +}) => { + const [expandedRowKeys, setExpandedRowKeys] = useState([]); + const [emptyRowsMap, setEmptyRowsMap] = useState>({}); + 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 inlineAddNewRow = useMemo(() => compChildren.inlineAddNewRow.getView(), [compChildren.inlineAddNewRow]); + const pagination = useMemo(() => compChildren.pagination.getView(), [compChildren.pagination]); + const size = useMemo(() => compChildren.size.getView(), [compChildren.size]); + const editModeClicks = useMemo(() => compChildren.editModeClicks.getView(), [compChildren.editModeClicks]); + const onEvent = useMemo(() => compChildren.onEvent.getView(), [compChildren.onEvent]); + const currentExpandedRows = useMemo(() => compChildren.currentExpandedRows.getView(), [compChildren.currentExpandedRows]); + const dynamicColumn = compChildren.dynamicColumn.getView(); + const dynamicColumnConfig = useMemo( + () => compChildren.dynamicColumnConfig.getView(), + [compChildren.dynamicColumnConfig] + ); + const columnsAggrData = comp.columnAggrData; + const expansion = useMemo(() => compChildren.expansion.getView(), [compChildren.expansion]); + const antdColumns = useMemo( + () => + columnsToAntdFormat( + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + editModeClicks, + onEvent, + ), + [ + columnViews, + sort, + toolbar.columnSetting, + size, + dynamicColumn, + dynamicColumnConfig, + columnsAggrData, + editModeClicks, + ] + ); + + const supportChildren = useMemo( + () => supportChildrenTree(compChildren.data.getView()), + [compChildren.data] + ); + + const updateEmptyRows = useCallback(() => { + if (!inlineAddNewRow) { + setEmptyRowsMap({}) + setTimeout(() => compChildren.columns.dispatchClearInsertSet()); + return; + } + + let emptyRows: Record = {...emptyRowsMap}; + const existingRowsKeys = Object.keys(emptyRows); + const existingRowsCount = existingRowsKeys.length; + const updatedRowsKeys = Object.keys(insertSet).filter( + key => key.startsWith(EMPTY_ROW_KEY) + ); + const updatedRowsCount = updatedRowsKeys.length; + const removedRowsKeys = existingRowsKeys.filter( + x => !updatedRowsKeys.includes(x) + ); + + if (removedRowsKeys.length === existingRowsCount) { + const newRowIndex = 0; + const newRowKey = `${EMPTY_ROW_KEY}_${newRowIndex}`; + setEmptyRowsMap({ + [newRowKey]: createNewEmptyRow(newRowIndex, columnsAggrData) + }); + const ele = document.querySelector(`[data-row-key=${newRowKey}]`); + if (ele) { + ele.style.display = ''; + } + return; + } + + removedRowsKeys.forEach(rowKey => { + if ( + rowKey === existingRowsKeys[existingRowsCount - 1] + || rowKey === existingRowsKeys[existingRowsCount - 2] + ) { + delete emptyRows[rowKey]; + } else { + const ele = document.querySelector(`[data-row-key=${rowKey}]`); + if (ele) { + ele.style.display = 'none'; + } + } + }) + const lastRowKey = updatedRowsCount ? updatedRowsKeys[updatedRowsCount - 1] : ''; + const lastRowIndex = lastRowKey ? parseInt(lastRowKey.replace(`${EMPTY_ROW_KEY}_`, '')) : -1; + + const newRowIndex = lastRowIndex + 1; + const newRowKey = `${EMPTY_ROW_KEY}_${newRowIndex}`; + emptyRows[newRowKey] = createNewEmptyRow(newRowIndex, columnsAggrData); + setEmptyRowsMap(emptyRows); + }, [ + inlineAddNewRow, + JSON.stringify(insertSet), + setEmptyRowsMap, + createNewEmptyRow, + ]); + + useEffect(() => { + updateEmptyRows(); + }, [updateEmptyRows]); + + useUpdateEffect(() => { + if (!isEqual(currentExpandedRows, expandedRowKeys)) { + compChildren.currentExpandedRows.dispatchChangeValueAction(expandedRowKeys); + } + }, [expandedRowKeys]); + + useUpdateEffect(() => { + if (!isEqual(currentExpandedRows, expandedRowKeys)) { + setExpandedRowKeys(currentExpandedRows); + } + }, [currentExpandedRows]); + + 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) => { + if (eventName === "saveChanges" && !compChildren.onEvent.isBind(eventName)) { + !viewMode && messageInstance.warning(trans("table.saveChangesNotBind")); + return; + } + 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`) + }} + hasChange={hasChange} + onSaveChanges={() => handleChangeEvent("saveChanges")} + onCancelChanges={() => { + handleChangeEvent("cancelChanges"); + if (inlineAddNewRow) { + setEmptyRowsMap({}); + } + }} + 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} + + + + expandable={{ + ...expansion.expandableConfig, + childrenColumnName: supportChildren + ? COLUMN_CHILDREN_KEY + : "OB_CHILDREN_KEY_PLACEHOLDER", + fixed: "left", + onExpand: (expanded) => { + if (expanded) { + handleChangeEvent('rowExpand') + } else { + handleChangeEvent('rowShrink') + } + }, + onExpandedRowsChange: (expandedRowKeys) => { + setExpandedRowKeys(expandedRowKeys as unknown as string[]); + }, + expandedRowKeys: expandedRowKeys, + }} + // rowKey={OB_ROW_ORI_INDEX} + rowColorFn={compChildren.rowColor.getView() as any} + rowHeightFn={compChildren.rowHeight.getView() as any} + {...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.concat(Object.values(emptyRowsMap))} + 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} + /> + + {expansion.expandModalView} + + + + {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 0000000000..47067a9e03 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableContext.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import _ from "lodash"; + +export const TableRowContext = React.createContext<{ + hover: boolean; + selected: boolean; +}>({ hover: false, selected: false }); + +export const TableCellContext = React.createContext<{ + isEditing: boolean; + setIsEditing: (e: boolean) => void; +}>({ isEditing: false, setIsEditing: _.noop }); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx new file mode 100644 index 0000000000..d38cdb9877 --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tableDynamicColumn.test.tsx @@ -0,0 +1,241 @@ +import { TableComp } from "comps/comps/tableComp/tableComp"; +import { columnsToAntdFormat } from "comps/comps/tableComp/tableUtils"; +import { evalAndReduce } from "comps/utils"; +import { reduceInContext } from "comps/utils/reduceContext"; +import _ from "lodash"; +import { changeChildAction, fromValue, SimpleNode } from "lowcoder-core"; +import { JSONObject } from "util/jsonTypes"; + +const expectColumn = ( + comp: InstanceType, + expectValues: Array<{ + dataIndex: string; + title: string; + hide?: boolean; + isCustom?: boolean; + }> +) => { + const columns = comp.children.columns.getView(); + const columnViews = columns.map((c) => c.getView()); + expect(expectValues.length).toEqual(columnViews.length); + expectValues.forEach((val) => { + const column = columnViews.find((c) => c.dataIndex === val.dataIndex); + if (!column) { + throw new Error(`expect column: ${JSON.stringify(val)}, but not found.`); + } + Object.keys(val).forEach((key) => { + const colVal = (column as any)[key]; + const expectVal = (val as any)[key]; + if (expectVal !== undefined) { + if (!_.isEqual(colVal, expectVal)) { + throw new Error(`ColumnKey:${key}, expect: "${expectVal}", but found: "${colVal}"`); + } + } + }); + }); + // with dynamic config + const dynamicColumnConfig = comp.children.dynamicColumnConfig.getView(); + if (dynamicColumnConfig?.length > 0) { + const onEvent = (eventName: any) => {}; + const antdColumns = columnsToAntdFormat( + columnViews, + comp.children.sort.getView(), + comp.children.toolbar.getView().columnSetting, + comp.children.size.getView(), + comp.children.dynamicColumn.getView(), + dynamicColumnConfig, + comp.columnAggrData, + comp.children.editModeClicks.getView(), + onEvent, + ); + expect(columnViews.length).toBeGreaterThanOrEqual(antdColumns.length); + antdColumns.forEach((column) => { + const dataIndex = (column as any).dataIndex; + const colView = columnViews.find((c) => c.dataIndex === dataIndex); + if (!colView) { + throw new Error(`Error, column should not be undefined, column: ${JSON.stringify(column)}`); + } + const configName = colView.isCustom ? colView.title : colView.dataIndex; + if (!dynamicColumnConfig.includes(configName)) { + throw new Error(`dynamic config test fail: unexpect column: ${configName}`); + } + }); + } +}; + +function getTableInitData() { + const exposingInfo: Record> = { + query1: fromValue({ data: [{ q1: 1 }] }), + query2: fromValue({ data: [{ q2: 2 }] }), + }; + return { + tableData: { + data: JSON.stringify([{ a: 1 }]), + columns: [ + { + dataIndex: "a", + title: "a", + hide: true, + }, + { + title: "custom", + dataIndex: "custom1", + isCustom: true, + }, + ], + }, + exposingInfo: exposingInfo, + initColumns: [ + { + dataIndex: "a", + hide: true, + title: "a", + }, + { + dataIndex: "custom1", + hide: false, + title: "custom", + isCustom: true, + }, + ], + }; +} + +async function sleep() { + await new Promise((r) => setTimeout(r, 20)); +} + +test("test table dynamic columns: Change unEvalValue", async () => { + // 0. Init check + const { initColumns, tableData, exposingInfo } = getTableInitData(); + let comp = new TableComp({ + dispatch: (action) => { + comp = evalAndReduce(comp.reduce(action), exposingInfo); + }, + value: tableData, + }); + comp = evalAndReduce(comp); + expectColumn(comp, initColumns); + /** 1. Change unEvalValue data, change column whatever **/ + // 1.1 add column c & d + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", JSON.stringify([{ a: 1, c: 2, d: 3 }]))) + ); + await sleep(); + const columnsAfterAdd = [ + ...initColumns, + { + dataIndex: "c", + hide: false, + title: "c", + }, + { + dataIndex: "d", + title: "d", + }, + ]; + expectColumn(comp, columnsAfterAdd); + // 1.2 del column a + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", JSON.stringify([{ c: 2, d: 3 }]))) + ); + await sleep(); + expectColumn( + comp, + columnsAfterAdd.filter((c) => c.dataIndex !== "a") + ); +}, 1000); + +async function dynamicColumnsTest( + dynamicColumn: boolean, + isViewMode: boolean, + dynamicConfigs?: Array +) { + const { initColumns, tableData, exposingInfo } = getTableInitData(); + // init comp + let comp = new TableComp({ + dispatch: (action) => { + let tmpComp; + if (isViewMode) { + tmpComp = reduceInContext({ readOnly: isViewMode }, () => comp.reduce(action)); + } else { + tmpComp = comp.reduce(action); + } + comp = evalAndReduce(tmpComp, exposingInfo); + }, + value: { + ...tableData, + dynamicColumn: dynamicColumn, + ...(dynamicColumn && + dynamicConfigs && { dynamicColumnConfig: JSON.stringify(dynamicConfigs) }), + }, + }); + comp = evalAndReduce(comp); + + const updateTableComp = async () => { + comp = evalAndReduce( + comp.reduce(comp.changeChildAction("data", "{{query1.data}}")), + exposingInfo + ); + await sleep(); + }; + // change data to query1 + const query1Columns = [ + { + dataIndex: "q1", + title: "q1", + }, + { + dataIndex: "custom1", + title: "custom", + isCustom: true, + }, + ]; + await updateTableComp(); + if (!dynamicColumn && isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, query1Columns); + } + // change query data, add column: a + const addData: Array = [{ q1: 1, a: 2 }]; + exposingInfo.query1 = fromValue({ data: addData }); + await updateTableComp(); + const columnsAfterAdd = [ + ...query1Columns, + { + dataIndex: "a", + title: "a", + }, + ]; + expect(comp.children.data.getView()).toEqual(addData); + if (!dynamicColumn && isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, columnsAfterAdd); + } + // change query data, del column: q1 + const delData = [{ a: 2 }]; + exposingInfo.query1 = fromValue({ data: delData }); + await updateTableComp(); + expect(comp.children.data.getView()).toEqual(delData); + if (dynamicColumn) { + expectColumn( + comp, + columnsAfterAdd.filter((c) => c.dataIndex !== "q1") + ); + } else if (isViewMode) { + expectColumn(comp, initColumns); + } else { + expectColumn(comp, columnsAfterAdd); + } +} + +test("test table dynamic columns", async () => { + await dynamicColumnsTest(false, false); + await dynamicColumnsTest(false, true); + await dynamicColumnsTest(true, false); + await dynamicColumnsTest(true, true); + await dynamicColumnsTest(true, false, ["custom", "q1"]); + await dynamicColumnsTest(true, true, ["custom", "q1"]); +}, 2000); diff --git a/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx new file mode 100644 index 0000000000..70bb8ce05a --- /dev/null +++ b/client/packages/lowcoder/src/comps/comps/tableLiteComp/tablePropertyView.tsx @@ -0,0 +1,650 @@ +import { + ColumnCompType, + newCustomColumn, + RawColumnType, +} from "comps/comps/tableComp/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 "comps/comps/tableComp/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.editable"), + value: "editable", + }, + { + 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) => { + if (childrenKey !== "editable") { + return false; + } + const columnType = c.children.render + .getOriginalComp() + .children.comp.children.compType.getView(); + return !ColumnTypeCompMap[columnType].canBeEditable(); + }), [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) => , + editable: (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 ( + <> +