Repository: Avaiga/taipy
Pull request: table cell action when value is a button
- a button is shown when cell value is \[button label](button value)
- fix lov clear selection
resolves #945
resolves #947
```

import pandas as pd

from taipy.gui import Gui, State

data = {
    "label": ["red", "white"],
    "timedelta": [pd.Timedelta(days=1), pd.Timedelta(days=2)],
    "links": ["[link 1](a value)", "[another link](http://www.google.com)"],
}

col_lov = ["value 1", "value 2", "value 3"]

def on_action(state: State, var_name: str, payload: dict):
    print(f"on_action(state, var_name: {var_name}, payload: {payload})")


def on_edit(state: State, var_name: str, payload: dict):
    print(f"on_edit(state, var_name: {var_name}, payload: {payload})")

md = """
<|{data}|table|lov[label]={col_lov}|on_action=on_action|on_edit=on_edit|>
"""

Gui(md).run()

```

Problem statement:
Clickable Links in a Table
**What would that feature address**
It is not possible to show clickable links in a table

***Description of the ideal solution***
It would be great to be able to tell Taipy that a column's data type is "link" in the table definition to make http links clickable. And it would be nice to pass markdown or html links into the table to have a link text and url property available.

***Caveats***
Maybe passing markdown or html links into the table is too much. Setting the data type to "link" would be great already!

***Other options***
-

**Acceptance Criteria**
- [ ] Ensure new code is unit tested, and check code coverage is at least 90%
- [ ] Create related issue in taipy-doc for documentation and Release Notes
- [ ] Check if a new demo could be provided based on this, or if legacy demos could be benefit from it
- [ ] Ensure any change is well documented


BUG-Empty value is valid despite restricted enumeration in table cell
**Description**

Since Taipy 3.1, table cell values can be restricted.
However, when you edit a table cell, you can use the Clear button (next to the Open button accessing the drop-down menu with predefined values), or simply select all the cell content and press de Delete key.
The cell is then set to the empty string.

Now if an empty string is *not* in the column's lov (and that lov does not have the None value), it is accepted by the table.

**Expected behavior**

The empty cell, if "" is not in the column's lov, should not be accepted.

**Acceptance Criteria**
- [ ] Ensure new code is unit tested, and check code coverage is at least 90%
- [ ] Create related issue in taipy-doc for documentation and Release Notes if relevant

Hints from issue discussion:
supporting a simple version of markdown links might be faisable
ie \[label](action)
would render as a button that will dispatch the table on_button_action with the label and action as args

Commit message: improve test

Patch to review:
diff --git a/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx b/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx
index 1c281e652c..a0d4a42da2 100644
--- a/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx
+++ b/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx
@@ -110,7 +110,7 @@ const Row = ({
         onRowSelection,
         onRowClick,
         lineStyle,
-        nanValue,
+        nanValue
     },
 }: {
     index: number;
@@ -450,12 +450,14 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
     );
 
     const onRowSelection: OnRowSelection = useCallback(
-        (rowIndex: number, colName?: string) =>
+        (rowIndex: number, colName?: string, value?: string) =>
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                     action: onAction,
                     index: getRowIndex(rows[rowIndex], rowIndex),
                     col: colName === undefined ? null : colName,
+                    value,
+                    reason: value === undefined ? "click": "button",
                     user_data: userData,
                 })
             ),
@@ -502,7 +504,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             onRowSelection: active && onAction ? onRowSelection : undefined,
             onRowClick: active && onAction ? onRowClick : undefined,
             lineStyle: props.lineStyle,
-            nanValue: props.nanValue,
+            nanValue: props.nanValue
         }),
         [
             rows,
@@ -521,7 +523,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             onRowClick,
             props.lineStyle,
             props.nanValue,
-            size,
+            size
         ]
     );
 
diff --git a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx
index b741a6adb0..303b4fa3fa 100644
--- a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx
+++ b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx
@@ -122,6 +122,33 @@ const editableColumns = JSON.stringify({
     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", () => {
     it("renders", async () => {
         const { getByText } = render(<PaginatedTable data={undefined} defaultColumns={tableColumns} />);
@@ -539,6 +566,45 @@ describe("PaginatedTable Component", () => {
                 args: [],
                 col: "int",
                 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",
         });
diff --git a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
index d7d87d6f38..f398e6cc32 100644
--- a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
+++ b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx
@@ -393,12 +393,14 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
     );
 
     const onRowSelection: OnRowSelection = useCallback(
-        (rowIndex: number, colName?: string) =>
+        (rowIndex: number, colName?: string, value?: string) =>
             dispatch(
                 createSendActionNameAction(updateVarName, module, {
                     action: onAction,
                     index: getRowIndex(rows[rowIndex], rowIndex, startIndex),
                     col: colName === undefined ? null : colName,
+                    value,
+                    reason: value === undefined ? "click": "button",
                     user_data: userData,
                 })
             ),
diff --git a/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx b/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx
index 9891f1b427..4b9d4ac9ae 100644
--- a/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx
+++ b/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx
@@ -39,7 +39,7 @@ import { isValid } from "date-fns";
 import { FormatConfig } from "../../context/taipyReducers";
 import { dateToString, getDateTime, getDateTimeString, getNumberString, getTimeZonedDate } from "../../utils/index";
 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.
@@ -155,7 +155,7 @@ export const iconInRowSx = { fontSize: "body2.fontSize" };
 export const iconsWrapperSx = { gridColumnStart: 2, display: "flex", alignItems: "center" } as CSSProperties;
 const cellBoxSx = { display: "grid", gridTemplateColumns: "1fr auto", alignItems: "center" } as CSSProperties;
 const tableFontSx = { fontSize: "body2.fontSize" };
-
+const ButtonSx = { minHeight: "unset", mb: "unset", padding: "unset", lineHeight: "unset" };
 export interface OnCellValidation {
     (value: RowValue, rowIndex: number, colName: string, userValue: string, tz?: string): void;
 }
@@ -165,7 +165,7 @@ export interface OnRowDeletion {
 }
 
 export interface OnRowSelection {
-    (rowIndex: number, colName?: string): void;
+    (rowIndex: number, colName?: string, value?: string): void;
 }
 
 export interface OnRowClick {
@@ -220,13 +220,6 @@ const isBooleanTrue = (val: RowValue) =>
 const defaultCursor = { cursor: "default" };
 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> => {
     switch (col.type) {
         case "bool":
@@ -272,6 +265,8 @@ const filter = createFilterOptions<string>();
 const getOptionKey = (option: string) => (Array.isArray(option) ? option[0] : option);
 const getOptionLabel = (option: string) => (Array.isArray(option) ? option[1] : option);
 
+const onCompleteClose = (evt: SyntheticEvent) => evt.stopPropagation();
+
 export const EditableCell = (props: EditableCellProps) => {
     const {
         onValidation,
@@ -291,55 +286,75 @@ export const EditableCell = (props: EditableCellProps) => {
     const [deletion, setDeletion] = useState(false);
 
     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 onDateChange = useCallback((date: Date | null) => setVal(date), []);
 
     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 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;
+            }
+            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]
+    );
 
     const onEditClick = useCallback(
-        (evt?: MouseEvent) => {
+        (evt?: MouseEvent<HTMLElement>) => {
             evt && evt.stopPropagation();
             colDesc.type?.startsWith("date")
                 ? setVal(getDateTime(value as string, formatConfig.timeZone, withTime))
@@ -363,10 +378,14 @@ export const EditableCell = (props: EditableCellProps) => {
         [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(
         (evt?: MouseEvent) => {
@@ -391,11 +410,11 @@ export const EditableCell = (props: EditableCellProps) => {
     );
 
     const onSelect = useCallback(
-        (e: MouseEvent<HTMLDivElement>) => {
+        (e: MouseEvent<HTMLElement>) => {
             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(
@@ -490,6 +509,7 @@ export const EditableCell = (props: EditableCellProps) => {
                             freeSolo={!!colDesc.freeLov}
                             value={val as string}
                             onChange={onCompleteChange}
+                            onOpen={onCompleteClose}
                             renderInput={(params) => (
                                 <TextField
                                     {...params}
@@ -501,6 +521,7 @@ export const EditableCell = (props: EditableCellProps) => {
                                     sx={tableFontSx}
                                 />
                             )}
+                            disableClearable={!colDesc.freeLov}
                         />
                         <Box sx={iconsWrapperSx}>
                             <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
@@ -558,8 +579,23 @@ export const EditableCell = (props: EditableCellProps) => {
                 ) : null
             ) : (
                 <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}>
                             <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
                                 <EditIcon fontSize="inherit" />
diff --git a/taipy/gui/viselements.json b/taipy/gui/viselements.json
index 80d404c302..cacadf35a3 100644
--- a/taipy/gui/viselements.json
+++ b/taipy/gui/viselements.json
@@ -1111,7 +1111,7 @@
           {
             "name": "on_action",
             "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: cmick or button.</li>\n<li>value (str): the value associated with the button when reason == button and original value is [label](button value).</li></ul></li></ul>.",
             "signature": [["state", "State"], ["var_name", "str"], ["payload", "dict"]]
           },
           {


Code review comments:
diff_hunk: @@ -1111,7 +1111,7 @@
           {
             "name": "on_action",
             "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: cmick or button.</li>\n<li>value (str): the value associated with the button when reason == button and original value is [label](button value).</li></ul></li></ul>.",
path: taipy/gui/viselements.json
text: @user1:
```suggestion
            "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>.",
```

What is 'payload.value' when *reason* is "click"?

diff_hunk: @@ -291,55 +286,75 @@ export const EditableCell = (props: EditableCellProps) => {
     const [deletion, setDeletion] = useState(false);
 
     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 onDateChange = useCallback((date: Date | null) => setVal(date), []);
 
     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 castedVal = val;
path: frontend/taipy-gui/src/components/Taipy/tableUtils.tsx
text: @user1:
```suggestion
            let castVal = val; // Please
```