Browse Source

sort scenario selector (#1318)

* sort scenario selector
resolves #1301

* do not show sort column if no option

* missing dependency

* remove unwanted file

* fix for python < 3.10

* format

* with doc and support for sorting on datanode properties

* format

* fix subclass error

* Good catch @fab

Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>

* Fab's comments

* fix

* no more Order label

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>
Fred Lefévère-Laoide 1 year ago
parent
commit
27f4a9a892

+ 15 - 0
frontend/taipy-gui/packaging/taipy-gui.d.ts

@@ -132,6 +132,21 @@ export interface TableFilterProps {
 }
 export declare const TableFilter: (props: TableFilterProps) => JSX.Element;
 
+export interface SortDesc {
+    col: string;
+    order: boolean;
+}
+
+export interface TableSortProps {
+    columns: Record<string, ColumnDesc>;
+    colsOrder?: Array<string>;
+    onValidate: (data: Array<SortDesc>) => void;
+    appliedSorts?: Array<SortDesc>;
+    className?: string;
+}
+
+export declare const TableSort: (props: TableSortProps) => JSX.Element;
+
 export declare const Router: () => JSX.Element;
 
 /**

+ 170 - 0
frontend/taipy-gui/src/components/Taipy/TableSort.spec.tsx

@@ -0,0 +1,170 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+import React from "react";
+import { getByTitle, render } from "@testing-library/react";
+import "@testing-library/jest-dom";
+import userEvent from "@testing-library/user-event";
+
+import TableSort from "./TableSort";
+import { ColumnDesc } from "./tableUtils";
+
+const tableColumns = {
+    StringCol: { dfid: "StringCol", type: "object", index: 0, format: "", filter: true },
+    NumberCol: { dfid: "NumberCol", type: "int", index: 1, format: "", filter: true },
+    BoolCol: { dfid: "BoolCol", type: "bool", index: 2, format: "", filter: true },
+    DateCol: { dfid: "DateCol", type: "datetime", index: 3, format: "", filter: true },
+} as Record<string, ColumnDesc>;
+const colsOrder = ["StringCol", "NumberCol", "BoolCol", "DateCol"];
+
+beforeEach(() => {
+    // add window.matchMedia
+    // this is necessary for the date picker to be rendered in desktop mode.
+    // if this is not provided, the mobile mode is rendered, which might lead to unexpected behavior
+    Object.defineProperty(window, "matchMedia", {
+        writable: true,
+        value: (query: string): MediaQueryList => ({
+            media: query,
+            // this is the media query that @material-ui/pickers uses to determine if a device is a desktop device
+            matches: query === "(pointer: fine)",
+            onchange: () => {},
+            addEventListener: () => {},
+            removeEventListener: () => {},
+            addListener: () => {},
+            removeListener: () => {},
+            dispatchEvent: () => false,
+        }),
+    });
+});
+
+afterEach(() => {
+    // @ts-ignore
+    delete window.matchMedia;
+});
+
+describe("Table Filter Component", () => {
+    it("renders an icon", async () => {
+        const { getByTestId } = render(
+            <TableSort columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+        );
+        const elt = getByTestId("SortByAlphaIcon");
+        expect(elt.parentElement?.parentElement?.tagName).toBe("BUTTON");
+    });
+    it("renders popover when clicked", async () => {
+        const { getByTestId, getAllByText, getAllByTestId } = render(
+            <TableSort columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+        );
+        const elt = getByTestId("SortByAlphaIcon");
+        await userEvent.click(elt);
+        expect(getAllByText("Column")).toHaveLength(2);
+        const dropdownElts = getAllByTestId("ArrowDropDownIcon");
+        expect(dropdownElts).toHaveLength(1);
+        expect(getByTestId("CheckIcon").parentElement).toBeDisabled();
+        expect(getByTestId("DeleteIcon").parentElement).toBeDisabled();
+    });
+    it("behaves on column", async () => {
+        const { getByTestId, getAllByTestId, findByRole, getByText } = render(
+            <TableSort columns={tableColumns} colsOrder={colsOrder} onValidate={jest.fn()} />
+        );
+        const elt = getByTestId("SortByAlphaIcon");
+        await userEvent.click(elt);
+        const dropdownElts = getAllByTestId("ArrowDropDownIcon");
+        expect(dropdownElts).toHaveLength(1);
+        await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]);
+        await findByRole("listbox");
+        await userEvent.click(getByText("StringCol"));
+        await findByRole("checkbox");
+        const validate = getByTestId("CheckIcon").parentElement;
+        expect(validate).not.toBeDisabled();
+    });
+    it("adds a row on validation", async () => {
+        const onValidate = jest.fn();
+        const { getByTestId, getAllByTestId, findByRole, getByText } = render(
+            <TableSort columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} />
+        );
+        const elt = getByTestId("SortByAlphaIcon");
+        await userEvent.click(elt);
+        const dropdownElts = getAllByTestId("ArrowDropDownIcon");
+        expect(dropdownElts).toHaveLength(1);
+        await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]);
+        await findByRole("listbox");
+        await userEvent.click(getByText("StringCol"));
+        await findByRole("checkbox");
+        const validate = getByTestId("CheckIcon");
+        expect(validate.parentElement).not.toBeDisabled();
+        await userEvent.click(validate);
+        const ddElts = getAllByTestId("ArrowDropDownIcon");
+        expect(ddElts).toHaveLength(2);
+        getByText("1");
+        expect(onValidate).toHaveBeenCalled();
+    });
+    it("delete a row", async () => {
+        const onValidate = jest.fn();
+        const { getByTestId, getAllByTestId, findByRole, getByText } = render(
+            <TableSort columns={tableColumns} colsOrder={colsOrder} onValidate={onValidate} />
+        );
+        const elt = getByTestId("SortByAlphaIcon");
+        await userEvent.click(elt);
+        const dropdownElts = getAllByTestId("ArrowDropDownIcon");
+        expect(dropdownElts).toHaveLength(1);
+        await userEvent.click(dropdownElts[0].parentElement?.firstElementChild || dropdownElts[0]);
+        await findByRole("listbox");
+        await userEvent.click(getByText("StringCol"));
+        await findByRole("checkbox");
+        const validate = getByTestId("CheckIcon");
+        expect(validate.parentElement).not.toBeDisabled();
+        await userEvent.click(validate);
+        const ddElts = getAllByTestId("ArrowDropDownIcon");
+        expect(ddElts).toHaveLength(2);
+        const deletes = getAllByTestId("DeleteIcon");
+        expect(deletes).toHaveLength(2);
+        expect(deletes[0].parentElement).not.toBeDisabled();
+        expect(deletes[1].parentElement).toBeDisabled();
+        await userEvent.click(deletes[0]);
+        const ddElts2 = getAllByTestId("ArrowDropDownIcon");
+        expect(ddElts2).toHaveLength(1);
+    });
+    it("reset filters", async () => {
+        const onValidate = jest.fn();
+        const { getAllByTestId, getByTestId } = render(
+            <TableSort
+                columns={tableColumns}
+                colsOrder={colsOrder}
+                onValidate={onValidate}
+                appliedSorts={[{ col: "StringCol", order: true }]}
+            />
+        );
+        const elt = getByTestId("SortByAlphaIcon");
+        await userEvent.click(elt);
+        const deletes = getAllByTestId("DeleteIcon");
+        expect(deletes).toHaveLength(2);
+        expect(deletes[0].parentElement).not.toBeDisabled();
+        expect(deletes[1].parentElement).toBeDisabled();
+        await userEvent.click(deletes[0]);
+        expect(onValidate).toHaveBeenCalled();
+    });
+    it("ignores unapplicable filters", async () => {
+        const { getAllByTestId, getByTestId } = render(
+            <TableSort
+                columns={tableColumns}
+                colsOrder={colsOrder}
+                onValidate={jest.fn()}
+                appliedSorts={[{ col: "unknown col", order: true }]}
+            />
+        );
+        const elt = getByTestId("SortByAlphaIcon");
+        await userEvent.click(elt);
+        const ddElts2 = getAllByTestId("ArrowDropDownIcon");
+        expect(ddElts2).toHaveLength(1);
+    });
+});

+ 265 - 0
frontend/taipy-gui/src/components/Taipy/TableSort.tsx

@@ -0,0 +1,265 @@
+/*
+ * Copyright 2021-2024 Avaiga Private Limited
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import CheckIcon from "@mui/icons-material/Check";
+import DeleteIcon from "@mui/icons-material/Delete";
+import SortByAlpha from "@mui/icons-material/SortByAlpha";
+import Badge from "@mui/material/Badge";
+import FormControl from "@mui/material/FormControl";
+import Grid from "@mui/material/Grid";
+import IconButton from "@mui/material/IconButton";
+import InputLabel from "@mui/material/InputLabel";
+import MenuItem from "@mui/material/MenuItem";
+import OutlinedInput from "@mui/material/OutlinedInput";
+import Popover, { PopoverOrigin } from "@mui/material/Popover";
+import Select, { SelectChangeEvent } from "@mui/material/Select";
+import Switch from "@mui/material/Switch";
+import Tooltip from "@mui/material/Tooltip";
+import Typography from "@mui/material/Typography";
+
+import { ColumnDesc, getsortByIndex, iconInRowSx } from "./tableUtils";
+import { getSuffixedClassNames } from "./utils";
+
+export interface SortDesc {
+    col: string;
+    order: boolean;
+}
+
+interface TableSortProps {
+    columns: Record<string, ColumnDesc>;
+    colsOrder?: Array<string>;
+    onValidate: (data: Array<SortDesc>) => void;
+    appliedSorts?: Array<SortDesc>;
+    className?: string;
+}
+
+interface SortRowProps {
+    idx: number;
+    sort?: SortDesc;
+    columns: Record<string, ColumnDesc>;
+    colsOrder: Array<string>;
+    setSort: (idx: number, fd: SortDesc, remove?: boolean) => void;
+    appliedSorts?: Array<SortDesc>;
+}
+
+const anchorOrigin = {
+    vertical: "bottom",
+    horizontal: "right",
+} as PopoverOrigin;
+
+const gridSx = { p: "0.5em", minWidth: "36rem" };
+const badgeSx = {
+    "& .MuiBadge-badge": {
+        height: "10px",
+        minWidth: "10px",
+        width: "10px",
+        borderRadius: "5px",
+    },
+};
+const orderCaptionSx = { ml: 1 };
+
+const getSortDesc = (columns: Record<string, ColumnDesc>, colId?: string, asc?: boolean) =>
+    colId && asc !== undefined
+        ? ({
+              col: columns[colId].dfid,
+              order: !!asc,
+          } as SortDesc)
+        : undefined;
+
+const SortRow = (props: SortRowProps) => {
+    const { idx, setSort, columns, colsOrder, sort, appliedSorts } = props;
+
+    const [colId, setColId] = useState("");
+    const [order, setOrder] = useState(true); // true => asc
+    const [enableCheck, setEnableCheck] = useState(false);
+    const [enableDel, setEnableDel] = useState(false);
+
+    const cols = useMemo(() => {
+        if (!Array.isArray(appliedSorts) || appliedSorts.length == 0) {
+            return colsOrder;
+        }
+        return colsOrder.filter((col) => col == sort?.col || !appliedSorts.some((fd) => col === fd.col));
+    }, [colsOrder, appliedSorts, sort?.col]);
+
+    const onColSelect = useCallback(
+        (e: SelectChangeEvent<string>) => {
+            setColId(e.target.value);
+            setEnableCheck(!!getSortDesc(columns, e.target.value, order));
+        },
+        [columns, order]
+    );
+    const onOrderSwitch = useCallback(
+        (e: ChangeEvent<HTMLInputElement>) => {
+            setOrder(e.target.checked);
+            setEnableCheck(!!getSortDesc(columns, colId, e.target.checked));
+        },
+        [columns, colId]
+    );
+
+    const onDeleteClick = useCallback(() => setSort(idx, undefined as unknown as SortDesc, true), [idx, setSort]);
+    const onCheckClick = useCallback(() => {
+        const fd = getSortDesc(columns, colId, order);
+        fd && setSort(idx, fd);
+    }, [idx, setSort, columns, colId, order]);
+
+    useEffect(() => {
+        if (sort && idx > -1) {
+            const col = Object.keys(columns).find((col) => columns[col].dfid === sort.col) || "";
+            setColId(col);
+            setOrder(sort.order);
+            setEnableCheck(false);
+            setEnableDel(!!getSortDesc(columns, col, sort.order));
+        } else {
+            setColId("");
+            setOrder(true);
+            setEnableCheck(false);
+            setEnableDel(false);
+        }
+    }, [columns, sort, idx]);
+
+    return cols.length ? (
+        <Grid container item xs={12} alignItems="center">
+            <Grid item xs={6}>
+                <FormControl margin="dense">
+                    <InputLabel>Column</InputLabel>
+                    <Select value={colId || ""} onChange={onColSelect} input={<OutlinedInput label="Column" />}>
+                        {cols.map((col) => (
+                            <MenuItem key={col} value={col}>
+                                {columns[col].title || columns[col].dfid}
+                            </MenuItem>
+                        ))}
+                    </Select>
+                </FormControl>
+            </Grid>
+            <Grid item xs={4}>
+                <Switch checked={order} onChange={onOrderSwitch} />
+                <Typography variant="caption" color="text.secondary" sx={orderCaptionSx}>
+                    {order ? "asc" : "desc"}
+                </Typography>
+            </Grid>
+            <Grid item xs={1}>
+                <Tooltip title="Validate">
+                    <span>
+                        <IconButton onClick={onCheckClick} disabled={!enableCheck} sx={iconInRowSx}>
+                            <CheckIcon />
+                        </IconButton>
+                    </span>
+                </Tooltip>
+            </Grid>
+            <Grid item xs={1}>
+                <Tooltip title="Delete">
+                    <span>
+                        <IconButton onClick={onDeleteClick} disabled={!enableDel} sx={iconInRowSx}>
+                            <DeleteIcon />
+                        </IconButton>
+                    </span>
+                </Tooltip>
+            </Grid>
+        </Grid>
+    ) : null;
+};
+
+const TableSort = (props: TableSortProps) => {
+    const { onValidate, appliedSorts, columns, className = "" } = props;
+
+    const [showSort, setShowSort] = useState(false);
+    const sortRef = useRef<HTMLButtonElement | null>(null);
+    const [sorts, setSorts] = useState<Array<SortDesc>>([]);
+
+    const colsOrder = useMemo(() => {
+        if (props.colsOrder) {
+            return props.colsOrder;
+        }
+        return Object.keys(columns).sort(getsortByIndex(columns));
+    }, [props.colsOrder, columns]);
+
+    const onShowSortClick = useCallback(() => setShowSort((f) => !f), []);
+
+    const updateSort = useCallback(
+        (idx: number, nsd: SortDesc, remove?: boolean) => {
+            setSorts((sds) => {
+                let newSds;
+                if (idx > -1) {
+                    if (remove) {
+                        sds.splice(idx, 1);
+                        newSds = [...sds];
+                    } else {
+                        newSds = sds.map((fd, index) => (index == idx ? nsd : fd));
+                    }
+                } else if (remove) {
+                    newSds = sds;
+                } else {
+                    newSds = [...sds, nsd];
+                }
+                onValidate([...newSds]);
+                return newSds;
+            });
+        },
+        [onValidate]
+    );
+
+    useEffect(() => {
+        columns &&
+            appliedSorts &&
+            setSorts(appliedSorts.filter((fd) => Object.values(columns).some((cd) => cd.dfid === fd.col)));
+    }, [columns, appliedSorts]);
+
+    return (
+        <>
+            <Tooltip title={`${sorts.length} sort${sorts.length > 1 ? "s" : ""} applied`}>
+                <IconButton
+                    onClick={onShowSortClick}
+                    size="small"
+                    ref={sortRef}
+                    sx={iconInRowSx}
+                    className={getSuffixedClassNames(className, "-sort-icon")}
+                >
+                    <Badge badgeContent={sorts.length} color="primary" sx={badgeSx}>
+                        <SortByAlpha fontSize="inherit" />
+                    </Badge>
+                </IconButton>
+            </Tooltip>
+            <Popover
+                anchorEl={sortRef.current}
+                anchorOrigin={anchorOrigin}
+                open={showSort}
+                onClose={onShowSortClick}
+                className={getSuffixedClassNames(className, "-filter")}
+            >
+                <Grid container sx={gridSx} gap={0.5}>
+                    {sorts.map((sd, idx) => (
+                        <SortRow
+                            key={"fd" + idx}
+                            idx={idx}
+                            sort={sd}
+                            columns={columns}
+                            colsOrder={colsOrder}
+                            setSort={updateSort}
+                            appliedSorts={sorts}
+                        />
+                    ))}
+                    <SortRow
+                        idx={-(sorts.length + 1)}
+                        columns={columns}
+                        colsOrder={colsOrder}
+                        setSort={updateSort}
+                        appliedSorts={sorts}
+                    />
+                </Grid>
+            </Popover>
+        </>
+    );
+};
+
+export default TableSort;

+ 3 - 0
frontend/taipy-gui/src/extensions/exports.ts

@@ -17,6 +17,7 @@ import Login from "../components/Taipy/Login";
 import Router from "../components/Router";
 import Table from "../components/Taipy/Table";
 import TableFilter, { FilterDesc } from "../components/Taipy/TableFilter";
+import TableSort, { SortDesc } from "../components/Taipy/TableSort";
 import Metric from "../components/Taipy/Metric";
 import { useLovListMemo, LoV, LoVElt } from "../components/Taipy/lovUtils";
 import { LovItem } from "../utils/lov";
@@ -46,6 +47,7 @@ export {
     Router,
     Table,
     TableFilter,
+    TableSort,
     Metric,
     TaipyContext as Context,
     createRequestDataUpdateAction,
@@ -70,6 +72,7 @@ export type {
     LovItem,
     RowType,
     RowValue,
+    SortDesc,
     TaipyStore as Store,
     TaipyState as State,
     TaipyBaseAction as Action,

+ 2 - 1
frontend/taipy/package.json

@@ -30,7 +30,8 @@
     "fast-deep-equal": "^3.1.3",
     "formik": "^2.2.9",
     "react": "^18.2.0",
-    "react-dom": "^18.2.0"
+    "react-dom": "^18.2.0",
+    "taipy-gui": "file:../../taipy/gui/webapp"
   },
   "scripts": {
     "postinstall": "node scripts/install.js",

+ 51 - 1
frontend/taipy/src/CoreSelector.tsx

@@ -47,6 +47,8 @@ import {
     ColumnDesc,
     FilterDesc,
     TableFilter,
+    SortDesc,
+    TableSort,
 } from "taipy-gui";
 
 import { Cycles, Cycle, DataNodes, NodeType, Scenarios, Scenario, DataNode, Sequence, Sequences } from "./utils/types";
@@ -107,6 +109,7 @@ interface CoreSelectorProps {
     onSelect?: (id: string | string[]) => void;
     updateCoreVars: string;
     filter?: string;
+    sort?: string;
     showSearch: boolean;
 }
 
@@ -510,6 +513,44 @@ const CoreSelector = (props: CoreSelectorProps) => {
         [updateVars, dispatch, props.id, updateCoreVars, lovPropertyName, module]
     );
 
+        // sort
+        const colSorts = useMemo(() => {
+            try {
+                const res = props.sort ? (JSON.parse(props.sort) as Array<[string]>) : undefined;
+                return Array.isArray(res)
+                    ? res.reduce((pv, [name], idx) => {
+                          pv[name] = { dfid: name, type: "str", index: idx};
+                          return pv;
+                      }, {} as Record<string, ColumnDesc>)
+                    : undefined;
+            } catch (e) {
+                return undefined;
+            }
+        }, [props.sort]);
+        const [sorts, setSorts] = useState<SortDesc[]>([]);
+
+        const applySorts = useCallback(
+            (sorts: SortDesc[]) => {
+                setSorts((old) => {
+                    if (old.length != sorts.length || JSON.stringify(old) != JSON.stringify(sorts)) {
+                        const sortVar = getUpdateVar(updateCoreVars, "sort");
+                        dispatch(
+                            createRequestUpdateAction(
+                                props.id,
+                                module,
+                                getUpdateVarNames(updateVars, lovPropertyName),
+                                true,
+                                sortVar ? { [sortVar]: sorts } : undefined
+                            )
+                        );
+                        return sorts;
+                    }
+                    return old;
+                });
+            },
+            [updateVars, dispatch, props.id, updateCoreVars, lovPropertyName, module]
+        );
+
     // Search
     const [searchValue, setSearchValue] = useState("");
     const onSearch = useCallback((e: ChangeEvent<HTMLInputElement>) => setSearchValue(e.currentTarget.value), []);
@@ -528,7 +569,7 @@ const CoreSelector = (props: CoreSelectorProps) => {
     return (
         <>
             <Grid container sx={switchBoxSx} gap={1}>
-                {active && colFilters ? (
+            {active && colFilters ? (
                     <Grid item>
                         <TableFilter
                             columns={colFilters}
@@ -538,6 +579,15 @@ const CoreSelector = (props: CoreSelectorProps) => {
                         ></TableFilter>
                     </Grid>
                 ) : null}
+                {active && colSorts ? (
+                    <Grid item>
+                        <TableSort
+                            columns={colSorts}
+                            appliedSorts={sorts}
+                            onValidate={applySorts}
+                        ></TableSort>
+                    </Grid>
+                ) : null}
                 {showPins ? (
                     <Grid item>
                         <FormControlLabel

+ 21 - 4
taipy/gui/_renderers/builder.py

@@ -597,8 +597,15 @@ class _Builder:
         return self
 
     def __set_list_attribute(
-        self, name: str, hash_name: t.Optional[str], val: t.Any, elt_type: t.Type, dynamic=True
+        self,
+        name: str,
+        hash_name: t.Optional[str],
+        val: t.Any,
+        elt_type: t.Type,
+        dynamic=True,
+        default_val: t.Optional[t.Any] = None,
     ) -> t.List[str]:
+        val = default_val if val is None else val
         if not hash_name and isinstance(val, str):
             val = [elt_type(t.strip()) for t in val.split(";")]
         if isinstance(val, list):
@@ -966,8 +973,15 @@ class _Builder:
                     attr[0], _get_tuple_val(attr, 2, None), _get_tuple_val(attr, 3, False)
                 )
             elif var_type == PropertyType.string_list:
-                self.__set_list_attribute(
-                    attr[0], self.__hashes.get(attr[0]), self.__attributes.get(attr[0]), str, False
+                self.__update_vars.extend(
+                    self.__set_list_attribute(
+                        attr[0],
+                        self.__hashes.get(attr[0]),
+                        self.__attributes.get(attr[0]),
+                        str,
+                        False,
+                        _get_tuple_val(attr, 2, None),
+                    )
                 )
             elif var_type == PropertyType.function:
                 self.__set_function_attribute(attr[0], _get_tuple_val(attr, 2, None), _get_tuple_val(attr, 3, True))
@@ -1013,7 +1027,10 @@ class _Builder:
                     self.__update_vars.append(f"{prop_name}={hash_name}")
                     self.__set_react_attribute(prop_name, hash_name)
                 else:
-                    self.set_attribute(prop_name, var_type(self.__attributes.get(attr[0]), "").get())
+                    val = self.__attributes.get(attr[0])
+                    self.set_attribute(
+                        prop_name, var_type(_get_tuple_val(attr, 2, None) if val is None else val, "").get()
+                    )
 
         self.__set_refresh_on_update()
         return self

+ 4 - 2
taipy/gui/extension/library.py

@@ -184,14 +184,16 @@ class Element:
                             hash_value = "None"
                         val = val[: m.start()] + hash_value + val[m.end() :]
                     # handling unique id replacement in inner properties <tp:uniq:...>
+                    has_uniq = False
                     while m := Element.__RE_UNIQUE_VAR.search(val):
+                        has_uniq = True
                         id = uniques.get(m.group(1))
                         if id is None:
                             id = len(uniques) + 1
                             uniques[m.group(1)] = id
                         val = f"{val[: m.start()]}{counter}{id}{val[m.end() :]}"
-                        if gui._is_expression(val):
-                            gui._evaluate_expr(val, True)
+                    if has_uniq and gui._is_expression(val):
+                        gui._evaluate_expr(val, True)
 
                 attributes[prop] = val
         # this modifies attributes

+ 8 - 3
taipy/gui_core/_GuiCoreLib.py

@@ -22,7 +22,8 @@ from ._adapters import (
     _GuiCoreDoNotUpdate,
     _GuiCoreScenarioAdapter,
     _GuiCoreScenarioDagAdapter,
-    _GuiCoreScenarioProperties,
+    _GuiCoreScenarioFilter,
+    _GuiCoreScenarioSort,
 )
 from ._context import _GuiCoreContext
 
@@ -44,6 +45,7 @@ class _GuiCore(ElementLibrary):
     __SCENARIO_SELECTOR_ERROR_VAR = "__tpgc_sc_error"
     __SCENARIO_SELECTOR_ID_VAR = "__tpgc_sc_id"
     __SCENARIO_SELECTOR_FILTER_VAR = "__tpgc_sc_filter"
+    __SCENARIO_SELECTOR_SORT_VAR = "__tpgc_sc_sort"
     __SCENARIO_VIZ_ERROR_VAR = "__tpgc_sv_error"
     __JOB_SELECTOR_ERROR_VAR = "__tpgc_js_error"
     __DATANODE_VIZ_ERROR_VAR = "__tpgc_dv_error"
@@ -73,14 +75,16 @@ class _GuiCore(ElementLibrary):
                 "show_dialog": ElementProperty(PropertyType.boolean, True),
                 __SEL_SCENARIOS_PROP: ElementProperty(PropertyType.dynamic_list),
                 "multiple": ElementProperty(PropertyType.boolean, False),
-                "filter": ElementProperty(_GuiCoreScenarioProperties, _GuiCoreScenarioProperties.DEFAULT),
+                "filter": ElementProperty(_GuiCoreScenarioFilter, _GuiCoreScenarioFilter.DEFAULT),
+                "sort": ElementProperty(_GuiCoreScenarioSort, _GuiCoreScenarioSort.DEFAULT),
                 "show_search": ElementProperty(PropertyType.boolean, True),
             },
             inner_properties={
                 "inner_scenarios": ElementProperty(
                     PropertyType.lov,
                     f"{{{__CTX_VAR_NAME}.get_scenarios(<tp:prop:{__SEL_SCENARIOS_PROP}>, "
-                    + f"{__SCENARIO_SELECTOR_FILTER_VAR}<tp:uniq:sc>)}}",
+                    + f"{__SCENARIO_SELECTOR_FILTER_VAR}<tp:uniq:sc>, "
+                    + f"{__SCENARIO_SELECTOR_SORT_VAR}<tp:uniq:sc>)}}",
                 ),
                 "on_scenario_crud": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.crud_scenario}}"),
                 "configs": ElementProperty(PropertyType.react, f"{{{__CTX_VAR_NAME}.get_scenario_configs()}}"),
@@ -95,6 +99,7 @@ class _GuiCore(ElementLibrary):
                 "update_sc_vars": ElementProperty(
                     PropertyType.string,
                     f"filter={__SCENARIO_SELECTOR_FILTER_VAR}<tp:uniq:sc>;"
+                    + f"sort={__SCENARIO_SELECTOR_SORT_VAR}<tp:uniq:sc>;"
                     + f"sc_id={__SCENARIO_SELECTOR_ID_VAR}<tp:uniq:sc>;"
                     + f"error_id={__SCENARIO_SELECTOR_ERROR_VAR}<tp:uniq:sc>",
                 ),

+ 56 - 16
taipy/gui_core/_adapters.py

@@ -11,7 +11,9 @@
 
 import json
 import math
+import sys
 import typing as t
+from abc import abstractmethod
 from datetime import date, datetime
 from enum import Enum
 from numbers import Number
@@ -19,16 +21,7 @@ from operator import attrgetter, contains, eq, ge, gt, le, lt, ne
 
 import pandas as pd
 
-from taipy.core import (
-    Cycle,
-    DataNode,
-    Scenario,
-    is_deletable,
-    is_editable,
-    is_promotable,
-    is_readable,
-    is_submittable,
-)
+from taipy.core import Cycle, DataNode, Scenario, is_deletable, is_editable, is_promotable, is_readable, is_submittable
 from taipy.core import get as core_get
 from taipy.core.config import Config
 from taipy.core.data._tabular_datanode_mixin import _TabularDataNodeMixin
@@ -264,7 +257,7 @@ def _get_datanode_property(attr: str):
 
 
 class _GuiCoreScenarioProperties(_TaipyBase):
-    __SC_TYPES = {
+    _SC_TYPES = {
         "Config id": "string",
         "Label": "string",
         "Creation date": "date",
@@ -284,7 +277,6 @@ class _GuiCoreScenarioProperties(_TaipyBase):
         "Primary": "is_primary",
         "Tags": "tags",
     }
-    DEFAULT = list(__SC_TYPES.keys())
     __DN_TYPES = {"Up to date": "boolean", "Valid": "boolean", "Last edit date": "date"}
     __DN_LABELS = {"Up to date": "is_up_to_date", "Valid": "is_valid", "Last edit date": "last_edit_date"}
     __ENUMS = None
@@ -297,7 +289,7 @@ class _GuiCoreScenarioProperties(_TaipyBase):
     def get_type(attr: str):
         if prop := _get_datanode_property(attr):
             return _GuiCoreScenarioProperties.__DN_TYPES.get(prop, "any")
-        return _GuiCoreScenarioProperties.__SC_TYPES.get(attr, "any")
+        return _GuiCoreScenarioProperties._SC_TYPES.get(attr, "any")
 
     @staticmethod
     def get_col_name(attr: str):
@@ -305,11 +297,21 @@ class _GuiCoreScenarioProperties(_TaipyBase):
             return f'{attr.split(".")[0]}.{_GuiCoreScenarioProperties.__DN_LABELS.get(prop, prop)}'
         return _GuiCoreScenarioProperties.__SC_LABELS.get(attr, attr)
 
+    @staticmethod
+    @abstractmethod
+    def get_default_list():
+        raise NotImplementedError
+
+    @staticmethod
+    @abstractmethod
+    def full_desc():
+        raise NotImplementedError
+
     def get(self):
         data = super().get()
         if _is_boolean(data):
             if _is_true(data):
-                data = _GuiCoreScenarioProperties.DEFAULT
+                data = self.get_default_list()
             else:
                 return None
         if isinstance(data, str):
@@ -318,10 +320,10 @@ class _GuiCoreScenarioProperties(_TaipyBase):
             flist = []
             for f in data:
                 if f == "*":
-                    flist.extend(_GuiCoreScenarioProperties.DEFAULT)
+                    flist.extend(self.get_default_list())
                 else:
                     flist.append(f)
-            if _GuiCoreScenarioProperties.__ENUMS is None:
+            if _GuiCoreScenarioProperties.__ENUMS is None and self.full_desc():
                 _GuiCoreScenarioProperties.__ENUMS = {
                     "Config id": [c for c in Config.scenarios.keys() if c != "default"],
                     "Tags": [t for s in Config.scenarios.values() for t in s.properties.get("authorized_tags", [])],
@@ -329,8 +331,46 @@ class _GuiCoreScenarioProperties(_TaipyBase):
             return json.dumps(
                 [
                     (attr, _GuiCoreScenarioProperties.get_type(attr), _GuiCoreScenarioProperties.__ENUMS.get(attr))
+                    if self.full_desc()
+                    else (attr,)
                     for attr in flist
                     if attr and isinstance(attr, str)
                 ]
             )
         return None
+
+
+class _GuiCoreScenarioFilter(_GuiCoreScenarioProperties):
+    DEFAULT = list(_GuiCoreScenarioProperties._SC_TYPES.keys())
+
+    @staticmethod
+    def full_desc():
+        return True
+
+    @staticmethod
+    def get_hash():
+        return _TaipyBase._HOLDER_PREFIX + "ScF"
+
+    @staticmethod
+    def get_default_list():
+        return _GuiCoreScenarioFilter.DEFAULT
+
+
+class _GuiCoreScenarioSort(_GuiCoreScenarioProperties):
+    DEFAULT = ["Config id", "Label", "Creation date"]
+
+    @staticmethod
+    def full_desc():
+        return False
+
+    @staticmethod
+    def get_hash():
+        return _TaipyBase._HOLDER_PREFIX + "ScS"
+
+    @staticmethod
+    def get_default_list():
+        return _GuiCoreScenarioSort.DEFAULT
+
+
+def _is_debugging() -> bool:
+    return hasattr(sys, "gettrace") and sys.gettrace() is not None

+ 40 - 17
taipy/gui_core/_context.py

@@ -12,8 +12,9 @@
 import json
 import typing as t
 from collections import defaultdict
-from datetime import datetime
+from datetime import date, datetime
 from numbers import Number
+from operator import attrgetter
 from threading import Lock
 
 try:
@@ -66,6 +67,7 @@ from ._adapters import (
     _GuiCoreDatanodeAdapter,
     _GuiCoreScenarioProperties,
     _invoke_action,
+    _is_debugging,
 )
 
 
@@ -203,7 +205,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
     def no_change_adapter(self, entity: t.List):
         return entity
 
-    def cycle_adapter(self, cycle: Cycle):
+    def cycle_adapter(self, cycle: Cycle, sorts: t.Optional[t.List[t.Dict[str, t.Any]]] = None):
         try:
             if (
                 isinstance(cycle, Cycle)
@@ -214,10 +216,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 return [
                     cycle.id,
                     cycle.get_simple_label(),
-                    sorted(
-                        self.scenario_by_cycle.get(cycle, []),
-                        key=_GuiCoreContext.get_entity_creation_date_iso,
-                    ),
+                    self.get_sorted_entity_list(self.scenario_by_cycle.get(cycle, []), sorts),
                     _EntityType.CYCLE.value,
                     False,
                 ]
@@ -257,8 +256,27 @@ class _GuiCoreContext(CoreEventConsumerBase):
         cycle[2] = [self.scenario_adapter(e) for e in cycle[2]]
         return cycle
 
+    def get_sorted_entity_list(
+        self,
+        entities: t.Union[t.List[t.Union[Cycle, Scenario]], t.List[Scenario]],
+        sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
+    ):
+        if sorts:
+            sorted_list = entities
+            for sd in reversed(sorts):
+                col = sd.get("col", "")
+                col = _GuiCoreScenarioProperties.get_col_name(col)
+                order = sd.get("order", True)
+                sorted_list = sorted(sorted_list, key=_GuiCoreContext.get_entity_property(col), reverse=not order)
+        else:
+            sorted_list = sorted(entities, key=_GuiCoreContext.get_entity_property("creation_date"))
+        return [self.cycle_adapter(e, sorts) if isinstance(e, Cycle) else e for e in sorted_list]
+
     def get_scenarios(
-        self, scenarios: t.Optional[t.List[t.Union[Cycle, Scenario]]], filters: t.Optional[t.List[t.Dict[str, t.Any]]]
+        self,
+        scenarios: t.Optional[t.List[t.Union[Cycle, Scenario]]],
+        filters: t.Optional[t.List[t.Dict[str, t.Any]]],
+        sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
     ):
         cycles_scenarios: t.List[t.Union[Cycle, Scenario]] = []
         with self.lock:
@@ -272,11 +290,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         cycles_scenarios.append(cycle)
         if scenarios is not None:
             cycles_scenarios = scenarios
-        # sorting
-        adapted_list = [
-            self.cycle_adapter(e) if isinstance(e, Cycle) else e
-            for e in sorted(cycles_scenarios, key=_GuiCoreContext.get_entity_creation_date_iso)
-        ]
+        adapted_list = self.get_sorted_entity_list(cycles_scenarios, sorts)
         if filters:
             # filtering
             filtered_list = list(adapted_list)
@@ -565,7 +579,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
             self.__do_datanodes_tree()
         if scenarios is None:
             return (self.data_nodes_by_owner.get(None, []) if self.data_nodes_by_owner else []) + (
-                self.get_scenarios(None, None) or []
+                self.get_scenarios(None, None, None) or []
             )
         if not self.data_nodes_by_owner:
             return []
@@ -745,9 +759,18 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         ent.properties.pop(key, None)
 
     @staticmethod
-    def get_entity_creation_date_iso(entity: t.Union[Scenario, Cycle]):
-        # we might be comparing naive and aware datetime ISO
-        return entity.creation_date.isoformat()
+    def get_entity_property(col: str):
+        def sort_key(entity: t.Union[Scenario, Cycle]):
+            # we compare only strings
+            try:
+                val = attrgetter(col)(entity)
+            except AttributeError as e:
+                if _is_debugging():
+                    _warn("Attribute", e)
+                val = ""
+            return val.isoformat() if isinstance(val, (datetime, date)) else str(val)
+
+        return sort_key
 
     def get_scenarios_for_owner(self, owner_id: str):
         cycles_scenarios: t.List[t.Union[Scenario, Cycle]] = []
@@ -767,7 +790,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         cycles_scenarios.extend(scenarios_cycle)
                     elif isinstance(entity, Scenario):
                         cycles_scenarios.append(entity)
-        return sorted(cycles_scenarios, key=_GuiCoreContext.get_entity_creation_date_iso)
+        return sorted(cycles_scenarios, key=_GuiCoreContext.get_entity_property("creation_date"))
 
     def get_data_node_history(self, id: str):
         if id and (dn := core_get(id)) and isinstance(dn, DataNode):

+ 15 - 2
taipy/gui_core/viselements.json

@@ -100,8 +100,21 @@
                     },
                     {
                         "name": "filter",
-                        "type": "str|list[str]",
-                        "doc": "TODO: a list of scenario attributes to filter on."
+                        "type": "bool|str|list[str]",
+                        "default_value": "Config id;Label;Creation date;Cycle label;Cycle start;Cycle end;Primary;Tags",
+                        "doc": "TODO: a list of scenario attributes to filter on. If False, do not allow filter."
+                    },
+                    {
+                        "name": "show_search",
+                        "type": "bool",
+                        "default_value": "True",
+                        "doc": "TODO: If True, allows the user to search locally on label."
+                    },
+                    {
+                        "name": "sort",
+                        "type": "bool|str|list[str]",
+                        "default_value": "Config id;Label;Creation date",
+                        "doc": "TODO: a list of scenario attributes to sort on. If False, do not allow sort."
                     }
                 ]
             }