Forráskód Böngészése

Merge branch 'develop' into bug/#746-scenario-submittable-not-clear-in-visual-element

Toan Quach 1 éve
szülő
commit
8bffdadcaf
55 módosított fájl, 1641 hozzáadás és 554 törlés
  1. 5 2
      README.md
  2. 15 0
      frontend/taipy-gui/packaging/taipy-gui.d.ts
  3. 11 4
      frontend/taipy-gui/src/components/Taipy/TableFilter.tsx
  4. 10 1
      frontend/taipy-gui/src/extensions/exports.ts
  5. 10 1
      frontend/taipy/src/CoreSelector.tsx
  6. 24 9
      frontend/taipy/src/DataNodeTable.tsx
  7. 15 11
      frontend/taipy/src/DataNodeViewer.tsx
  8. 7 3
      frontend/taipy/src/JobSelector.tsx
  9. 22 4
      frontend/taipy/src/PropertiesEditor.tsx
  10. 87 11
      frontend/taipy/src/ScenarioSelector.tsx
  11. 34 9
      frontend/taipy/src/ScenarioViewer.tsx
  12. 1 0
      pyproject.toml
  13. 7 0
      taipy/core/_manager/_manager.py
  14. 28 0
      taipy/core/_repository/_abstract_repository.py
  15. 15 0
      taipy/core/_version/_version_manager.py
  16. 3 2
      taipy/core/_version/_version_manager_factory.py
  17. 1 2
      taipy/core/cycle/_cycle_manager_factory.py
  18. 20 2
      taipy/core/data/_data_manager.py
  19. 1 2
      taipy/core/data/_data_manager_factory.py
  20. 4 4
      taipy/core/data/_filter.py
  21. 15 14
      taipy/core/data/data_node.py
  22. 35 8
      taipy/core/exceptions/exceptions.py
  23. 3 1
      taipy/core/job/_job_manager.py
  24. 1 2
      taipy/core/job/_job_manager_factory.py
  25. 92 3
      taipy/core/scenario/_scenario_manager.py
  26. 1 2
      taipy/core/scenario/_scenario_manager_factory.py
  27. 1 2
      taipy/core/submission/_submission_manager_factory.py
  28. 111 57
      taipy/core/taipy.py
  29. 1 2
      taipy/core/task/_task_manager_factory.py
  30. 5 2
      taipy/gui/_renderers/builder.py
  31. 1 1
      taipy/gui/data/utils.py
  32. 11 4
      taipy/gui/extension/library.py
  33. 3 3
      taipy/gui/gui.py
  34. 3 2
      taipy/gui/utils/_adapter.py
  35. 15 5
      taipy/gui/utils/_evaluator.py
  36. 3 2
      taipy/gui/utils/types.py
  37. 63 46
      taipy/gui_core/_GuiCoreLib.py
  38. 82 26
      taipy/gui_core/_adapters.py
  39. 155 103
      taipy/gui_core/_context.py
  40. 5 0
      taipy/gui_core/viselements.json
  41. 15 0
      tests/core/data/test_data_node.py
  42. 80 52
      tests/core/test_taipy/test_export.py
  43. 63 56
      tests/core/test_taipy/test_export_with_sql_repo.py
  44. 213 0
      tests/core/test_taipy/test_import.py
  45. 174 0
      tests/core/test_taipy/test_import_with_sql_repo.py
  46. 4 4
      tests/gui/utils/test_types.py
  47. 12 8
      tests/gui_core/test_context_is_deletable.py
  48. 39 25
      tests/gui_core/test_context_is_editable.py
  49. 6 4
      tests/gui_core/test_context_is_promotable.py
  50. 66 32
      tests/gui_core/test_context_is_readable.py
  51. 6 4
      tests/gui_core/test_context_is_submitable.py
  52. 16 0
      tests/rest/conftest.py
  53. 3 1
      tests/rest/test_end_to_end.py
  54. 7 8
      tests/rest/test_scenario.py
  55. 11 8
      tests/rest/test_sequence.py

+ 5 - 2
README.md

@@ -1,8 +1,11 @@
+[![Taipy Designer](https://github.com/nevo-david/taipy/assets/100117126/e787ba7b-ec7a-4d3f-a7e4-0f195daadce7)
+](https://taipy.io/enterprise)
+
 <div align="center">
   <a href="https://taipy.io?utm_source=github" target="_blank">
   <picture>
-    <source media="(prefers-color-scheme: dark)" srcset="https://github.com/Avaiga/taipy/assets/100117126/f59f70e9-1905-4abc-8760-8631b57c14c2">
-    <img alt="Taipy" src="readme_img/readme_logo.png" width="200" />
+    <source media="(prefers-color-scheme: dark)" srcset="https://github.com/Avaiga/taipy/assets/100117126/509bf101-54c2-4321-adaf-a2af63af9682">
+    <img alt="Taipy" src="https://github.com/Avaiga/taipy/assets/100117126/4df8a733-d8d0-4893-acf0-d24ef9e8b58a" width="400" />
   </picture>
   </a>
 </div>

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

@@ -117,6 +117,21 @@ export interface TableProps extends TaipyPaginatedTableProps {
 }
 export declare const Table: (props: TableProps) => JSX.Element;
 
+export interface FilterDesc {
+    col: string;
+    action: string;
+    value: string | number | boolean | Date;
+}
+export interface TableFilterProps {
+    columns: Record<string, ColumnDesc>;
+    colsOrder?: Array<string>;
+    onValidate: (data: Array<FilterDesc>) => void;
+    appliedFilters?: Array<FilterDesc>;
+    className?: string;
+    filteredCount: number;
+}
+export declare const TableFilter: (props: TableFilterProps) => JSX.Element;
+
 export declare const Router: () => JSX.Element;
 
 /**

+ 11 - 4
frontend/taipy-gui/src/components/Taipy/TableFilter.tsx

@@ -11,7 +11,7 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
+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 FilterListIcon from "@mui/icons-material/FilterList";
@@ -29,7 +29,7 @@ import Tooltip from "@mui/material/Tooltip";
 import { DateField, LocalizationProvider } from "@mui/x-date-pickers";
 import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
 
-import { ColumnDesc, defaultDateFormat, iconInRowSx } from "./tableUtils";
+import { ColumnDesc, defaultDateFormat, getsortByIndex, iconInRowSx } from "./tableUtils";
 import { getDateTime, getTypeFromDf } from "../../utils";
 import { getSuffixedClassNames } from "./utils";
 
@@ -41,7 +41,7 @@ export interface FilterDesc {
 
 interface TableFilterProps {
     columns: Record<string, ColumnDesc>;
-    colsOrder: Array<string>;
+    colsOrder?: Array<string>;
     onValidate: (data: Array<FilterDesc>) => void;
     appliedFilters?: Array<FilterDesc>;
     className?: string;
@@ -279,12 +279,19 @@ const FilterRow = (props: FilterRowProps) => {
 };
 
 const TableFilter = (props: TableFilterProps) => {
-    const { onValidate, appliedFilters, columns, colsOrder, className = "", filteredCount } = props;
+    const { onValidate, appliedFilters, columns, className = "", filteredCount } = props;
 
     const [showFilter, setShowFilter] = useState(false);
     const filterRef = useRef<HTMLButtonElement | null>(null);
     const [filters, setFilters] = useState<Array<FilterDesc>>([]);
 
+    const colsOrder = useMemo(()=> {
+        if (props.colsOrder) {
+            return props.colsOrder;
+        }
+        return Object.keys(columns).sort(getsortByIndex(columns));
+    }, [props.colsOrder, columns]);
+
     const onShowFilterClick = useCallback(() => setShowFilter((f) => !f), []);
 
     const updateFilter = useCallback(

+ 10 - 1
frontend/taipy-gui/src/extensions/exports.ts

@@ -16,13 +16,20 @@ import Dialog from "../components/Taipy/Dialog";
 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 { useLovListMemo, LoV, LoVElt } from "../components/Taipy/lovUtils";
 import { LovItem } from "../utils/lov";
 import { getUpdateVar } from "../components/Taipy/utils";
 import { ColumnDesc, RowType, RowValue } from "../components/Taipy/tableUtils";
 import { TaipyContext, TaipyStore } from "../context/taipyContext";
 import { TaipyBaseAction, TaipyState } from "../context/taipyReducers";
-import { useClassNames, useDispatchRequestUpdateOnFirstRender, useDispatch, useDynamicProperty, useModule } from "../utils/hooks";
+import {
+    useClassNames,
+    useDispatchRequestUpdateOnFirstRender,
+    useDispatch,
+    useDynamicProperty,
+    useModule,
+} from "../utils/hooks";
 import {
     createSendActionNameAction,
     createSendUpdateAction,
@@ -36,6 +43,7 @@ export {
     Login,
     Router,
     Table,
+    TableFilter,
     TaipyContext as Context,
     createRequestDataUpdateAction,
     createRequestUpdateAction,
@@ -52,6 +60,7 @@ export {
 
 export type {
     ColumnDesc,
+    FilterDesc,
     LoV,
     LoVElt,
     LovItem,

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

@@ -31,6 +31,7 @@ import {
     createSendUpdateAction,
     useDispatchRequestUpdateOnFirstRender,
     createRequestUpdateAction,
+    useDynamicProperty,
 } from "taipy-gui";
 
 import { Cycles, Cycle, DataNodes, NodeType, Scenarios, Scenario, DataNode, Sequence } from "./utils/types";
@@ -54,6 +55,7 @@ import {
 
 export interface EditProps {
     id: string;
+    active: boolean;
 }
 
 const treeSlots = { expandIcon: ChevronRight };
@@ -64,6 +66,8 @@ type Pinned = Record<string, boolean>;
 
 interface CoreSelectorProps {
     id?: string;
+    active?: boolean;
+    defaultActive?: boolean;
     updateVarName?: string;
     entities?: Entities;
     coreChanged?: Record<string, unknown>;
@@ -109,6 +113,7 @@ const CoreItem = (props: {
     pins: [Pinned, Pinned];
     onPin?: (e: MouseEvent<HTMLElement>) => void;
     hideNonPinned: boolean;
+    active: boolean;
 }) => {
     const [id, label, items = EmptyArray, nodeType, primary] = props.item;
     const isPinned = props.pins[0][id];
@@ -126,6 +131,7 @@ const CoreItem = (props: {
                     pins={props.pins}
                     onPin={props.onPin}
                     hideNonPinned={props.hideNonPinned}
+                    active={props.active}
                 />
             ))}
         </>
@@ -161,7 +167,7 @@ const CoreItem = (props: {
                     </Grid>
                     {props.editComponent && nodeType === props.leafType ? (
                         <Grid item xs="auto">
-                            <props.editComponent id={id} />
+                            <props.editComponent id={id} active={props.active} />
                         </Grid>
                     ) : null}
                     {props.onPin ? (
@@ -194,6 +200,7 @@ const CoreItem = (props: {
                     pins={props.pins}
                     onPin={props.onPin}
                     hideNonPinned={props.hideNonPinned}
+                    active={props.active}
                 />
             ))}
         </TreeItem>
@@ -261,6 +268,7 @@ const CoreSelector = (props: CoreSelectorProps) => {
     const [hideNonPinned, setShowPinned] = useState(false);
     const [expandedItems, setExpandedItems] = useState<string[]>([]);
 
+    const active = useDynamicProperty(props.active, props.defaultActive, true);
     const dispatch = useDispatch();
     const module = useModule();
 
@@ -437,6 +445,7 @@ const CoreSelector = (props: CoreSelectorProps) => {
                               onPin={showPins ? onPin : undefined}
                               pins={pins}
                               hideNonPinned={hideNonPinned}
+                              active={!!active}
                           />
                       ))
                     : null}

+ 24 - 9
frontend/taipy/src/DataNodeTable.tsx

@@ -33,7 +33,15 @@ import TextField from "@mui/material/TextField";
 import ToggleButton from "@mui/material/ToggleButton";
 import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
 
-import { ColumnDesc, Table, TraceValueType, createSendActionNameAction, useDispatch, useModule } from "taipy-gui";
+import {
+    ColumnDesc,
+    Table,
+    TraceValueType,
+    createSendActionNameAction,
+    getUpdateVar,
+    useDispatch,
+    useModule,
+} from "taipy-gui";
 
 import { ChartViewType, MenuProps, TableViewType, selectSx, tabularHeaderSx } from "./utils";
 
@@ -51,13 +59,13 @@ interface DataNodeTableProps {
     editInProgress?: boolean;
     editLock: MutableRefObject<boolean>;
     editable: boolean;
-    idVar?: string;
+    updateDnVars?: string;
 }
 
 const pushRightSx = { ml: "auto" };
 
 const DataNodeTable = (props: DataNodeTableProps) => {
-    const { uniqid, configId, nodeId, columns = "", onViewTypeChange, editable } = props;
+    const { uniqid, configId, nodeId, columns = "", onViewTypeChange, editable, updateDnVars = "" } = props;
 
     const dispatch = useDispatch();
     const module = useModule();
@@ -112,17 +120,24 @@ const DataNodeTable = (props: DataNodeTableProps) => {
         () =>
             setTableEdit((e) => {
                 props.editLock.current = !e;
-                dispatch(createSendActionNameAction("", module, props.onLock, { id: nodeId, lock: !e }));
+                dispatch(
+                    createSendActionNameAction("", module, props.onLock, {
+                        id: nodeId,
+                        lock: !e,
+                        error_id: getUpdateVar(updateDnVars, "error_id"),
+                    })
+                );
                 return !e;
             }),
-        [nodeId, dispatch, module, props.onLock, props.editLock]
+        [nodeId, dispatch, module, props.onLock, props.editLock, updateDnVars]
     );
 
     const userData = useMemo(() => {
-        const ret: Record<string, unknown> = {dn_id: nodeId, comment: ""};
-        props.idVar && (ret.context = { [props.idVar]: nodeId });
-        return ret
-    }, [nodeId, props.idVar]);
+        const ret: Record<string, unknown> = { dn_id: nodeId, comment: "" };
+        const idVar = getUpdateVar(updateDnVars, "data_id");
+        idVar && (ret.context = { [idVar]: nodeId, data_id: idVar, error_id: getUpdateVar(updateDnVars, "error_id") });
+        return ret;
+    }, [nodeId, updateDnVars]);
     const [comment, setComment] = useState("");
     const changeComment = useCallback(
         (e: ChangeEvent<HTMLInputElement>) => {

+ 15 - 11
frontend/taipy/src/DataNodeViewer.tsx

@@ -310,7 +310,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                 createRequestUpdateAction(
                                     id,
                                     module,
-                                    getUpdateVarNames(updateVars, "properties"),
+                                    getUpdateVarNames(updateVars, "dnProperties"),
                                     true,
                                     idVar ? { [idVar]: dnId } : undefined
                                 )
@@ -354,6 +354,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                             createSendActionNameAction(id, module, props.onLock, {
                                 id: oldId,
                                 lock: false,
+                                error_id: getUpdateVar(updateDnVars, "error_id")
                             })
                         ),
                     1
@@ -442,9 +443,9 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         () => () => {
             dnId &&
                 editLock.current &&
-                dispatch(createSendActionNameAction(id, module, props.onLock, { id: dnId, lock: false }));
+                dispatch(createSendActionNameAction(id, module, props.onLock, { id: dnId, lock: false, error_id: getUpdateVar(updateDnVars, "error_id") }));
         },
-        [dnId, id, dispatch, module, props.onLock]
+        [dnId, id, dispatch, module, props.onLock, updateDnVars]
     );
 
     const active = useDynamicProperty(props.active, props.defaultActive, true) && dnReadable;
@@ -469,11 +470,11 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
             e.stopPropagation();
             setFocusName(e.currentTarget.dataset.focus || "");
             if (e.currentTarget.dataset.focus === dataValueFocus && !editLock.current) {
-                dispatch(createSendActionNameAction(id, module, props.onLock, { id: dnId, lock: true }));
+                dispatch(createSendActionNameAction(id, module, props.onLock, { id: dnId, lock: true, error_id: getUpdateVar(updateDnVars, "error_id") }));
                 editLock.current = true;
             }
         },
-        [dnId, props.onLock, id, dispatch, module]
+        [dnId, props.onLock, id, dispatch, module, updateDnVars]
     );
 
     // Label
@@ -482,11 +483,11 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         (e: MouseEvent<HTMLElement>) => {
             e.stopPropagation();
             if (valid) {
-                dispatch(createSendActionNameAction(id, module, props.onEdit, { id: dnId, name: label }));
+                dispatch(createSendActionNameAction(id, module, props.onEdit, { id: dnId, name: label, error_id: getUpdateVar(updateDnVars, "error_id") }));
                 setFocusName("");
             }
         },
-        [valid, props.onEdit, dnId, label, id, dispatch, module]
+        [valid, props.onEdit, dnId, label, id, dispatch, module, updateDnVars]
     );
     const cancelLabel = useCallback(
         (e: MouseEvent<HTMLElement>) => {
@@ -548,21 +549,23 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                         value: dataValue,
                         type: dtType,
                         comment: comment,
+                        error_id: getUpdateVar(updateDnVars, "error_id"),
+                        data_id: getUpdateVar(updateDnVars, "data_id")
                     })
                 );
                 setFocusName("");
             }
         },
-        [valid, props.onDataValue, dnId, dataValue, dtType, id, dispatch, module, comment]
+        [valid, props.onDataValue, dnId, dataValue, dtType, id, dispatch, module, comment, updateDnVars]
     );
     const cancelDataValue = useCallback(
         (e: MouseEvent<HTMLElement>) => {
             e.stopPropagation();
             setDataValue(getDataValue(dtValue, dtType));
             setFocusName("");
-            dispatch(createSendActionNameAction(id, module, props.onLock, { id: dnId, lock: false }));
+            dispatch(createSendActionNameAction(id, module, props.onLock, { id: dnId, lock: false, error_id: getUpdateVar(updateDnVars, "error_id") }));
         },
-        [dtValue, dtType, dnId, id, dispatch, module, props.onLock]
+        [dtValue, dtType, dnId, id, dispatch, module, props.onLock, updateDnVars]
     );
     const onDataValueChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setDataValue(e.target.value), []);
     const onDataValueDateChange = useCallback((d: Date | null) => d && setDataValue(d), []);
@@ -831,6 +834,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                     onFocus={onFocus}
                                     onEdit={props.onEdit}
                                     editable={dnEditable}
+                                    updatePropVars={updateDnVars}
                                 />
                             </Grid>
                         </div>
@@ -1059,7 +1063,7 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                                                 editInProgress={dnEditInProgress && dnEditorId !== editorId}
                                                 editLock={editLock}
                                                 editable={dnEditable}
-                                                idVar={getUpdateVar(updateDnVars, "data_id")}
+                                                updateDnVars={updateDnVars}
                                             />
                                         ) : (
                                             <DataNodeChart

+ 7 - 3
frontend/taipy/src/JobSelector.tsx

@@ -74,6 +74,7 @@ interface JobSelectorProps {
     value?: string;
     defaultValue?: string;
     propagate?: boolean;
+    updateJbVars?: string;
 }
 
 // job id, job name, empty list, entity id, entity name, submit id, creation date, status
@@ -406,7 +407,7 @@ const JobSelectedTableRow = ({
     showSubmissionId,
     showDate,
     showCancel,
-    showDelete,
+    showDelete
 }: JobSelectedTableRowProps) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     const [id, jobName, _, entityId, entityName, submitId, creationDate, status] = row;
@@ -481,6 +482,7 @@ const JobSelector = (props: JobSelectorProps) => {
         showCancel = true,
         showDelete = true,
         propagate = true,
+        updateJbVars = ""
     } = props;
     const [checked, setChecked] = useState<string[]>([]);
     const [selected, setSelected] = useState<string[]>([]);
@@ -611,13 +613,14 @@ const JobSelector = (props: JobSelectorProps) => {
                     createSendActionNameAction(props.id, module, props.onJobAction, {
                         id: multiple === false ? [id] : JSON.parse(id),
                         action: "cancel",
+                        error_id: getUpdateVar(updateJbVars, "error_id")
                     })
                 );
             } catch (e) {
                 console.warn("Error parsing ids for cancel.", e);
             }
         },
-        [dispatch, module, props.id, props.onJobAction]
+        [dispatch, module, props.id, props.onJobAction, updateJbVars]
     );
 
     const handleDeleteJobs = useCallback(
@@ -629,13 +632,14 @@ const JobSelector = (props: JobSelectorProps) => {
                     createSendActionNameAction(props.id, module, props.onJobAction, {
                         id: multiple === false ? [id] : JSON.parse(id),
                         action: "delete",
+                        error_id: getUpdateVar(updateJbVars, "error_id")
                     })
                 );
             } catch (e) {
                 console.warn("Error parsing ids for delete.", e);
             }
         },
-        [dispatch, module, props.id, props.onJobAction]
+        [dispatch, module, props.id, props.onJobAction, updateJbVars]
     );
 
     const allowCancelJobs = useMemo(

+ 22 - 4
frontend/taipy/src/PropertiesEditor.tsx

@@ -20,7 +20,7 @@ import Tooltip from "@mui/material/Tooltip";
 import Typography from "@mui/material/Typography";
 import { DeleteOutline, CheckCircle, Cancel } from "@mui/icons-material";
 
-import { createSendActionNameAction, useDispatch, useModule } from "taipy-gui";
+import { createSendActionNameAction, getUpdateVar, useDispatch, useModule } from "taipy-gui";
 
 import { DeleteIconSx, FieldNoMaxWidth, IconPaddingSx, disableColor, hoverSx } from "./utils";
 
@@ -34,6 +34,7 @@ type PropertiesEditPayload = {
     id: string;
     properties?: Property[];
     deleted_properties?: Array<Partial<Property>>;
+    error_id?: string;
 };
 
 export type DatanodeProperties = Array<[string, string]>;
@@ -50,10 +51,23 @@ interface PropertiesEditorProps {
     isDefined: boolean;
     onEdit?: string;
     editable: boolean;
+    updatePropVars?: string;
 }
 
 const PropertiesEditor = (props: PropertiesEditorProps) => {
-    const { id, entityId, isDefined, show, active, onFocus, focusName, setFocusName, entProperties, editable } = props;
+    const {
+        id,
+        entityId,
+        isDefined,
+        show,
+        active,
+        onFocus,
+        focusName,
+        setFocusName,
+        entProperties,
+        editable,
+        updatePropVars = "",
+    } = props;
 
     const dispatch = useDispatch();
     const module = useModule();
@@ -85,7 +99,11 @@ const PropertiesEditor = (props: PropertiesEditorProps) => {
                 const property = propId ? properties.find((p) => p.id === propId) : newProp;
                 if (property) {
                     const oldId = property.id;
-                    const payload: PropertiesEditPayload = { id: entityId, properties: [property] };
+                    const payload: PropertiesEditPayload = {
+                        id: entityId,
+                        properties: [property],
+                        error_id: getUpdateVar(updatePropVars, "error_id"),
+                    };
                     if (oldId && oldId != property.key) {
                         payload.deleted_properties = [{ key: oldId }];
                     }
@@ -95,7 +113,7 @@ const PropertiesEditor = (props: PropertiesEditorProps) => {
                 setFocusName("");
             }
         },
-        [isDefined, props.onEdit, entityId, properties, newProp, id, dispatch, module, setFocusName]
+        [isDefined, props.onEdit, entityId, properties, newProp, id, dispatch, module, setFocusName, updatePropVars]
     );
     const cancelProperty = useCallback(
         (e?: MouseEvent<HTMLElement>, dataset?: DOMStringMap) => {

+ 87 - 11
frontend/taipy/src/ScenarioSelector.tsx

@@ -11,7 +11,7 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useEffect, useState, useCallback } from "react";
+import React, { useEffect, useState, useCallback, useMemo } from "react";
 import { Theme, Tooltip, alpha } from "@mui/material";
 
 import Box from "@mui/material/Box";
@@ -35,10 +35,21 @@ import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers";
 import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
 import { useFormik } from "formik";
 
-import { useDispatch, useModule, createSendActionNameAction, getUpdateVar, createSendUpdateAction } from "taipy-gui";
+import {
+    useDispatch,
+    useModule,
+    createSendActionNameAction,
+    getUpdateVar,
+    createSendUpdateAction,
+    TableFilter,
+    ColumnDesc,
+    FilterDesc,
+    useDynamicProperty,
+    createRequestUpdateAction,
+} from "taipy-gui";
 
 import ConfirmDialog from "./utils/ConfirmDialog";
-import { MainTreeBoxSx, ScFProps, ScenarioFull, useClassNames, tinyIconButtonSx } from "./utils";
+import { MainTreeBoxSx, ScFProps, ScenarioFull, useClassNames, tinyIconButtonSx, getUpdateVarNames } from "./utils";
 import CoreSelector, { EditProps } from "./CoreSelector";
 import { Cycles, NodeType, Scenarios } from "./utils/types";
 
@@ -62,6 +73,8 @@ interface ScenarioDict {
 
 interface ScenarioSelectorProps {
     id?: string;
+    active?: boolean;
+    defaultActive?: boolean;
     showAddButton?: boolean;
     displayCycles?: boolean;
     showPrimaryFlag?: boolean;
@@ -86,6 +99,8 @@ interface ScenarioSelectorProps {
     showPins?: boolean;
     showDialog?: boolean;
     multiple?: boolean;
+    filterBy?: string;
+    updateScVars?: string;
 }
 
 interface ScenarioEditDialogProps {
@@ -414,22 +429,68 @@ const ScenarioEditDialog = ({ scenario, submit, open, actionEdit, configs, close
 };
 
 const ScenarioSelector = (props: ScenarioSelectorProps) => {
-    const { showAddButton = true, propagate = true, showPins = false, showDialog = true, multiple = false } = props;
+    const {
+        showAddButton = true,
+        propagate = true,
+        showPins = false,
+        showDialog = true,
+        multiple = false,
+        updateVars = "",
+        updateScVars = "",
+    } = props;
     const [open, setOpen] = useState(false);
     const [actionEdit, setActionEdit] = useState<boolean>(false);
 
+    const active = useDynamicProperty(props.active, props.defaultActive, true);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
 
     const dispatch = useDispatch();
     const module = useModule();
 
+    const colFilters = useMemo(() => {
+        try {
+            const res = props.filterBy ? (JSON.parse(props.filterBy) as Array<[string, string]>) : undefined;
+            return Array.isArray(res)
+                ? res.reduce((pv, [name, coltype], idx) => {
+                      pv[name] = { dfid: name, type: coltype, index: idx, filter: true };
+                      return pv;
+                  }, {} as Record<string, ColumnDesc>)
+                : undefined;
+        } catch (e) {
+            return undefined;
+        }
+    }, [props.filterBy]);
+    const [filters, setFilters] = useState<FilterDesc[]>([]);
+
+    const applyFilters = useCallback(
+        (filters: FilterDesc[]) => {
+            setFilters((old) => {
+                if (old.length != filters.length || JSON.stringify(old) != JSON.stringify(filters)) {
+                    const filterVar = getUpdateVar(updateScVars, "filter");
+                    dispatch(
+                        createRequestUpdateAction(
+                            props.id,
+                            module,
+                            getUpdateVarNames(updateVars, "innerScenarios"),
+                            true,
+                            filterVar ? { [filterVar]: filters } : undefined
+                        )
+                    );
+                    return filters;
+                }
+                return old;
+            });
+        },
+        [updateVars, dispatch, props.id, updateScVars, module]
+    );
+
     const onSubmit = useCallback(
         (...values: unknown[]) => {
             dispatch(
                 createSendActionNameAction(
                     props.id,
                     module,
-                    props.onScenarioCrud,
+                    { action: props.onScenarioCrud, error_id: getUpdateVar(updateScVars, "error_id") },
                     props.onCreation,
                     props.updateVarName,
                     ...values
@@ -437,7 +498,7 @@ const ScenarioSelector = (props: ScenarioSelectorProps) => {
             );
             if (values.length > 1 && values[1]) {
                 // delete requested => unselect current node
-                const lovVar = getUpdateVar(props.updateVars, "innerScenarios");
+                const lovVar = getUpdateVar(updateVars, "innerScenarios");
                 dispatch(
                     createSendUpdateAction(props.updateVarName, undefined, module, props.onChange, propagate, lovVar)
                 );
@@ -451,8 +512,9 @@ const ScenarioSelector = (props: ScenarioSelectorProps) => {
             propagate,
             props.onChange,
             props.updateVarName,
-            props.updateVars,
+            updateVars,
             props.onCreation,
+            updateScVars,
         ]
     );
 
@@ -474,19 +536,25 @@ const ScenarioSelector = (props: ScenarioSelectorProps) => {
         (e: React.MouseEvent<HTMLElement>) => {
             e.stopPropagation();
             const { id: scenId } = e.currentTarget?.dataset || {};
+            const varName = getUpdateVar(updateScVars, "sc_id");
             scenId &&
                 props.onScenarioSelect &&
-                dispatch(createSendActionNameAction(props.id, module, props.onScenarioSelect, scenId));
+                dispatch(createSendActionNameAction(props.id, module, props.onScenarioSelect, varName, scenId));
             setOpen(true);
             setActionEdit(true);
         },
-        [props.onScenarioSelect, props.id, dispatch, module]
+        [props.onScenarioSelect, props.id, dispatch, module, updateScVars]
     );
 
     const EditScenario = useCallback(
         (props: EditProps) => (
             <Tooltip title="Edit Scenario">
-                <IconButton data-id={props.id} onClick={openEditDialog} sx={tinyEditIconButtonSx}>
+                <IconButton
+                    data-id={props.id}
+                    onClick={openEditDialog}
+                    sx={tinyEditIconButtonSx}
+                    disabled={props.active}
+                >
                     <EditOutlined />
                 </IconButton>
             </Tooltip>
@@ -497,6 +565,14 @@ const ScenarioSelector = (props: ScenarioSelectorProps) => {
     return (
         <>
             <Box sx={MainTreeBoxSx} id={props.id} className={className}>
+                {active && colFilters ? (
+                    <TableFilter
+                        columns={colFilters}
+                        appliedFilters={filters}
+                        filteredCount={0}
+                        onValidate={applyFilters}
+                    ></TableFilter>
+                ) : null}
                 <CoreSelector
                     {...props}
                     entities={props.innerScenarios}
@@ -507,7 +583,7 @@ const ScenarioSelector = (props: ScenarioSelectorProps) => {
                     multiple={multiple}
                 />
                 {showAddButton ? (
-                    <Button variant="outlined" onClick={onDialogOpen} fullWidth endIcon={<Add />}>
+                    <Button variant="outlined" onClick={onDialogOpen} fullWidth endIcon={<Add />} disabled={!active}>
                         Add scenario
                     </Button>
                 ) : null}

+ 34 - 9
frontend/taipy/src/ScenarioViewer.tsx

@@ -38,6 +38,7 @@ import deepEqual from "fast-deep-equal/es6";
 import {
     createRequestUpdateAction,
     createSendActionNameAction,
+    getUpdateVar,
     useDispatch,
     useDynamicProperty,
     useModule,
@@ -87,6 +88,7 @@ interface ScenarioViewerProps {
     className?: string;
     dynamicClassName?: string;
     onSubmissionChange?: string;
+    updateScVar?: string;
 }
 
 interface SequencesRowProps {
@@ -340,6 +342,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
         showSubmit = true,
         showSubmitSequences = true,
         showTags = true,
+        updateScVar = "",
     } = props;
 
     const dispatch = useDispatch();
@@ -401,9 +404,15 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
     const onPromote = useCallback(() => {
         setPrimaryDialog(false);
         if (valid) {
-            dispatch(createSendActionNameAction(id, module, props.onEdit, { id: scId, primary: true }));
+            dispatch(
+                createSendActionNameAction(id, module, props.onEdit, {
+                    id: scId,
+                    primary: true,
+                    error_id: getUpdateVar(updateScVar, "error_id"),
+                })
+            );
         }
-    }, [valid, props.onEdit, scId, id, dispatch, module]);
+    }, [valid, props.onEdit, scId, id, dispatch, module, updateScVar]);
 
     // userExpanded
     const [userExpanded, setUserExpanded] = useState(valid && expanded);
@@ -421,10 +430,11 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                         id: scId,
                         sequence: label,
                         on_submission_change: props.onSubmissionChange,
+                        error_id: getUpdateVar(updateScVar, "error_id")
                     })
                 );
         },
-        [scId, props.onSubmit, props.onSubmissionChange, id, dispatch, module]
+        [scId, props.onSubmit, props.onSubmissionChange, id, dispatch, module, updateScVar]
     );
 
     const submitScenario = useCallback(
@@ -435,11 +445,12 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                     createSendActionNameAction(id, module, props.onSubmit, {
                         id: scId,
                         on_submission_change: props.onSubmissionChange,
+                        error_id: getUpdateVar(updateScVar, "error_id")
                     })
                 );
             }
         },
-        [valid, props.onSubmit, props.onSubmissionChange, id, scId, dispatch, module]
+        [valid, props.onSubmit, props.onSubmissionChange, id, scId, dispatch, module, updateScVar]
     );
 
     // focus
@@ -455,11 +466,17 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
         (e?: MouseEvent<HTMLElement>) => {
             e && e.stopPropagation();
             if (valid) {
-                dispatch(createSendActionNameAction(id, module, props.onEdit, { id: scId, name: label }));
+                dispatch(
+                    createSendActionNameAction(id, module, props.onEdit, {
+                        id: scId,
+                        name: label,
+                        error_id: getUpdateVar(updateScVar, "error_id"),
+                    })
+                );
                 setFocusName("");
             }
         },
-        [valid, props.onEdit, scId, label, id, dispatch, module]
+        [valid, props.onEdit, scId, label, id, dispatch, module, updateScVar]
     );
     const cancelLabel = useCallback(
         (e?: MouseEvent<HTMLElement>) => {
@@ -493,11 +510,17 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
         (e?: MouseEvent<HTMLElement>) => {
             e && e.stopPropagation();
             if (valid) {
-                dispatch(createSendActionNameAction(id, module, props.onEdit, { id: scId, tags: tags }));
+                dispatch(
+                    createSendActionNameAction(id, module, props.onEdit, {
+                        id: scId,
+                        tags: tags,
+                        error_id: getUpdateVar(updateScVar, "error_id"),
+                    })
+                );
                 setFocusName("");
             }
         },
-        [valid, props.onEdit, scId, tags, id, dispatch, module]
+        [valid, props.onEdit, scId, tags, id, dispatch, module, updateScVar]
     );
     const cancelTags = useCallback(
         (e?: MouseEvent<HTMLElement>) => {
@@ -532,6 +555,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                             name: label,
                             task_ids: taskIds,
                             del: !!del,
+                            error_id: getUpdateVar(updateScVar, "error_id"),
                         })
                     );
                 } else {
@@ -540,7 +564,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                 setFocusName("");
             }
         },
-        [valid, id, scId, props.onEdit, dispatch, module]
+        [valid, id, scId, props.onEdit, dispatch, module, updateScVar]
     );
     const isValidSequence = useCallback(
         (sLabel: string, label: string) => !!label && (sLabel == label || !sequences.find((seq) => seq[0] === label)),
@@ -800,6 +824,7 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                                 onFocus={onFocus}
                                 onEdit={props.onEdit}
                                 editable={scEditable}
+                                updatePropVars={updateScVar}
                             />
                             {showSequences ? (
                                 <>

+ 1 - 0
pyproject.toml

@@ -37,6 +37,7 @@ unfixable = []
 "_init.py" = ["F401", "F403"]  # unused import
 "taipy/config/stubs/pyi_header.py" = ["F401", "F403"]  # unused import
 "taipy/templates/*" = ["F401", "F403", "T201"]  # unused import, `print` found
+"taipy/gui/utils/types.py" = ["B024"] # abstract base class with no abstract methods
 
 [tool.ruff.lint.mccabe]
 max-complexity = 18

+ 7 - 0
taipy/core/_manager/_manager.py

@@ -157,6 +157,13 @@ class _Manager(Generic[EntityType]):
     def _export(cls, id: str, folder_path: Union[str, pathlib.Path], **kwargs):
         return cls._repository._export(id, folder_path)
 
+    @classmethod
+    def _import(cls, entity_file: pathlib.Path, version: str, **kwargs) -> EntityType:
+        imported_entity = cls._repository._import(entity_file)
+        imported_entity._version = version
+        cls._set(imported_entity)
+        return imported_entity
+
     @classmethod
     def _is_editable(cls, entity: Union[EntityType, str]) -> bool:
         return True

+ 28 - 0
taipy/core/_repository/_abstract_repository.py

@@ -9,10 +9,14 @@
 # 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 json
 import pathlib
 from abc import abstractmethod
 from typing import Any, Dict, Generic, Iterable, List, Optional, TypeVar, Union
 
+from ..exceptions import FileCannotBeRead
+from ._decoder import _Decoder
+
 ModelType = TypeVar("ModelType")
 Entity = TypeVar("Entity")
 
@@ -122,3 +126,27 @@ class _AbstractRepository(Generic[ModelType, Entity]):
             folder_path (Union[str, pathlib.Path]): The folder path to export the entity to.
         """
         raise NotImplementedError
+
+    def _import(self, entity_file_path: pathlib.Path) -> Entity:
+        """
+        Import an entity from an exported file.
+
+        Parameters:
+            folder_path (Union[str, pathlib.Path]): The folder path to export the entity to.
+
+        Returns:
+            The imported entity.
+        """
+        if not entity_file_path.is_file():
+            raise FileNotFoundError
+
+        try:
+            with entity_file_path.open("r", encoding="UTF-8") as f:
+                file_content = f.read()
+        except Exception:
+            raise FileCannotBeRead(str(entity_file_path)) from None
+
+        if isinstance(file_content, str):
+            file_content = json.loads(file_content, cls=_Decoder)
+        model = self.model_type.from_dict(file_content)  # type: ignore[attr-defined]
+        return self.converter._model_to_entity(model)  # type: ignore[attr-defined]

+ 15 - 0
taipy/core/_version/_version_manager.py

@@ -9,6 +9,7 @@
 # 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 pathlib
 import uuid
 from typing import List, Optional, Union
 
@@ -230,3 +231,17 @@ class _VersionManager(_Manager[_Version]):
     @classmethod
     def _delete_entities_of_multiple_types(cls, _entity_ids):
         raise NotImplementedError
+
+    @classmethod
+    def _import(cls, entity_file: pathlib.Path, version: str, **kwargs) -> _Version:
+        imported_version = cls._repository._import(entity_file)
+
+        comparator_result = Config._comparator._find_conflict_config(  # type: ignore[attr-defined]
+            imported_version.config,
+            Config._applied_config,  # type: ignore[attr-defined]
+            imported_version.id,
+        )
+        if comparator_result.get(_ComparatorResult.CONFLICTED_SECTION_KEY):
+            raise ConflictedConfigurationError()
+
+        return imported_version

+ 3 - 2
taipy/core/_version/_version_manager_factory.py

@@ -9,6 +9,8 @@
 # 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.
 
+from typing import Type
+
 from .._manager._manager_factory import _ManagerFactory
 from ..common import _utils
 from ._version_fs_repository import _VersionFSRepository
@@ -17,11 +19,10 @@ from ._version_sql_repository import _VersionSQLRepository
 
 
 class _VersionManagerFactory(_ManagerFactory):
-
     __REPOSITORY_MAP = {"default": _VersionFSRepository, "sql": _VersionSQLRepository}
 
     @classmethod
-    def _build_manager(cls) -> _VersionManager:  # type: ignore
+    def _build_manager(cls) -> Type[_VersionManager]:
         if cls._using_enterprise():
             version_manager = _utils._load_fct(
                 cls._TAIPY_ENTERPRISE_CORE_MODULE + "._version._version_manager", "_VersionManager"

+ 1 - 2
taipy/core/cycle/_cycle_manager_factory.py

@@ -19,11 +19,10 @@ from ._cycle_sql_repository import _CycleSQLRepository
 
 
 class _CycleManagerFactory(_ManagerFactory):
-
     __REPOSITORY_MAP = {"default": _CycleFSRepository, "sql": _CycleSQLRepository}
 
     @classmethod
-    def _build_manager(cls) -> Type[_CycleManager]:  # type: ignore
+    def _build_manager(cls) -> Type[_CycleManager]:
         if cls._using_enterprise():
             cycle_manager = _load_fct(
                 cls._TAIPY_ENTERPRISE_CORE_MODULE + ".cycle._cycle_manager", "_CycleManager"

+ 20 - 2
taipy/core/data/_data_manager.py

@@ -181,10 +181,28 @@ class _DataManager(_Manager[DataNode], _VersionMixin):
         else:
             folder = folder_path
 
-        data_export_dir = folder / Config.core.storage_folder
+        data_export_dir = folder / Config.core.storage_folder / os.path.dirname(data_node.path)
         if not data_export_dir.exists():
             data_export_dir.mkdir(parents=True)
 
         data_export_path = data_export_dir / os.path.basename(data_node.path)
         if os.path.exists(data_node.path):
-            shutil.copy(data_node.path, data_export_path)
+            shutil.copy2(data_node.path, data_export_path)
+
+    @classmethod
+    def _import(cls, entity_file: pathlib.Path, version: str, **kwargs) -> DataNode:
+        imported_data_node = cls._repository._import(entity_file)
+        imported_data_node._version = version
+        cls._set(imported_data_node)
+
+        if not (isinstance(imported_data_node, _FileDataNodeMixin) and isinstance(imported_data_node, DataNode)):
+            return imported_data_node
+
+        data_folder: pathlib.Path = pathlib.Path(str(kwargs.get("data_folder")))
+        if not data_folder.exists():
+            return imported_data_node
+
+        if (data_folder / imported_data_node.path).exists():
+            shutil.copy2(data_folder / imported_data_node.path, imported_data_node.path)
+
+        return imported_data_node

+ 1 - 2
taipy/core/data/_data_manager_factory.py

@@ -19,11 +19,10 @@ from ._data_sql_repository import _DataSQLRepository
 
 
 class _DataManagerFactory(_ManagerFactory):
-
     __REPOSITORY_MAP = {"default": _DataFSRepository, "sql": _DataSQLRepository}
 
     @classmethod
-    def _build_manager(cls) -> Type[_DataManager]:  # type: ignore
+    def _build_manager(cls) -> Type[_DataManager]:
         if cls._using_enterprise():
             data_manager = _load_fct(
                 cls._TAIPY_ENTERPRISE_CORE_MODULE + ".data._data_manager", "_DataManager"

+ 4 - 4
taipy/core/data/_filter.py

@@ -135,7 +135,7 @@ class _FilterDataNode:
         elif join_operator == JoinOperator.OR:
             how = "outer"
         else:
-            return NotImplementedError
+            raise NotImplementedError
 
         filtered_df_data = [
             _FilterDataNode.__filter_dataframe_per_key_value(df_data, key, value, operator)
@@ -177,7 +177,7 @@ class _FilterDataNode:
         elif join_operator == JoinOperator.OR:
             join_conditions = reduce(or_, conditions)
         else:
-            return NotImplementedError
+            raise NotImplementedError
 
         return data[join_conditions]
 
@@ -199,7 +199,7 @@ class _FilterDataNode:
         if operator == Operator.GREATER_OR_EQUAL:
             return array_data[:, key] >= value
 
-        return NotImplementedError
+        raise NotImplementedError
 
     @staticmethod
     def __filter_list(list_data: List, operators: Union[List, Tuple], join_operator=JoinOperator.AND):
@@ -218,7 +218,7 @@ class _FilterDataNode:
                 return list({frozenset(item.items()) for item in merged_list})
             return list(set(merged_list))
         else:
-            return NotImplementedError
+            raise NotImplementedError
 
     @staticmethod
     def __filter_list_per_key_value(list_data: List, key: str, value, operator: Operator):

+ 15 - 14
taipy/core/data/data_node.py

@@ -540,20 +540,21 @@ class DataNode(_Entity, _Labeled):
             or the selected data is invalid.<br/>
             True otherwise.
         """
-
-        from ..scenario.scenario import Scenario
-        from ..taipy import get_parents
-
-        parent_scenarios: Set[Scenario] = get_parents(self)["scenario"]  # type: ignore
-        for parent_scenario in parent_scenarios:
-            for ancestor_node in nx.ancestors(parent_scenario._build_dag(), self):
-                if (
-                    isinstance(ancestor_node, DataNode)
-                    and ancestor_node.last_edit_date
-                    and ancestor_node.last_edit_date > self.last_edit_date
-                ):
-                    return False
-        return self.is_valid
+        if self.is_valid:
+            from ..scenario.scenario import Scenario
+            from ..taipy import get_parents
+
+            parent_scenarios: Set[Scenario] = get_parents(self)["scenario"]  # type: ignore
+            for parent_scenario in parent_scenarios:
+                for ancestor_node in nx.ancestors(parent_scenario._build_dag(), self):
+                    if (
+                        isinstance(ancestor_node, DataNode)
+                        and ancestor_node.last_edit_date
+                        and ancestor_node.last_edit_date > self.last_edit_date
+                    ):
+                        return False
+            return True
+        return False
 
     @staticmethod
     def _class_map():

+ 35 - 8
taipy/core/exceptions/exceptions.py

@@ -261,7 +261,7 @@ class NonExistingScenarioConfig(Exception):
         self.message = f"Scenario config: {scenario_config_id} does not exist."
 
 
-class InvalidSscenario(Exception):
+class InvalidScenario(Exception):
     """Raised if a Scenario is not a Directed Acyclic Graph."""
 
     def __init__(self, scenario_id: str):
@@ -339,10 +339,6 @@ class ModeNotAvailable(Exception):
     """Raised if the mode in JobConfig is not supported."""
 
 
-class InvalidExportPath(Exception):
-    """Raised if the export path is not valid."""
-
-
 class NonExistingVersion(Exception):
     """Raised if request a Version that is not known by the Version Manager."""
 
@@ -373,16 +369,47 @@ class FileCannotBeRead(Exception):
     """Raised when a file cannot be read."""
 
 
-class ExportFolderAlreadyExists(Exception):
+class ExportPathAlreadyExists(Exception):
     """Raised when the export folder already exists."""
 
-    def __init__(self, folder_path: str, scenario_id: str):
+    def __init__(self, export_path: str, scenario_id: str):
         self.message = (
-            f"Folder '{folder_path}' already exists and can not be used to export scenario '{scenario_id}'."
+            f"The path '{export_path}' already exists and can not be used to export scenario '{scenario_id}'."
             " Please use the 'override' parameter to override it."
         )
 
 
+class EntitiesToBeImportAlredyExist(Exception):
+    """Raised when entities in the scenario to be imported have already exists"""
+
+    def __init__(self, import_path):
+        self.message = f"The import archive file {import_path} contains entities that have already existed."
+
+
+class DataToBeImportAlredyExist(Exception):
+    """Raised when data files in the scenario to be imported have already exists"""
+
+    def __init__(self, import_path):
+        self.message = (
+            f"The import archive file {import_path} contains data files that have already existed."
+            " Please use the 'override' parameter to override those."
+        )
+
+
+class ImportArchiveDoesntContainAnyScenario(Exception):
+    """Raised when the import archive file doesn't contain any scenario"""
+
+    def __init__(self, import_path):
+        self.message = f"The import archive file {import_path} doesn't contain any scenario."
+
+
+class ImportScenarioDoesntHaveAVersion(Exception):
+    """Raised when the import scenario doesn't have a version"""
+
+    def __init__(self, import_path):
+        self.message = f"The import scenario in the import archive file {import_path} doesn't have a version."
+
+
 class SQLQueryCannotBeExecuted(Exception):
     """Raised when an SQL Query cannot be executed."""
 

+ 3 - 1
taipy/core/job/_job_manager.py

@@ -58,7 +58,9 @@ class _JobManager(_Manager[Job], _VersionMixin):
         return job
 
     @classmethod
-    def _delete(cls, job: Job, force=False):
+    def _delete(cls, job: Union[Job, JobId], force=False):
+        if isinstance(job, str):
+            job = cls._get(job)
         if cls._is_deletable(job) or force:
             super()._delete(job.id)
         else:

+ 1 - 2
taipy/core/job/_job_manager_factory.py

@@ -19,11 +19,10 @@ from ._job_sql_repository import _JobSQLRepository
 
 
 class _JobManagerFactory(_ManagerFactory):
-
     __REPOSITORY_MAP = {"default": _JobFSRepository, "sql": _JobSQLRepository}
 
     @classmethod
-    def _build_manager(cls) -> Type[_JobManager]:  # type: ignore
+    def _build_manager(cls) -> Type[_JobManager]:
         if cls._using_enterprise():
             job_manager = _load_fct(
                 cls._TAIPY_ENTERPRISE_CORE_MODULE + ".job._job_manager", "_JobManager"

+ 92 - 3
taipy/core/scenario/_scenario_manager.py

@@ -10,14 +10,18 @@
 # specific language governing permissions and limitations under the License.
 
 import datetime
+import pathlib
+import tempfile
+import zipfile
 from functools import partial
-from typing import Any, Callable, List, Literal, Optional, Union
+from typing import Any, Callable, Dict, List, Literal, Optional, Type, Union
 
 from taipy.config import Config
 
 from .._entity._entity_ids import _EntityIds
 from .._manager._manager import _Manager
 from .._repository._abstract_repository import _AbstractRepository
+from .._version._version_manager_factory import _VersionManagerFactory
 from .._version._version_mixin import _VersionMixin
 from ..common.warn_if_inputs_not_ready import _warn_if_inputs_not_ready
 from ..config.scenario_config import ScenarioConfig
@@ -28,9 +32,12 @@ from ..exceptions.exceptions import (
     DeletingPrimaryScenario,
     DifferentScenarioConfigs,
     DoesNotBelongToACycle,
+    EntitiesToBeImportAlredyExist,
+    ImportArchiveDoesntContainAnyScenario,
+    ImportScenarioDoesntHaveAVersion,
     InsufficientScenarioToCompare,
+    InvalidScenario,
     InvalidSequence,
-    InvalidSscenario,
     NonExistingComparator,
     NonExistingScenario,
     NonExistingScenarioConfig,
@@ -180,7 +187,7 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
         cls._set(scenario)
 
         if not scenario._is_consistent():
-            raise InvalidSscenario(scenario.id)
+            raise InvalidScenario(scenario.id)
 
         actual_sequences = scenario._get_sequences()
         for sequence_name in sequences.keys():
@@ -451,3 +458,85 @@ class _ScenarioManager(_Manager[Scenario], _VersionMixin):
         for fil in filters:
             fil.update({"config_id": config_id})
         return cls._repository._load_all(filters)
+
+    @classmethod
+    def _import_scenario_and_children_entities(
+        cls,
+        zip_file_path: pathlib.Path,
+        override: bool,
+        entity_managers: Dict[str, Type[_Manager]],
+    ) -> Optional[Scenario]:
+        with tempfile.TemporaryDirectory() as tmp_dir:
+            with zipfile.ZipFile(zip_file_path) as zip_file:
+                zip_file.extractall(tmp_dir)
+
+            tmp_dir_path = pathlib.Path(tmp_dir)
+
+            if not ((tmp_dir_path / "scenarios").exists() or (tmp_dir_path / "scenario").exists()):
+                raise ImportArchiveDoesntContainAnyScenario(zip_file_path)
+
+            if not (tmp_dir_path / "version").exists():
+                raise ImportScenarioDoesntHaveAVersion(zip_file_path)
+
+            # Import the version to check for compatibility
+            entity_managers["version"]._import(next((tmp_dir_path / "version").iterdir()), "")
+
+            valid_entity_folders = list(entity_managers.keys())
+            valid_data_folder = Config.core.storage_folder
+
+            imported_scenario = None
+            imported_entities: Dict[str, List] = {}
+
+            for entity_folder in tmp_dir_path.iterdir():
+                if not entity_folder.is_dir() or entity_folder.name not in valid_entity_folders + [valid_data_folder]:
+                    cls._logger.warning(f"{entity_folder} is not a valid Taipy folder and will not be imported.")
+                    continue
+
+            try:
+                for entity_type in valid_entity_folders:
+                    # Skip the version folder as it is already handled
+                    if entity_type == "version":
+                        continue
+
+                    entity_folder = tmp_dir_path / entity_type
+                    if not entity_folder.exists():
+                        continue
+
+                    manager = entity_managers[entity_type]
+                    imported_entities[entity_type] = []
+
+                    for entity_file in entity_folder.iterdir():
+                        # Check if the to-be-imported entity already exists
+                        entity_id = entity_file.stem
+                        if manager._exists(entity_id):
+                            if override:
+                                cls._logger.warning(f"{entity_id} already exists and will be overridden.")
+                            else:
+                                cls._logger.error(
+                                    f"{entity_id} already exists. Please use the 'override' parameter to override it."
+                                )
+                                raise EntitiesToBeImportAlredyExist(zip_file_path)
+
+                        # Import the entity
+                        imported_entity = manager._import(
+                            entity_file,
+                            version=_VersionManagerFactory._build_manager()._get_latest_version(),
+                            data_folder=tmp_dir_path / valid_data_folder,
+                        )
+
+                        imported_entities[entity_type].append(imported_entity.id)
+                        if entity_type in ["scenario", "scenarios"]:
+                            imported_scenario = imported_entity
+            except Exception as err:
+                cls._logger.error(f"An error occurred during the import: {err}. Rollback the import.")
+
+                # Rollback the import
+                for entity_type, entity_ids in list(imported_entities.items())[::-1]:
+                    manager = entity_managers[entity_type]
+                    for entity_id in entity_ids:
+                        if manager._exists(entity_id):
+                            manager._delete(entity_id)
+                raise err
+
+        cls._logger.info(f"Scenario {imported_scenario.id} has been successfully imported.")  # type: ignore[union-attr]
+        return imported_scenario

+ 1 - 2
taipy/core/scenario/_scenario_manager_factory.py

@@ -19,11 +19,10 @@ from ._scenario_sql_repository import _ScenarioSQLRepository
 
 
 class _ScenarioManagerFactory(_ManagerFactory):
-
     __REPOSITORY_MAP = {"default": _ScenarioFSRepository, "sql": _ScenarioSQLRepository}
 
     @classmethod
-    def _build_manager(cls) -> Type[_ScenarioManager]:  # type: ignore
+    def _build_manager(cls) -> Type[_ScenarioManager]:
         if cls._using_enterprise():
             scenario_manager = _load_fct(
                 cls._TAIPY_ENTERPRISE_CORE_MODULE + ".scenario._scenario_manager", "_ScenarioManager"

+ 1 - 2
taipy/core/submission/_submission_manager_factory.py

@@ -19,11 +19,10 @@ from ._submission_sql_repository import _SubmissionSQLRepository
 
 
 class _SubmissionManagerFactory(_ManagerFactory):
-
     __REPOSITORY_MAP = {"default": _SubmissionFSRepository, "sql": _SubmissionSQLRepository}
 
     @classmethod
-    def _build_manager(cls) -> Type[_SubmissionManager]:  # type: ignore
+    def _build_manager(cls) -> Type[_SubmissionManager]:
         if cls._using_enterprise():
             submission_manager = _load_fct(
                 cls._TAIPY_ENTERPRISE_CORE_MODULE + ".submission._submission_manager", "_SubmissionManager"

+ 111 - 57
taipy/core/taipy.py

@@ -12,14 +12,16 @@
 import os
 import pathlib
 import shutil
+import tempfile
 from datetime import datetime
-from typing import Any, Callable, Dict, List, Literal, Optional, Set, Union, overload
+from typing import Any, Callable, Dict, List, Literal, Optional, Set, Type, Union, overload
 
-from taipy.config import Config, Scope
+from taipy.config import Scope
 from taipy.logger._taipy_logger import _TaipyLogger
 
 from ._core import Core
 from ._entity._entity import _Entity
+from ._manager._manager import _Manager
 from ._version._version_manager_factory import _VersionManagerFactory
 from .common._check_instance import (
     _is_cycle,
@@ -41,8 +43,7 @@ from .data.data_node import DataNode
 from .data.data_node_id import DataNodeId
 from .exceptions.exceptions import (
     DataNodeConfigIsNotGlobal,
-    ExportFolderAlreadyExists,
-    InvalidExportPath,
+    ExportPathAlreadyExists,
     ModelNotFound,
     NonExistingVersion,
     VersionIsNotProductionVersion,
@@ -65,7 +66,7 @@ from .task.task_id import TaskId
 __logger = _TaipyLogger._get_logger()
 
 
-def set(entity: Union[DataNode, Task, Sequence, Scenario, Cycle]):
+def set(entity: Union[DataNode, Task, Sequence, Scenario, Cycle, Submission]):
     """Save or update an entity.
 
     This function allows you to save or update an entity in Taipy.
@@ -518,24 +519,25 @@ def get_scenarios(
     """Retrieve a list of existing scenarios filtered by cycle or tag.
 
     This function allows you to retrieve a list of scenarios based on optional
-    filtering criteria. If both a _cycle_ and a _tag_ are provided, the returned
-    list contains scenarios that belong to the specified _cycle_ **and** also
-    have the specified _tag_.
+    filtering criteria. If both a *cycle* and a *tag* are provided, the returned
+    list contains scenarios that belong to the specified *cycle* and also
+    have the specified *tag*.
 
     Parameters:
-         cycle (Optional[Cycle^]): The optional `Cycle^` to filter scenarios by.
-         tag (Optional[str]): The optional tag to filter scenarios by.
-         is_sorted (bool): The option to sort scenarios. The default sorting key is name.
-         descending (bool): The option to sort scenarios on the sorting key in descending order.
-         sort_key (Literal["name", "id", "creation_date", "tags"]): The optiononal sort_key to
-             decide upon what key scenarios are sorted. The sorting is in increasing order for
-             dates, in alphabetical order for name and id, in lexographical order for tags.
+        cycle (Optional[Cycle^]): The optional `Cycle^` to filter scenarios by.
+        tag (Optional[str]): The optional tag to filter scenarios by.
+        is_sorted (bool): If True, sort the output list of scenarios using the sorting key.
+            The default value is False.
+        descending (bool): If True, sort the output list of scenarios in descending order.
+            The default value is False.
+        sort_key (Literal["name", "id", "creation_date", "tags"]): The optiononal sort_key to
+            decide upon what key scenarios are sorted. The sorting is in increasing order for
+            dates, in alphabetical order for name and id, and in lexicographical order for tags.
+            The default value is "name".<br/>
+            If an incorrect sorting key is provided, the scenarios are sorted by name.
 
     Returns:
-        The list of scenarios filtered by cycle or tag and optionally sorted by name, id, creation_date or tags.
-            If no filtering criterion is provided, this method returns all existing scenarios.
-            If is_sorted is set to true, the scenarios are sorted by sort_key. The scenarios
-            are sorted by name if an incorrect or no sort_key is provided.
+        The list of scenarios filtered by cycle or tag.
     """
     scenario_manager = _ScenarioManagerFactory._build_manager()
     if not cycle and not tag:
@@ -576,17 +578,18 @@ def get_primary_scenarios(
     """Retrieve a list of all primary scenarios.
 
     Parameters:
-         is_sorted (bool): The option to sort scenarios. The default sorting key is name.
-         descending (bool): The option to sort scenarios on the sorting key in descending order.
-         sort_key (Literal["name", "id", "creation_date", "tags"]): The optiononal sort_key to
-             decide upon what key scenarios are sorted. The sorting is in increasing order for
-             dates, in alphabetical order for name and id, in lexographical order for tags.
+        is_sorted (bool): If True, sort the output list of scenarios using the sorting key.
+            The default value is False.
+        descending (bool): If True, sort the output list of scenarios in descending order.
+            The default value is False.
+        sort_key (Literal["name", "id", "creation_date", "tags"]): The optiononal sort_key to
+            decide upon what key scenarios are sorted. The sorting is in increasing order for
+            dates, in alphabetical order for name and id, and in lexicographical order for tags.
+            The default value is "name".<br/>
+            If an incorrect sorting key is provided, the scenarios are sorted by name.
 
     Returns:
-        The list containing all primary scenarios, optionally sorted by name, id, creation_date or tags.
-            The sorting is in increasing order for dates, in alphabetical order for name and
-            id, and in lexicographical order for tags. If sorted is set to true, but if an
-            incorrect or no sort_key is provided, the scenarios are sorted by name.
+        A list contains all primary scenarios.
     """
     scenario_manager = _ScenarioManagerFactory._build_manager()
     scenarios = scenario_manager._get_primary_scenarios()
@@ -595,7 +598,6 @@ def get_primary_scenarios(
     return scenarios
 
 
-
 def is_promotable(scenario: Union[Scenario, ScenarioId]) -> bool:
     """Determine if a scenario can be promoted to become a primary scenario.
 
@@ -981,27 +983,28 @@ def clean_all_entities(version_number: str) -> bool:
 
 def export_scenario(
     scenario_id: ScenarioId,
-    folder_path: Union[str, pathlib.Path],
+    output_path: Union[str, pathlib.Path],
     override: bool = False,
     include_data: bool = False,
 ):
-    """Export all related entities of a scenario to a folder.
+    """Export all related entities of a scenario to a archive zip file.
 
     This function exports all related entities of the specified scenario to the
-    specified folder.
+    specified archive zip file.
 
     Parameters:
         scenario_id (ScenarioId): The ID of the scenario to export.
-        folder_path (Union[str, pathlib.Path]): The folder path to export the scenario to.
+        output_path (Union[str, pathlib.Path]): The path to export the scenario to.
+            The path should include the file name without the extension or with the `.zip` extension.
             If the path exists and the override parameter is False, an exception is raised.
-        override (bool): If True, the existing folder will be overridden. Default is False.
+        override (bool): If True, the existing folder will be overridden. The default value is False.
         include_data (bool): If True, the file-based data nodes are exported as well.
             This includes Pickle, CSV, Excel, Parquet, and JSON data nodes.
             If the scenario has a data node that is not file-based, a warning will be logged, and the data node
             will not be exported. The default value is False.
 
     Raises:
-        ExportFolderAlreadyExist^: If the `folder_path` already exists and the override parameter is False.
+        ExportPathAlreadyExists^: If the `output_path` already exists and the override parameter is False.
     """
     manager = _ScenarioManagerFactory._build_manager()
     scenario = manager._get(scenario_id)
@@ -1010,31 +1013,82 @@ def export_scenario(
     if scenario.cycle:
         entity_ids.cycle_ids = {scenario.cycle.id}
 
-    if folder_path == Config.core.taipy_storage_folder:
-        raise InvalidExportPath("The export folder must not be the storage folder.")
+    output_filename = os.path.splitext(output_path)[0] if str(output_path).endswith(".zip") else str(output_path)
+    output_zip_path = pathlib.Path(output_filename + ".zip")
 
-    if os.path.exists(folder_path):
+    if output_zip_path.exists():
         if override:
-            __logger.warning(f"Override the existing folder '{folder_path}'")
-            shutil.rmtree(folder_path, ignore_errors=True)
+            __logger.warning(f"Override the existing path '{output_zip_path}' to export scenario {scenario_id}.")
+            output_zip_path.unlink()
         else:
-            raise ExportFolderAlreadyExists(str(folder_path), scenario_id)
-
-    for data_node_id in entity_ids.data_node_ids:
-        _DataManagerFactory._build_manager()._export(data_node_id, folder_path, include_data=include_data)
-    for task_id in entity_ids.task_ids:
-        _TaskManagerFactory._build_manager()._export(task_id, folder_path)
-    for sequence_id in entity_ids.sequence_ids:
-        _SequenceManagerFactory._build_manager()._export(sequence_id, folder_path)
-    for cycle_id in entity_ids.cycle_ids:
-        _CycleManagerFactory._build_manager()._export(cycle_id, folder_path)
-    for scenario_id in entity_ids.scenario_ids:
-        _ScenarioManagerFactory._build_manager()._export(scenario_id, folder_path)
-    for job_id in entity_ids.job_ids:
-        _JobManagerFactory._build_manager()._export(job_id, folder_path)
-    for submission_id in entity_ids.submission_ids:
-        _SubmissionManagerFactory._build_manager()._export(submission_id, folder_path)
-    _VersionManagerFactory._build_manager()._export(scenario.version, folder_path)
+            raise ExportPathAlreadyExists(str(output_zip_path), scenario_id)
+
+    with tempfile.TemporaryDirectory() as tmp_dir:
+        for data_node_id in entity_ids.data_node_ids:
+            _DataManagerFactory._build_manager()._export(data_node_id, tmp_dir, include_data=include_data)
+        for task_id in entity_ids.task_ids:
+            _TaskManagerFactory._build_manager()._export(task_id, tmp_dir)
+        for sequence_id in entity_ids.sequence_ids:
+            _SequenceManagerFactory._build_manager()._export(sequence_id, tmp_dir)
+        for cycle_id in entity_ids.cycle_ids:
+            _CycleManagerFactory._build_manager()._export(cycle_id, tmp_dir)
+        for scenario_id in entity_ids.scenario_ids:
+            _ScenarioManagerFactory._build_manager()._export(scenario_id, tmp_dir)
+        for job_id in entity_ids.job_ids:
+            _JobManagerFactory._build_manager()._export(job_id, tmp_dir)
+        for submission_id in entity_ids.submission_ids:
+            _SubmissionManagerFactory._build_manager()._export(submission_id, tmp_dir)
+        _VersionManagerFactory._build_manager()._export(scenario.version, tmp_dir)
+
+        shutil.make_archive(output_filename, "zip", tmp_dir)
+
+
+def import_scenario(input_path: Union[str, pathlib.Path], override: bool = False) -> Optional[Scenario]:
+    """Import from an archive zip file containing an exported scenario into the current Taipy application.
+
+    The zip file should be created by the `taipy.import()^` method, which contains all related entities
+    of the scenario.
+    All entities should belong to the same version that is compatible with the current Taipy application version.
+
+    Parameters:
+        input_path (Union[str, pathlib.Path]): The path to the archive scenario to import.
+            If the path doesn't exist, an exception is raised.
+        override (bool): If True, override the entities if existed. The default value is False.
+
+    Return:
+        The imported scenario.
+
+    Raises:
+        FileNotFoundError: If the import path does not exist.
+        ImportArchiveDoesntContainAnyScenario: If the unzip folder doesn't contain any scenario.
+        ConflictedConfigurationError: If the configuration of the imported scenario is conflicted with the current one.
+    """
+    if isinstance(input_path, str):
+        zip_file_path: pathlib.Path = pathlib.Path(input_path)
+    else:
+        zip_file_path = input_path
+
+    if not zip_file_path.exists():
+        raise FileNotFoundError(f"The import archive path '{zip_file_path}' does not exist.")
+
+    entity_managers: Dict[str, Type[_Manager]] = {
+        "cycles": _CycleManagerFactory._build_manager(),
+        "cycle": _CycleManagerFactory._build_manager(),
+        "data_nodes": _DataManagerFactory._build_manager(),
+        "data_node": _DataManagerFactory._build_manager(),
+        "tasks": _TaskManagerFactory._build_manager(),
+        "task": _TaskManagerFactory._build_manager(),
+        "scenarios": _ScenarioManagerFactory._build_manager(),
+        "scenario": _ScenarioManagerFactory._build_manager(),
+        "jobs": _JobManagerFactory._build_manager(),
+        "job": _JobManagerFactory._build_manager(),
+        "submission": _SubmissionManagerFactory._build_manager(),
+        "version": _VersionManagerFactory._build_manager(),
+    }
+
+    return _ScenarioManagerFactory._build_manager()._import_scenario_and_children_entities(
+        zip_file_path, override, entity_managers
+    )
 
 
 def get_parents(

+ 1 - 2
taipy/core/task/_task_manager_factory.py

@@ -19,11 +19,10 @@ from ._task_sql_repository import _TaskSQLRepository
 
 
 class _TaskManagerFactory(_ManagerFactory):
-
     __REPOSITORY_MAP = {"default": _TaskFSRepository, "sql": _TaskSQLRepository}
 
     @classmethod
-    def _build_manager(cls) -> Type[_TaskManager]:  # type: ignore
+    def _build_manager(cls) -> Type[_TaskManager]:
         if cls._using_enterprise():
             task_manager = _load_fct(
                 cls._TAIPY_ENTERPRISE_CORE_MODULE + ".task._task_manager", "_TaskManager"

+ 5 - 2
taipy/gui/_renderers/builder.py

@@ -1005,12 +1005,14 @@ class _Builder:
             elif var_type == PropertyType.toHtmlContent:
                 self.__set_html_content(attr[0], "page", var_type)
             elif isclass(var_type) and issubclass(var_type, _TaipyBase):
+                prop_name = _to_camel_case(attr[0])
                 if hash_name := self.__hashes.get(attr[0]):
-                    prop_name = _to_camel_case(attr[0])
                     expr = self.__gui._get_expr_from_hash(hash_name)
                     hash_name = self.__gui._evaluate_bind_holder(var_type, expr)
                     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())
 
         self.__set_refresh_on_update()
         return self
@@ -1025,7 +1027,8 @@ class _Builder:
             name (str): The name of the attribute.
             value (Any): The value of the attribute (must be json serializable).
         """
-        self.el.set(name, value)
+        if value is not None:
+            self.el.set(name, value)
         return self
 
     def get_element(self):

+ 1 - 1
taipy/gui/data/utils.py

@@ -79,7 +79,7 @@ class Decimator(ABC):
                 from *data* should be preserved, or False requires that this
                 data point be dropped.
         """
-        return NotImplementedError  # type: ignore
+        raise NotImplementedError
 
 
 def _df_data_filter(

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

@@ -159,12 +159,17 @@ class Element:
         properties: t.Optional[t.Dict[str, t.Any]],
         lib: "ElementLibrary",
         is_html: t.Optional[bool] = False,
-        counter: int = 0
+        counter: int = 0,
     ) -> t.Union[t.Any, t.Tuple[str, str]]:
         attributes = properties if isinstance(properties, dict) else {}
         if self.inner_properties:
             uniques: t.Dict[str, int] = {}
-            self.attributes.update(self.inner_properties)
+            self.attributes.update(
+                {
+                    prop: ElementProperty(attr.property_type, None, attr._js_name, attr.with_update)
+                    for prop, attr in self.inner_properties.items()
+                }
+            )
             for prop, attr in self.inner_properties.items():
                 val = attr.default_value
                 if val:
@@ -184,7 +189,9 @@ class Element:
                         if id is None:
                             id = len(uniques) + 1
                             uniques[m.group(1)] = id
-                        val = f"{val[: m.start()]}'{counter}.{id}'{val[m.end() :]}"
+                        val = f"{val[: m.start()]}{counter}{id}{val[m.end() :]}"
+                        if gui._is_expression(val):
+                            gui._evaluate_expr(val, True)
 
                 attributes[prop] = val
         # this modifies attributes
@@ -286,7 +293,7 @@ class ElementLibrary(ABC):
             because each JavaScript module will have to have a unique name.
 
         """
-        return NotImplementedError  # type: ignore
+        raise NotImplementedError
 
     def get_js_module_name(self) -> str:
         """

+ 3 - 3
taipy/gui/gui.py

@@ -1031,7 +1031,7 @@ class Gui:
                     json.dumps(newvalue, cls=_TaipyJsonEncoder)
                     if len(warns):
                         keep_value = True
-                        for w in list(warns):
+                        for w in warns:
                             if is_debugging():
                                 debug_warnings.append(w)
                             if w.category is not DeprecationWarning and w.category is not PendingDeprecationWarning:
@@ -1468,8 +1468,8 @@ class Gui:
             return False
 
     # Proxy methods for Evaluator
-    def _evaluate_expr(self, expr: str) -> t.Any:
-        return self.__evaluator.evaluate_expr(self, expr)
+    def _evaluate_expr(self, expr: str, lazy_declare: t.Optional[bool] = False) -> t.Any:
+        return self.__evaluator.evaluate_expr(self, expr, lazy_declare)
 
     def _re_evaluate_expr(self, var_name: str) -> t.Set[str]:
         return self.__evaluator.re_evaluate_expr(self, var_name)

+ 3 - 2
taipy/gui/utils/_adapter.py

@@ -12,6 +12,7 @@
 from __future__ import annotations
 
 import typing as t
+from operator import add
 
 from .._warnings import _warn
 from ..icon import Icon
@@ -129,8 +130,8 @@ class _Adapter:
                 if not id_only and len(tpl_res) > 2 and isinstance(tpl_res[2], list) and len(tpl_res[2]) > 0:
                     tpl_res = (tpl_res[0], tpl_res[1], self.__on_tree(adapter, tpl_res[2]))
                 return (
-                    (tpl_res + result[len(tpl_res) :])
-                    if isinstance(result, tuple) and isinstance(tpl_res, tuple)
+                    add(type(result)(tpl_res), result[len(tpl_res) :])
+                    if isinstance(result, (tuple, list)) and isinstance(tpl_res, (tuple, list))
                     else tpl_res
                 )
         except Exception as e:

+ 15 - 5
taipy/gui/utils/_evaluator.py

@@ -15,8 +15,9 @@ import ast
 import builtins
 import re
 import typing as t
+import warnings
 
-from .._warnings import _warn
+from .._warnings import TaipyGuiWarning, _warn
 
 if t.TYPE_CHECKING:
     from ..gui import Gui
@@ -84,7 +85,9 @@ class _Evaluator:
     def _fetch_expression_list(self, expr: str) -> t.List:
         return [v[0] for v in _Evaluator.__EXPR_RE.findall(expr)]
 
-    def _analyze_expression(self, gui: Gui, expr: str) -> t.Tuple[t.Dict[str, t.Any], t.Dict[str, str]]:
+    def _analyze_expression(
+        self, gui: Gui, expr: str, lazy_declare: t.Optional[bool] = False
+    ) -> t.Tuple[t.Dict[str, t.Any], t.Dict[str, str]]:
         var_val: t.Dict[str, t.Any] = {}
         var_map: t.Dict[str, str] = {}
         non_vars = list(self.__global_ctx.keys())
@@ -105,7 +108,14 @@ class _Evaluator:
                     var_name = node.id.split(sep=".")[0]
                     if var_name not in args and var_name not in targets and var_name not in non_vars:
                         try:
-                            encoded_var_name = gui._bind_var(var_name)
+                            if lazy_declare and var_name.startswith("__"):
+                                with warnings.catch_warnings(record=True) as warns:
+                                    warnings.resetwarnings()
+                                    encoded_var_name = gui._bind_var(var_name)
+                                    if next((w for w in warns if w.category is TaipyGuiWarning), None):
+                                        gui._bind_var_val(var_name, None)
+                            else:
+                                encoded_var_name = gui._bind_var(var_name)
                             var_val[var_name] = _getscopeattr_drill(gui, encoded_var_name)
                             var_map[var_name] = encoded_var_name
                         except AttributeError as e:
@@ -200,10 +210,10 @@ class _Evaluator:
             _warn(f"Cannot evaluate expression {holder.__name__}({expr_hash},'{expr_hash}') for {expr}", e)
         return None
 
-    def evaluate_expr(self, gui: Gui, expr: str) -> t.Any:
+    def evaluate_expr(self, gui: Gui, expr: str, lazy_declare: t.Optional[bool] = False) -> t.Any:
         if not self._is_expression(expr):
             return expr
-        var_val, var_map = self._analyze_expression(gui, expr)
+        var_val, var_map = self._analyze_expression(gui, expr, lazy_declare)
         expr_hash = None
         is_edge_case = False
 

+ 3 - 2
taipy/gui/utils/types.py

@@ -12,7 +12,7 @@
 
 import json
 import typing as t
-from abc import ABC
+from abc import ABC, abstractmethod
 from datetime import datetime
 from importlib.util import find_spec
 
@@ -55,8 +55,9 @@ class _TaipyBase(ABC):
             return self.__hash_name
 
     @staticmethod
+    @abstractmethod
     def get_hash():
-        return NotImplementedError
+        raise NotImplementedError
 
     @staticmethod
     def _get_holder_prefixes() -> t.List[str]:

+ 63 - 46
taipy/gui_core/_GuiCoreLib.py

@@ -12,7 +12,7 @@
 import typing as t
 from datetime import datetime
 
-from taipy.gui import Gui, State
+from taipy.gui import Gui
 from taipy.gui.extension import Element, ElementLibrary, ElementProperty, PropertyType
 
 from ..version import _get_version
@@ -20,6 +20,7 @@ from ._adapters import (
     _GuiCoreDatanodeAdapter,
     _GuiCoreScenarioAdapter,
     _GuiCoreScenarioDagAdapter,
+    _GuiCoreScenarioProperties,
 )
 from ._context import _GuiCoreContext
 
@@ -30,18 +31,21 @@ class _GuiCore(ElementLibrary):
     __SCENARIO_ADAPTER = "tgc_scenario"
     __DATANODE_ADAPTER = "tgc_datanode"
     __JOB_ADAPTER = "tgc_job"
-    __INNER_VARS = (
-        _GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR,
-        _GuiCoreContext._SCENARIO_SELECTOR_ID_VAR,
-        _GuiCoreContext._SCENARIO_VIZ_ERROR_VAR,
-        _GuiCoreContext._JOB_SELECTOR_ERROR_VAR,
-        _GuiCoreContext._DATANODE_VIZ_ERROR_VAR,
-        _GuiCoreContext._DATANODE_VIZ_OWNER_ID_VAR,
-        _GuiCoreContext._DATANODE_VIZ_HISTORY_ID_VAR,
-        _GuiCoreContext._DATANODE_VIZ_DATA_ID_VAR,
-        _GuiCoreContext._DATANODE_VIZ_DATA_CHART_ID_VAR,
-        _GuiCoreContext._DATANODE_VIZ_PROPERTIES_ID_VAR,
-    )
+
+    __SCENARIO_SELECTOR_ERROR_VAR = "__tpgc_sc_error"
+    __SCENARIO_SELECTOR_ID_VAR = "__tpgc_sc_id"
+    __SCENARIO_SELECTOR_FILTER_VAR = "__tpgc_sc_filter"
+    __SCENARIO_VIZ_ERROR_VAR = "__tpgc_sv_error"
+    __JOB_SELECTOR_ERROR_VAR = "__tpgc_js_error"
+    __DATANODE_VIZ_ERROR_VAR = "__tpgc_dv_error"
+    __DATANODE_VIZ_OWNER_ID_VAR = "__tpgc_dv_owner_id"
+    __DATANODE_VIZ_HISTORY_ID_VAR = "__tpgc_dv_history_id"
+    __DATANODE_VIZ_PROPERTIES_ID_VAR = "__tpgc_dv_properties_id"
+    __DATANODE_VIZ_DATA_ID_VAR = "__tpgc_dv_data_id"
+    __DATANODE_VIZ_DATA_CHART_ID_VAR = "__tpgc_dv_data_chart_id"
+    __DATANODE_VIZ_DATA_NODE_PROP = "data_node"
+    __DATANODE_SEL_SCENARIO_PROP = "scenario"
+    __SEL_SCENARIOS_PROP = "scenarios"
 
     __elts = {
         "scenario_selector": Element(
@@ -58,24 +62,34 @@ class _GuiCore(ElementLibrary):
                 "show_pins": ElementProperty(PropertyType.boolean, False),
                 "on_creation": ElementProperty(PropertyType.function),
                 "show_dialog": ElementProperty(PropertyType.boolean, True),
-                _GuiCoreContext._SEL_SCENARIOS_PROP: ElementProperty(PropertyType.dynamic_list),
+                __SEL_SCENARIOS_PROP: ElementProperty(PropertyType.dynamic_list),
                 "multiple": ElementProperty(PropertyType.boolean, False),
+                "filter_by": ElementProperty(_GuiCoreScenarioProperties),
             },
             inner_properties={
                 "inner_scenarios": ElementProperty(
                     PropertyType.lov,
-                    f"{{{__CTX_VAR_NAME}.get_scenarios(<tp:prop:{_GuiCoreContext._SEL_SCENARIOS_PROP}>)}}",
+                    f"{{{__CTX_VAR_NAME}.get_scenarios(<tp:prop:{__SEL_SCENARIOS_PROP}>, "
+                    + f"{__SCENARIO_SELECTOR_FILTER_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()}}"),
                 "core_changed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._CORE_CHANGED_NAME),
-                "error": ElementProperty(PropertyType.react, f"{{{_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR}}}"),
+                "error": ElementProperty(
+                    PropertyType.react, f"{{{__SCENARIO_SELECTOR_ERROR_VAR}<tp:uniq:sc>}}"
+                ),
                 "type": ElementProperty(PropertyType.inner, __SCENARIO_ADAPTER),
                 "scenario_edit": ElementProperty(
                     _GuiCoreScenarioAdapter,
-                    f"{{{__CTX_VAR_NAME}.get_scenario_by_id({_GuiCoreContext._SCENARIO_SELECTOR_ID_VAR})}}",
+                    f"{{{__CTX_VAR_NAME}.get_scenario_by_id({__SCENARIO_SELECTOR_ID_VAR}<tp:uniq:sc>)}}",
                 ),
                 "on_scenario_select": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.select_scenario}}"),
+                "update_sc_vars": ElementProperty(
+                    PropertyType.string,
+                    f"filter={__SCENARIO_SELECTOR_FILTER_VAR}<tp:uniq:sc>;"
+                    + f"sc_id={__SCENARIO_SELECTOR_ID_VAR}<tp:uniq:sc>;"
+                    + f"error_id={__SCENARIO_SELECTOR_ERROR_VAR}<tp:uniq:sc>",
+                ),
             },
         ),
         "scenario": Element(
@@ -102,7 +116,13 @@ class _GuiCore(ElementLibrary):
                 "on_submit": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.submit_entity}}"),
                 "on_delete": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.crud_scenario}}"),
                 "core_changed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._CORE_CHANGED_NAME),
-                "error": ElementProperty(PropertyType.react, f"{{{_GuiCoreContext._SCENARIO_VIZ_ERROR_VAR}}}"),
+                "error": ElementProperty(
+                    PropertyType.react, f"{{{__SCENARIO_VIZ_ERROR_VAR}<tp:uniq:sv>}}"
+                ),
+                "update_sc_vars": ElementProperty(
+                    PropertyType.string,
+                    f"error_id={__SCENARIO_SELECTOR_ERROR_VAR}<tp:uniq:sv>",
+                ),
             },
         ),
         "scenario_dag": Element(
@@ -133,23 +153,23 @@ class _GuiCore(ElementLibrary):
                 "height": ElementProperty(PropertyType.string, "50vh"),
                 "class_name": ElementProperty(PropertyType.dynamic_string),
                 "show_pins": ElementProperty(PropertyType.boolean, True),
-                _GuiCoreContext._DATANODE_SEL_SCENARIO_PROP: ElementProperty(PropertyType.dynamic_list),
+                __DATANODE_SEL_SCENARIO_PROP: ElementProperty(PropertyType.dynamic_list),
                 "multiple": ElementProperty(PropertyType.boolean, False),
             },
             inner_properties={
                 "datanodes": ElementProperty(
                     PropertyType.lov,
-                    f"{{{__CTX_VAR_NAME}.get_datanodes_tree(<tp:prop:{_GuiCoreContext._DATANODE_SEL_SCENARIO_PROP}>)}}",
+                    f"{{{__CTX_VAR_NAME}.get_datanodes_tree(<tp:prop:{__DATANODE_SEL_SCENARIO_PROP}>)}}",
                 ),
                 "core_changed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._CORE_CHANGED_NAME),
                 "type": ElementProperty(PropertyType.inner, __DATANODE_ADAPTER),
             },
         ),
         "data_node": Element(
-            _GuiCoreContext._DATANODE_VIZ_DATA_NODE_PROP,
+            __DATANODE_VIZ_DATA_NODE_PROP,
             {
                 "id": ElementProperty(PropertyType.string),
-                _GuiCoreContext._DATANODE_VIZ_DATA_NODE_PROP: ElementProperty(_GuiCoreDatanodeAdapter),
+                __DATANODE_VIZ_DATA_NODE_PROP: ElementProperty(_GuiCoreDatanodeAdapter),
                 "active": ElementProperty(PropertyType.dynamic_boolean, True),
                 "expandable": ElementProperty(PropertyType.boolean, True),
                 "expanded": ElementProperty(PropertyType.boolean, True),
@@ -168,43 +188,39 @@ class _GuiCore(ElementLibrary):
             inner_properties={
                 "on_edit": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.edit_data_node}}"),
                 "core_changed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._CORE_CHANGED_NAME),
-                "error": ElementProperty(PropertyType.react, f"{{{_GuiCoreContext._DATANODE_VIZ_ERROR_VAR}}}"),
+                "error": ElementProperty(
+                    PropertyType.react, f"{{{__DATANODE_VIZ_ERROR_VAR}<tp:uniq:dn>}}"
+                ),
                 "scenarios": ElementProperty(
                     PropertyType.lov,
-                    f"{{{__CTX_VAR_NAME}.get_scenarios_for_owner({_GuiCoreContext._DATANODE_VIZ_OWNER_ID_VAR},"
-                    + "<tp:uniq:dn>)}",
+                    f"{{{__CTX_VAR_NAME}.get_scenarios_for_owner({__DATANODE_VIZ_OWNER_ID_VAR}<tp:uniq:dn>)}}",
                 ),
                 "type": ElementProperty(PropertyType.inner, __SCENARIO_ADAPTER),
                 "dn_properties": ElementProperty(
                     PropertyType.react,
                     f"{{{__CTX_VAR_NAME}.get_data_node_properties("
-                    + f"{_GuiCoreContext._DATANODE_VIZ_PROPERTIES_ID_VAR},"
-                    + "<tp:uniq:dn>)}",
+                    + f"{__DATANODE_VIZ_PROPERTIES_ID_VAR}<tp:uniq:dn>)}}",
                 ),
                 "history": ElementProperty(
                     PropertyType.react,
                     f"{{{__CTX_VAR_NAME}.get_data_node_history("
-                    + f"{_GuiCoreContext._DATANODE_VIZ_HISTORY_ID_VAR},"
-                    + "<tp:uniq:dn>)}",
+                    + f"{__DATANODE_VIZ_HISTORY_ID_VAR}<tp:uniq:dn>)}}",
                 ),
                 "tabular_data": ElementProperty(
                     PropertyType.data,
                     f"{{{__CTX_VAR_NAME}.get_data_node_tabular_data("
-                    + f"{_GuiCoreContext._DATANODE_VIZ_DATA_ID_VAR},"
-                    + "<tp:uniq:dn>)}",
+                    + f"{__DATANODE_VIZ_DATA_ID_VAR}<tp:uniq:dn>)}}",
                 ),
                 "tabular_columns": ElementProperty(
                     PropertyType.dynamic_string,
                     f"{{{__CTX_VAR_NAME}.get_data_node_tabular_columns("
-                    + f"{_GuiCoreContext._DATANODE_VIZ_DATA_ID_VAR},"
-                    + "<tp:uniq:dn>)}",
+                    + f"{__DATANODE_VIZ_DATA_ID_VAR}<tp:uniq:dn>)}}",
                     with_update=True,
                 ),
                 "chart_config": ElementProperty(
                     PropertyType.dynamic_string,
                     f"{{{__CTX_VAR_NAME}.get_data_node_chart_config("
-                    + f"{_GuiCoreContext._DATANODE_VIZ_DATA_CHART_ID_VAR},"
-                    + "<tp:uniq:dn>)}",
+                    + f"{__DATANODE_VIZ_DATA_CHART_ID_VAR}<tp:uniq:dn>)}}",
                     with_update=True,
                 ),
                 "on_data_value": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.update_data}}"),
@@ -214,11 +230,12 @@ class _GuiCore(ElementLibrary):
                 "on_lock": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.lock_datanode_for_edit}}"),
                 "update_dn_vars": ElementProperty(
                     PropertyType.string,
-                    f"data_id={_GuiCoreContext._DATANODE_VIZ_DATA_ID_VAR};"
-                    + f"history_id={_GuiCoreContext._DATANODE_VIZ_HISTORY_ID_VAR};"
-                    + f"owner_id={_GuiCoreContext._DATANODE_VIZ_OWNER_ID_VAR};"
-                    + f"chart_id={_GuiCoreContext._DATANODE_VIZ_DATA_CHART_ID_VAR};"
-                    + f"properties_id={_GuiCoreContext._DATANODE_VIZ_PROPERTIES_ID_VAR}",
+                    f"data_id={__DATANODE_VIZ_DATA_ID_VAR}<tp:uniq:dn>;"
+                    + f"history_id={__DATANODE_VIZ_HISTORY_ID_VAR}<tp:uniq:dn>;"
+                    + f"owner_id={__DATANODE_VIZ_OWNER_ID_VAR}<tp:uniq:dn>;"
+                    + f"chart_id={__DATANODE_VIZ_DATA_CHART_ID_VAR}<tp:uniq:dn>;"
+                    + f"properties_id={__DATANODE_VIZ_PROPERTIES_ID_VAR}<tp:uniq:dn>;"
+                    + f"error_id={__DATANODE_VIZ_ERROR_VAR}<tp:uniq:dn>",
                 ),
             },
         ),
@@ -243,7 +260,12 @@ class _GuiCore(ElementLibrary):
                 "core_changed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._CORE_CHANGED_NAME),
                 "type": ElementProperty(PropertyType.inner, __JOB_ADAPTER),
                 "on_job_action": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.act_on_jobs}}"),
-                "error": ElementProperty(PropertyType.dynamic_string, f"{{{_GuiCoreContext._JOB_SELECTOR_ERROR_VAR}}}"),
+                "error": ElementProperty(
+                    PropertyType.dynamic_string, f"{{{__JOB_SELECTOR_ERROR_VAR}<tp:uniq:jb>}}"
+                ),
+                "update_jb_vars": ElementProperty(
+                    PropertyType.string, f"error_id={__JOB_SELECTOR_ERROR_VAR}<tp:uniq:jb>"
+                ),
             },
         ),
     }
@@ -258,17 +280,12 @@ class _GuiCore(ElementLibrary):
         return ["lib/taipy-gui-core.js"]
 
     def on_init(self, gui: Gui) -> t.Optional[t.Tuple[str, t.Any]]:
-        gui._get_default_locals_bind().update({v: "" for v in _GuiCore.__INNER_VARS})
         ctx = _GuiCoreContext(gui)
         gui._add_adapter_for_type(_GuiCore.__SCENARIO_ADAPTER, ctx.scenario_adapter)
         gui._add_adapter_for_type(_GuiCore.__DATANODE_ADAPTER, ctx.data_node_adapter)
         gui._add_adapter_for_type(_GuiCore.__JOB_ADAPTER, ctx.job_adapter)
         return _GuiCore.__CTX_VAR_NAME, ctx
 
-    def on_user_init(self, state: State):
-        for var in _GuiCore.__INNER_VARS:
-            state._add_attribute(var, "")
-
     def get_version(self) -> str:
         if not hasattr(self, "version"):
             self.version = _get_version()

+ 82 - 26
taipy/gui_core/_adapters.py

@@ -9,10 +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 typing as t
 from enum import Enum
 from numbers import Number
+from operator import attrgetter, contains, eq, ge, gt, le, lt, ne
 
 import pandas as pd
 
@@ -167,7 +170,6 @@ class _GuiCoreScenarioNoUpdate(_TaipyBase, _DoNotUpdate):
 
 
 class _GuiCoreDatanodeAdapter(_TaipyBase):
-
     @staticmethod
     def _is_tabular_data(datanode: DataNode, value: t.Any):
         if isinstance(datanode, _TabularDataNodeMixin):
@@ -177,32 +179,31 @@ class _GuiCoreDatanodeAdapter(_TaipyBase):
         return False
 
     def __get_data(self, dn: DataNode):
-            if dn._last_edit_date:
-                if isinstance(dn, _TabularDataNodeMixin):
+        if dn._last_edit_date:
+            if isinstance(dn, _TabularDataNodeMixin):
+                return (None, None, True, None)
+            try:
+                value = dn.read()
+                if _GuiCoreDatanodeAdapter._is_tabular_data(dn, value):
                     return (None, None, True, None)
-                try:
-                    value = dn.read()
-                    if _GuiCoreDatanodeAdapter._is_tabular_data(dn, value):
-                        return (None, None, True, None)
-                    val_type = (
-                        "date"
-                        if "date" in type(value).__name__
-                        else type(value).__name__
-                        if isinstance(value, Number)
-                        else None
-                    )
-                    if isinstance(value, float):
-                        if math.isnan(value):
-                            value = None
-                    return (
-                        value,
-                        val_type,
-                        None,
-                        None,
-                    )
-                except Exception as e:
-                    return (None, None, None, f"read data_node: {e}")
-            return (None, None, None, f"Data unavailable for {dn.get_simple_label()}")
+                val_type = (
+                    "date"
+                    if "date" in type(value).__name__
+                    else type(value).__name__
+                    if isinstance(value, Number)
+                    else None
+                )
+                if isinstance(value, float) and math.isnan(value):
+                        value = None
+                return (
+                    value,
+                    val_type,
+                    None,
+                    None,
+                )
+            except Exception as e:
+                return (None, None, None, f"read data_node: {e}")
+        return (None, None, None, f"Data unavailable for {dn.get_simple_label()}")
 
     def get(self):
         data = super().get()
@@ -240,3 +241,58 @@ class _GuiCoreDatanodeAdapter(_TaipyBase):
     @staticmethod
     def get_hash():
         return _TaipyBase._HOLDER_PREFIX + "Dn"
+
+
+def _attr_filter(attrVal: t.Any):
+    return not inspect.isroutine(attrVal)
+
+
+def _attr_type(attr: str):
+    return "date" if "date" in attr else "boolean" if attr.startswith("is_") else "string"
+
+
+_operators: t.Dict[str, t.Callable] = {
+    "==": eq,
+    "!=": ne,
+    "<": lt,
+    "<=": le,
+    ">": gt,
+    ">=": ge,
+    "contains": contains,
+}
+
+
+def _invoke_action(ent: t.Any, col: str, action: str, val: t.Any) -> bool:
+    try:
+        if op := _operators.get(action):
+            return op(attrgetter(col)(ent), val)
+    except Exception as e:
+        _warn(f"Error filtering with {col} {action} {val} on {ent}.", e)
+    return True
+
+
+class _GuiCoreScenarioProperties(_TaipyBase):
+    __SCENARIO_ATTRIBUTES = [a[0] for a in inspect.getmembers(Scenario, _attr_filter) if not a[0].startswith("_")]
+    __DATANODE_ATTRIBUTES = [a[0] for a in inspect.getmembers(DataNode, _attr_filter) if not a[0].startswith("_")]
+
+    @staticmethod
+    def get_hash():
+        return _TaipyBase._HOLDER_PREFIX + "ScP"
+
+    def get(self):
+        data = super().get()
+        if isinstance(data, str):
+            data = data.split(";")
+        if isinstance(data, (list, tuple)):
+            return json.dumps(
+                [
+                    (attr, _attr_type(attr))
+                    for attr in data
+                    if attr
+                    and isinstance(attr, str)
+                    and (parts := attr.split("."))
+                    and (len(parts) > 1 and parts[1] in _GuiCoreScenarioProperties.__DATANODE_ATTRIBUTES)
+                    or attr in _GuiCoreScenarioProperties.__SCENARIO_ATTRIBUTES
+                ]
+            )
+        return None

+ 155 - 103
taipy/gui_core/_context.py

@@ -12,6 +12,7 @@
 import json
 import typing as t
 from collections import defaultdict
+from datetime import datetime
 from numbers import Number
 from threading import Lock
 
@@ -59,7 +60,7 @@ from taipy.gui import Gui, State
 from taipy.gui._warnings import _warn
 from taipy.gui.gui import _DoNotUpdate
 
-from ._adapters import _EntityType, _GuiCoreDatanodeAdapter
+from ._adapters import _attr_type, _EntityType, _GuiCoreDatanodeAdapter, _invoke_action
 
 
 class _GuiCoreContext(CoreEventConsumerBase):
@@ -73,19 +74,6 @@ class _GuiCoreContext(CoreEventConsumerBase):
     __ENTITY_PROPS = (__PROP_CONFIG_ID, __PROP_DATE, __PROP_ENTITY_NAME)
     __ACTION = "action"
     _CORE_CHANGED_NAME = "core_changed"
-    _SCENARIO_SELECTOR_ERROR_VAR = "gui_core_sc_error"
-    _SCENARIO_SELECTOR_ID_VAR = "gui_core_sc_id"
-    _SCENARIO_VIZ_ERROR_VAR = "gui_core_sv_error"
-    _JOB_SELECTOR_ERROR_VAR = "gui_core_js_error"
-    _DATANODE_VIZ_ERROR_VAR = "gui_core_dv_error"
-    _DATANODE_VIZ_OWNER_ID_VAR = "gui_core_dv_owner_id"
-    _DATANODE_VIZ_HISTORY_ID_VAR = "gui_core_dv_history_id"
-    _DATANODE_VIZ_PROPERTIES_ID_VAR = "gui_core_dv_properties_id"
-    _DATANODE_VIZ_DATA_ID_VAR = "gui_core_dv_data_id"
-    _DATANODE_VIZ_DATA_CHART_ID_VAR = "gui_core_dv_data_chart_id"
-    _DATANODE_VIZ_DATA_NODE_PROP = "data_node"
-    _DATANODE_SEL_SCENARIO_PROP = "scenario"
-    _SEL_SCENARIOS_PROP = "scenarios"
 
     def __init__(self, gui: Gui) -> None:
         self.gui = gui
@@ -206,61 +194,113 @@ class _GuiCoreContext(CoreEventConsumerBase):
         finally:
             self.gui._broadcast(_GuiCoreContext._CORE_CHANGED_NAME, {"jobs": True})
 
-    def scenario_adapter(self, scenario_or_cycle):
+    def no_change_adapter(self, entity: t.List):
+        return entity
+
+    def cycle_adapter(self, cycle: Cycle):
         try:
             if (
-                hasattr(scenario_or_cycle, "id")
-                and is_readable(scenario_or_cycle.id)
-                and core_get(scenario_or_cycle.id) is not None
+                isinstance(cycle, Cycle)
+                and is_readable(cycle.id)
+                and core_get(cycle.id) is not None
+                and self.scenario_by_cycle
             ):
-                if self.scenario_by_cycle and isinstance(scenario_or_cycle, Cycle):
-                    return (
-                        scenario_or_cycle.id,
-                        scenario_or_cycle.get_simple_label(),
-                        sorted(
-                            self.scenario_by_cycle.get(scenario_or_cycle, []),
-                            key=_GuiCoreContext.get_entity_creation_date_iso,
-                        ),
-                        _EntityType.CYCLE.value,
-                        False,
-                    )
-                elif isinstance(scenario_or_cycle, Scenario):
-                    return (
-                        scenario_or_cycle.id,
-                        scenario_or_cycle.get_simple_label(),
-                        None,
-                        _EntityType.SCENARIO.value,
-                        scenario_or_cycle.is_primary,
-                    )
+                return [
+                    cycle.id,
+                    cycle.get_simple_label(),
+                    sorted(
+                        self.scenario_by_cycle.get(cycle, []),
+                        key=_GuiCoreContext.get_entity_creation_date_iso,
+                    ),
+                    _EntityType.CYCLE.value,
+                    False,
+                ]
+        except Exception as e:
+            _warn(
+                f"Access to {type(cycle).__name__} " + f"({cycle.id if hasattr(cycle, 'id') else 'No_id'})" + " failed",
+                e,
+            )
+        return None
+
+    def scenario_adapter(self, scenario: Scenario):
+        if isinstance(scenario, (tuple, list)):
+            return scenario
+        try:
+            if isinstance(scenario, Scenario) and is_readable(scenario.id) and core_get(scenario.id) is not None:
+                return [
+                    scenario.id,
+                    scenario.get_simple_label(),
+                    None,
+                    _EntityType.SCENARIO.value,
+                    scenario.is_primary,
+                ]
         except Exception as e:
             _warn(
-                f"Access to {type(scenario_or_cycle)} "
-                + f"({scenario_or_cycle.id if hasattr(scenario_or_cycle, 'id') else 'No_id'})"
+                f"Access to {type(scenario).__name__} "
+                + f"({scenario.id if hasattr(scenario, 'id') else 'No_id'})"
                 + " failed",
                 e,
             )
         return None
 
-    def get_scenarios(self, scenarios: t.Optional[t.List[t.Union[Cycle, Scenario]]]):
+    def filter_scenarios(self, cycle: t.List, col: str, action: str, val: t.Any):
+        cycle[2] = [e for e in cycle[2] if _invoke_action(e, col, action, val)]
+        return cycle
+
+    def adapt_scenarios(self, cycle: t.List):
+        cycle[2] = [self.scenario_adapter(e) for e in cycle[2]]
+        return cycle
+
+    def get_scenarios(
+        self, scenarios: t.Optional[t.List[t.Union[Cycle, Scenario]]], filters: t.Optional[t.List[t.Dict[str, t.Any]]]
+    ):
         cycles_scenarios: t.List[t.Union[Cycle, Scenario]] = []
-        if scenarios is None:
-            with self.lock:
-                if self.scenario_by_cycle is None:
-                    self.scenario_by_cycle = get_cycles_scenarios()
+        with self.lock:
+            if self.scenario_by_cycle is None:
+                self.scenario_by_cycle = get_cycles_scenarios()
+            if scenarios is None:
                 for cycle, c_scenarios in self.scenario_by_cycle.items():
                     if cycle is None:
                         cycles_scenarios.extend(c_scenarios)
                     else:
                         cycles_scenarios.append(cycle)
-        else:
+        if scenarios is not None:
             cycles_scenarios = scenarios
-        return sorted(cycles_scenarios, key=_GuiCoreContext.get_entity_creation_date_iso)
+        # 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)
+        ]
+        if filters:
+            # filtering
+            filtered_list = list(adapted_list)
+            for fd in filters:
+                col = fd.get("col", "")
+                col_type = _attr_type(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, action, val)
+                ]
+                # level 2 filtering
+                filtered_list = [
+                    self.filter_scenarios(e, col, 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]))
+            ]
+        return adapted_list
 
     def select_scenario(self, state: State, id: str, payload: t.Dict[str, str]):
         args = payload.get("args")
-        if args is None or not isinstance(args, list) or len(args) == 0:
+        if args is None or not isinstance(args, list) or len(args) < 2:
             return
-        state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ID_VAR, args[0])
+        state.assign(args[0], args[1])
 
     def get_scenario_by_id(self, id: str) -> t.Optional[Scenario]:
         if not id or not is_readable(t.cast(ScenarioId, id)):
@@ -290,6 +330,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
             or not isinstance(args[start_idx + 2], dict)
         ):
             return
+        error_var = payload.get("error_id")
         update = args[start_idx]
         delete = args[start_idx + 1]
         data = args[start_idx + 2]
@@ -301,18 +342,14 @@ class _GuiCoreContext(CoreEventConsumerBase):
             scenario_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
             if delete:
                 if not is_deletable(scenario_id):
-                    state.assign(
-                        _GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"Scenario. {scenario_id} is not deletable."
-                    )
+                    state.assign(error_var, f"Scenario. {scenario_id} is not deletable.")
                     return
                 try:
                     core_delete(scenario_id)
                 except Exception as e:
-                    state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"Error deleting Scenario. {e}")
+                    state.assign(error_var, f"Error deleting Scenario. {e}")
             else:
-                if not self.__check_readable_editable(
-                    state, scenario_id, "Scenario", _GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR
-                ):
+                if not self.__check_readable_editable(state, scenario_id, "Scenario", error_var):
                     return
                 scenario = core_get(scenario_id)
         else:
@@ -320,15 +357,13 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 config_id = data.get(_GuiCoreContext.__PROP_CONFIG_ID)
                 scenario_config = Config.scenarios.get(config_id)
                 if with_dialog and scenario_config is None:
-                    state.assign(
-                        _GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"Invalid configuration id ({config_id})"
-                    )
+                    state.assign(error_var, f"Invalid configuration id ({config_id})")
                     return
                 date_str = data.get(_GuiCoreContext.__PROP_DATE)
                 try:
                     date = parser.parse(date_str) if isinstance(date_str, str) else None
                 except Exception as e:
-                    state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"Invalid date ({date_str}).{e}")
+                    state.assign(error_var, f"Invalid date ({date_str}).{e}")
                     return
             else:
                 scenario_config = None
@@ -356,17 +391,17 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         if isinstance(res, Scenario):
                             # everything's fine
                             scenario_id = res.id
-                            state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, "")
+                            state.assign(error_var, "")
                             return
                         if res:
                             # do not create
-                            state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"{res}")
+                            state.assign(error_var, f"{res}")
                             return
                     except Exception as e:  # pragma: no cover
                         if not gui._call_on_exception(on_creation, e):
                             _warn(f"on_creation(): Exception raised in '{on_creation}()'", e)
                         state.assign(
-                            _GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR,
+                            error_var,
                             f"Error creating Scenario with '{on_creation}()'. {e}",
                         )
                         return
@@ -377,7 +412,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         scenario_config = next(sc for k, sc in Config.scenarios.items() if k != "default")
                     else:
                         state.assign(
-                            _GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR,
+                            error_var,
                             "Error creating Scenario: only one scenario config needed "
                             + f"({len(Config.scenarios) - 1}) found.",
                         )
@@ -386,7 +421,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 scenario = create_scenario(scenario_config, date, name)
                 scenario_id = scenario.id
             except Exception as e:
-                state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"Error creating Scenario. {e}")
+                state.assign(error_var, f"Error creating Scenario. {e}")
             finally:
                 self.scenario_refresh(scenario_id)
                 if scenario and (sel_scenario_var := args[1] if isinstance(args[1], str) else None):
@@ -397,9 +432,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         _warn("Can't find value variable name in context", e)
         if scenario:
             if not is_editable(scenario):
-                state.assign(
-                    _GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"Scenario {scenario_id or name} is not editable."
-                )
+                state.assign(error_var, f"Scenario {scenario_id or name} is not editable.")
                 return
             with scenario as sc:
                 sc.properties[_GuiCoreContext.__PROP_ENTITY_NAME] = name
@@ -413,18 +446,24 @@ class _GuiCoreContext(CoreEventConsumerBase):
                             key = prop.get("key")
                             if key and key not in _GuiCoreContext.__ENTITY_PROPS:
                                 sc._properties[key] = prop.get("value")
-                        state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, "")
+                        state.assign(error_var, "")
                     except Exception as e:
-                        state.assign(_GuiCoreContext._SCENARIO_SELECTOR_ERROR_VAR, f"Error creating Scenario. {e}")
+                        state.assign(error_var, f"Error creating Scenario. {e}")
+
+    @staticmethod
+    def __assign_var(state: State, var_name: t.Optional[str], msg: str):
+        if var_name:
+            state.assign(var_name, msg)
 
     def edit_entity(self, state: State, id: str, payload: t.Dict[str, str]):
         args = payload.get("args")
         if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
             return
+        error_var = payload.get("error_id")
         data = args[0]
         entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
         sequence = data.get("sequence")
-        if not self.__check_readable_editable(state, entity_id, "Scenario", _GuiCoreContext._SCENARIO_VIZ_ERROR_VAR):
+        if not self.__check_readable_editable(state, entity_id, "Scenario", error_var):
             return
         scenario: Scenario = core_get(entity_id)
         if scenario:
@@ -436,8 +475,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         primary = data.get(_GuiCoreContext.__PROP_SCENARIO_PRIMARY)
                         if primary is True:
                             if not is_promotable(scenario):
-                                state.assign(
-                                    _GuiCoreContext._SCENARIO_VIZ_ERROR_VAR, f"Scenario {entity_id} is not promotable."
+                                _GuiCoreContext.__assign_var(
+                                    state, error_var, f"Scenario {entity_id} is not promotable."
                                 )
                                 return
                             set_primary(scenario)
@@ -453,21 +492,23 @@ class _GuiCoreContext(CoreEventConsumerBase):
                             seqEntity.tasks = data.get("task_ids")
                             self.__edit_properties(seqEntity, data)
                         else:
-                            state.assign(
-                                _GuiCoreContext._SCENARIO_VIZ_ERROR_VAR,
+                            _GuiCoreContext.__assign_var(
+                                state,
+                                error_var,
                                 f"Sequence {name} is not available in Scenario {entity_id}.",
                             )
                             return
 
-                state.assign(_GuiCoreContext._SCENARIO_VIZ_ERROR_VAR, "")
+                _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                state.assign(_GuiCoreContext._SCENARIO_VIZ_ERROR_VAR, f"Error updating {type(scenario).__name__}. {e}")
+                _GuiCoreContext.__assign_var(state, error_var, f"Error updating {type(scenario).__name__}. {e}")
 
     def submit_entity(self, state: State, id: str, payload: t.Dict[str, str]):
         args = payload.get("args")
         if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
             return
         data = args[0]
+        error_var = payload.get("error_id")
         try:
             scenario_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
             entity = core_get(scenario_id)
@@ -475,8 +516,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 entity = entity.sequences.get(sequence)
 
             if not is_submittable(entity):
-                state.assign(
-                    _GuiCoreContext._SCENARIO_VIZ_ERROR_VAR,
+                _GuiCoreContext.__assign_var(
+                    state,
+                    error_var,
                     f"{'Sequence' if sequence else 'Scenario'} {sequence or scenario_id} is not submittable.",
                 )
                 return
@@ -495,9 +537,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         with self.submissions_lock:
                             self.client_submission[submission_entity.id] = SubmissionStatus.SUBMITTED
                         self.submission_status_callback(submission_entity.id)
-                state.assign(_GuiCoreContext._SCENARIO_VIZ_ERROR_VAR, "")
+                _GuiCoreContext.__assign_var(state, error_var, "")
         except Exception as e:
-            state.assign(_GuiCoreContext._SCENARIO_VIZ_ERROR_VAR, f"Error submitting entity. {e}")
+            _GuiCoreContext.__assign_var(state, error_var, f"Error submitting entity. {e}")
 
     def __do_datanodes_tree(self):
         if self.data_nodes_by_owner is None:
@@ -509,7 +551,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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)
+            return (self.data_nodes_by_owner.get(None) if self.data_nodes_by_owner else []) + self.get_scenarios(
+                None, None
+            )
         if not self.data_nodes_by_owner:
             return []
         if isinstance(scenarios, (list, tuple)) and len(scenarios) > 1:
@@ -518,6 +562,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
         return [d for owner in owners for d in t.cast(list, self.data_nodes_by_owner.get(owner.id))]
 
     def data_node_adapter(self, data):
+        if isinstance(data, (tuple, list)):
+            return data
         try:
             if hasattr(data, "id") and is_readable(data.id) and core_get(data.id) is not None:
                 if isinstance(data, DataNode):
@@ -620,31 +666,33 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         cancel_job(job_id)
                     except Exception as e:
                         errs.append(f"Error canceling job. {e}")
-            state.assign(_GuiCoreContext._JOB_SELECTOR_ERROR_VAR, "<br/>".join(errs) if errs else "")
+            _GuiCoreContext.__assign_var(state, payload.get("error_id"), "<br/>".join(errs) if errs else "")
 
     def edit_data_node(self, state: State, id: str, payload: t.Dict[str, str]):
         args = payload.get("args")
         if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
             return
+        error_var = payload.get("error_id")
         data = args[0]
         entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
-        if not self.__check_readable_editable(state, entity_id, "DataNode", _GuiCoreContext._DATANODE_VIZ_ERROR_VAR):
+        if not self.__check_readable_editable(state, entity_id, "DataNode", error_var):
             return
         entity: DataNode = core_get(entity_id)
         if isinstance(entity, DataNode):
             try:
                 self.__edit_properties(entity, data)
-                state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, "")
+                _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, f"Error updating Datanode. {e}")
+                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode. {e}")
 
     def lock_datanode_for_edit(self, state: State, id: str, payload: t.Dict[str, str]):
         args = payload.get("args")
         if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
             return
         data = args[0]
+        error_var = payload.get("error_id")
         entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
-        if not self.__check_readable_editable(state, entity_id, "Datanode", _GuiCoreContext._DATANODE_VIZ_ERROR_VAR):
+        if not self.__check_readable_editable(state, entity_id, "Datanode", error_var):
             return
         lock = data.get("lock", True)
         entity: DataNode = core_get(entity_id)
@@ -654,9 +702,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     entity.lock_edit(self.gui._get_client_id())
                 else:
                     entity.unlock_edit(self.gui._get_client_id())
-                state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, "")
+                _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, f"Error locking Datanode. {e}")
+                _GuiCoreContext.__assign_var(state, error_var, f"Error locking Datanode. {e}")
 
     def __edit_properties(self, entity: t.Union[Scenario, Sequence, DataNode], data: t.Dict[str, str]):
         with entity as ent:
@@ -731,12 +779,12 @@ class _GuiCoreContext(CoreEventConsumerBase):
             return sorted(res, key=lambda r: r[0], reverse=True)
         return _DoNotUpdate()
 
-    def __check_readable_editable(self, state: State, id: str, ent_type: str, var: str):
+    def __check_readable_editable(self, state: State, id: str, ent_type: str, var: t.Optional[str]):
         if not is_readable(t.cast(ScenarioId, id)):
-            state.assign(var, f"{ent_type} {id} is not readable.")
+            _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not readable.")
             return False
         if not is_editable(t.cast(ScenarioId, id)):
-            state.assign(var, f"{ent_type} {id} is not editable.")
+            _GuiCoreContext.__assign_var(state, var, f"{ent_type} {id} is not editable.")
             return False
         return True
 
@@ -745,8 +793,9 @@ class _GuiCoreContext(CoreEventConsumerBase):
         if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict):
             return
         data = args[0]
+        error_var = payload.get("error_id")
         entity_id = data.get(_GuiCoreContext.__PROP_ENTITY_ID)
-        if not self.__check_readable_editable(state, entity_id, "DataNode", _GuiCoreContext._DATANODE_VIZ_ERROR_VAR):
+        if not self.__check_readable_editable(state, entity_id, "DataNode", error_var):
             return
         entity: DataNode = core_get(entity_id)
         if isinstance(entity, DataNode):
@@ -762,15 +811,16 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     comment=data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT),
                 )
                 entity.unlock_edit(self.gui._get_client_id())
-                state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, "")
+                _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, f"Error updating Datanode value. {e}")
-            state.assign(_GuiCoreContext._DATANODE_VIZ_DATA_ID_VAR, entity_id)  # this will update the data value
+                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode value. {e}")
+            _GuiCoreContext.__assign_var(state, payload.get("data_id"), entity_id)  # this will update the data value
 
     def tabular_data_edit(self, state: State, var_name: str, payload: dict):
+        error_var = payload.get("error_id")
         user_data = payload.get("user_data", {})
         dn_id = user_data.get("dn_id")
-        if not self.__check_readable_editable(state, dn_id, "DataNode", _GuiCoreContext._DATANODE_VIZ_ERROR_VAR):
+        if not self.__check_readable_editable(state, dn_id, "DataNode", error_var):
             return
         datanode = core_get(dn_id) if dn_id else None
         if isinstance(datanode, DataNode):
@@ -812,23 +862,25 @@ class _GuiCoreContext(CoreEventConsumerBase):
                             data[idx] = val
                             new_data = data
                         else:
-                            state.assign(
-                                _GuiCoreContext._DATANODE_VIZ_ERROR_VAR,
+                            _GuiCoreContext.__assign_var(
+                                state,
+                                error_var,
                                 "Error updating Datanode: cannot handle multi-column list value.",
                             )
                         if data_tuple and new_data is not None:
                             new_data = tuple(new_data)
                     else:
-                        state.assign(
-                            _GuiCoreContext._DATANODE_VIZ_ERROR_VAR,
+                        _GuiCoreContext.__assign_var(
+                            state,
+                            error_var,
                             "Error updating Datanode tabular value: type does not support at[] indexer.",
                         )
                 if new_data is not None:
                     datanode.write(new_data, comment=user_data.get(_GuiCoreContext.__PROP_ENTITY_COMMENT))
-                    state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, "")
+                    _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                state.assign(_GuiCoreContext._DATANODE_VIZ_ERROR_VAR, f"Error updating Datanode tabular value. {e}")
-        setattr(state, _GuiCoreContext._DATANODE_VIZ_DATA_ID_VAR, dn_id)
+                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode tabular value. {e}")
+        _GuiCoreContext.__assign_var(state, payload.get("data_id"), dn_id)
 
     def get_data_node_properties(self, id: str, uid: str):
         if id and is_readable(t.cast(DataNodeId, id)) and (dn := core_get(id)) and isinstance(dn, DataNode):

+ 5 - 0
taipy/gui_core/viselements.json

@@ -97,6 +97,11 @@
                         "type": "bool",
                         "default_value": "False",
                         "doc": "TODO: If True, the user can select multiple scenarios."
+                    },
+                    {
+                        "name": "filter_by",
+                        "type": "str|list[str]",
+                        "doc": "TODO: a list of scenario attributes to filter on."
                     }
                 ]
             }

+ 15 - 0
tests/core/data/test_data_node.py

@@ -75,6 +75,21 @@ class TestDataNode:
         assert not dn.is_ready_for_reading
         assert len(dn.properties) == 0
 
+    def test_is_up_to_date_when_not_written(self):
+        dn_confg_1 = Config.configure_in_memory_data_node("dn_1", default_data="a")
+        dn_confg_2 = Config.configure_in_memory_data_node("dn_2")
+        task_config_1 = Config.configure_task("t1", funct_a_b, [dn_confg_1], [dn_confg_2])
+        scenario_config = Config.configure_scenario("sc", [task_config_1])
+
+        scenario = tp.create_scenario(scenario_config)
+
+        assert scenario.dn_1.is_up_to_date is True
+        assert scenario.dn_2.is_up_to_date is False
+
+        tp.submit(scenario)
+        assert scenario.dn_1.is_up_to_date is True
+        assert scenario.dn_2.is_up_to_date is True
+
     def test_create(self):
         a_date = datetime.now()
         dn = DataNode(

+ 80 - 52
tests/core/test_taipy/test_export.py

@@ -10,21 +10,23 @@
 # specific language governing permissions and limitations under the License.
 
 import os
-import shutil
+import zipfile
 
 import pandas as pd
 import pytest
 
 import taipy.core.taipy as tp
 from taipy import Config, Frequency, Scope
-from taipy.core.exceptions import ExportFolderAlreadyExists, InvalidExportPath
+from taipy.core.exceptions import ExportPathAlreadyExists
 
 
 @pytest.fixture(scope="function", autouse=True)
-def clean_tmp_folder():
-    shutil.rmtree("./tmp", ignore_errors=True)
+def clean_export_zip_file():
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
     yield
-    shutil.rmtree("./tmp", ignore_errors=True)
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
 
 
 def plus_1(x):
@@ -57,15 +59,28 @@ def configure_test_scenario(input_data, frequency=None):
     return scenario_cfg
 
 
-def test_export_scenario_to_the_storage_folder():
+def test_export_scenario_with_and_without_zip_extension(tmp_path):
     scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+
     scenario = tp.create_scenario(scenario_cfg)
+    tp.submit(scenario)
+
+    # Export without the .zip extension should create the tmp.zip file
+    tp.export_scenario(scenario.id, f"{tmp_path}/tmp")
+    assert os.path.exists(f"{tmp_path}/tmp.zip")
+
+    os.remove(f"{tmp_path}/tmp.zip")
 
-    with pytest.raises(InvalidExportPath):
-        tp.export_scenario(scenario.id, Config.core.taipy_storage_folder)
+    # Export with the .zip extension should also create the tmp.zip file
+    tp.export_scenario(scenario.id, f"{tmp_path}/tmp.zip")
+    assert os.path.exists(f"{tmp_path}/tmp.zip")
 
+    # Export with another extension should create the tmp.<extension>.zip file
+    tp.export_scenario(scenario.id, f"{tmp_path}/tmp.tar.gz")
+    assert os.path.exists(f"{tmp_path}/tmp.tar.gz.zip")
 
-def test_export_scenario_with_cycle():
+
+def test_export_scenario_with_cycle(tmp_path):
     scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
 
     scenario = tp.create_scenario(scenario_cfg)
@@ -73,9 +88,11 @@ def test_export_scenario_with_cycle():
     jobs = submission.jobs
 
     # Export the submitted scenario
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario")
+    tp.export_scenario(scenario.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path)
 
-    assert sorted(os.listdir("./tmp/exp_scenario/data_nodes")) == sorted(
+    assert sorted(os.listdir(f"{tmp_path}/data_nodes")) == sorted(
         [
             f"{scenario.i_1.id}.json",
             f"{scenario.o_1_csv.id}.json",
@@ -84,7 +101,7 @@ def test_export_scenario_with_cycle():
             f"{scenario.o_1_json.id}.json",
         ]
     )
-    assert sorted(os.listdir("./tmp/exp_scenario/tasks")) == sorted(
+    assert sorted(os.listdir(f"{tmp_path}/tasks")) == sorted(
         [
             f"{scenario.t_1_csv.id}.json",
             f"{scenario.t_1_excel.id}.json",
@@ -92,32 +109,34 @@ def test_export_scenario_with_cycle():
             f"{scenario.t_1_json.id}.json",
         ]
     )
-    assert sorted(os.listdir("./tmp/exp_scenario/scenarios")) == sorted([f"{scenario.id}.json"])
-    assert sorted(os.listdir("./tmp/exp_scenario/jobs")) == sorted(
+    assert sorted(os.listdir(f"{tmp_path}/scenarios")) == sorted([f"{scenario.id}.json"])
+    assert sorted(os.listdir(f"{tmp_path}/jobs")) == sorted(
         [f"{jobs[0].id}.json", f"{jobs[1].id}.json", f"{jobs[2].id}.json", f"{jobs[3].id}.json"]
     )
-    assert os.listdir("./tmp/exp_scenario/submission") == [f"{submission.id}.json"]
-    assert sorted(os.listdir("./tmp/exp_scenario/cycles")) == sorted([f"{scenario.cycle.id}.json"])
+    assert os.listdir(f"{tmp_path}/submission") == [f"{submission.id}.json"]
+    assert sorted(os.listdir(f"{tmp_path}/cycles")) == sorted([f"{scenario.cycle.id}.json"])
 
 
-def test_export_scenario_without_cycle():
+def test_export_scenario_without_cycle(tmp_path):
     scenario_cfg = configure_test_scenario(1)
 
     scenario = tp.create_scenario(scenario_cfg)
     tp.submit(scenario)
 
     # Export the submitted scenario
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario")
+    tp.export_scenario(scenario.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path)
 
-    assert os.path.exists("./tmp/exp_scenario/data_nodes")
-    assert os.path.exists("./tmp/exp_scenario/tasks")
-    assert os.path.exists("./tmp/exp_scenario/scenarios")
-    assert os.path.exists("./tmp/exp_scenario/jobs")
-    assert os.path.exists("./tmp/exp_scenario/submission")
-    assert not os.path.exists("./tmp/exp_scenario/cycles")  # No cycle
+    assert os.path.exists(f"{tmp_path}/data_nodes")
+    assert os.path.exists(f"{tmp_path}/tasks")
+    assert os.path.exists(f"{tmp_path}/scenarios")
+    assert os.path.exists(f"{tmp_path}/jobs")
+    assert os.path.exists(f"{tmp_path}/submission")
+    assert not os.path.exists(f"{tmp_path}/cycles")  # No cycle
 
 
-def test_export_scenario_override_existing_files():
+def test_export_scenario_override_existing_files(tmp_path):
     scenario_1_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
     scenario_2_cfg = configure_test_scenario(2)
 
@@ -125,45 +144,54 @@ def test_export_scenario_override_existing_files():
     tp.submit(scenario_1)
 
     # Export the submitted scenario_1
-    tp.export_scenario(scenario_1.id, "./tmp/exp_scenario")
-    assert os.path.exists("./tmp/exp_scenario/data_nodes")
-    assert os.path.exists("./tmp/exp_scenario/tasks")
-    assert os.path.exists("./tmp/exp_scenario/scenarios")
-    assert os.path.exists("./tmp/exp_scenario/jobs")
-    assert os.path.exists("./tmp/exp_scenario/submission")
-    assert os.path.exists("./tmp/exp_scenario/cycles")
+    tp.export_scenario(scenario_1.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_1")
+    assert os.path.exists(f"{tmp_path}/scenario_1/data_nodes")
+    assert os.path.exists(f"{tmp_path}/scenario_1/tasks")
+    assert os.path.exists(f"{tmp_path}/scenario_1/scenarios")
+    assert os.path.exists(f"{tmp_path}/scenario_1/jobs")
+    assert os.path.exists(f"{tmp_path}/scenario_1/submission")
+    assert os.path.exists(f"{tmp_path}/scenario_1/cycles")
 
     scenario_2 = tp.create_scenario(scenario_2_cfg)
     tp.submit(scenario_2)
 
-    # Export the submitted scenario_2 to the same folder should raise an error
-    with pytest.raises(ExportFolderAlreadyExists):
-        tp.export_scenario(scenario_2.id, "./tmp/exp_scenario")
+    # Export the submitted scenario_2 to the same path should raise an error
+    with pytest.raises(ExportPathAlreadyExists):
+        tp.export_scenario(scenario_2.id, "tmp.zip")
 
     # Export the submitted scenario_2 without a cycle and override the existing files
-    tp.export_scenario(scenario_2.id, "./tmp/exp_scenario", override=True)
-    assert os.path.exists("./tmp/exp_scenario/data_nodes")
-    assert os.path.exists("./tmp/exp_scenario/tasks")
-    assert os.path.exists("./tmp/exp_scenario/scenarios")
-    assert os.path.exists("./tmp/exp_scenario/jobs")
-    assert os.path.exists("./tmp/exp_scenario/submission")
-    # The cycles folder should be removed when overriding
-    assert not os.path.exists("./tmp/exp_scenario/cycles")
-
-
-def test_export_scenario_filesystem_with_data():
+    tp.export_scenario(scenario_2.id, "tmp.zip", override=True)
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_2")
+    assert os.path.exists(f"{tmp_path}/scenario_2/data_nodes")
+    assert os.path.exists(f"{tmp_path}/scenario_2/tasks")
+    assert os.path.exists(f"{tmp_path}/scenario_2/scenarios")
+    assert os.path.exists(f"{tmp_path}/scenario_2/jobs")
+    assert os.path.exists(f"{tmp_path}/scenario_2/submission")
+    # The cycles folder should not exists since the new scenario does not have a cycle
+    assert not os.path.exists(f"{tmp_path}/scenario_2/cycles")
+
+
+def test_export_scenario_filesystem_with_data(tmp_path):
     scenario_cfg = configure_test_scenario(1)
     scenario = tp.create_scenario(scenario_cfg)
     tp.submit(scenario)
 
     # Export scenario without data
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario")
-    assert not os.path.exists("./tmp/exp_scenario/user_data")
+    tp.export_scenario(scenario.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_without_data")
+    assert not os.path.exists(f"{tmp_path}/scenario_without_data/user_data")
 
     # Export scenario with data
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario", include_data=True, override=True)
-    assert os.path.exists("./tmp/exp_scenario/user_data")
-    data_files = [f for _, _, files in os.walk("./tmp/exp_scenario/user_data") for f in files]
+    tp.export_scenario(scenario.id, "tmp.zip", include_data=True, override=True)
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_with_data")
+    assert os.path.exists(f"{tmp_path}/scenario_with_data/user_data")
+
+    data_files = [f for _, _, files in os.walk(f"{tmp_path}/scenario_with_data/user_data") for f in files]
     assert sorted(data_files) == sorted(
         [
             f"{scenario.i_1.id}.p",
@@ -188,6 +216,6 @@ def test_export_non_file_based_data_node_raise_warning(caplog):
     tp.submit(scenario)
 
     # Export scenario with in-memory data node
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario", include_data=True)
+    tp.export_scenario(scenario.id, "tmp.zip", include_data=True)
     expected_warning = f"Data node {scenario.o_mem.id} is not a file-based data node and the data will not be exported"
     assert expected_warning in caplog.text

+ 63 - 56
tests/core/test_taipy/test_export_with_sql_repo.py

@@ -10,21 +10,23 @@
 # specific language governing permissions and limitations under the License.
 
 import os
-import shutil
+import zipfile
 
 import pandas as pd
 import pytest
 
 import taipy.core.taipy as tp
 from taipy import Config, Frequency, Scope
-from taipy.core.exceptions import ExportFolderAlreadyExists, InvalidExportPath
+from taipy.core.exceptions import ExportPathAlreadyExists
 
 
 @pytest.fixture(scope="function", autouse=True)
-def clean_tmp_folder():
-    shutil.rmtree("./tmp", ignore_errors=True)
+def clean_export_zip_file():
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
     yield
-    shutil.rmtree("./tmp", ignore_errors=True)
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
 
 
 def plus_1(x):
@@ -57,15 +59,7 @@ def configure_test_scenario(input_data, frequency=None):
     return scenario_cfg
 
 
-def test_export_scenario_to_the_storage_folder(init_sql_repo):
-    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
-    scenario = tp.create_scenario(scenario_cfg)
-
-    with pytest.raises(InvalidExportPath):
-        tp.export_scenario(scenario.id, Config.core.taipy_storage_folder)
-
-
-def test_export_scenario_with_cycle(init_sql_repo):
+def test_export_scenario_with_cycle(tmp_path, init_sql_repo):
     scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
 
     scenario = tp.create_scenario(scenario_cfg)
@@ -73,9 +67,11 @@ def test_export_scenario_with_cycle(init_sql_repo):
     jobs = submission.jobs
 
     # Export the submitted scenario
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario")
+    tp.export_scenario(scenario.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path)
 
-    assert sorted(os.listdir("./tmp/exp_scenario/data_node")) == sorted(
+    assert sorted(os.listdir(f"{tmp_path}/data_node")) == sorted(
         [
             f"{scenario.i_1.id}.json",
             f"{scenario.o_1_csv.id}.json",
@@ -84,7 +80,7 @@ def test_export_scenario_with_cycle(init_sql_repo):
             f"{scenario.o_1_json.id}.json",
         ]
     )
-    assert sorted(os.listdir("./tmp/exp_scenario/task")) == sorted(
+    assert sorted(os.listdir(f"{tmp_path}/task")) == sorted(
         [
             f"{scenario.t_1_csv.id}.json",
             f"{scenario.t_1_excel.id}.json",
@@ -92,32 +88,34 @@ def test_export_scenario_with_cycle(init_sql_repo):
             f"{scenario.t_1_json.id}.json",
         ]
     )
-    assert sorted(os.listdir("./tmp/exp_scenario/scenario")) == sorted([f"{scenario.id}.json"])
-    assert sorted(os.listdir("./tmp/exp_scenario/job")) == sorted(
+    assert sorted(os.listdir(f"{tmp_path}/scenario")) == sorted([f"{scenario.id}.json"])
+    assert sorted(os.listdir(f"{tmp_path}/job")) == sorted(
         [f"{jobs[0].id}.json", f"{jobs[1].id}.json", f"{jobs[2].id}.json", f"{jobs[3].id}.json"]
     )
-    assert os.listdir("./tmp/exp_scenario/submission") == [f"{submission.id}.json"]
-    assert sorted(os.listdir("./tmp/exp_scenario/cycle")) == sorted([f"{scenario.cycle.id}.json"])
+    assert os.listdir(f"{tmp_path}/submission") == [f"{submission.id}.json"]
+    assert sorted(os.listdir(f"{tmp_path}/cycle")) == sorted([f"{scenario.cycle.id}.json"])
 
 
-def test_export_scenario_without_cycle(init_sql_repo):
+def test_export_scenario_without_cycle(tmp_path, init_sql_repo):
     scenario_cfg = configure_test_scenario(1)
 
     scenario = tp.create_scenario(scenario_cfg)
     tp.submit(scenario)
 
     # Export the submitted scenario
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario")
+    tp.export_scenario(scenario.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path)
 
-    assert os.path.exists("./tmp/exp_scenario/data_node")
-    assert os.path.exists("./tmp/exp_scenario/task")
-    assert os.path.exists("./tmp/exp_scenario/scenario")
-    assert os.path.exists("./tmp/exp_scenario/job")
-    assert os.path.exists("./tmp/exp_scenario/submission")
-    assert not os.path.exists("./tmp/exp_scenario/cycle")  # No cycle
+    assert os.path.exists(f"{tmp_path}/data_node")
+    assert os.path.exists(f"{tmp_path}/task")
+    assert os.path.exists(f"{tmp_path}/scenario")
+    assert os.path.exists(f"{tmp_path}/job")
+    assert os.path.exists(f"{tmp_path}/submission")
+    assert not os.path.exists(f"{tmp_path}/cycle")  # No cycle
 
 
-def test_export_scenario_override_existing_files(init_sql_repo):
+def test_export_scenario_override_existing_files(tmp_path, init_sql_repo):
     scenario_1_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
     scenario_2_cfg = configure_test_scenario(2)
 
@@ -125,45 +123,54 @@ def test_export_scenario_override_existing_files(init_sql_repo):
     tp.submit(scenario_1)
 
     # Export the submitted scenario_1
-    tp.export_scenario(scenario_1.id, "./tmp/exp_scenario")
-    assert os.path.exists("./tmp/exp_scenario/data_node")
-    assert os.path.exists("./tmp/exp_scenario/task")
-    assert os.path.exists("./tmp/exp_scenario/scenario")
-    assert os.path.exists("./tmp/exp_scenario/job")
-    assert os.path.exists("./tmp/exp_scenario/submission")
-    assert os.path.exists("./tmp/exp_scenario/cycle")
+    tp.export_scenario(scenario_1.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_1")
+    assert os.path.exists(f"{tmp_path}/scenario_1/data_node")
+    assert os.path.exists(f"{tmp_path}/scenario_1/task")
+    assert os.path.exists(f"{tmp_path}/scenario_1/scenario")
+    assert os.path.exists(f"{tmp_path}/scenario_1/job")
+    assert os.path.exists(f"{tmp_path}/scenario_1/submission")
+    assert os.path.exists(f"{tmp_path}/scenario_1/cycle")
 
     scenario_2 = tp.create_scenario(scenario_2_cfg)
     tp.submit(scenario_2)
 
     # Export the submitted scenario_2 to the same folder should raise an error
-    with pytest.raises(ExportFolderAlreadyExists):
-        tp.export_scenario(scenario_2.id, "./tmp/exp_scenario")
+    with pytest.raises(ExportPathAlreadyExists):
+        tp.export_scenario(scenario_2.id, "tmp.zip")
 
     # Export the submitted scenario_2 without a cycle and override the existing files
-    tp.export_scenario(scenario_2.id, "./tmp/exp_scenario", override=True)
-    assert os.path.exists("./tmp/exp_scenario/data_node")
-    assert os.path.exists("./tmp/exp_scenario/task")
-    assert os.path.exists("./tmp/exp_scenario/scenario")
-    assert os.path.exists("./tmp/exp_scenario/job")
-    assert os.path.exists("./tmp/exp_scenario/submission")
-    # The cycles folder should be removed when overriding
-    assert not os.path.exists("./tmp/exp_scenario/cycle")
-
-
-def test_export_scenario_filesystem_with_data(init_sql_repo):
+    tp.export_scenario(scenario_2.id, "tmp.zip", override=True)
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_2")
+    assert os.path.exists(f"{tmp_path}/scenario_2/data_node")
+    assert os.path.exists(f"{tmp_path}/scenario_2/task")
+    assert os.path.exists(f"{tmp_path}/scenario_2/scenario")
+    assert os.path.exists(f"{tmp_path}/scenario_2/job")
+    assert os.path.exists(f"{tmp_path}/scenario_2/submission")
+    # The cycles folder should not exists since the new scenario does not have a cycle
+    assert not os.path.exists(f"{tmp_path}/scenario_2/cycle")
+
+
+def test_export_scenario_sql_repo_with_data(tmp_path, init_sql_repo):
     scenario_cfg = configure_test_scenario(1)
     scenario = tp.create_scenario(scenario_cfg)
     tp.submit(scenario)
 
     # Export scenario without data
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario")
-    assert not os.path.exists("./tmp/exp_scenario/user_data")
+    tp.export_scenario(scenario.id, "tmp.zip")
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_without_data")
+    assert not os.path.exists(f"{tmp_path}/scenario_without_data/user_data")
 
     # Export scenario with data
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario", include_data=True, override=True)
-    assert os.path.exists("./tmp/exp_scenario/user_data")
-    data_files = [f for _, _, files in os.walk("./tmp/exp_scenario/user_data") for f in files]
+    tp.export_scenario(scenario.id, "tmp.zip", include_data=True, override=True)
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path / "scenario_with_data")
+    assert os.path.exists(f"{tmp_path}/scenario_with_data/user_data")
+
+    data_files = [f for _, _, files in os.walk(f"{tmp_path}/scenario_with_data/user_data") for f in files]
     assert sorted(data_files) == sorted(
         [
             f"{scenario.i_1.id}.p",
@@ -188,6 +195,6 @@ def test_export_non_file_based_data_node_raise_warning(init_sql_repo, caplog):
     tp.submit(scenario)
 
     # Export scenario with in-memory data node
-    tp.export_scenario(scenario.id, "./tmp/exp_scenario", include_data=True)
+    tp.export_scenario(scenario.id, "tmp.zip", include_data=True)
     expected_warning = f"Data node {scenario.o_mem.id} is not a file-based data node and the data will not be exported"
     assert expected_warning in caplog.text

+ 213 - 0
tests/core/test_taipy/test_import.py

@@ -0,0 +1,213 @@
+# 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 os
+import shutil
+import zipfile
+
+import pandas as pd
+import pytest
+
+import taipy.core.taipy as tp
+from taipy import Config, Frequency, Scope
+from taipy.core._version._version_manager import _VersionManager
+from taipy.core.cycle._cycle_manager import _CycleManager
+from taipy.core.data._data_manager import _DataManager
+from taipy.core.exceptions.exceptions import (
+    ConflictedConfigurationError,
+    EntitiesToBeImportAlredyExist,
+    ImportArchiveDoesntContainAnyScenario,
+    ImportScenarioDoesntHaveAVersion,
+)
+from taipy.core.job._job_manager import _JobManager
+from taipy.core.scenario._scenario_manager import _ScenarioManager
+from taipy.core.submission._submission_manager import _SubmissionManager
+from taipy.core.task._task_manager import _TaskManager
+
+
+@pytest.fixture(scope="function", autouse=True)
+def clean_export_zip_file():
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
+    yield
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
+
+
+def plus_1(x):
+    return x + 1
+
+
+def plus_1_dataframe(x):
+    return pd.DataFrame({"output": [x + 1]})
+
+
+def configure_test_scenario(input_data, frequency=None):
+    input_cfg = Config.configure_data_node(
+        id=f"i_{input_data}", storage_type="pickle", scope=Scope.SCENARIO, default_data=input_data
+    )
+    csv_output_cfg = Config.configure_data_node(id=f"o_{input_data}_csv", storage_type="csv")
+    excel_output_cfg = Config.configure_data_node(id=f"o_{input_data}_excel", storage_type="excel")
+    parquet_output_cfg = Config.configure_data_node(id=f"o_{input_data}_parquet", storage_type="parquet")
+    json_output_cfg = Config.configure_data_node(id=f"o_{input_data}_json", storage_type="json")
+
+    csv_task_cfg = Config.configure_task(f"t_{input_data}_csv", plus_1_dataframe, input_cfg, csv_output_cfg)
+    excel_task_cfg = Config.configure_task(f"t_{input_data}_excel", plus_1_dataframe, input_cfg, excel_output_cfg)
+    parquet_task_cfg = Config.configure_task(f"t_{input_data}_parquet", plus_1_dataframe, input_cfg, parquet_output_cfg)
+    json_task_cfg = Config.configure_task(f"t_{input_data}_json", plus_1, input_cfg, json_output_cfg)
+    scenario_cfg = Config.configure_scenario(
+        id=f"s_{input_data}",
+        task_configs=[csv_task_cfg, excel_task_cfg, parquet_task_cfg, json_task_cfg],
+        frequency=frequency,
+    )
+
+    return scenario_cfg
+
+
+def export_test_scenario(scenario_cfg, export_path="tmp.zip", override=False, include_data=False):
+    scenario = tp.create_scenario(scenario_cfg)
+    tp.submit(scenario)
+
+    # Export the submitted scenario
+    tp.export_scenario(scenario.id, export_path, override, include_data)
+    return scenario
+
+
+def test_import_scenario_without_data(init_managers):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    scenario = export_test_scenario(scenario_cfg)
+
+    init_managers()
+
+    assert _ScenarioManager._get_all() == []
+    imported_scenario = tp.import_scenario("tmp.zip")
+
+    # The imported scenario should be the same as the exported scenario
+    assert _ScenarioManager._get_all() == [imported_scenario]
+    assert imported_scenario == scenario
+
+    # All entities belonging to the scenario should be imported
+    assert len(_CycleManager._get_all()) == 1
+    assert len(_TaskManager._get_all()) == 4
+    assert len(_DataManager._get_all()) == 5
+    assert len(_JobManager._get_all()) == 4
+    assert len(_SubmissionManager._get_all()) == 1
+    assert len(_VersionManager._get_all()) == 1
+
+
+def test_import_scenario_with_data(init_managers):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg, include_data=True)
+
+    init_managers()
+
+    assert _ScenarioManager._get_all() == []
+    imported_scenario = tp.import_scenario("tmp.zip")
+
+    # All data of all data nodes should be imported
+    assert all(os.path.exists(dn.path) for dn in imported_scenario.data_nodes.values())
+
+
+def test_import_scenario_when_entities_are_already_existed_should_rollback(caplog):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg)
+
+    caplog.clear()
+
+    _CycleManager._delete_all()
+    _TaskManager._delete_all()
+    _DataManager._delete_all()
+    _JobManager._delete_all()
+    _ScenarioManager._delete_all()
+
+    assert len(_CycleManager._get_all()) == 0
+    assert len(_TaskManager._get_all()) == 0
+    assert len(_DataManager._get_all()) == 0
+    assert len(_JobManager._get_all()) == 0
+    assert len(_SubmissionManager._get_all()) == 1  # Keep the submission entity to test the rollback
+    submission_id = _SubmissionManager._get_all()[0].id
+    assert len(_ScenarioManager._get_all()) == 0
+
+    # Import the scenario when the old entities still exist should raise an error
+    with pytest.raises(EntitiesToBeImportAlredyExist):
+        tp.import_scenario("tmp.zip")
+    assert all(log.levelname in ["ERROR", "INFO"] for log in caplog.records)
+    assert "An error occurred during the import" in caplog.text
+    assert f"{submission_id} already exists. Please use the 'override' parameter to override it" in caplog.text
+
+    # No entity should be imported and the old entities should be kept
+    assert len(_CycleManager._get_all()) == 0
+    assert len(_TaskManager._get_all()) == 0
+    assert len(_DataManager._get_all()) == 0
+    assert len(_JobManager._get_all()) == 0
+    assert len(_SubmissionManager._get_all()) == 1  # Keep the submission entity to test the rollback
+    assert len(_ScenarioManager._get_all()) == 0
+
+    caplog.clear()
+
+    # Import with override flag
+    tp.import_scenario("tmp.zip", override=True)
+    assert all(log.levelname in ["WARNING", "INFO"] for log in caplog.records)
+    assert f"{submission_id} already exists and will be overridden" in caplog.text
+
+    # The scenario is imported and overridden the old one
+    assert len(_ScenarioManager._get_all()) == 1
+    assert len(_CycleManager._get_all()) == 1
+    assert len(_TaskManager._get_all()) == 4
+    assert len(_DataManager._get_all()) == 5
+    assert len(_JobManager._get_all()) == 4
+    assert len(_SubmissionManager._get_all()) == 1
+    assert len(_VersionManager._get_all()) == 1
+
+
+def test_import_incompatible_scenario(init_managers):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg)
+
+    Config.unblock_update()
+
+    # Configure a new dn to make the exported version incompatible
+    Config.configure_data_node("new_dn")
+
+    with pytest.raises(ConflictedConfigurationError):
+        tp.import_scenario("tmp.zip")
+
+
+def test_import_a_non_exists_folder():
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg)
+
+    with pytest.raises(FileNotFoundError):
+        tp.import_scenario("non_exists_folder")
+
+
+def test_import_an_empty_archive(tmpdir_factory):
+    empty_folder = tmpdir_factory.mktemp("empty_folder").strpath
+    shutil.make_archive("tmp", "zip", empty_folder)
+
+    with pytest.raises(ImportArchiveDoesntContainAnyScenario):
+        tp.import_scenario("tmp.zip")
+
+
+def test_import_with_no_version(tmp_path):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg)
+
+    # Extract the zip,
+    with zipfile.ZipFile("./tmp.zip") as zip_file:
+        zip_file.extractall(tmp_path)
+    # remove the version,
+    shutil.rmtree(f"{tmp_path}/version")
+    # and archive the scenario without the version again
+    shutil.make_archive("tmp", "zip", tmp_path)
+
+    with pytest.raises(ImportScenarioDoesntHaveAVersion):
+        tp.import_scenario("tmp.zip")

+ 174 - 0
tests/core/test_taipy/test_import_with_sql_repo.py

@@ -0,0 +1,174 @@
+# 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 os
+
+import pandas as pd
+import pytest
+
+import taipy.core.taipy as tp
+from taipy import Config, Frequency, Scope
+from taipy.core._version._version_manager import _VersionManager
+from taipy.core.cycle._cycle_manager import _CycleManager
+from taipy.core.data._data_manager import _DataManager
+from taipy.core.exceptions.exceptions import ConflictedConfigurationError, EntitiesToBeImportAlredyExist
+from taipy.core.job._job_manager import _JobManager
+from taipy.core.scenario._scenario_manager import _ScenarioManager
+from taipy.core.submission._submission_manager import _SubmissionManager
+from taipy.core.task._task_manager import _TaskManager
+
+
+@pytest.fixture(scope="function", autouse=True)
+def clean_export_zip_file():
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
+    yield
+    if os.path.exists("./tmp.zip"):
+        os.remove("./tmp.zip")
+
+
+def plus_1(x):
+    return x + 1
+
+
+def plus_1_dataframe(x):
+    return pd.DataFrame({"output": [x + 1]})
+
+
+def configure_test_scenario(input_data, frequency=None):
+    input_cfg = Config.configure_data_node(
+        id=f"i_{input_data}", storage_type="pickle", scope=Scope.SCENARIO, default_data=input_data
+    )
+    csv_output_cfg = Config.configure_data_node(id=f"o_{input_data}_csv", storage_type="csv")
+    excel_output_cfg = Config.configure_data_node(id=f"o_{input_data}_excel", storage_type="excel")
+    parquet_output_cfg = Config.configure_data_node(id=f"o_{input_data}_parquet", storage_type="parquet")
+    json_output_cfg = Config.configure_data_node(id=f"o_{input_data}_json", storage_type="json")
+
+    csv_task_cfg = Config.configure_task(f"t_{input_data}_csv", plus_1_dataframe, input_cfg, csv_output_cfg)
+    excel_task_cfg = Config.configure_task(f"t_{input_data}_excel", plus_1_dataframe, input_cfg, excel_output_cfg)
+    parquet_task_cfg = Config.configure_task(f"t_{input_data}_parquet", plus_1_dataframe, input_cfg, parquet_output_cfg)
+    json_task_cfg = Config.configure_task(f"t_{input_data}_json", plus_1, input_cfg, json_output_cfg)
+    scenario_cfg = Config.configure_scenario(
+        id=f"s_{input_data}",
+        task_configs=[csv_task_cfg, excel_task_cfg, parquet_task_cfg, json_task_cfg],
+        frequency=frequency,
+    )
+
+    return scenario_cfg
+
+
+def export_test_scenario(scenario_cfg, export_path="tmp.zip", override=False, include_data=False):
+    scenario = tp.create_scenario(scenario_cfg)
+    tp.submit(scenario)
+
+    # Export the submitted scenario
+    tp.export_scenario(scenario.id, export_path, override, include_data)
+    return scenario
+
+
+def test_import_scenario_without_data(init_sql_repo, init_managers):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    scenario = export_test_scenario(scenario_cfg)
+
+    init_managers()
+
+    assert _ScenarioManager._get_all() == []
+    imported_scenario = tp.import_scenario("tmp.zip")
+
+    # The imported scenario should be the same as the exported scenario
+    assert _ScenarioManager._get_all() == [imported_scenario]
+    assert imported_scenario == scenario
+
+    # All entities belonging to the scenario should be imported
+    assert len(_CycleManager._get_all()) == 1
+    assert len(_TaskManager._get_all()) == 4
+    assert len(_DataManager._get_all()) == 5
+    assert len(_JobManager._get_all()) == 4
+    assert len(_SubmissionManager._get_all()) == 1
+    assert len(_VersionManager._get_all()) == 1
+
+
+def test_import_scenario_with_data(init_sql_repo, init_managers):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg, include_data=True)
+
+    init_managers()
+
+    assert _ScenarioManager._get_all() == []
+    imported_scenario = tp.import_scenario("tmp.zip")
+
+    # All data of all data nodes should be imported
+    assert all(os.path.exists(dn.path) for dn in imported_scenario.data_nodes.values())
+
+
+def test_import_scenario_when_entities_are_already_existed_should_rollback(init_sql_repo, caplog):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg)
+
+    caplog.clear()
+
+    _CycleManager._delete_all()
+    _TaskManager._delete_all()
+    _DataManager._delete_all()
+    _JobManager._delete_all()
+    _ScenarioManager._delete_all()
+
+    assert len(_CycleManager._get_all()) == 0
+    assert len(_TaskManager._get_all()) == 0
+    assert len(_DataManager._get_all()) == 0
+    assert len(_JobManager._get_all()) == 0
+    assert len(_SubmissionManager._get_all()) == 1  # Keep the submission entity to test the rollback
+    submission_id = _SubmissionManager._get_all()[0].id
+    assert len(_ScenarioManager._get_all()) == 0
+
+    # Import the scenario when the old entities still exist should raise an error
+    with pytest.raises(EntitiesToBeImportAlredyExist):
+        tp.import_scenario("tmp.zip")
+    assert all(log.levelname in ["ERROR", "INFO"] for log in caplog.records)
+    assert "An error occurred during the import" in caplog.text
+    assert f"{submission_id} already exists. Please use the 'override' parameter to override it" in caplog.text
+
+    # No entity should be imported and the old entities should be kept
+    assert len(_CycleManager._get_all()) == 0
+    assert len(_TaskManager._get_all()) == 0
+    assert len(_DataManager._get_all()) == 0
+    assert len(_JobManager._get_all()) == 0
+    assert len(_SubmissionManager._get_all()) == 1  # Keep the submission entity to test the rollback
+    assert len(_ScenarioManager._get_all()) == 0
+
+    caplog.clear()
+
+    # Import with override flag
+    tp.import_scenario("tmp.zip", override=True)
+    assert all(log.levelname in ["WARNING", "INFO"] for log in caplog.records)
+    assert f"{submission_id} already exists and will be overridden" in caplog.text
+
+    # The scenario is imported and overridden the old one
+    assert len(_ScenarioManager._get_all()) == 1
+    assert len(_CycleManager._get_all()) == 1
+    assert len(_TaskManager._get_all()) == 4
+    assert len(_DataManager._get_all()) == 5
+    assert len(_JobManager._get_all()) == 4
+    assert len(_SubmissionManager._get_all()) == 1
+    assert len(_VersionManager._get_all()) == 1
+
+
+def test_import_incompatible_scenario(init_sql_repo, init_managers):
+    scenario_cfg = configure_test_scenario(1, frequency=Frequency.DAILY)
+    export_test_scenario(scenario_cfg)
+
+    Config.unblock_update()
+
+    # Configure a new dn to make the exported version incompatible
+    Config.configure_data_node("new_dn")
+
+    with pytest.raises(ConflictedConfigurationError):
+        tp.import_scenario("tmp.zip")

+ 4 - 4
tests/gui/utils/test_types.py

@@ -15,16 +15,16 @@ import warnings
 import pytest
 
 from taipy.gui.utils.date import _string_to_date
-from taipy.gui.utils.types import _TaipyBase, _TaipyBool, _TaipyDate, _TaipyNumber
+from taipy.gui.utils.types import _TaipyBool, _TaipyData, _TaipyDate, _TaipyNumber
 
 
-def test_taipy_base():
-    tb = _TaipyBase("value", "hash")
+def test_taipy_data():
+    tb = _TaipyData("value", "hash")
     assert tb.get() == "value"
     assert tb.get_name() == "hash"
     tb.set("a value")
     assert tb.get() == "a value"
-    assert tb.get_hash() == NotImplementedError
+    assert tb.get_hash() == "_TpD"
 
 
 def test_taipy_bool():

+ 12 - 8
tests/gui_core/test_context_is_deletable.py

@@ -63,11 +63,12 @@ class TestGuiCoreContext_is_deletable:
                         True,
                         True,
                         {"name": "name", "id": a_scenario.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_sc_error"
+            assert assign.call_args.args[0] == "error_var"
             assert str(assign.call_args.args[1]).startswith("Error deleting Scenario.")
 
             with patch("taipy.gui_core._context.is_deletable", side_effect=mock_is_deletable_false):
@@ -82,11 +83,12 @@ class TestGuiCoreContext_is_deletable:
                             True,
                             True,
                             {"name": "name", "id": a_scenario.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_sc_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not deletable.")
 
     def test_act_on_jobs(self):
@@ -101,11 +103,12 @@ class TestGuiCoreContext_is_deletable:
                 {
                     "args": [
                         {"id": [a_job.id], "action": "delete"},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_js_error"
+            assert assign.call_args.args[0] == "error_var"
             assert str(assign.call_args.args[1]).find("is not deletable.") == -1
             assign.reset_mock()
 
@@ -116,9 +119,10 @@ class TestGuiCoreContext_is_deletable:
                     {
                         "args": [
                             {"id": [a_job.id], "action": "delete"},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_js_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")

+ 39 - 25
tests/gui_core/test_context_is_editable.py

@@ -62,7 +62,8 @@ class TestGuiCoreContext_is_editable:
                         True,
                         False,
                         {"name": "name", "id": a_scenario.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_not_called()
@@ -79,11 +80,12 @@ class TestGuiCoreContext_is_editable:
                             True,
                             False,
                             {"name": "name", "id": a_scenario.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_sc_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not editable.")
 
     def test_edit_entity(self):
@@ -96,11 +98,12 @@ class TestGuiCoreContext_is_editable:
                 {
                     "args": [
                         {"name": "name", "id": a_scenario.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_sv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[1] == ""
 
             with patch("taipy.gui_core._context.is_editable", side_effect=mock_is_editable_false):
@@ -111,11 +114,12 @@ class TestGuiCoreContext_is_editable:
                     {
                         "args": [
                             {"name": "name", "id": a_scenario.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_sv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not editable.")
 
     def test_act_on_jobs(self):
@@ -130,11 +134,12 @@ class TestGuiCoreContext_is_editable:
                 {
                     "args": [
                         {"id": [a_job.id], "action": "cancel"},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_js_error"
+            assert assign.call_args.args[0] == "error_var"
             assert str(assign.call_args.args[1]).find("is not editable.") == -1
             assign.reset_mock()
 
@@ -145,11 +150,12 @@ class TestGuiCoreContext_is_editable:
                     {
                         "args": [
                             {"id": [a_job.id], "action": "cancel"},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_js_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_edit_data_node(self):
@@ -162,11 +168,12 @@ class TestGuiCoreContext_is_editable:
                 {
                     "args": [
                         {"id": a_datanode.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_dv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[1] == ""
 
             with patch("taipy.gui_core._context.is_editable", side_effect=mock_is_editable_false):
@@ -177,11 +184,12 @@ class TestGuiCoreContext_is_editable:
                     {
                         "args": [
                             {"id": a_datanode.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not editable.")
 
     def test_lock_datanode_for_edit(self):
@@ -196,11 +204,12 @@ class TestGuiCoreContext_is_editable:
                 {
                     "args": [
                         {"id": a_datanode.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_dv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[1] == ""
 
             with patch("taipy.gui_core._context.is_editable", side_effect=mock_is_editable_false):
@@ -211,11 +220,12 @@ class TestGuiCoreContext_is_editable:
                     {
                         "args": [
                             {"id": a_datanode.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not editable.")
 
     def test_update_data(self):
@@ -230,11 +240,12 @@ class TestGuiCoreContext_is_editable:
                 {
                     "args": [
                         {"id": a_datanode.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called()
-            assert assign.call_args_list[0].args[0] == "gui_core_dv_error"
+            assert assign.call_args_list[0].args[0] == "error_var"
             assert assign.call_args_list[0].args[1] == ""
             assign.reset_mock()
 
@@ -245,11 +256,12 @@ class TestGuiCoreContext_is_editable:
                     {
                         "args": [
                             {"id": a_datanode.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not editable.")
 
     def test_tabular_data_edit(self):
@@ -263,10 +275,11 @@ class TestGuiCoreContext_is_editable:
                 "",
                 {
                     "user_data": {"dn_id": a_datanode.id},
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args_list[0].args[0] == "gui_core_dv_error"
+            assert assign.call_args_list[0].args[0] == "error_var"
             assert (
                 assign.call_args_list[0].args[1]
                 == "Error updating Datanode tabular value: type does not support at[] indexer."
@@ -279,8 +292,9 @@ class TestGuiCoreContext_is_editable:
                     "",
                     {
                         "user_data": {"dn_id": a_datanode.id},
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not editable.")

+ 6 - 4
tests/gui_core/test_context_is_promotable.py

@@ -59,11 +59,12 @@ class TestGuiCoreContext_is_promotable:
                 {
                     "args": [
                         {"name": "name", "id": a_scenario.id, "primary": True},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_sv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert str(assign.call_args.args[1]).endswith("to primary because it doesn't belong to a cycle.")
             assign.reset_mock()
 
@@ -74,9 +75,10 @@ class TestGuiCoreContext_is_promotable:
                     {
                         "args": [
                             {"name": "name", "id": a_scenario.id, "primary": True},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_sv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not promotable.")

+ 66 - 32
tests/gui_core/test_context_is_readable.py

@@ -11,15 +11,18 @@
 
 import contextlib
 import typing as t
+from datetime import datetime
 from unittest.mock import Mock, patch
 
+from taipy.config.common.frequency import Frequency
 from taipy.config.common.scope import Scope
-from taipy.core import Job, JobId, Scenario, Task
+from taipy.core import Cycle, CycleId, Job, JobId, Scenario, Task
 from taipy.core.data.pickle import PickleDataNode
 from taipy.core.submission.submission import Submission, SubmissionStatus
 from taipy.gui import Gui
 from taipy.gui_core._context import _GuiCoreContext
 
+a_cycle = Cycle(Frequency.DAILY, {}, datetime.now(), datetime.now(), datetime.now(), id=CycleId("CYCLE_id"))
 a_scenario = Scenario("scenario_config_id", None, {}, sequences={"sequence": {}})
 a_task = Task("task_config_id", {}, print)
 a_job = Job(t.cast(JobId, "JOB_job_id"), a_task, "submit_id", a_scenario.id)
@@ -50,6 +53,8 @@ def mock_core_get(entity_id):
         return a_datanode
     if entity_id == a_submission.id:
         return a_submission
+    if entity_id == a_cycle.id:
+        return a_cycle
     return a_task
 
 
@@ -62,14 +67,27 @@ class TestGuiCoreContext_is_readable:
     def test_scenario_adapter(self):
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get):
             gui_core_context = _GuiCoreContext(Mock())
+            gui_core_context.scenario_by_cycle = {}
             outcome = gui_core_context.scenario_adapter(a_scenario)
-            assert isinstance(outcome, tuple)
+            assert isinstance(outcome, list)
             assert outcome[0] == a_scenario.id
 
             with patch("taipy.gui_core._context.is_readable", side_effect=mock_is_readable_false):
                 outcome = gui_core_context.scenario_adapter(a_scenario)
                 assert outcome is None
 
+    def test_cycle_adapter(self):
+        with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get):
+            gui_core_context = _GuiCoreContext(Mock())
+            gui_core_context.scenario_by_cycle = {"a": 1}
+            outcome = gui_core_context.cycle_adapter(a_cycle)
+            assert isinstance(outcome, list)
+            assert outcome[0] == a_cycle.id
+
+            with patch("taipy.gui_core._context.is_readable", side_effect=mock_is_readable_false):
+                outcome = gui_core_context.cycle_adapter(a_cycle)
+                assert outcome is None
+
     def test_get_scenario_by_id(self):
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get):
             gui_core_context = _GuiCoreContext(Mock())
@@ -94,7 +112,8 @@ class TestGuiCoreContext_is_readable:
                         True,
                         False,
                         {"name": "name", "id": a_scenario.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_not_called()
@@ -111,11 +130,12 @@ class TestGuiCoreContext_is_readable:
                             True,
                             False,
                             {"name": "name", "id": a_scenario.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_sc_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_edit_entity(self):
@@ -128,11 +148,12 @@ class TestGuiCoreContext_is_readable:
                 {
                     "args": [
                         {"name": "name", "id": a_scenario.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_sv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[1] == ""
 
             with patch("taipy.gui_core._context.is_readable", side_effect=mock_is_readable_false):
@@ -143,11 +164,12 @@ class TestGuiCoreContext_is_readable:
                     {
                         "args": [
                             {"name": "name", "id": a_scenario.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_sv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_submission_status_callback(self):
@@ -209,11 +231,12 @@ class TestGuiCoreContext_is_readable:
                 {
                     "args": [
                         {"id": [a_job.id], "action": "delete"},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_js_error"
+            assert assign.call_args.args[0] == "error_var"
             assert str(assign.call_args.args[1]).find("is not readable.") == -1
             assign.reset_mock()
 
@@ -223,11 +246,12 @@ class TestGuiCoreContext_is_readable:
                 {
                     "args": [
                         {"id": [a_job.id], "action": "cancel"},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_js_error"
+            assert assign.call_args.args[0] == "error_var"
             assert str(assign.call_args.args[1]).find("is not readable.") == -1
             assign.reset_mock()
 
@@ -238,11 +262,12 @@ class TestGuiCoreContext_is_readable:
                     {
                         "args": [
                             {"id": [a_job.id], "action": "delete"},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_js_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
                 assign.reset_mock()
 
@@ -252,11 +277,12 @@ class TestGuiCoreContext_is_readable:
                     {
                         "args": [
                             {"id": [a_job.id], "action": "cancel"},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_js_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_edit_data_node(self):
@@ -269,11 +295,12 @@ class TestGuiCoreContext_is_readable:
                 {
                     "args": [
                         {"id": a_datanode.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_dv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[1] == ""
 
             with patch("taipy.gui_core._context.is_readable", side_effect=mock_is_readable_false):
@@ -284,11 +311,12 @@ class TestGuiCoreContext_is_readable:
                     {
                         "args": [
                             {"id": a_datanode.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_lock_datanode_for_edit(self):
@@ -303,11 +331,12 @@ class TestGuiCoreContext_is_readable:
                 {
                     "args": [
                         {"id": a_datanode.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_dv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert assign.call_args.args[1] == ""
 
             with patch("taipy.gui_core._context.is_readable", side_effect=mock_is_readable_false):
@@ -318,17 +347,18 @@ class TestGuiCoreContext_is_readable:
                     {
                         "args": [
                             {"id": a_datanode.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_get_scenarios_for_owner(self):
         with patch("taipy.gui_core._context.core_get", side_effect=mock_core_get) as mockget:
             gui_core_context = _GuiCoreContext(Mock())
-            gui_core_context.get_scenarios_for_owner(a_scenario.id, '')
+            gui_core_context.get_scenarios_for_owner(a_scenario.id, "")
             mockget.assert_called_once()
             mockget.reset_mock()
 
@@ -348,11 +378,12 @@ class TestGuiCoreContext_is_readable:
                 {
                     "args": [
                         {"id": a_datanode.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called()
-            assert assign.call_args_list[0].args[0] == "gui_core_dv_error"
+            assert assign.call_args_list[0].args[0] == "error_var"
             assert assign.call_args_list[0].args[1] == ""
             assign.reset_mock()
 
@@ -363,11 +394,12 @@ class TestGuiCoreContext_is_readable:
                     {
                         "args": [
                             {"id": a_datanode.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_tabular_data_edit(self):
@@ -381,10 +413,11 @@ class TestGuiCoreContext_is_readable:
                 "",
                 {
                     "user_data": {"dn_id": a_datanode.id},
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args_list[0].args[0] == "gui_core_dv_error"
+            assert assign.call_args_list[0].args[0] == "error_var"
             assert (
                 assign.call_args_list[0].args[1]
                 == "Error updating Datanode tabular value: type does not support at[] indexer."
@@ -397,10 +430,11 @@ class TestGuiCoreContext_is_readable:
                     "",
                     {
                         "user_data": {"dn_id": a_datanode.id},
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_dv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not readable.")
 
     def test_get_data_node_tabular_data(self):

+ 6 - 4
tests/gui_core/test_context_is_submitable.py

@@ -59,11 +59,12 @@ class TestGuiCoreContext_is_submittable:
                 {
                     "args": [
                         {"name": "name", "id": a_scenario.id},
-                    ]
+                    ],
+                    "error_id": "error_var",
                 },
             )
             assign.assert_called_once()
-            assert assign.call_args.args[0] == "gui_core_sv_error"
+            assert assign.call_args.args[0] == "error_var"
             assert str(assign.call_args.args[1]).startswith("Error submitting entity.")
 
             with patch("taipy.gui_core._context.is_submittable", side_effect=mock_is_submittable_false):
@@ -74,9 +75,10 @@ class TestGuiCoreContext_is_submittable:
                     {
                         "args": [
                             {"name": "name", "id": a_scenario.id},
-                        ]
+                        ],
+                        "error_id": "error_var",
                     },
                 )
                 assign.assert_called_once()
-                assert assign.call_args.args[0] == "gui_core_sv_error"
+                assert assign.call_args.args[0] == "error_var"
                 assert str(assign.call_args.args[1]).endswith("is not submittable.")

+ 16 - 0
tests/rest/conftest.py

@@ -13,6 +13,7 @@ import os
 import shutil
 import uuid
 from datetime import datetime, timedelta
+from queue import Queue
 
 import pandas as pd
 import pytest
@@ -22,6 +23,7 @@ from taipy.config import Config
 from taipy.config.common.frequency import Frequency
 from taipy.config.common.scope import Scope
 from taipy.core import Cycle, DataNodeId, Job, JobId, Scenario, Sequence, Task
+from taipy.core._orchestrator._orchestrator_factory import _OrchestratorFactory
 from taipy.core.cycle._cycle_manager import _CycleManager
 from taipy.core.data.pickle import PickleDataNode
 from taipy.core.job._job_manager import _JobManager
@@ -323,3 +325,17 @@ def cleanup_files(reset_configuration_singleton, inject_core_sections):
     for path in [".data", ".my_data", "user_data", ".taipy"]:
         if os.path.exists(path):
             shutil.rmtree(path, ignore_errors=True)
+
+
+@pytest.fixture
+def init_orchestrator():
+    def _init_orchestrator():
+        _OrchestratorFactory._remove_dispatcher()
+
+        if _OrchestratorFactory._orchestrator is None:
+            _OrchestratorFactory._build_orchestrator()
+        _OrchestratorFactory._build_dispatcher(force_restart=True)
+        _OrchestratorFactory._orchestrator.jobs_to_run = Queue()
+        _OrchestratorFactory._orchestrator.blocked_jobs = []
+
+    return _init_orchestrator

+ 3 - 1
tests/rest/test_end_to_end.py

@@ -60,7 +60,7 @@ def delete(url, client):
     assert response.status_code == 200
 
 
-def test_end_to_end(client, setup_end_to_end):
+def test_end_to_end(client, setup_end_to_end, init_orchestrator):
     # Create Scenario: Should also create all of its dependencies(sequences, tasks, datanodes, etc)
     scenario = create_and_submit_scenario("scenario", client)
 
@@ -104,3 +104,5 @@ def test_end_to_end(client, setup_end_to_end):
     url_without_slash = url_for("api.scenarios")[:-1]
     get_all(url_with_slash, 1, client)
     get_all(url_without_slash, 1, client)
+
+    init_orchestrator()

+ 7 - 8
tests/rest/test_scenario.py

@@ -11,7 +11,6 @@
 
 from unittest import mock
 
-import pytest
 from flask import url_for
 
 
@@ -75,16 +74,16 @@ def test_get_all_scenarios(client, default_sequence, default_scenario_config_lis
     assert len(results) == 10
 
 
-@pytest.mark.xfail()
-def test_execute_scenario(client, default_scenario):
+def test_execute_scenario(client, default_scenario_config):
     # test 404
     user_url = url_for("api.scenario_submit", scenario_id="foo")
     rep = client.post(user_url)
     assert rep.status_code == 404
 
-    with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._get") as manager_mock:
-        manager_mock.return_value = default_scenario
+    with mock.patch("taipy.rest.api.resources.scenario.ScenarioList.fetch_config") as config_mock:
+        config_mock.return_value = default_scenario_config
+        scenarios_url = url_for("api.scenarios", config_id="bar")
+        scn = client.post(scenarios_url)
 
-        # test get_scenario
-        rep = client.post(url_for("api.scenario_submit", scenario_id="foo"))
-        assert rep.status_code == 200
+    rep = client.post(url_for("api.scenario_submit", scenario_id=scn.json["scenario"]["id"]))
+    assert rep.status_code == 200

+ 11 - 8
tests/rest/test_sequence.py

@@ -11,7 +11,6 @@
 
 from unittest import mock
 
-import pytest
 from flask import url_for
 
 from taipy.core.scenario._scenario_manager_factory import _ScenarioManagerFactory
@@ -85,16 +84,20 @@ def test_get_all_sequences(client, default_scenario_config_list):
     assert len(results) == 10
 
 
-@pytest.mark.xfail()
-def test_execute_sequence(client, default_sequence):
+def test_execute_sequence(client, default_scenario):
     # test 404
     user_url = url_for("api.sequence_submit", sequence_id="foo")
     rep = client.post(user_url)
     assert rep.status_code == 404
 
-    with mock.patch("taipy.core.sequence._sequence_manager._SequenceManager._get") as manager_mock:
-        manager_mock.return_value = default_sequence
+    _ScenarioManagerFactory._build_manager()._set(default_scenario)
+    with mock.patch("taipy.core.scenario._scenario_manager._ScenarioManager._get") as config_mock:
+        config_mock.return_value = default_scenario
+        sequences_url = url_for("api.sequences")
+        seq = client.post(
+            sequences_url, json={"scenario_id": default_scenario.id, "sequence_name": "sequence", "tasks": []}
+        )
 
-        # test get_sequence
-        rep = client.post(url_for("api.sequence_submit", sequence_id="foo"))
-        assert rep.status_code == 200
+    # test submit
+    rep = client.post(url_for("api.sequence_submit", sequence_id=seq.json["sequence"]["id"]))
+    assert rep.status_code == 200