Browse Source

datanode selector filter, sort and search (#1372)

* datanode selector filter node and search
resolves #824
resolves #1352

* WiP

* right name for refresh

* with doc

* fix test

* correct cycle attribute names
resolves #1374

* fix date comparison
resolves #1373

* Fab's comments

---------

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

File diff suppressed because it is too large
+ 521 - 221
frontend/taipy-gui/package-lock.json


File diff suppressed because it is too large
+ 473 - 209
frontend/taipy/package-lock.json


+ 19 - 15
frontend/taipy/src/CoreSelector.tsx

@@ -126,6 +126,7 @@ const tinyPinIconButtonSx = (theme: Theme) => ({
 
 const switchBoxSx = { ml: 2, width: (theme: Theme) => `calc(100% - ${theme.spacing(2)})` };
 const iconInRowSx = { fontSize: "body2.fontSize" };
+const labelInRowSx = {"& .MuiFormControlLabel-label": iconInRowSx};
 
 const CoreItem = (props: {
     item: Entity;
@@ -373,7 +374,7 @@ const CoreSelector = (props: CoreSelectorProps) => {
             setSelectedItems(() => {
                 const lovVar = getUpdateVar(updateVars, lovPropertyName);
                 const val = multiple ? nodeId : isSelectable ? nodeId : "";
-                setTimeout(
+                setTimeout( // to avoid set state while render react errors
                     () => dispatch(createSendUpdateAction(updateVarName, val, module, onChange, propagate, lovVar)),
                     1
                 );
@@ -524,15 +525,16 @@ const CoreSelector = (props: CoreSelectorProps) => {
                 if (old.length != filters.length || JSON.stringify(old) != jsonFilters) {
                     localStoreSet(jsonFilters, id, lovPropertyName, "filter");
                     const filterVar = getUpdateVar(updateCoreVars, "filter");
-                    dispatch(
+                    const lovVar = getUpdateVarNames(updateVars, lovPropertyName);
+                    setTimeout(() => dispatch(
                         createRequestUpdateAction(
                             id,
                             module,
-                            getUpdateVarNames(updateVars, lovPropertyName),
+                            lovVar,
                             true,
                             filterVar ? { [filterVar]: filters } : undefined
                         )
-                    );
+                    ), 1);
                     return filters;
                 }
                 return old;
@@ -623,6 +625,17 @@ const CoreSelector = (props: CoreSelectorProps) => {
                         <TableSort columns={colSorts} appliedSorts={sorts} onValidate={applySorts}></TableSort>
                     </Grid>
                 ) : null}
+                {showSearch ? (
+                    <Grid item>
+                        <IconButton onClick={onRevealSearch} size="small" sx={iconInRowSx}>
+                            {revealSearch ? (
+                                <SearchOffOutlined fontSize="inherit" />
+                            ) : (
+                                <SearchOutlined fontSize="inherit" />
+                            )}
+                        </IconButton>
+                    </Grid>
+                ) : null}
                 {showPins ? (
                     <Grid item>
                         <FormControlLabel
@@ -631,23 +644,14 @@ const CoreSelector = (props: CoreSelectorProps) => {
                                     onChange={onShowPinsChange}
                                     checked={hideNonPinned}
                                     disabled={!hideNonPinned && !Object.keys(pins[0]).length}
+                                    size="small"
                                 />
                             }
                             label="Pinned only"
+                            sx={labelInRowSx}
                         />
                     </Grid>
                 ) : null}
-                {showSearch ? (
-                    <Grid item>
-                        <IconButton onClick={onRevealSearch} size="small" sx={iconInRowSx}>
-                            {revealSearch ? (
-                                <SearchOffOutlined fontSize="inherit" />
-                            ) : (
-                                <SearchOutlined fontSize="inherit" />
-                            )}
-                        </IconButton>
-                    </Grid>
-                ) : null}
                 {showSearch && revealSearch ? (
                     <Grid item xs={12}>
                         <TextField

+ 10 - 6
frontend/taipy/src/NodeSelector.tsx

@@ -21,7 +21,7 @@ import CoreSelector from "./CoreSelector";
 interface NodeSelectorProps {
     id?: string;
     updateVarName?: string;
-    datanodes?: Cycles | Scenarios | DataNodes;
+    innerDatanodes?: Cycles | Scenarios | DataNodes;
     coreChanged?: Record<string, unknown>;
     updateVars: string;
     onChange?: string;
@@ -37,22 +37,26 @@ interface NodeSelectorProps {
     dynamicClassName?: string;
     showPins?: boolean;
     multiple?: boolean;
+    updateDnVars?: string;
+    filter?: string;
+    sort?: string;
+    showSearch?: boolean;
 }
 
 const NodeSelector = (props: NodeSelectorProps) => {
-    const { showPins = true, multiple = false } = props;
+    const { showPins = true, multiple = false, updateDnVars = "", showSearch = true } = props;
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     return (
         <Box sx={MainTreeBoxSx} id={props.id} className={className}>
             <CoreSelector
                 {...props}
-                entities={props.datanodes}
+                entities={props.innerDatanodes}
                 leafType={NodeType.NODE}
-                lovPropertyName="datanodes"
+                lovPropertyName="innerDatanodes"
                 showPins={showPins}
                 multiple={multiple}
-                showSearch={false}
-                updateCoreVars=""
+                showSearch={showSearch}
+                updateCoreVars={updateDnVars}
             />
             <Box>{props.error}</Box>
         </Box>

+ 1 - 0
frontend/taipy/src/ScenarioSelector.tsx

@@ -96,6 +96,7 @@ interface ScenarioSelectorProps {
     showDialog?: boolean;
     multiple?: boolean;
     filter?: string;
+    sort?: string;
     updateScVars?: string;
     showSearch?: boolean;
 }

+ 22 - 2
taipy/gui_core/_GuiCoreLib.py

@@ -19,6 +19,8 @@ from taipy.gui.extension import Element, ElementLibrary, ElementProperty, Proper
 from ..version import _get_version
 from ._adapters import (
     _GuiCoreDatanodeAdapter,
+    _GuiCoreDatanodeFilter,
+    _GuiCoreDatanodeSort,
     _GuiCoreDoNotUpdate,
     _GuiCoreScenarioAdapter,
     _GuiCoreScenarioDagAdapter,
@@ -57,6 +59,10 @@ class _GuiCore(ElementLibrary):
     __DATANODE_VIZ_DATA_NODE_PROP = "data_node"
     __DATANODE_SEL_SCENARIO_PROP = "scenario"
     __SEL_SCENARIOS_PROP = "scenarios"
+    __SEL_DATANODES_PROP = "datanodes"
+    __DATANODE_SELECTOR_FILTER_VAR = "__tpgc_dn_filter"
+    __DATANODE_SELECTOR_SORT_VAR = "__tpgc_dn_sort"
+    __DATANODE_SELECTOR_ERROR_VAR = "__tpgc_dn_error"
 
     __elts = {
         "scenario_selector": Element(
@@ -165,15 +171,29 @@ class _GuiCore(ElementLibrary):
                 "class_name": ElementProperty(PropertyType.dynamic_string),
                 "show_pins": ElementProperty(PropertyType.boolean, True),
                 __DATANODE_SEL_SCENARIO_PROP: ElementProperty(PropertyType.dynamic_list),
+                __SEL_DATANODES_PROP: ElementProperty(PropertyType.dynamic_list),
                 "multiple": ElementProperty(PropertyType.boolean, False),
+                "filter": ElementProperty(_GuiCoreDatanodeFilter, "*"),
+                "sort": ElementProperty(_GuiCoreDatanodeSort, "*"),
+                "show_search": ElementProperty(PropertyType.boolean, True),
             },
             inner_properties={
-                "datanodes": ElementProperty(
+                "inner_datanodes": ElementProperty(
                     PropertyType.lov,
-                    f"{{{__CTX_VAR_NAME}.get_datanodes_tree(<tp:prop:{__DATANODE_SEL_SCENARIO_PROP}>)}}",
+                    f"{{{__CTX_VAR_NAME}.get_datanodes_tree(<tp:prop:{__DATANODE_SEL_SCENARIO_PROP}>, "
+                    + f"<tp:prop:{__SEL_DATANODES_PROP}>, "
+                    + f"{__DATANODE_SELECTOR_FILTER_VAR}<tp:uniq:dns>, "
+                    + f"{__DATANODE_SELECTOR_SORT_VAR}<tp:uniq:dns>)}}",
                 ),
                 "core_changed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._CORE_CHANGED_NAME),
                 "type": ElementProperty(PropertyType.inner, __DATANODE_ADAPTER),
+                "error": ElementProperty(PropertyType.react, f"{{{__DATANODE_SELECTOR_ERROR_VAR}<tp:uniq:dns>}}"),
+                "update_dn_vars": ElementProperty(
+                    PropertyType.string,
+                    f"filter={__DATANODE_SELECTOR_FILTER_VAR}<tp:uniq:dns>;"
+                    + f"sort={__DATANODE_SELECTOR_SORT_VAR}<tp:uniq:dns>;"
+                    + f"error_id={__DATANODE_SELECTOR_ERROR_VAR}<tp:uniq:dns>",
+                ),
             },
         ),
         "data_node": Element(

+ 225 - 56
taipy/gui_core/_adapters.py

@@ -9,11 +9,13 @@
 # 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 inspect
 import json
 import math
 import sys
 import typing as t
-from abc import abstractmethod
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
 from datetime import date, datetime
 from enum import Enum
 from numbers import Number
@@ -21,7 +23,17 @@ 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,
+    Sequence,
+    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
@@ -235,63 +247,72 @@ _operators: t.Dict[str, t.Callable] = {
 }
 
 
-def _invoke_action(ent: t.Any, col: str, col_type: str, is_dn: bool, action: str, val: t.Any) -> bool:
+def _invoke_action(
+    ent: t.Any, col: str, col_type: str, is_dn: bool, action: str, val: t.Any, col_fn: t.Optional[str]
+) -> bool:
+    if ent is None:
+        return False
     try:
         if col_type == "any":
             # when a property is not found, return True only if action is not equals
-            entity = getattr(ent, col.split(".")[0]) if is_dn else ent
-            if not hasattr(entity, "properties") or not entity.properties.get(col):
+            if not is_dn and not hasattr(ent, "properties") or not ent.properties.get(col_fn or col):
                 return action == "!="
         if op := _operators.get(action):
-            cur_val = attrgetter(col)(ent)
+            cur_val = attrgetter(col_fn or col)(ent)
+            cur_val = cur_val() if col_fn else cur_val
             return op(cur_val.isoformat() if isinstance(cur_val, (datetime, date)) else cur_val, val)
     except Exception as e:
-        _warn(f"Error filtering with {col} {action} {val} on {ent}.", e)
+        if _is_debugging():
+            _warn(f"Error filtering with {col} {action} {val} on {ent}.", e)
+        return col_type == "any" and action == "!="
     return True
 
 
+def _get_entity_property(col: str, a_type: t.Type):
+    col_parts = col.split("(")  # handle the case where the col is a method (ie get_simple_label())
+    col_fn = (
+        next(
+            (col_parts[0] for i in inspect.getmembers(a_type, predicate=inspect.isfunction) if i[0] == col_parts[0]),
+            None,
+        )
+        if len(col_parts) > 1
+        else None
+    )
+
+    def sort_key(entity: t.Union[Scenario, Cycle, Sequence, DataNode]):
+        # we compare only strings
+        if isinstance(entity, a_type):
+            try:
+                val = attrgetter(col_fn or col)(entity)
+                if col_fn:
+                    val = val()
+            except AttributeError as e:
+                if _is_debugging():
+                    _warn("Attribute", e)
+                val = ""
+        else:
+            val = ""
+        return val.isoformat() if isinstance(val, (datetime, date)) else str(val)
+
+    return sort_key
+
+
 def _get_datanode_property(attr: str):
     if (parts := attr.split(".")) and len(parts) > 1:
         return parts[1]
     return None
 
 
-class _GuiCoreScenarioProperties(_TaipyBase):
-    _SC_PROPS: t.Dict[str, t.Dict[str, t.Union[str, bool]]] = {
-        "Config id": {"attr": "config_id", "type": "string"},
-        "Label": {"attr": "name", "type": "string"},
-        "Creation date": {"attr": "creation_date", "type": "date"},
-        "Cycle label": {"attr": "cycle.name", "type": "string", "for_cycle": True},
-        "Cycle start": {"attr": "cycle.start", "type": "date", "for_cycle": True},
-        "Cycle end": {"attr": "cycle.end", "type": "date", "for_cycle": True},
-        "Primary": {"attr": "is_primary", "type": "boolean", "for_cycle": True},
-        "Tags": {"attr": "tags", "type": "string"},
-    }
-    __DN_PROPS = {
-        "Up to date": {"attr": "is_up_to_date", "type": "boolean"},
-        "Valid": {"attr": "is_valid", "type": "boolean"},
-        "Last edit date": {"attr": "last_edit_date", "type": "date"},
-    }
-    __ENUMS = None
-
-    @staticmethod
-    def get_hash():
-        return _TaipyBase._HOLDER_PREFIX + "ScP"
-
+class _GuiCoreProperties(ABC):
     @staticmethod
+    @abstractmethod
     def get_type(attr: str):
-        if prop := _get_datanode_property(attr):
-            return _GuiCoreScenarioProperties.__DN_PROPS.get(prop, {"type": "any"}).get("type", "any")
-        return _GuiCoreScenarioProperties._SC_PROPS.get(attr, {"type": "any"}).get("type", "any")
+        raise NotImplementedError
 
     @staticmethod
+    @abstractmethod
     def get_col_name(attr: str):
-        if prop := _get_datanode_property(attr):
-            return (
-                attr.split(".")[0]
-                + f'.{_GuiCoreScenarioProperties.__DN_PROPS.get(prop, {"attr": prop}).get("attr", prop)}'
-            )
-        return _GuiCoreScenarioProperties._SC_PROPS.get(attr, {"attr": attr}).get("attr", attr)
+        raise NotImplementedError
 
     @staticmethod
     @abstractmethod
@@ -303,6 +324,9 @@ class _GuiCoreScenarioProperties(_TaipyBase):
     def full_desc():
         raise NotImplementedError
 
+    def get_enums(self):
+        return {}
+
     def get(self):
         data = super().get()
         if _is_boolean(data):
@@ -319,18 +343,9 @@ class _GuiCoreScenarioProperties(_TaipyBase):
                     flist.extend(self.get_default_list())
                 else:
                     flist.append(f)
-            if _GuiCoreScenarioProperties.__ENUMS is None and self.full_desc():
-                _GuiCoreScenarioProperties.__ENUMS = {
-                    "Config id": [c for c in Config.scenarios.keys() if c != "default"],
-                    "Tags": list(
-                        {t for s in Config.scenarios.values() for t in s.properties.get("authorized_tags", [])}
-                    ),
-                }
             return json.dumps(
                 [
-                    (attr, _GuiCoreScenarioProperties.get_type(attr), _GuiCoreScenarioProperties.__ENUMS.get(attr))
-                    if self.full_desc()
-                    else (attr,)
+                    (attr, self.get_type(attr), self.get_enums().get(attr)) if self.full_desc() else (attr,)
                     for attr in flist
                     if attr and isinstance(attr, str)
                 ]
@@ -338,11 +353,72 @@ class _GuiCoreScenarioProperties(_TaipyBase):
         return None
 
 
-class _GuiCoreScenarioFilter(_GuiCoreScenarioProperties):
+@dataclass(frozen=True)
+class _GuiCorePropDesc:
+    attr: str
+    type: str
+    extended: bool = False
+    for_sort: bool = False
+
+
+_EMPTY_PROP_DESC = _GuiCorePropDesc("", "any")
+
+
+class _GuiCoreScenarioProperties(_GuiCoreProperties):
+    _SC_PROPS: t.Dict[str, _GuiCorePropDesc] = {
+        "Config id": _GuiCorePropDesc("config_id", "string", for_sort=True),
+        "Label": _GuiCorePropDesc("get_simple_label()", "string", for_sort=True),
+        "Creation date": _GuiCorePropDesc("creation_date", "date", for_sort=True),
+        "Cycle label": _GuiCorePropDesc("cycle.name", "string", extended=True),
+        "Cycle start": _GuiCorePropDesc("cycle.start_date", "date", extended=True),
+        "Cycle end": _GuiCorePropDesc("cycle.end_date", "date", extended=True),
+        "Primary": _GuiCorePropDesc("is_primary", "boolean", extended=True),
+        "Tags": _GuiCorePropDesc("tags", "string"),
+    }
+    __DN_PROPS = {
+        "Up to date": _GuiCorePropDesc("is_up_to_date", "boolean"),
+        "Valid": _GuiCorePropDesc("is_valid", "boolean"),
+        "Last edit date": _GuiCorePropDesc("last_edit_date", "date"),
+    }
+    __ENUMS = None
+    __SC_CYCLE = None
+
+    @staticmethod
+    def get_type(attr: str):
+        if prop := _get_datanode_property(attr):
+            return _GuiCoreScenarioProperties.__DN_PROPS.get(prop, _EMPTY_PROP_DESC).type
+        return _GuiCoreScenarioProperties._SC_PROPS.get(attr, _EMPTY_PROP_DESC).type
+
+    @staticmethod
+    def get_col_name(attr: str):
+        if prop := _get_datanode_property(attr):
+            return (
+                attr.split(".")[0]
+                + f".{_GuiCoreScenarioProperties.__DN_PROPS.get(prop, _EMPTY_PROP_DESC).attr or prop}"
+            )
+        return _GuiCoreScenarioProperties._SC_PROPS.get(attr, _EMPTY_PROP_DESC).attr or attr
+
+    def get_enums(self):
+        if _GuiCoreScenarioProperties.__ENUMS is None:
+            _GuiCoreScenarioProperties.__ENUMS = {
+                "Config id": [c for c in Config.scenarios.keys() if c != "default"],
+                "Tags": list({t for s in Config.scenarios.values() for t in s.properties.get("authorized_tags", [])}),
+            }
+        return _GuiCoreScenarioProperties.__ENUMS if self.full_desc() else {}
+
+    @staticmethod
+    def has_cycle():
+        if _GuiCoreScenarioProperties.__SC_CYCLE is None:
+            _GuiCoreScenarioProperties.__SC_CYCLE = (
+                next(filter(lambda sc: sc.frequency is not None, Config.scenarios.values()), None) is not None
+            )
+        return _GuiCoreScenarioProperties.__SC_CYCLE
+
+
+class _GuiCoreScenarioFilter(_GuiCoreScenarioProperties, _TaipyBase):
     DEFAULT = list(_GuiCoreScenarioProperties._SC_PROPS.keys())
     DEFAULT_NO_CYCLE = [
-        p[0]
-        for p in filter(lambda prop: not prop[1].get("for_cycle", False), _GuiCoreScenarioProperties._SC_PROPS.items())
+        p[0] for p in filter(lambda prop: not prop[1].extended, _GuiCoreScenarioProperties._SC_PROPS.items())
     ]
 
     @staticmethod
@@ -355,12 +431,21 @@ class _GuiCoreScenarioFilter(_GuiCoreScenarioProperties):
 
     @staticmethod
     def get_default_list():
-        has_cycle = next(filter(lambda sc: sc.frequency is not None, Config.scenarios.values()), None) is not None
-        return _GuiCoreScenarioFilter.DEFAULT if has_cycle else _GuiCoreScenarioFilter.DEFAULT_NO_CYCLE
+        return (
+            _GuiCoreScenarioFilter.DEFAULT
+            if _GuiCoreScenarioProperties.has_cycle()
+            else _GuiCoreScenarioFilter.DEFAULT_NO_CYCLE
+        )
 
 
-class _GuiCoreScenarioSort(_GuiCoreScenarioProperties):
-    DEFAULT = ["Config id", "Label", "Creation date"]
+class _GuiCoreScenarioSort(_GuiCoreScenarioProperties, _TaipyBase):
+    DEFAULT = [p[0] for p in filter(lambda prop: prop[1].for_sort, _GuiCoreScenarioProperties._SC_PROPS.items())]
+    DEFAULT_NO_CYCLE = [
+        p[0]
+        for p in filter(
+            lambda prop: prop[1].for_sort and not prop[1].extended, _GuiCoreScenarioProperties._SC_PROPS.items()
+        )
+    ]
 
     @staticmethod
     def full_desc():
@@ -372,7 +457,91 @@ class _GuiCoreScenarioSort(_GuiCoreScenarioProperties):
 
     @staticmethod
     def get_default_list():
-        return _GuiCoreScenarioSort.DEFAULT
+        return (
+            _GuiCoreScenarioSort.DEFAULT
+            if _GuiCoreScenarioProperties.has_cycle()
+            else _GuiCoreScenarioSort.DEFAULT_NO_CYCLE
+        )
+
+
+class _GuiCoreDatanodeProperties(_GuiCoreProperties):
+    _DN_PROPS: t.Dict[str, _GuiCorePropDesc] = {
+        "Config id": _GuiCorePropDesc("config_id", "string", for_sort=True),
+        "Label": _GuiCorePropDesc("get_simple_label()", "string", for_sort=True),
+        "Up to date": _GuiCorePropDesc("is_up_to_date", "boolean"),
+        "Last edit date": _GuiCorePropDesc("last_edit_date", "date", for_sort=True),
+        "Input": _GuiCorePropDesc("is_input", "boolean"),
+        "Output": _GuiCorePropDesc("is_output", "boolean"),
+        "Intermediate": _GuiCorePropDesc("is_intermediate", "boolean"),
+        "Expiration date": _GuiCorePropDesc("expiration_date", "date", extended=True, for_sort=True),
+        "Expired": _GuiCorePropDesc("is_expired", "boolean", extended=True),
+    }
+    __DN_VALIDITY = None
+
+    @staticmethod
+    def get_type(attr: str):
+        return _GuiCoreDatanodeProperties._DN_PROPS.get(attr, _EMPTY_PROP_DESC).type
+
+    @staticmethod
+    def get_col_name(attr: str):
+        return _GuiCoreDatanodeProperties._DN_PROPS.get(attr, _EMPTY_PROP_DESC).attr or attr
+
+    @staticmethod
+    def has_validity():
+        if _GuiCoreDatanodeProperties.__DN_VALIDITY is None:
+            _GuiCoreDatanodeProperties.__DN_VALIDITY = (
+                next(filter(lambda dn: dn.validity_period is not None, Config.data_nodes.values()), None) is not None
+            )
+        return _GuiCoreDatanodeProperties.__DN_VALIDITY
+
+
+class _GuiCoreDatanodeFilter(_GuiCoreDatanodeProperties, _TaipyBase):
+    DEFAULT = list(_GuiCoreDatanodeProperties._DN_PROPS.keys())
+    DEFAULT_NO_VALIDITY = [
+        p[0] for p in filter(lambda prop: not prop[1].extended, _GuiCoreDatanodeProperties._DN_PROPS.items())
+    ]
+
+    @staticmethod
+    def full_desc():
+        return True
+
+    @staticmethod
+    def get_hash():
+        return _TaipyBase._HOLDER_PREFIX + "DnF"
+
+    @staticmethod
+    def get_default_list():
+        return (
+            _GuiCoreDatanodeFilter.DEFAULT
+            if _GuiCoreDatanodeProperties.has_validity()
+            else _GuiCoreDatanodeFilter.DEFAULT_NO_VALIDITY
+        )
+
+
+class _GuiCoreDatanodeSort(_GuiCoreDatanodeProperties, _TaipyBase):
+    DEFAULT = [p[0] for p in filter(lambda prop: prop[1].for_sort, _GuiCoreDatanodeProperties._DN_PROPS.items())]
+    DEFAULT_NO_VALIDITY = [
+        p[0]
+        for p in filter(
+            lambda prop: prop[1].for_sort and not prop[1].extended, _GuiCoreDatanodeProperties._DN_PROPS.items()
+        )
+    ]
+
+    @staticmethod
+    def full_desc():
+        return False
+
+    @staticmethod
+    def get_hash():
+        return _TaipyBase._HOLDER_PREFIX + "DnS"
+
+    @staticmethod
+    def get_default_list():
+        return (
+            _GuiCoreDatanodeSort.DEFAULT
+            if _GuiCoreDatanodeProperties.has_validity()
+            else _GuiCoreDatanodeSort.DEFAULT_NO_VALIDITY
+        )
 
 
 def _is_debugging() -> bool:

+ 181 - 94
taipy/gui_core/_context.py

@@ -12,9 +12,8 @@
 import json
 import typing as t
 from collections import defaultdict
-from datetime import date, datetime
+from datetime import datetime
 from numbers import Number
-from operator import attrgetter
 from threading import Lock
 
 try:
@@ -64,10 +63,11 @@ from taipy.gui.gui import _DoNotUpdate
 from ._adapters import (
     _EntityType,
     _get_datanode_property,
+    _get_entity_property,
     _GuiCoreDatanodeAdapter,
+    _GuiCoreDatanodeProperties,
     _GuiCoreScenarioProperties,
     _invoke_action,
-    _is_debugging,
 )
 
 
@@ -223,7 +223,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 return [
                     cycle.id,
                     cycle.get_simple_label(),
-                    self.get_sorted_entity_list(self.scenario_by_cycle.get(cycle, []), sorts),
+                    self.get_sorted_scenario_list(self.scenario_by_cycle.get(cycle, []), sorts),
                     _EntityType.CYCLE.value,
                     False,
                 ]
@@ -255,15 +255,19 @@ class _GuiCoreContext(CoreEventConsumerBase):
             )
         return None
 
-    def filter_scenarios(self, cycle: t.List, col: str, col_type: str, is_dn: bool, action: str, val: t.Any):
-        cycle[2] = [e for e in cycle[2] if _invoke_action(e, col, col_type, is_dn, action, val)]
-        return cycle
+    def filter_entities(
+        self, cycle_scenario: t.List, col: str, col_type: str, is_dn: bool, action: str, val: t.Any, col_fn=None
+    ):
+        cycle_scenario[2] = [
+            e for e in cycle_scenario[2] if _invoke_action(e, col, col_type, is_dn, action, val, col_fn)
+        ]
+        return cycle_scenario
 
     def adapt_scenarios(self, cycle: t.List):
         cycle[2] = [self.scenario_adapter(e) for e in cycle[2]]
         return cycle
 
-    def get_sorted_entity_list(
+    def get_sorted_scenario_list(
         self,
         entities: t.Union[t.List[t.Union[Cycle, Scenario]], t.List[Scenario]],
         sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
@@ -274,11 +278,45 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 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)
+                sorted_list = sorted(sorted_list, key=_get_entity_property(col, Scenario), reverse=not order)
         else:
-            sorted_list = sorted(entities, key=_GuiCoreContext.get_entity_property("creation_date"))
+            sorted_list = sorted(entities, key=_get_entity_property("creation_date", Scenario))
         return [self.cycle_adapter(e, sorts) if isinstance(e, Cycle) else e for e in sorted_list]
 
+    def get_filtered_scenario_list(
+        self,
+        entities: t.List[t.Union[t.List, Scenario]],
+        filters: t.Optional[t.List[t.Dict[str, t.Any]]],
+    ):
+        if not filters:
+            return entities
+        # filtering
+        filtered_list = list(entities)
+        for fd in filters:
+            col = fd.get("col", "")
+            is_datanode_prop = _get_datanode_property(col) is not None
+            col_type = _GuiCoreScenarioProperties.get_type(col)
+            col = _GuiCoreScenarioProperties.get_col_name(col)
+            col_fn = cp[0] if (cp := col.split("(")) and len(cp) > 1 else None
+            val = fd.get("value")
+            action = fd.get("action", "")
+            # level 1 filtering
+            filtered_list = [
+                e
+                for e in filtered_list
+                if not isinstance(e, Scenario)
+                or _invoke_action(e, col, col_type, is_datanode_prop, action, val, col_fn)
+            ]
+            # level 2 filtering
+            filtered_list = [
+                e
+                if isinstance(e, Scenario)
+                else self.filter_entities(e, col, col_type, is_datanode_prop, action, val, col_fn)
+                for e in filtered_list
+            ]
+        # remove empty cycles
+        return [e for e in filtered_list if isinstance(e, Scenario) or (isinstance(e, (tuple, list)) and len(e[2]))]
+
     def get_scenarios(
         self,
         scenarios: t.Optional[t.List[t.Union[Cycle, Scenario]]],
@@ -287,6 +325,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
     ):
         cycles_scenarios: t.List[t.Union[Cycle, Scenario]] = []
         with self.lock:
+            # always needed to get scenarios for a cycle in cycle_adapter
             if self.scenario_by_cycle is None:
                 self.scenario_by_cycle = get_cycles_scenarios()
             if scenarios is None:
@@ -297,36 +336,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         cycles_scenarios.append(cycle)
         if scenarios is not None:
             cycles_scenarios = scenarios
-        adapted_list = self.get_sorted_entity_list(cycles_scenarios, sorts)
-        if filters:
-            # filtering
-            filtered_list = list(adapted_list)
-            for fd in filters:
-                col = fd.get("col", "")
-                is_datanode_prop = _get_datanode_property(col) is not None
-                col_type = _GuiCoreScenarioProperties.get_type(col)
-                col = _GuiCoreScenarioProperties.get_col_name(col)
-                val = fd.get("value")
-                action = fd.get("action", "")
-                if isinstance(val, str) and col_type == "date":
-                    val = datetime.fromisoformat(val[:-1])
-                # level 1 filtering
-                filtered_list = [
-                    e
-                    for e in filtered_list
-                    if not isinstance(e, Scenario) or _invoke_action(e, col, col_type, is_datanode_prop, action, val)
-                ]
-                # level 2 filtering
-                filtered_list = [
-                    self.filter_scenarios(e, col, col_type, is_datanode_prop, action, val)
-                    if not isinstance(e, Scenario)
-                    else e
-                    for e in filtered_list
-                ]
-            # remove empty cycles
-            adapted_list = [
-                e for e in filtered_list if isinstance(e, Scenario) or (isinstance(e, (tuple, list)) and len(e[2]))
-            ]
+        adapted_list = self.get_sorted_scenario_list(cycles_scenarios, sorts)
+        adapted_list = self.get_filtered_scenario_list(adapted_list, filters)
         return adapted_list
 
     def select_scenario(self, state: State, id: str, payload: t.Dict[str, str]):
@@ -575,62 +586,152 @@ class _GuiCoreContext(CoreEventConsumerBase):
         except Exception as e:
             _GuiCoreContext.__assign_var(state, error_var, f"Error submitting entity. {e}")
 
+    def get_filtered_datanode_list(
+        self,
+        entities: t.List[t.Union[t.List, DataNode]],
+        filters: t.Optional[t.List[t.Dict[str, t.Any]]],
+    ):
+        if not filters or not entities:
+            return entities
+        # filtering
+        filtered_list = list(entities)
+        for fd in filters:
+            col = fd.get("col", "")
+            col_type = _GuiCoreDatanodeProperties.get_type(col)
+            col = _GuiCoreDatanodeProperties.get_col_name(col)
+            col_fn = cp[0] if (cp := col.split("(")) and len(cp) > 1 else None
+            val = fd.get("value")
+            action = fd.get("action", "")
+            if isinstance(val, str) and col_type == "date":
+                val = datetime.fromisoformat(val[:-1])
+            # level 1 filtering
+            filtered_list = [
+                e
+                for e in filtered_list
+                if not isinstance(e, DataNode) or _invoke_action(e, col, col_type, False, action, val, col_fn)
+            ]
+            # level 3 filtering
+            filtered_list = [
+                e if isinstance(e, DataNode) else self.filter_entities(d, col, col_type, False, action, val, col_fn)
+                for e in filtered_list
+                for d in e[2]
+            ]
+        # remove empty cycles
+        return [e for e in filtered_list if isinstance(e, DataNode) or (isinstance(e, (tuple, list)) and len(e[2]))]
+
+    def get_sorted_datanode_list(
+        self,
+        entities: t.Union[
+            t.List[t.Union[Cycle, Scenario, DataNode]], t.List[t.Union[Scenario, DataNode]], t.List[DataNode]
+        ],
+        sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
+        adapt_dn=False,
+    ):
+        if not entities:
+            return entities
+        if sorts:
+            sorted_list = entities
+            for sd in reversed(sorts):
+                col = sd.get("col", "")
+                col = _GuiCoreDatanodeProperties.get_col_name(col)
+                order = sd.get("order", True)
+                sorted_list = sorted(sorted_list, key=_get_entity_property(col, DataNode), reverse=not order)
+        else:
+            sorted_list = entities
+        return [self.data_node_adapter(e, sorts, adapt_dn) for e in sorted_list]
+
     def __do_datanodes_tree(self):
         if self.data_nodes_by_owner is None:
             self.data_nodes_by_owner = defaultdict(list)
             for dn in get_data_nodes():
                 self.data_nodes_by_owner[dn.owner_id].append(dn)
 
-    def get_datanodes_tree(self, scenarios: t.Optional[t.Union[Scenario, t.List[Scenario]]]):
+    def get_datanodes_tree(
+        self,
+        scenarios: t.Optional[t.Union[Scenario, t.List[Scenario]]],
+        datanodes: t.Optional[t.List[DataNode]],
+        filters: t.Optional[t.List[t.Dict[str, t.Any]]],
+        sorts: t.Optional[t.List[t.Dict[str, t.Any]]],
+    ):
+        base_list = []
         with self.lock:
             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, None) or []
-            )
-        if not self.data_nodes_by_owner:
-            return []
-        if isinstance(scenarios, (list, tuple)) and len(scenarios) > 1:
-            return scenarios
-        owners = scenarios if isinstance(scenarios, (list, tuple)) else [scenarios]
-        return [d for owner in owners for d in self.data_nodes_by_owner.get(owner.id, [])]
-
-    def data_node_adapter(self, data):
-        if isinstance(data, (tuple, list)):
+        if datanodes is None:
+            if scenarios is None:
+                base_list = (self.data_nodes_by_owner or {}).get(None, []) + (
+                    self.get_scenarios(None, None, None) or []
+                )
+            else:
+                if isinstance(scenarios, (list, tuple)) and len(scenarios) > 1:
+                    base_list = scenarios
+                else:
+                    if self.data_nodes_by_owner:
+                        owners = scenarios if isinstance(scenarios, (list, tuple)) else [scenarios]
+                        base_list = [d for owner in owners for d in (self.data_nodes_by_owner).get(owner.id, [])]
+                    else:
+                        base_list = []
+        else:
+            base_list = datanodes
+        adapted_list = self.get_sorted_datanode_list(base_list, sorts)
+        return self.get_filtered_datanode_list(adapted_list, filters)
+
+    def data_node_adapter(
+        self,
+        data: t.Union[Cycle, Scenario, Sequence, DataNode],
+        sorts: t.Optional[t.List[t.Dict[str, t.Any]]] = None,
+        adapt_dn=True,
+    ):
+        if isinstance(data, tuple):
+            raise NotImplementedError
+        if isinstance(data, list):
+            if data[2] and isinstance(data[2][0], (Cycle, Scenario, Sequence, DataNode)):
+                data[2] = self.get_sorted_datanode_list(data[2], sorts, adapt_dn)
             return data
         try:
             if hasattr(data, "id") and is_readable(data.id) and core_get(data.id) is not None:
                 if isinstance(data, DataNode):
-                    return (data.id, data.get_simple_label(), None, _EntityType.DATANODE.value, False)
+                    return (
+                        [data.id, data.get_simple_label(), None, _EntityType.DATANODE.value, False]
+                        if adapt_dn
+                        else data
+                    )
 
                 with self.lock:
                     self.__do_datanodes_tree()
-                    if self.data_nodes_by_owner:
-                        if isinstance(data, Cycle):
-                            return (
-                                data.id,
-                                data.get_simple_label(),
-                                self.data_nodes_by_owner[data.id] + self.scenario_by_cycle.get(data, []),
-                                _EntityType.CYCLE.value,
-                                False,
-                            )
-                        elif isinstance(data, Scenario):
-                            return (
+                if self.data_nodes_by_owner:
+                    if isinstance(data, Cycle):
+                        return [
+                            data.id,
+                            data.get_simple_label(),
+                            self.get_sorted_datanode_list(
+                                self.data_nodes_by_owner.get(data.id, [])
+                                + (self.scenario_by_cycle or {}).get(data, []),
+                                sorts,
+                                adapt_dn,
+                            ),
+                            _EntityType.CYCLE.value,
+                            False,
+                        ]
+                    elif isinstance(data, Scenario):
+                        return [
+                            data.id,
+                            data.get_simple_label(),
+                            self.get_sorted_datanode_list(
+                                self.data_nodes_by_owner.get(data.id, []) + list(data.sequences.values()),
+                                sorts,
+                                adapt_dn,
+                            ),
+                            _EntityType.SCENARIO.value,
+                            data.is_primary,
+                        ]
+                    elif isinstance(data, Sequence):
+                        if datanodes := self.data_nodes_by_owner.get(data.id):
+                            return [
                                 data.id,
                                 data.get_simple_label(),
-                                self.data_nodes_by_owner[data.id] + list(data.sequences.values()),
-                                _EntityType.SCENARIO.value,
-                                data.is_primary,
-                            )
-                        elif isinstance(data, Sequence):
-                            if datanodes := self.data_nodes_by_owner.get(data.id):
-                                return (
-                                    data.id,
-                                    data.get_simple_label(),
-                                    datanodes,
-                                    _EntityType.SEQUENCE.value,
-                                    False,
-                                )
+                                self.get_sorted_datanode_list(datanodes, sorts, adapt_dn),
+                                _EntityType.SEQUENCE.value,
+                            ]
         except Exception as e:
             _warn(
                 f"Access to {type(data)} ({data.id if hasattr(data, 'id') else 'No_id'}) failed",
@@ -765,20 +866,6 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     if key and key not in _GuiCoreContext.__ENTITY_PROPS:
                         ent.properties.pop(key, None)
 
-    @staticmethod
-    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]] = []
         with self.lock:
@@ -797,7 +884,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_property("creation_date"))
+        return sorted(cycles_scenarios, key=_get_entity_property("creation_date", Scenario))
 
     def get_data_node_history(self, id: str):
         if id and (dn := core_get(id)) and isinstance(dn, DataNode):

+ 26 - 3
taipy/gui_core/viselements.json

@@ -90,7 +90,7 @@
                     {
                         "name": "scenarios",
                         "type": "dynamic(list[Scenario|Cycle])",
-                        "doc": "TODO: The list of Scenario/Cycle to show. Shows all Cycle/Scenario if value is None."
+                        "doc": "TODO: The list of <code>Scenario^</code>/<code>Cycle^</code> to show. Shows all Cycle/Scenario if value is None."
                     },
                     {
                         "name": "multiple",
@@ -102,7 +102,7 @@
                         "name": "filter",
                         "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."
+                        "doc": "TODO: a list of <code>Scenario^</code> attributes to filter on. If False, do not allow filter."
                     },
                     {
                         "name": "show_search",
@@ -114,7 +114,7 @@
                         "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."
+                        "doc": "TODO: a list of <code>Scenario^</code> attributes to sort on. If False, do not allow sort."
                     }
                 ]
             }
@@ -342,11 +342,34 @@
                         "type": "dynamic(Scenario|list[Scenario])",
                         "doc": "TODO: If the <code>Scenario^</code> is set, the selector will only show datanodes owned by this scenario."
                     },
+                    {
+                        "name": "datanodes",
+                        "type": "dynamic(list[DataNode|Scenario|Cycle])",
+                        "doc": "TODO: The list of <code>DataNode^</code>/<code>Scenario^</code>/<code>Cycle^</code> to show. Shows all Cycle/Scenario/DataNode if value is None."
+                    },
                     {
                         "name": "multiple",
                         "type": "bool",
                         "default_value": "False",
                         "doc": "TODO: If True, the user can select multiple datanodes."
+                    },
+                    {
+                        "name": "filter",
+                        "type": "bool|str|list[str]",
+                        "default_value": "\"Config id;Label;Up to date;Last edit date;Input;Output;Intermediate;Expiration date;Expired\"",
+                        "doc": "TODO: a list of <code>DataNode^</code> 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;Last edit date;Expiration date\"",
+                        "doc": "TODO: a list of <code>DataNode^</code> attributes to sort on. If False, do not allow sort."
                     }
                 ]
             }

+ 1 - 1
tests/gui_core/test_context_is_readable.py

@@ -201,7 +201,7 @@ class TestGuiCoreContext_is_readable:
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get):
             gui_core_context = _GuiCoreContext(Mock())
             outcome = gui_core_context.data_node_adapter(a_datanode)
-            assert isinstance(outcome, tuple)
+            assert isinstance(outcome, list)
             assert outcome[0] == a_datanode.id
 
             with patch("taipy.gui_core._context.is_readable", side_effect=mock_is_readable_false):

Some files were not shown because too many files changed in this diff