瀏覽代碼

table cell action when value is a button (#948)

* table cell action when value is [button label](button value)
fix lov clear selection
resolves #945
resolves #947

* improve test

* fab's comments

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 1 年之前
父節點
當前提交
ea8f892ece

+ 6 - 4
frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx

@@ -110,7 +110,7 @@ const Row = ({
         onRowSelection,
         onRowSelection,
         onRowClick,
         onRowClick,
         lineStyle,
         lineStyle,
-        nanValue,
+        nanValue
     },
     },
 }: {
 }: {
     index: number;
     index: number;
@@ -450,12 +450,14 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
     );
     );
 
 
     const onRowSelection: OnRowSelection = useCallback(
     const onRowSelection: OnRowSelection = useCallback(
-        (rowIndex: number, colName?: string) =>
+        (rowIndex: number, colName?: string, value?: string) =>
             dispatch(
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                 createSendActionNameAction(updateVarName, module, {
                     action: onAction,
                     action: onAction,
                     index: getRowIndex(rows[rowIndex], rowIndex),
                     index: getRowIndex(rows[rowIndex], rowIndex),
                     col: colName === undefined ? null : colName,
                     col: colName === undefined ? null : colName,
+                    value,
+                    reason: value === undefined ? "click": "button",
                     user_data: userData,
                     user_data: userData,
                 })
                 })
             ),
             ),
@@ -502,7 +504,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             onRowSelection: active && onAction ? onRowSelection : undefined,
             onRowSelection: active && onAction ? onRowSelection : undefined,
             onRowClick: active && onAction ? onRowClick : undefined,
             onRowClick: active && onAction ? onRowClick : undefined,
             lineStyle: props.lineStyle,
             lineStyle: props.lineStyle,
-            nanValue: props.nanValue,
+            nanValue: props.nanValue
         }),
         }),
         [
         [
             rows,
             rows,
@@ -521,7 +523,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             onRowClick,
             onRowClick,
             props.lineStyle,
             props.lineStyle,
             props.nanValue,
             props.nanValue,
-            size,
+            size
         ]
         ]
     );
     );
 
 

+ 66 - 0
frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx

@@ -122,6 +122,33 @@ const editableColumns = JSON.stringify({
     Code: { dfid: "Code", type: "str", index: 3 },
     Code: { dfid: "Code", type: "str", index: 3 },
 });
 });
 
 
+const buttonValue = {
+    "0--1-bool,int,float,Code--asc": {
+        data: [
+            {
+                bool: true,
+                int: 856,
+                float: 1.5,
+                Code: "[Button Label](button action)",
+            },
+            {
+                bool: false,
+                int: 823,
+                float: 2.5,
+                Code: "ZZZ",
+            },
+        ],
+        rowcount: 2,
+        start: 0,
+    },
+};
+const buttonColumns = JSON.stringify({
+    bool: { dfid: "bool", type: "bool", index: 0 },
+    int: { dfid: "int", type: "int", index: 1 },
+    float: { dfid: "float", type: "float", index: 2 },
+    Code: { dfid: "Code", type: "str", index: 3 },
+});
+
 describe("PaginatedTable Component", () => {
 describe("PaginatedTable Component", () => {
     it("renders", async () => {
     it("renders", async () => {
         const { getByText } = render(<PaginatedTable data={undefined} defaultColumns={tableColumns} />);
         const { getByText } = render(<PaginatedTable data={undefined} defaultColumns={tableColumns} />);
@@ -539,6 +566,45 @@ describe("PaginatedTable Component", () => {
                 args: [],
                 args: [],
                 col: "int",
                 col: "int",
                 index: 1,
                 index: 1,
+                reason: "click",
+                value: undefined
+            },
+            type: "SEND_ACTION_ACTION",
+        });
+    });
+    it("can click on button", async () => {
+        const dispatch = jest.fn();
+        const state: TaipyState = INITIAL_STATE;
+        const { getByText, rerender } = render(
+            <TaipyContext.Provider value={{ state, dispatch }}>
+                <PaginatedTable data={undefined} defaultColumns={editableColumns} showAll={true} onAction="onSelect" />
+            </TaipyContext.Provider>
+        );
+
+        rerender(
+            <TaipyContext.Provider value={{ state: { ...state }, dispatch }}>
+                <PaginatedTable
+                    data={buttonValue as TableValueType}
+                    defaultColumns={buttonColumns}
+                    showAll={true}
+                    onAction="onSelect"
+                />
+            </TaipyContext.Provider>
+        );
+
+        dispatch.mockClear();
+        const elt = getByText("Button Label");
+        expect(elt.tagName).toBe("BUTTON");
+        await userEvent.click(elt);
+        expect(dispatch).toHaveBeenCalledWith({
+            name: "",
+            payload: {
+                action: "onSelect",
+                args: [],
+                col: "Code",
+                index: 0,
+                reason: "button",
+                value: "button action"
             },
             },
             type: "SEND_ACTION_ACTION",
             type: "SEND_ACTION_ACTION",
         });
         });

+ 3 - 1
frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx

@@ -393,12 +393,14 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
     );
     );
 
 
     const onRowSelection: OnRowSelection = useCallback(
     const onRowSelection: OnRowSelection = useCallback(
-        (rowIndex: number, colName?: string) =>
+        (rowIndex: number, colName?: string, value?: string) =>
             dispatch(
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                 createSendActionNameAction(updateVarName, module, {
                     action: onAction,
                     action: onAction,
                     index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
                     index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
                     col: colName === undefined ? null : colName,
                     col: colName === undefined ? null : colName,
+                    value,
+                    reason: value === undefined ? "click": "button",
                     user_data: userData,
                     user_data: userData,
                 })
                 })
             ),
             ),

+ 96 - 60
frontend/taipy-gui/src/components/Taipy/tableUtils.tsx

@@ -39,7 +39,7 @@ import { isValid } from "date-fns";
 import { FormatConfig } from "../../context/taipyReducers";
 import { FormatConfig } from "../../context/taipyReducers";
 import { dateToString, getDateTime, getDateTimeString, getNumberString, getTimeZonedDate } from "../../utils/index";
 import { dateToString, getDateTime, getDateTimeString, getNumberString, getTimeZonedDate } from "../../utils/index";
 import { TaipyActiveProps, TaipyMultiSelectProps, getSuffixedClassNames } from "./utils";
 import { TaipyActiveProps, TaipyMultiSelectProps, getSuffixedClassNames } from "./utils";
-import { FilterOptionsState, TextField } from "@mui/material";
+import { Button, FilterOptionsState, TextField } from "@mui/material";
 
 
 /**
 /**
  * A column description as received by the backend.
  * A column description as received by the backend.
@@ -155,7 +155,7 @@ export const iconInRowSx = { fontSize: "body2.fontSize" };
 export const iconsWrapperSx = { gridColumnStart: 2, display: "flex", alignItems: "center" } as CSSProperties;
 export const iconsWrapperSx = { gridColumnStart: 2, display: "flex", alignItems: "center" } as CSSProperties;
 const cellBoxSx = { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center" } as CSSProperties;
 const cellBoxSx = { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center" } as CSSProperties;
 const tableFontSx = { fontSize: "body2.fontSize" };
 const tableFontSx = { fontSize: "body2.fontSize" };
-
+const ButtonSx = { minHeight: "unset", mb: "unset", padding: "unset", lineHeight: "unset" };
 export interface OnCellValidation {
 export interface OnCellValidation {
     (value: RowValue, rowIndex: number, colName: string, userValue: string, tz?: string): void;
     (value: RowValue, rowIndex: number, colName: string, userValue: string, tz?: string): void;
 }
 }
@@ -165,7 +165,7 @@ export interface OnRowDeletion {
 }
 }
 
 
 export interface OnRowSelection {
 export interface OnRowSelection {
-    (rowIndex: number, colName?: string): void;
+    (rowIndex: number, colName?: string, value?: string): void;
 }
 }
 
 
 export interface OnRowClick {
 export interface OnRowClick {
@@ -220,13 +220,6 @@ const isBooleanTrue = (val: RowValue) =>
 const defaultCursor = { cursor: "default" };
 const defaultCursor = { cursor: "default" };
 const defaultCursorIcon = { ...iconInRowSx, "& .MuiSwitch-input": defaultCursor };
 const defaultCursorIcon = { ...iconInRowSx, "& .MuiSwitch-input": defaultCursor };
 
 
-const renderCellValue = (val: RowValue | boolean, col: ColumnDesc, formatConf: FormatConfig, nanValue?: string) => {
-    if (val !== null && val !== undefined && col.type && col.type.startsWith("bool")) {
-        return <Switch checked={val as boolean} size="small" title={val ? "True" : "False"} sx={defaultCursorIcon} />;
-    }
-    return <span style={defaultCursor}>{formatValue(val as RowValue, col, formatConf, nanValue)}</span>;
-};
-
 const getCellProps = (col: ColumnDesc, base: Partial<TableCellProps> = {}): Partial<TableCellProps> => {
 const getCellProps = (col: ColumnDesc, base: Partial<TableCellProps> = {}): Partial<TableCellProps> => {
     switch (col.type) {
     switch (col.type) {
         case "bool":
         case "bool":
@@ -272,6 +265,8 @@ const filter = createFilterOptions<string>();
 const getOptionKey = (option: string) => (Array.isArray(option) ? option[0] : option);
 const getOptionKey = (option: string) => (Array.isArray(option) ? option[0] : option);
 const getOptionLabel = (option: string) => (Array.isArray(option) ? option[1] : option);
 const getOptionLabel = (option: string) => (Array.isArray(option) ? option[1] : option);
 
 
+const onCompleteClose = (evt: SyntheticEvent) => evt.stopPropagation();
+
 export const EditableCell = (props: EditableCellProps) => {
 export const EditableCell = (props: EditableCellProps) => {
     const {
     const {
         onValidation,
         onValidation,
@@ -291,55 +286,75 @@ export const EditableCell = (props: EditableCellProps) => {
     const [deletion, setDeletion] = useState(false);
     const [deletion, setDeletion] = useState(false);
 
 
     const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setVal(e.target.value), []);
     const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setVal(e.target.value), []);
-    const onCompleteChange = useCallback((e: SyntheticEvent, value: string | null) => setVal(value), []);
+    const onCompleteChange = useCallback((e: SyntheticEvent, value: string | null) => {
+        e.stopPropagation();
+        setVal(value);
+    }, []);
     const onBoolChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setVal(e.target.checked), []);
     const onBoolChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setVal(e.target.checked), []);
     const onDateChange = useCallback((date: Date | null) => setVal(date), []);
     const onDateChange = useCallback((date: Date | null) => setVal(date), []);
 
 
     const withTime = useMemo(() => !!colDesc.format && colDesc.format.toLowerCase().includes("h"), [colDesc.format]);
     const withTime = useMemo(() => !!colDesc.format && colDesc.format.toLowerCase().includes("h"), [colDesc.format]);
 
 
-    const onCheckClick = useCallback(() => {
-        let castedVal = val;
-        switch (colDesc.type) {
-            case "bool":
-                castedVal = isBooleanTrue(val as RowValue);
-                break;
-            case "int":
-                try {
-                    castedVal = parseInt(val as string, 10);
-                } catch (e) {
-                    // ignore
-                }
-                break;
-            case "float":
-                try {
-                    castedVal = parseFloat(val as string);
-                } catch (e) {
-                    // ignore
-                }
-                break;
-            case "datetime":
-                if (val === null) {
-                    castedVal = val;
-                } else if (isValid(val)) {
-                    castedVal = dateToString(getTimeZonedDate(val as Date, formatConfig.timeZone, withTime), withTime);
-                } else {
-                    return;
-                }
-                break;
+    const button = useMemo(() => {
+        if (onSelection && typeof value == "string" && value.startsWith("[") && value.endsWith(")")) {
+            const parts = value.slice(1, -1).split("](");
+            if (parts.length == 2) {
+                return parts as [string, string];
+            }
         }
         }
-        onValidation &&
-            onValidation(
-                castedVal as RowValue,
-                rowIndex,
-                colDesc.dfid,
-                val as string,
-                colDesc.type == "datetime" ? formatConfig.timeZone : undefined
-            );
-        setEdit((e) => !e);
-    }, [onValidation, val, rowIndex, colDesc.dfid, colDesc.type, formatConfig.timeZone, withTime]);
+        return undefined;
+    }, [value, onSelection]);
+
+    const onCheckClick = useCallback(
+        (evt?: MouseEvent<HTMLElement>) => {
+            evt && evt.stopPropagation();
+            let castVal = val;
+            switch (colDesc.type) {
+                case "bool":
+                    castVal = isBooleanTrue(val as RowValue);
+                    break;
+                case "int":
+                    try {
+                        castVal = parseInt(val as string, 10);
+                    } catch (e) {
+                        // ignore
+                    }
+                    break;
+                case "float":
+                    try {
+                        castVal = parseFloat(val as string);
+                    } catch (e) {
+                        // ignore
+                    }
+                    break;
+                case "datetime":
+                    if (val === null) {
+                        castVal = val;
+                    } else if (isValid(val)) {
+                        castVal = dateToString(
+                            getTimeZonedDate(val as Date, formatConfig.timeZone, withTime),
+                            withTime
+                        );
+                    } else {
+                        return;
+                    }
+                    break;
+            }
+            onValidation &&
+                onValidation(
+                    castVal as RowValue,
+                    rowIndex,
+                    colDesc.dfid,
+                    val as string,
+                    colDesc.type == "datetime" ? formatConfig.timeZone : undefined
+                );
+            setEdit((e) => !e);
+        },
+        [onValidation, val, rowIndex, colDesc.dfid, colDesc.type, formatConfig.timeZone, withTime]
+    );
 
 
     const onEditClick = useCallback(
     const onEditClick = useCallback(
-        (evt?: MouseEvent) => {
+        (evt?: MouseEvent<HTMLElement>) => {
             evt && evt.stopPropagation();
             evt && evt.stopPropagation();
             colDesc.type?.startsWith("date")
             colDesc.type?.startsWith("date")
                 ? setVal(getDateTime(value as string, formatConfig.timeZone, withTime))
                 ? setVal(getDateTime(value as string, formatConfig.timeZone, withTime))
@@ -363,10 +378,14 @@ export const EditableCell = (props: EditableCellProps) => {
         [onCheckClick, onEditClick]
         [onCheckClick, onEditClick]
     );
     );
 
 
-    const onDeleteCheckClick = useCallback(() => {
-        onDeletion && onDeletion(rowIndex);
-        setDeletion((d) => !d);
-    }, [onDeletion, rowIndex]);
+    const onDeleteCheckClick = useCallback(
+        (evt?: MouseEvent<HTMLElement>) => {
+            evt && evt.stopPropagation();
+            onDeletion && onDeletion(rowIndex);
+            setDeletion((d) => !d);
+        },
+        [onDeletion, rowIndex]
+    );
 
 
     const onDeleteClick = useCallback(
     const onDeleteClick = useCallback(
         (evt?: MouseEvent) => {
         (evt?: MouseEvent) => {
@@ -391,11 +410,11 @@ export const EditableCell = (props: EditableCellProps) => {
     );
     );
 
 
     const onSelect = useCallback(
     const onSelect = useCallback(
-        (e: MouseEvent<HTMLDivElement>) => {
+        (e: MouseEvent<HTMLElement>) => {
             e.stopPropagation();
             e.stopPropagation();
-            onSelection && onSelection(rowIndex, colDesc.dfid);
+            onSelection && onSelection(rowIndex, colDesc.dfid, button && button[1]);
         },
         },
-        [onSelection, rowIndex, colDesc.dfid]
+        [onSelection, rowIndex, colDesc.dfid, button]
     );
     );
 
 
     const filterOptions = useCallback(
     const filterOptions = useCallback(
@@ -490,6 +509,7 @@ export const EditableCell = (props: EditableCellProps) => {
                             freeSolo={!!colDesc.freeLov}
                             freeSolo={!!colDesc.freeLov}
                             value={val as string}
                             value={val as string}
                             onChange={onCompleteChange}
                             onChange={onCompleteChange}
+                            onOpen={onCompleteClose}
                             renderInput={(params) => (
                             renderInput={(params) => (
                                 <TextField
                                 <TextField
                                     {...params}
                                     {...params}
@@ -501,6 +521,7 @@ export const EditableCell = (props: EditableCellProps) => {
                                     sx={tableFontSx}
                                     sx={tableFontSx}
                                 />
                                 />
                             )}
                             )}
+                            disableClearable={!colDesc.freeLov}
                         />
                         />
                         <Box sx={iconsWrapperSx}>
                         <Box sx={iconsWrapperSx}>
                             <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
                             <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
@@ -558,8 +579,23 @@ export const EditableCell = (props: EditableCellProps) => {
                 ) : null
                 ) : null
             ) : (
             ) : (
                 <Box sx={cellBoxSx} onClick={onSelect}>
                 <Box sx={cellBoxSx} onClick={onSelect}>
-                    {renderCellValue(value, colDesc, formatConfig, nanValue)}
-                    {onValidation ? (
+                    {button ? (
+                        <Button size="small" onClick={onSelect} sx={ButtonSx}>
+                            {formatValue(button[0] as RowValue, colDesc, formatConfig, nanValue)}
+                        </Button>
+                    ) : val !== null && val !== undefined && colDesc.type && colDesc.type.startsWith("bool") ? (
+                        <Switch
+                            checked={val as boolean}
+                            size="small"
+                            title={val ? "True" : "False"}
+                            sx={defaultCursorIcon}
+                        />
+                    ) : (
+                        <span style={defaultCursor}>
+                            {formatValue(val as RowValue, colDesc, formatConfig, nanValue)}
+                        </span>
+                    )}
+                    {onValidation && !button ? (
                         <Box sx={iconsWrapperSx}>
                         <Box sx={iconsWrapperSx}>
                             <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
                             <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
                                 <EditIcon fontSize="inherit" />
                                 <EditIcon fontSize="inherit" />

+ 1 - 1
taipy/gui/viselements.json

@@ -1111,7 +1111,7 @@
           {
           {
             "name": "on_action",
             "name": "on_action",
             "type": "str",
             "type": "str",
-            "doc": "The name of a function that is triggered when the user selects a row.<br/>All parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>var_name (str): the name of the tabular data variable.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>This dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>index (int): the row index.</li>\n<li>col (str): the column name.</li></ul></li></ul>.",
+            "doc": "The name of a function that is triggered when the user selects a row.<br/>All parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>var_name (str): the name of the tabular data variable.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>This dictionary has the following keys:\n<ul>\n<li>action: the name of the action that triggered this callback.</li>\n<li>index (int): the row index.</li>\n<li>col (str): the column name.</li>\n<li>reason (str): the origin of the action: \"click\", or \"button\" if the cell contains a Markdown link syntax.</li>\n<li>value (str): the *link value* indicated in the cell when using a Markdown link syntax (that is, <i>reason</i> is set to \"button\").</li></ul></li></ul>.",
             "signature": [["state", "State"], ["var_name", "str"], ["payload", "dict"]]
             "signature": [["state", "State"], ["var_name", "str"], ["payload", "dict"]]
           },
           },
           {
           {