Browse Source

support image in table (#1784)

* support image in table
resolves #1758

* respect md syntax

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 8 months ago
parent
commit
893568dbc8

+ 5 - 0
frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx

@@ -82,6 +82,7 @@ interface RowData {
     columns: Record<string, ColumnDesc>;
     rows: RowType[];
     classes: Record<string, string>;
+    tableClassName: string;
     cellProps: Partial<TableCellProps>[];
     isItemLoaded: (index: number) => boolean;
     selection: number[];
@@ -103,6 +104,7 @@ const Row = ({
         columns,
         rows,
         classes,
+        tableClassName,
         cellProps,
         isItemLoaded,
         selection,
@@ -136,6 +138,7 @@ const Row = ({
                 <EditableCell
                     key={"val" + index + "-" + cidx}
                     className={getClassName(rows[index], columns[col].style, col)}
+                    tableClassName={tableClassName}
                     colDesc={columns[col]}
                     value={rows[index][col]}
                     formatConfig={formatConfig}
@@ -531,6 +534,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             columns: columns,
             rows: rows,
             classes: {},
+            tableClassName: className,
             cellProps: colsOrder.map((col) => ({
                 sx: getCellSx(columns[col].width || columns[col].widthHint, size),
                 component: "div",
@@ -555,6 +559,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             active,
             colsOrder,
             columns,
+            className,
             selected,
             formatConfig,
             editable,

+ 76 - 4
frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx

@@ -136,7 +136,7 @@ const editableColumns = JSON.stringify({
     Code: { dfid: "Code", type: "str", index: 3 },
 });
 
-const buttonValue = {
+const buttonImgValue = {
     "0--1-bool,int,float,Code--asc": {
         data: [
             {
@@ -151,6 +151,12 @@ const buttonValue = {
                 float: 2.5,
                 Code: "ZZZ",
             },
+            {
+                bool: true,
+                int: 478,
+                float: 3.5,
+                Code: "![Taipy!](https://docs.taipy.io/en/latest/assets/images/favicon.png)",
+            },
         ],
         rowcount: 2,
         start: 0,
@@ -668,10 +674,9 @@ describe("PaginatedTable Component", () => {
         rerender(
             <TaipyContext.Provider value={{ state: { ...state }, dispatch }}>
                 <PaginatedTable
-                    data={buttonValue as TableValueType}
+                    data={buttonImgValue as TableValueType}
                     defaultColumns={buttonColumns}
                     showAll={true}
-                    onAction="onSelect"
                 />
             </TaipyContext.Provider>
         );
@@ -679,7 +684,23 @@ describe("PaginatedTable Component", () => {
         dispatch.mockClear();
         const elt = getByText("Button Label");
         expect(elt.tagName).toBe("BUTTON");
-        await userEvent.click(elt);
+        expect(elt).toBeDisabled();
+
+        rerender(
+            <TaipyContext.Provider value={{ state: { ...state }, dispatch }}>
+                <PaginatedTable
+                    data={buttonImgValue as TableValueType}
+                    defaultColumns={buttonColumns}
+                    showAll={true}
+                    onAction="onSelect"
+                />
+            </TaipyContext.Provider>
+        );
+
+        dispatch.mockClear();
+        const elt2 = getByText("Button Label");
+        expect(elt2.tagName).toBe("BUTTON");
+        await userEvent.click(elt2);
         expect(dispatch).toHaveBeenCalledWith({
             name: "",
             payload: {
@@ -693,6 +714,57 @@ describe("PaginatedTable Component", () => {
             type: "SEND_ACTION_ACTION",
         });
     });
+    it("can show an image", async () => {
+        const dispatch = jest.fn();
+        const state: TaipyState = INITIAL_STATE;
+        const { getByAltText, 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={buttonImgValue as TableValueType}
+                    defaultColumns={buttonColumns}
+                    showAll={true}
+                />
+            </TaipyContext.Provider>
+        );
+
+        dispatch.mockClear();
+        const elt = getByAltText("Taipy!");
+        expect(elt.tagName).toBe("IMG");
+
+        rerender(
+            <TaipyContext.Provider value={{ state: { ...state }, dispatch }}>
+                <PaginatedTable
+                    data={buttonImgValue as TableValueType}
+                    defaultColumns={buttonColumns}
+                    showAll={true}
+                    onAction="onSelect"
+                />
+            </TaipyContext.Provider>
+        );
+
+        dispatch.mockClear();
+        const elt2 = getByAltText("Taipy!");
+        expect(elt2.tagName).toBe("IMG");
+        await userEvent.click(elt2);
+        expect(dispatch).toHaveBeenCalledWith({
+            name: "",
+            payload: {
+                action: "onSelect",
+                args: [],
+                col: "Code",
+                index: 2,
+                reason: "button",
+                value: "Taipy!",
+            },
+            type: "SEND_ACTION_ACTION",
+        });
+    });
     it("should render correctly when style is applied to columns", async () => {
         const dispatch = jest.fn();
         const state: TaipyState = INITIAL_STATE;

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

@@ -584,6 +584,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                                 <EditableCell
                                                     key={"val" + index + "-" + cidx}
                                                     className={getClassName(row, columns[col].style, col)}
+                                                    tableClassName={className}
                                                     colDesc={columns[col]}
                                                     value={row[col]}
                                                     formatConfig={formatConfig}

+ 173 - 142
frontend/taipy-gui/src/components/Taipy/tableUtils.tsx

@@ -107,6 +107,8 @@ export const LINE_STYLE = "__taipy_line_style__";
 
 export const defaultDateFormat = "yyyy/MM/dd";
 
+const imgButtonRe = /^(!)?\[([^\]]*)]\(([^)]*)\)$/;
+
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type TableValueType = Record<string, Record<string, any>>;
 
@@ -187,6 +189,7 @@ interface EditableCellProps {
     onSelection?: OnRowSelection;
     nanValue?: string;
     className?: string;
+    tableClassName?: string;
     tooltip?: string;
     tableCellProps?: Partial<TableCellProps>;
     comp?: RowValue;
@@ -286,6 +289,7 @@ export const EditableCell = (props: EditableCellProps) => {
         onSelection,
         nanValue,
         className,
+        tableClassName,
         tooltip,
         tableCellProps = emptyObject,
         comp,
@@ -304,12 +308,15 @@ export const EditableCell = (props: EditableCellProps) => {
 
     const withTime = useMemo(() => !!colDesc.format && colDesc.format.toLowerCase().includes("h"), [colDesc.format]);
 
-    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];
-            }
+    const buttonImg = useMemo(() => {
+        let m;
+        if (typeof value == "string" && (m = imgButtonRe.exec(value)) !== null) {
+            return {
+                text: !!m[1] ? m[3]: m[2],
+                value: !!m[1] ? m[2]: m[3],
+                img: !!m[1],
+                action: !!onSelection,
+            };
         }
         return undefined;
     }, [value, onSelection]);
@@ -421,9 +428,9 @@ export const EditableCell = (props: EditableCellProps) => {
     const onSelect = useCallback(
         (e: MouseEvent<HTMLElement>) => {
             e.stopPropagation();
-            onSelection && onSelection(rowIndex, colDesc.dfid, button && button[1]);
+            onSelection && onSelection(rowIndex, colDesc.dfid, buttonImg && buttonImg.value);
         },
-        [onSelection, rowIndex, colDesc.dfid, button]
+        [onSelection, rowIndex, colDesc.dfid, buttonImg]
     );
 
     const filterOptions = useCallback(
@@ -453,104 +460,54 @@ export const EditableCell = (props: EditableCellProps) => {
             className={
                 onValidation ? getSuffixedClassNames(className || "tpc", edit ? "-editing" : "-editable") : className
             }
-            title={tooltip || comp ? `${tooltip ? tooltip : ""}${comp ? " " + formatValue(comp as RowValue, colDesc, formatConfig, nanValue) : ""}` : undefined}
+            title={
+                tooltip || comp
+                    ? `${tooltip ? tooltip : ""}${
+                          comp ? " " + formatValue(comp as RowValue, colDesc, formatConfig, nanValue) : ""
+                      }`
+                    : undefined
+            }
         >
             <Badge color="primary" variant="dot" invisible={comp === undefined || comp === null}>
-            {edit ? (
-                colDesc.type?.startsWith("bool") ? (
-                    <Box sx={cellBoxSx}>
-                        <Switch
-                            checked={val as boolean}
-                            size="small"
-                            title={val ? "True" : "False"}
-                            sx={iconInRowSx}
-                            onChange={onBoolChange}
-                            inputRef={setInputFocus}
-                        />
-                        <Box sx={iconsWrapperSx}>
-                            <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
-                                <CheckIcon fontSize="inherit" />
-                            </IconButton>
-                            <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
-                                <ClearIcon fontSize="inherit" />
-                            </IconButton>
-                        </Box>
-                    </Box>
-                ) : colDesc.type?.startsWith("date") ? (
-                    <Box sx={cellBoxSx}>
-                        {withTime ? (
-                            <DateTimePicker
-                                value={val as Date}
-                                onChange={onDateChange}
-                                slotProps={textFieldProps}
+                {edit ? (
+                    colDesc.type?.startsWith("bool") ? (
+                        <Box sx={cellBoxSx}>
+                            <Switch
+                                checked={val as boolean}
+                                size="small"
+                                title={val ? "True" : "False"}
+                                sx={iconInRowSx}
+                                onChange={onBoolChange}
                                 inputRef={setInputFocus}
-                                sx={tableFontSx}
                             />
-                        ) : (
-                            <DatePicker
-                                value={val as Date}
-                                onChange={onDateChange}
-                                slotProps={textFieldProps}
-                                inputRef={setInputFocus}
-                                sx={tableFontSx}
-                            />
-                        )}
-                        <Box sx={iconsWrapperSx}>
-                            <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
-                                <CheckIcon fontSize="inherit" />
-                            </IconButton>
-                            <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
-                                <ClearIcon fontSize="inherit" />
-                            </IconButton>
+                            <Box sx={iconsWrapperSx}>
+                                <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
+                                    <CheckIcon fontSize="inherit" />
+                                </IconButton>
+                                <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
+                                    <ClearIcon fontSize="inherit" />
+                                </IconButton>
+                            </Box>
                         </Box>
-                    </Box>
-                ) : colDesc.lov ? (
-                    <Box sx={cellBoxSx}>
-                        <Autocomplete
-                            autoComplete={true}
-                            fullWidth
-                            selectOnFocus={!!colDesc.freeLov}
-                            clearOnBlur={!!colDesc.freeLov}
-                            handleHomeEndKeys={!!colDesc.freeLov}
-                            options={colDesc.lov}
-                            getOptionKey={getOptionKey}
-                            getOptionLabel={getOptionLabel}
-                            filterOptions={filterOptions}
-                            freeSolo={!!colDesc.freeLov}
-                            value={val as string}
-                            onChange={onCompleteChange}
-                            onOpen={onCompleteClose}
-                            renderInput={(params) => (
-                                <TextField
-                                    {...params}
-                                    fullWidth
+                    ) : colDesc.type?.startsWith("date") ? (
+                        <Box sx={cellBoxSx}>
+                            {withTime ? (
+                                <DateTimePicker
+                                    value={val as Date}
+                                    onChange={onDateChange}
+                                    slotProps={textFieldProps}
+                                    inputRef={setInputFocus}
+                                    sx={tableFontSx}
+                                />
+                            ) : (
+                                <DatePicker
+                                    value={val as Date}
+                                    onChange={onDateChange}
+                                    slotProps={textFieldProps}
                                     inputRef={setInputFocus}
-                                    onChange={colDesc.freeLov ? onChange : undefined}
-                                    margin="dense"
-                                    variant="standard"
                                     sx={tableFontSx}
                                 />
                             )}
-                            disableClearable={!colDesc.freeLov}
-                        />
-                        <Box sx={iconsWrapperSx}>
-                            <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
-                                <CheckIcon fontSize="inherit" />
-                            </IconButton>
-                            <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
-                                <ClearIcon fontSize="inherit" />
-                            </IconButton>
-                        </Box>
-                    </Box>
-                ) : (
-                    <Input
-                        value={val}
-                        onChange={onChange}
-                        onKeyDown={onKeyDown}
-                        inputRef={setInputFocus}
-                        margin="dense"
-                        sx={tableFontSx}
-                        endAdornment={
                             <Box sx={iconsWrapperSx}>
                                 <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
                                     <CheckIcon fontSize="inherit" />
@@ -559,61 +516,135 @@ export const EditableCell = (props: EditableCellProps) => {
                                     <ClearIcon fontSize="inherit" />
                                 </IconButton>
                             </Box>
-                        }
-                    />
-                )
-            ) : EDIT_COL === colDesc.dfid ? (
-                deletion ? (
-                    <Input
-                        value="Confirm"
-                        onKeyDown={onDeleteKeyDown}
-                        inputRef={setInputFocus}
-                        sx={tableFontSx}
-                        endAdornment={
+                        </Box>
+                    ) : colDesc.lov ? (
+                        <Box sx={cellBoxSx}>
+                            <Autocomplete
+                                autoComplete={true}
+                                fullWidth
+                                selectOnFocus={!!colDesc.freeLov}
+                                clearOnBlur={!!colDesc.freeLov}
+                                handleHomeEndKeys={!!colDesc.freeLov}
+                                options={colDesc.lov}
+                                getOptionKey={getOptionKey}
+                                getOptionLabel={getOptionLabel}
+                                filterOptions={filterOptions}
+                                freeSolo={!!colDesc.freeLov}
+                                value={val as string}
+                                onChange={onCompleteChange}
+                                onOpen={onCompleteClose}
+                                renderInput={(params) => (
+                                    <TextField
+                                        {...params}
+                                        fullWidth
+                                        inputRef={setInputFocus}
+                                        onChange={colDesc.freeLov ? onChange : undefined}
+                                        margin="dense"
+                                        variant="standard"
+                                        sx={tableFontSx}
+                                    />
+                                )}
+                                disableClearable={!colDesc.freeLov}
+                            />
                             <Box sx={iconsWrapperSx}>
-                                <IconButton onClick={onDeleteCheckClick} size="small" sx={iconInRowSx}>
+                                <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
                                     <CheckIcon fontSize="inherit" />
                                 </IconButton>
-                                <IconButton onClick={onDeleteClick} size="small" sx={iconInRowSx}>
+                                <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
                                     <ClearIcon fontSize="inherit" />
                                 </IconButton>
                             </Box>
-                        }
-                    />
-                ) : onDeletion ? (
-                    <Box sx={iconsWrapperSx}>
-                        <IconButton onClick={onDeleteClick} size="small" sx={iconInRowSx}>
-                            <DeleteIcon fontSize="inherit" />
-                        </IconButton>
-                    </Box>
-                ) : null
-            ) : (
-                <Box sx={cellBoxSx} onClick={onSelect}>
-                    {button ? (
-                        <Button size="small" onClick={onSelect} sx={ButtonSx}>
-                            {formatValue(button[0] as RowValue, colDesc, formatConfig, nanValue)}
-                        </Button>
-                    ) : value !== null && value !== undefined && colDesc.type && colDesc.type.startsWith("bool") ? (
-                        <Switch
-                            checked={value as boolean}
-                            size="small"
-                            title={value ? "True" : "False"}
-                            sx={defaultCursorIcon}
-                        />
+                        </Box>
                     ) : (
-                        <span style={defaultCursor}>
-                            {formatValue(value as RowValue, colDesc, formatConfig, nanValue)}
-                        </span>
-                    )}
-                    {onValidation && !button ? (
+                        <Input
+                            value={val}
+                            onChange={onChange}
+                            onKeyDown={onKeyDown}
+                            inputRef={setInputFocus}
+                            margin="dense"
+                            sx={tableFontSx}
+                            endAdornment={
+                                <Box sx={iconsWrapperSx}>
+                                    <IconButton onClick={onCheckClick} size="small" sx={iconInRowSx}>
+                                        <CheckIcon fontSize="inherit" />
+                                    </IconButton>
+                                    <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
+                                        <ClearIcon fontSize="inherit" />
+                                    </IconButton>
+                                </Box>
+                            }
+                        />
+                    )
+                ) : EDIT_COL === colDesc.dfid ? (
+                    deletion ? (
+                        <Input
+                            value="Confirm"
+                            onKeyDown={onDeleteKeyDown}
+                            inputRef={setInputFocus}
+                            sx={tableFontSx}
+                            endAdornment={
+                                <Box sx={iconsWrapperSx}>
+                                    <IconButton onClick={onDeleteCheckClick} size="small" sx={iconInRowSx}>
+                                        <CheckIcon fontSize="inherit" />
+                                    </IconButton>
+                                    <IconButton onClick={onDeleteClick} size="small" sx={iconInRowSx}>
+                                        <ClearIcon fontSize="inherit" />
+                                    </IconButton>
+                                </Box>
+                            }
+                        />
+                    ) : onDeletion ? (
                         <Box sx={iconsWrapperSx}>
-                            <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
-                                <EditIcon fontSize="inherit" />
+                            <IconButton onClick={onDeleteClick} size="small" sx={iconInRowSx}>
+                                <DeleteIcon fontSize="inherit" />
                             </IconButton>
                         </Box>
-                    ) : null}
-                </Box>
-            )}
+                    ) : null
+                ) : (
+                    <Box sx={cellBoxSx} onClick={onSelect}>
+                        {buttonImg ? (
+                            buttonImg.img ? (
+                                <img
+                                    src={buttonImg.text}
+                                    className={getSuffixedClassNames(tableClassName, "-img")}
+                                    alt={buttonImg.value}
+                                    onClick={onSelect}
+                                    title={buttonImg.value}
+                                />
+                            ) : (
+                                <Button
+                                    size="small"
+                                    onClick={onSelect}
+                                    sx={ButtonSx}
+                                    className={getSuffixedClassNames(tableClassName, "-btn")}
+                                    disabled={!buttonImg.action}
+                                    title={buttonImg.value}
+                                >
+                                    {formatValue(buttonImg.text, colDesc, formatConfig, nanValue)}
+                                </Button>
+                            )
+                        ) : value !== null && value !== undefined && colDesc.type && colDesc.type.startsWith("bool") ? (
+                            <Switch
+                                checked={value as boolean}
+                                size="small"
+                                title={value ? "True" : "False"}
+                                sx={defaultCursorIcon}
+                                className={getSuffixedClassNames(tableClassName, "-bool")}
+                            />
+                        ) : (
+                            <span style={defaultCursor}>
+                                {formatValue(value as RowValue, colDesc, formatConfig, nanValue)}
+                            </span>
+                        )}
+                        {onValidation && !buttonImg ? (
+                            <Box sx={iconsWrapperSx}>
+                                <IconButton onClick={onEditClick} size="small" sx={iconInRowSx}>
+                                    <EditIcon fontSize="inherit" />
+                                </IconButton>
+                            </Box>
+                        ) : null}
+                    </Box>
+                )}
             </Badge>
         </TableCell>
     );