Browse Source

add import export to datanode viewer (#1730)

* add import export to datanode viewer
resolves #1487

* dependencies

* fix test

* test

* forgot declaration

* add comment for unused parameter

* disable export button if no file

* better error msg

* update messages

* export => download

* messages

* fix test

* fix test

* Expose 2 APIs on File based data nodes to check if a file can be downloaded or uploaded. The APIs return ReasonCollection objects.

* use dn.is_downloadable and is_uploadable

* typos

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Dinh Long Nguyen <dinhlongviolin1@gmail.com>
Co-authored-by: jrobinAV <jeanrobin.medori@avaiga.com>
Fred Lefévère-Laoide 8 months ago
parent
commit
9e98a8f3f6

+ 1 - 1
frontend/taipy-gui/base/src/app.ts

@@ -243,7 +243,7 @@ export class TaipyApp {
     }
 
     upload(encodedName: string, files: FileList, progressCallback: (val: number) => void) {
-        return uploadFile(encodedName, files, progressCallback, this.clientId);
+        return uploadFile(encodedName, undefined, undefined, undefined, files, progressCallback, this.clientId);
     }
 
     getPageMetadata() {

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

@@ -148,6 +148,23 @@ export interface TableSortProps {
 
 export declare const TableSort: (props: TableSortProps) => JSX.Element;
 
+export interface FileSelectorProps extends TaipyActiveProps {
+    onAction?: string;
+    defaultLabel?: string;
+    label?: string;
+    multiple?: boolean;
+    extensions?: string;
+    dropMessage?: string;
+    notify?: boolean;
+    width?: string | number;
+    icon?: React.ReactNode;
+    withBorder?: boolean;
+    onUploadAction?: string;
+    uploadData?: string;
+}
+
+export declare const FileSelector: (props: FileSelectorProps) => JSX.Element;
+
 export declare const Router: () => JSX.Element;
 
 /**

+ 51 - 13
frontend/taipy-gui/src/components/Taipy/FileSelector.tsx

@@ -11,7 +11,17 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { ChangeEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import React, {
+    ChangeEvent,
+    CSSProperties,
+    ReactNode,
+    useCallback,
+    useContext,
+    useEffect,
+    useMemo,
+    useRef,
+    useState,
+} from "react";
 import Button from "@mui/material/Button";
 import LinearProgress from "@mui/material/LinearProgress";
 import Tooltip from "@mui/material/Tooltip";
@@ -20,8 +30,9 @@ import UploadFile from "@mui/icons-material/UploadFile";
 import { TaipyContext } from "../../context/taipyContext";
 import { createAlertAction, createSendActionNameAction } from "../../context/taipyReducers";
 import { useClassNames, useDynamicProperty, useModule } from "../../utils/hooks";
-import { getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
+import { expandSx, getCssSize, noDisplayStyle, TaipyActiveProps } from "./utils";
 import { uploadFile } from "../../workers/fileupload";
+import { SxProps } from "@mui/material";
 
 interface FileSelectorProps extends TaipyActiveProps {
     onAction?: string;
@@ -32,6 +43,10 @@ interface FileSelectorProps extends TaipyActiveProps {
     dropMessage?: string;
     notify?: boolean;
     width?: string | number;
+    icon?: ReactNode;
+    withBorder?: boolean;
+    onUploadAction?: string;
+    uploadData?: string;
 }
 
 const handleDragOver = (evt: DragEvent) => {
@@ -53,9 +68,10 @@ const FileSelector = (props: FileSelectorProps) => {
         dropMessage = "Drop here to Upload",
         label,
         notify = true,
+        withBorder = true,
     } = props;
     const [dropLabel, setDropLabel] = useState("");
-    const [dropSx, setDropSx] = useState(defaultSx);
+    const [dropSx, setDropSx] = useState<SxProps>(defaultSx);
     const [upload, setUpload] = useState(false);
     const [progress, setProgress] = useState(0);
     const { state, dispatch } = useContext(TaipyContext);
@@ -67,7 +83,17 @@ const FileSelector = (props: FileSelectorProps) => {
     const active = useDynamicProperty(props.active, props.defaultActive, true);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
 
-    useEffect(() => setDropSx((sx) => (props.width ? { ...sx, width: getCssSize(props.width) } : sx)), [props.width]);
+    useEffect(
+        () =>
+            setDropSx((sx: SxProps) =>
+                expandSx(
+                    sx,
+                    props.width ? { width: getCssSize(props.width) } : undefined,
+                    withBorder ? undefined : { border: "none" }
+                )
+            ),
+        [props.width, withBorder]
+    );
 
     const handleFiles = useCallback(
         (files: FileList | undefined | null, evt: Event | ChangeEvent) => {
@@ -75,7 +101,15 @@ const FileSelector = (props: FileSelectorProps) => {
             evt.preventDefault();
             if (files?.length) {
                 setUpload(true);
-                uploadFile(updateVarName, files, setProgress, state.id).then(
+                uploadFile(
+                    updateVarName,
+                    module,
+                    props.onUploadAction,
+                    props.uploadData,
+                    files,
+                    setProgress,
+                    state.id
+                ).then(
                     (value) => {
                         setUpload(false);
                         onAction && dispatch(createSendActionNameAction(id, module, onAction));
@@ -94,7 +128,7 @@ const FileSelector = (props: FileSelectorProps) => {
                 );
             }
         },
-        [state.id, id, onAction, notify, updateVarName, dispatch, module]
+        [state.id, id, onAction, props.onUploadAction, props.uploadData, notify, updateVarName, dispatch, module]
     );
 
     const handleChange = useCallback(
@@ -105,7 +139,7 @@ const FileSelector = (props: FileSelectorProps) => {
     const handleDrop = useCallback(
         (e: DragEvent) => {
             setDropLabel("");
-            setDropSx((sx) => ({ ...sx, ...defaultSx }));
+            setDropSx((sx: SxProps) => ({ ...sx, ...defaultSx }));
             handleFiles(e.dataTransfer?.files, e);
         },
         [handleFiles]
@@ -113,15 +147,19 @@ const FileSelector = (props: FileSelectorProps) => {
 
     const handleDragLeave = useCallback(() => {
         setDropLabel("");
-        setDropSx((sx) => ({ ...sx, ...defaultSx }));
+        setDropSx((sx: SxProps) => ({ ...sx, ...defaultSx }));
     }, []);
 
     const handleDragOverWithLabel = useCallback(
         (evt: DragEvent) => {
-            console.log(evt);
             const target = evt.currentTarget as HTMLElement;
-            setDropSx((sx) =>
-                sx.minWidth === defaultSx.minWidth && target ? { ...sx, minWidth: target.clientWidth + "px" } : sx
+            setDropSx((sx: SxProps) =>
+                expandSx(
+                    sx,
+                    (sx as CSSProperties).minWidth === defaultSx.minWidth && target
+                        ? { minWidth: target.clientWidth + "px" }
+                        : undefined
+                )
             );
             setDropLabel(dropMessage);
             handleDragOver(evt);
@@ -164,12 +202,12 @@ const FileSelector = (props: FileSelectorProps) => {
                         id={id}
                         component="span"
                         aria-label="upload"
-                        variant="outlined"
+                        variant={withBorder ? "outlined" : undefined}
                         disabled={!active || upload}
                         sx={dropSx}
                         ref={butRef}
                     >
-                        <UploadFile /> {dropLabel || label || defaultLabel}
+                        {props.icon || <UploadFile />} {dropLabel || label || defaultLabel}
                     </Button>
                 </span>
             </Tooltip>

+ 10 - 0
frontend/taipy-gui/src/components/Taipy/utils.ts

@@ -12,6 +12,7 @@
  */
 
 import { MouseEvent } from "react";
+import { SxProps } from "@mui/material";
 
 export interface TaipyActiveProps extends TaipyDynamicProps, TaipyHoverProps {
     defaultActive?: boolean;
@@ -146,3 +147,12 @@ export const getProps = (p: DateProps, start: boolean, val: Date | null, withTim
     }
     return { ...p, [propName]: val };
 };
+
+export const expandSx = (sx: SxProps, ...partials: (SxProps | undefined)[]) => {
+    return partials.reduce((prevSx: SxProps, partialSx) => {
+        if (partialSx) {
+            return { ...prevSx, ...partialSx } as SxProps;
+        }
+        return prevSx;
+    }, sx);
+};

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

@@ -13,6 +13,7 @@
 
 import Chart from "../components/Taipy/Chart";
 import Dialog from "../components/Taipy/Dialog";
+import FileSelector from "../components/Taipy/FileSelector";
 import Login from "../components/Taipy/Login";
 import Router from "../components/Router";
 import Table from "../components/Taipy/Table";
@@ -43,6 +44,7 @@ import {
 export {
     Chart,
     Dialog,
+    FileSelector,
     Login,
     Router,
     Table,

+ 4 - 1
frontend/taipy-gui/src/workers/fileupload.ts

@@ -19,6 +19,9 @@ const UPLOAD_URL = "/taipy-uploads";
 
 export const uploadFile = (
     varName: string,
+    context: string | undefined,
+    onAction: string | undefined,
+    uploadData: string | undefined,
     files: FileList,
     progressCallback: (val: number) => void,
     id: string,
@@ -35,6 +38,6 @@ export const uploadFile = (
             }
         };
         worker.onerror = (evt: ErrorEvent) => reject(evt);
-        worker.postMessage({ files: files, uploadUrl: uploadUrl, varName: varName, id: id } as FileUploadData);
+        worker.postMessage({ files, uploadUrl, varName, context, onAction, uploadData, id } as FileUploadData);
     });
 };

+ 3 - 0
frontend/taipy-gui/src/workers/fileupload.utils.ts

@@ -13,6 +13,9 @@
 
 export interface FileUploadData {
     varName: string;
+    context?: string;
+    onAction?: string;
+    uploadData?: string;
     files: FileList;
     uploadUrl: string;
     id: string;

+ 19 - 2
frontend/taipy-gui/src/workers/fileupload.worker.ts

@@ -17,6 +17,9 @@ const uploadFile = (
     blobOrFile: Blob,
     uploadUrl: string,
     varName: string,
+    context: string | undefined,
+    onAction: string | undefined,
+    uploadData: string | undefined,
     part: number,
     total: number,
     fileName: string,
@@ -33,6 +36,9 @@ const uploadFile = (
     fdata.append("part", part.toString());
     fdata.append("total", total.toString());
     fdata.append("var_name", varName);
+    context && fdata.append("context", context);
+    onAction && fdata.append("on_action", onAction);
+    uploadData && fdata.append("upload_data", uploadData);
     fdata.append("multiple", multiple ? "True" : "False");
     xhr.send(fdata);
 };
@@ -46,7 +52,15 @@ const getProgressCallback = (globalSize: number, offset: number) => (uploaded: n
         done: false,
     } as FileUploadReturn);
 
-const process = (files: FileList, uploadUrl: string, varName: string, id: string) => {
+const process = (
+    files: FileList,
+    uploadUrl: string,
+    varName: string,
+    context: string | undefined,
+    onAction: string | undefined,
+    uploadData: string | undefined,
+    id: string
+) => {
     if (files) {
         let globalSize = 0;
         for (let i = 0; i < files.length; i++) {
@@ -70,6 +84,9 @@ const process = (files: FileList, uploadUrl: string, varName: string, id: string
                     chunk,
                     uploadUrl,
                     varName,
+                    context,
+                    onAction,
+                    uploadData,
                     Math.floor(start / BYTES_PER_CHUNK),
                     tot,
                     blob.name,
@@ -94,5 +111,5 @@ const process = (files: FileList, uploadUrl: string, varName: string, id: string
 };
 
 self.onmessage = (e: MessageEvent<FileUploadData>) => {
-    process(e.data.files, e.data.uploadUrl, e.data.varName, e.data.id);
+    process(e.data.files, e.data.uploadUrl, e.data.varName, e.data.context, e.data.onAction, e.data.uploadData, e.data.id);
 };

+ 1 - 0
frontend/taipy/package-lock.json

@@ -41,6 +41,7 @@
       }
     },
     "../../taipy/gui/webapp": {
+      "name": "taipy-gui",
       "version": "4.0.0"
     },
     "node_modules/@babel/code-frame": {

+ 130 - 43
frontend/taipy/src/DataNodeViewer.tsx

@@ -28,23 +28,27 @@ import AccordionDetails from "@mui/material/AccordionDetails";
 import AccordionSummary from "@mui/material/AccordionSummary";
 import Alert from "@mui/material/Alert";
 import Box from "@mui/material/Box";
+import Button from "@mui/material/Button";
 import Divider from "@mui/material/Divider";
 import Grid from "@mui/material/Grid";
 import IconButton from "@mui/material/IconButton";
 import InputAdornment from "@mui/material/InputAdornment";
 import Popover from "@mui/material/Popover";
 import Switch from "@mui/material/Switch";
+import Stack from "@mui/material/Stack";
 import Tab from "@mui/material/Tab";
 import Tabs from "@mui/material/Tabs";
 import TextField from "@mui/material/TextField";
 import Tooltip from "@mui/material/Tooltip";
 import Typography from "@mui/material/Typography";
 
-import CheckCircle from "@mui/icons-material/CheckCircle";
-import Cancel from "@mui/icons-material/Cancel";
 import ArrowForwardIosSharp from "@mui/icons-material/ArrowForwardIosSharp";
+import Cancel from "@mui/icons-material/Cancel";
+import CheckCircle from "@mui/icons-material/CheckCircle";
+import Download from "@mui/icons-material/Download";
 import Launch from "@mui/icons-material/Launch";
 import LockOutlined from "@mui/icons-material/LockOutlined";
+import Upload from "@mui/icons-material/Upload";
 
 import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
 import { BaseDateTimePickerSlotProps } from "@mui/x-date-pickers/DateTimePicker/shared";
@@ -64,6 +68,7 @@ import {
     useDynamicProperty,
     useModule,
     Store,
+    FileSelector,
 } from "taipy-gui";
 
 import { Cycle as CycleIcon, Scenario as ScenarioIcon } from "./icons";
@@ -100,6 +105,7 @@ const editSx = {
     "& > div": { writingMode: "vertical-rl", transform: "rotate(180deg)", paddingBottom: "1em" },
 };
 const textFieldProps = { textField: { margin: "dense" } } as BaseDateTimePickerSlotProps<Date>;
+const buttonSx = { minWidth: "0px" };
 
 type DataNodeFull = [
     string, // id
@@ -115,7 +121,10 @@ type DataNodeFull = [
     boolean, // editInProgress
     string, // editorId
     string, // notReadableReason
-    string // notEditableReason
+    string, // notEditableReason
+    boolean, // is file based
+    string, // notDownloadableReason
+    string // notUploadableReason
 ];
 
 enum DataNodeFullProps {
@@ -133,6 +142,9 @@ enum DataNodeFullProps {
     editorId,
     notReadableReason,
     notEditableReason,
+    isFileBased,
+    notDownloadableReason,
+    notUploadableReason,
 }
 const DataNodeFullLength = Object.keys(DataNodeFullProps).length / 2;
 
@@ -180,6 +192,10 @@ interface DataNodeViewerProps {
     width?: string;
     onLock?: string;
     updateDnVars?: string;
+    fileDownload?: boolean;
+    fileUpload?: boolean;
+    uploadCheck?: string;
+    onFileAction?: string;
 }
 
 const dataValueFocus = "data-value";
@@ -208,6 +224,9 @@ const invalidDatanode: DataNodeFull = [
     "",
     "invalid",
     "invalid",
+    false,
+    "invalid",
+    "invalid",
 ];
 
 enum TabValues {
@@ -230,6 +249,8 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         showData = true,
         updateVars = "",
         updateDnVars = "",
+        fileDownload = false,
+        fileUpload = false,
     } = props;
 
     const { state, dispatch } = useContext<Store>(Context);
@@ -255,6 +276,9 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         dnEditorId,
         dnNotReadableReason,
         dnNotEditableReason,
+        isFileBased,
+        dnNotDownloadableReason,
+        dnNotUploadableReason,
     ] = datanode;
     const dtType = dnData[DatanodeDataProps.type];
     const dtValue = dnData[DatanodeDataProps.value] ?? (dtType == "float" ? null : undefined);
@@ -611,6 +635,37 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
         [props.width]
     );
 
+    // file action
+    const onfileHandler = useCallback(
+        (e: MouseEvent<HTMLElement>) => {
+            e.stopPropagation();
+            const { action = "import" } = e.currentTarget.dataset || {};
+            if (action == "export") {
+                dispatch(
+                    createSendActionNameAction(id, module, props.onFileAction, {
+                        id: dnId,
+                        action: action,
+                        type: "raw",
+                        error_id: getUpdateVar(updateDnVars, "error_id"),
+                    })
+                );
+            }
+        },
+        [props.onFileAction, dispatch, dnId, id, module, updateDnVars]
+    );
+
+    const uploadData = useMemo(
+        () =>
+            valid && isFileBased && fileUpload
+                ? JSON.stringify({
+                      id: dnId,
+                      error_id: getUpdateVar(updateDnVars, "error_id"),
+                      upload_check: props.uploadCheck,
+                  })
+                : undefined,
+        [dnId, valid, isFileBased, fileUpload, props.uploadCheck, updateDnVars]
+    );
+
     // Refresh on broadcast
     useEffect(() => {
         const ids = props.coreChanged?.datanode;
@@ -627,50 +682,82 @@ const DataNodeViewer = (props: DataNodeViewerProps) => {
                         expandIcon={expandable ? <ArrowForwardIosSharp sx={AccordionIconSx} /> : null}
                         sx={AccordionSummarySx}
                     >
-                        <Grid container alignItems="baseline" direction="row" spacing={1}>
-                            <Grid item>{dnLabel}</Grid>
-                            <Grid item>
-                                <Typography fontSize="smaller">{dnType}</Typography>
-                            </Grid>
-                        </Grid>
+                        <Stack direction="row" spacing={1} alignItems="center">
+                            <Typography>{dnLabel}</Typography>
+                            <Typography fontSize="smaller">{dnType}</Typography>
+                        </Stack>
                     </AccordionSummary>
                     <AccordionDetails>
                         <Box sx={tabBoxSx}>
-                            <Tabs value={tabValue} onChange={handleTabChange}>
-                                <Tab
-                                    label={
-                                        <Grid container alignItems="center">
-                                            <Grid item>Data</Grid>
-                                            {dnEditInProgress ? (
-                                                <Grid item>
-                                                    <Tooltip
-                                                        title={"locked " + (dnEditorId === editorId ? "by you" : "")}
+                            <Stack direction="row" justifyContent="space-between">
+                                <Tabs value={tabValue} onChange={handleTabChange}>
+                                    <Tab
+                                        label={
+                                            <Grid container alignItems="center">
+                                                <Grid item>Data</Grid>
+                                                {dnEditInProgress ? (
+                                                    <Grid item>
+                                                        <Tooltip
+                                                            title={
+                                                                "locked " + (dnEditorId === editorId ? "by you" : "")
+                                                            }
+                                                        >
+                                                            <LockOutlined
+                                                                fontSize="small"
+                                                                color={dnEditorId === editorId ? "disabled" : "primary"}
+                                                            />
+                                                        </Tooltip>
+                                                    </Grid>
+                                                ) : null}
+                                            </Grid>
+                                        }
+                                        id={`${uniqid}-data`}
+                                        aria-controls={`${uniqid}-dn-tabpanel-data`}
+                                        style={showData ? undefined : noDisplay}
+                                    />
+                                    <Tab
+                                        label="Properties"
+                                        id={`${uniqid}-properties`}
+                                        aria-controls={`${uniqid}-dn-tabpanel-properties`}
+                                    />
+                                    <Tab
+                                        label="History"
+                                        id={`${uniqid}-history`}
+                                        aria-controls={`${uniqid}-dn-tabpanel-history`}
+                                        style={showHistory ? undefined : noDisplay}
+                                    />
+                                </Tabs>
+                                {valid && isFileBased && (fileDownload || fileUpload) ? (
+                                    <Stack direction="row" spacing={1}>
+                                        {fileDownload ? (
+                                            <Tooltip
+                                                title={dnNotDownloadableReason ? dnNotDownloadableReason : "Download"}
+                                            >
+                                                <span>
+                                                    <Button
+                                                        data-action="export"
+                                                        onClick={onfileHandler}
+                                                        sx={buttonSx}
+                                                        disabled={!!dnNotDownloadableReason}
                                                     >
-                                                        <LockOutlined
-                                                            fontSize="small"
-                                                            color={dnEditorId === editorId ? "disabled" : "primary"}
-                                                        />
-                                                    </Tooltip>
-                                                </Grid>
-                                            ) : null}
-                                        </Grid>
-                                    }
-                                    id={`${uniqid}-data`}
-                                    aria-controls={`${uniqid}-dn-tabpanel-data`}
-                                    style={showData ? undefined : noDisplay}
-                                />
-                                <Tab
-                                    label="Properties"
-                                    id={`${uniqid}-properties`}
-                                    aria-controls={`${uniqid}-dn-tabpanel-properties`}
-                                />
-                                <Tab
-                                    label="History"
-                                    id={`${uniqid}-history`}
-                                    aria-controls={`${uniqid}-dn-tabpanel-history`}
-                                    style={showHistory ? undefined : noDisplay}
-                                />
-                            </Tabs>
+                                                        <Download />
+                                                    </Button>
+                                                </span>
+                                            </Tooltip>
+                                        ) : null}
+                                        {fileUpload ? (
+                                            <FileSelector
+                                                hoverText={dnNotUploadableReason ? dnNotUploadableReason : "Upload"}
+                                                icon={<Upload />}
+                                                withBorder={false}
+                                                onUploadAction={props.onFileAction}
+                                                uploadData={uploadData}
+                                                defaultActive={!dnNotUploadableReason}
+                                            />
+                                        ) : null}
+                                    </Stack>
+                                ) : null}
+                            </Stack>
                         </Box>
                         <div
                             role="tabpanel"

+ 26 - 35
frontend/taipy/src/ScenarioViewer.tsx

@@ -23,9 +23,11 @@ import Divider from "@mui/material/Divider";
 import Grid from "@mui/material/Grid";
 import IconButton from "@mui/material/IconButton";
 import InputAdornment from "@mui/material/InputAdornment";
+import Stack from "@mui/material/Stack";
 import TextField from "@mui/material/TextField";
 import Tooltip from "@mui/material/Tooltip";
 import Typography from "@mui/material/Typography";
+
 import Add from "@mui/icons-material/Add";
 import ArrowForwardIosSharp from "@mui/icons-material/ArrowForwardIosSharp";
 import Cancel from "@mui/icons-material/Cancel";
@@ -627,16 +629,9 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                         expandIcon={expandable ? <ArrowForwardIosSharp sx={AccordionIconSx} /> : null}
                         sx={AccordionSummarySx}
                     >
-                        <Grid
-                            container
-                            alignItems="center"
-                            direction="row"
-                            flexWrap="nowrap"
-                            justifyContent="space-between"
-                            spacing={1}
-                        >
-                            <Grid item>
-                                {scLabel}
+                        <Stack direction="row" justifyContent="space-between" width="100%" alignItems="center">
+                            <Stack direction="row" spacing={1}>
+                                <Typography>{scLabel}</Typography>
                                 {scPrimary ? (
                                     <Chip
                                         color="primary"
@@ -646,31 +641,27 @@ const ScenarioViewer = (props: ScenarioViewerProps) => {
                                     />
                                 ) : null}
                                 {submissionStatus > -1 ? <StatusChip status={submissionStatus} sx={ChipSx} /> : null}
-                            </Grid>
-                            <Grid item>
-                                {showSubmit ? (
-                                    <Tooltip
-                                        title={
-                                            disabled
-                                                ? scNotSubmittableReason || "Cannot submit Scenario"
-                                                : "Submit Scenario"
-                                        }
-                                    >
-                                        <span>
-                                            <Button
-                                                onClick={submitScenario}
-                                                disabled={disabled}
-                                                endIcon={
-                                                    <Send fontSize="medium" color={disableColor("info", disabled)} />
-                                                }
-                                            >
-                                                Submit
-                                            </Button>
-                                        </span>
-                                    </Tooltip>
-                                ) : null}
-                            </Grid>
-                        </Grid>
+                            </Stack>
+                            {showSubmit ? (
+                                <Tooltip
+                                    title={
+                                        disabled
+                                            ? scNotSubmittableReason || "Cannot submit Scenario"
+                                            : "Submit Scenario"
+                                    }
+                                >
+                                    <span>
+                                        <Button
+                                            onClick={submitScenario}
+                                            disabled={disabled}
+                                            endIcon={<Send fontSize="medium" color={disableColor("info", disabled)} />}
+                                        >
+                                            Submit
+                                        </Button>
+                                    </span>
+                                </Tooltip>
+                            ) : null}
+                        </Stack>
                     </AccordionSummary>
                     <AccordionDetails>
                         <Grid container rowSpacing={2}>

+ 23 - 1
taipy/core/data/_file_datanode_mixin.py

@@ -20,7 +20,7 @@ from taipy.config.config import Config
 from taipy.logger._taipy_logger import _TaipyLogger
 
 from .._entity._reload import _self_reload
-from ..reason import InvalidUploadFile, ReasonCollection, UploadFileCanNotBeRead
+from ..reason import InvalidUploadFile, NoFileToDownload, NotAFile, ReasonCollection, UploadFileCanNotBeRead
 from .data_node import DataNode
 from .data_node_id import Edit
 
@@ -97,6 +97,28 @@ class _FileDataNodeMixin(object):
             shutil.move(old_path, new_path)
         return new_path
 
+    def is_downloadable(self) -> ReasonCollection:
+        """Check if the data node is downloadable.
+
+        Returns:
+            A `ReasonCollection^` object containing the reasons why the data node is not downloadable.
+        """
+        collection = ReasonCollection()
+        if not os.path.exists(self.path):
+            collection._add_reason(self.id, NoFileToDownload(self.path, self.id))  # type: ignore[attr-defined]
+        elif not isfile(self.path):
+            collection._add_reason(self.id, NotAFile(self.path, self.id))  # type: ignore[attr-defined]
+        return collection
+
+    def is_uploadable(self) -> ReasonCollection:
+        """Check if the data node is uploadable.
+
+        Returns:
+            A `ReasonCollection^` object containing the reasons why the data node is not uploadable.
+        """
+
+        return ReasonCollection()
+
     def _get_downloadable_path(self) -> str:
         """Get the downloadable path of the file data of the data node.
 

+ 2 - 0
taipy/core/reason/__init__.py

@@ -16,6 +16,8 @@ from .reason import (
     EntityIsNotSubmittableEntity,
     InvalidUploadFile,
     JobIsNotFinished,
+    NoFileToDownload,
+    NotAFile,
     NotGlobalScope,
     Reason,
     ScenarioDoesNotBelongToACycle,

+ 28 - 0
taipy/core/reason/reason.py

@@ -143,6 +143,34 @@ class UploadFileCanNotBeRead(Reason, _DataNodeReasonMixin):
         _DataNodeReasonMixin.__init__(self, datanode_id)
 
 
+class NoFileToDownload(Reason, _DataNodeReasonMixin):
+    """
+    There is no file to download, therefore the download action cannot be performed.
+
+    Attributes:
+        datanode_id (str): The id of the data node that the file is intended to download from.
+    """
+
+    def __init__(self, file_path: str, datanode_id: str):
+        Reason.__init__(self, f"Path '{file_path}' from data node '{datanode_id}'"
+                              f" does not exist and cannot be downloaded.")
+        _DataNodeReasonMixin.__init__(self, datanode_id)
+
+
+class NotAFile(Reason, _DataNodeReasonMixin):
+    """
+    The data node path is not a file, therefore the download action cannot be performed.
+
+    Attributes:
+        datanode_id (str): The datanode id that the file is intended to download from.
+    """
+
+    def __init__(self, file_path: str, datanode_id: str):
+        Reason.__init__(self, f"Path '{file_path}' from data node '{datanode_id}'"
+                              f" is not a file and can t be downloaded.")
+        _DataNodeReasonMixin.__init__(self, datanode_id)
+
+
 class InvalidUploadFile(Reason, _DataNodeReasonMixin):
     """
     The uploaded file has invalid data, therefore is not a valid data file for the data node.

+ 1 - 0
taipy/gui/_renderers/factory.py

@@ -406,6 +406,7 @@ class _Factory:
                 ("on_action", PropertyType.function),
                 ("label",),
                 ("change_delay", PropertyType.number, gui._get_config("change_delay", None)),
+                ("width", PropertyType.string_or_number),
             ]
         ),
         "pane": lambda gui, control_type, attrs: _Builder(

+ 31 - 14
taipy/gui/gui.py

@@ -965,20 +965,23 @@ class Gui:
 
     def __upload_files(self):
         self.__set_client_id_in_context()
-        if "var_name" not in request.form:
-            _warn("No var name")
-            return ("No var name", 400)
-        var_name = request.form["var_name"]
+        on_upload_action = request.form.get("on_action", None)
+        var_name = request.form.get("var_name", None)
+        if not var_name and not on_upload_action:
+            _warn("upload files: No var name")
+            return ("upload files: No var name", 400)
+        context = request.form.get("context", None)
+        upload_data = request.form.get("upload_data", None)
         multiple = "multiple" in request.form and request.form["multiple"] == "True"
-        if "blob" not in request.files:
-            _warn("No file part")
-            return ("No file part", 400)
-        file = request.files["blob"]
+        file = request.files.get("blob", None)
+        if not file:
+            _warn("upload files: No file part")
+            return ("upload files: No file part", 400)
         # If the user does not select a file, the browser submits an
         # empty file without a filename.
         if file.filename == "":
-            _warn("No selected file")
-            return ("No selected file", 400)
+            _warn("upload files: No selected file")
+            return ("upload files: No selected file", 400)
         suffix = ""
         complete = True
         part = 0
@@ -1007,13 +1010,27 @@ class Gui:
                         return (f"Cannot group file after chunk upload for {file.filename}", 500)
                 # notify the file is uploaded
                 newvalue = str(file_path)
-                if multiple:
+                if multiple and var_name:
                     value = _getscopeattr(self, var_name)
                     if not isinstance(value, t.List):
                         value = [] if value is None else [value]
                     value.append(newvalue)
                     newvalue = value
-                setattr(self._bindings(), var_name, newvalue)
+                with self._set_locals_context(context):
+                    if on_upload_action:
+                        data = {}
+                        if upload_data:
+                            try:
+                                data = json.loads(upload_data)
+                            except Exception:
+                                pass
+                        data["path"] = file_path
+                        file_fn = self._get_user_function(on_upload_action)
+                        if not callable(file_fn):
+                            file_fn = _getscopeattr(self, on_upload_action)
+                        self._call_function_with_state(file_fn, ["file_upload", {"args": [data]}])
+                    else:
+                        setattr(self._bindings(), var_name, newvalue)
         return ("", 200)
 
     _data_request_counter = 1
@@ -2398,8 +2415,8 @@ class Gui:
             elif script_file.is_dir() and (script_file / "taipy.css").exists():
                 css_file = "taipy.css"
         if css_file is None:
-             script_file = script_file.with_name("taipy").with_suffix(".css")
-             if script_file.exists():
+            script_file = script_file.with_name("taipy").with_suffix(".css")
+            if script_file.exists():
                 css_file = f"{script_file.stem}.css"
         self.__css_file = css_file
 

+ 4 - 0
taipy/gui_core/_GuiCoreLib.py

@@ -219,6 +219,9 @@ class _GuiCore(ElementLibrary):
                 "class_name": ElementProperty(PropertyType.dynamic_string),
                 "scenario": ElementProperty(PropertyType.lov_value, "optional"),
                 "width": ElementProperty(PropertyType.string),
+                "file_download": ElementProperty(PropertyType.boolean, False),
+                "file_upload": ElementProperty(PropertyType.boolean, False),
+                "upload_check": ElementProperty(PropertyType.function),
             },
             inner_properties={
                 "on_edit": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.edit_data_node}}"),
@@ -259,6 +262,7 @@ class _GuiCore(ElementLibrary):
                     PropertyType.function, f"{{{__CTX_VAR_NAME}.tabular_data_edit}}"
                 ),
                 "on_lock": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.lock_datanode_for_edit}}"),
+                "on_file_action": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.on_file_action}}"),
                 "update_dn_vars": ElementProperty(
                     PropertyType.string,
                     f"data_id={__DATANODE_VIZ_DATA_ID_VAR}<tp:uniq:dn>;"

+ 12 - 3
taipy/gui_core/_adapters.py

@@ -36,6 +36,7 @@ from taipy.core import (
 )
 from taipy.core import get as core_get
 from taipy.core.config import Config
+from taipy.core.data._file_datanode_mixin import _FileDataNodeMixin
 from taipy.core.data._tabular_datanode_mixin import _TabularDataNodeMixin
 from taipy.core.reason import ReasonCollection
 from taipy.gui._warnings import _warn
@@ -59,6 +60,7 @@ class _EntityType(Enum):
 def _get_reason(rc: ReasonCollection, message: str):
     return "" if rc else f"{message}: {rc.reasons}"
 
+
 class _GuiCoreScenarioAdapter(_TaipyBase):
     __INNER_PROPS = ["name"]
 
@@ -225,11 +227,18 @@ class _GuiCoreDatanodeAdapter(_TaipyBase):
                         self.__get_data(datanode),
                         datanode._edit_in_progress,
                         datanode._editor_id,
-                        _get_reason(is_readable(datanode), "Datanode not readable"),
-                        _get_reason(is_editable(datanode), "Datanode not editable"),
+                        _get_reason(is_readable(datanode), "Data node not readable"),
+                        _get_reason(is_editable(datanode), "Data node not editable"),
+                        isinstance(datanode, _FileDataNodeMixin),
+                        f"Data unavailable: {reason.reasons}"
+                        if isinstance(datanode, _FileDataNodeMixin) and not (reason := datanode.is_downloadable())
+                        else "",
+                        f"Data unavailable: {reason.reasons}"
+                        if isinstance(datanode, _FileDataNodeMixin) and not (reason := datanode.is_uploadable())
+                        else "",
                     ]
             except Exception as e:
-                _warn(f"Access to datanode ({data.id if hasattr(data, 'id') else 'No_id'}) failed", e)
+                _warn(f"Access to data node ({data.id if hasattr(data, 'id') else 'No_id'}) failed", e)
 
         return None
 

+ 47 - 10
taipy/gui_core/_context.py

@@ -14,6 +14,7 @@ import json
 import typing as t
 from collections import defaultdict
 from numbers import Number
+from pathlib import Path
 from threading import Lock
 
 try:
@@ -53,6 +54,7 @@ from taipy.core import (
 from taipy.core import delete as core_delete
 from taipy.core import get as core_get
 from taipy.core import submit as core_submit
+from taipy.core.data._file_datanode_mixin import _FileDataNodeMixin
 from taipy.core.notification import CoreEventConsumerBase, EventEntityType
 from taipy.core.notification.event import Event, EventOperation
 from taipy.core.notification.notifier import Notifier
@@ -896,7 +898,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 self.__edit_properties(entity, data)
                 _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode. {e}")
+                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Data node. {e}")
 
     def lock_datanode_for_edit(self, state: State, id: str, payload: t.Dict[str, str]):
         self.__lazy_start()
@@ -906,7 +908,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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", error_var):
+        if not self.__check_readable_editable(state, entity_id, "Data node", error_var):
             return
         lock = data.get("lock", True)
         entity: DataNode = core_get(entity_id)
@@ -918,7 +920,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                     entity.unlock_edit(self.gui._get_client_id())
                 _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                _GuiCoreContext.__assign_var(state, error_var, f"Error locking Datanode. {e}")
+                _GuiCoreContext.__assign_var(state, error_var, f"Error locking Data node. {e}")
 
     def __edit_properties(self, entity: t.Union[Scenario, Sequence, DataNode], data: t.Dict[str, str]):
         with entity as ent:
@@ -1007,7 +1009,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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", error_var):
+        if not self.__check_readable_editable(state, entity_id, "Data node", error_var):
             return
         entity: DataNode = core_get(entity_id)
         if isinstance(entity, DataNode):
@@ -1025,7 +1027,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 entity.unlock_edit(self.gui._get_client_id())
                 _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode value. {e}")
+                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Data node 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):  # noqa:C901
@@ -1033,7 +1035,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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", error_var):
+        if not self.__check_readable_editable(state, dn_id, "Data node", error_var):
             return
         datanode = core_get(dn_id) if dn_id else None
         if isinstance(datanode, DataNode):
@@ -1070,7 +1072,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         _GuiCoreContext.__assign_var(
                             state,
                             error_var,
-                            "Error updating Datanode: dict values must be list or tuple.",
+                            "Error updating Data node: dict values must be list or tuple.",
                         )
                 else:
                     data_tuple = False
@@ -1095,7 +1097,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
                             _GuiCoreContext.__assign_var(
                                 state,
                                 error_var,
-                                "Error updating Datanode: cannot handle multi-column list value.",
+                                "Error updating data node: cannot handle multi-column list value.",
                             )
                         if data_tuple and new_data is not None:
                             new_data = tuple(new_data)
@@ -1103,13 +1105,13 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         _GuiCoreContext.__assign_var(
                             state,
                             error_var,
-                            "Error updating Datanode tabular value: type does not support at[] indexer.",
+                            "Error updating data node 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))
                     _GuiCoreContext.__assign_var(state, error_var, "")
             except Exception as e:
-                _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode tabular value. {e}")
+                _GuiCoreContext.__assign_var(state, error_var, f"Error updating data node tabular value. {e}")
         _GuiCoreContext.__assign_var(state, payload.get("data_id"), dn_id)
 
     def get_data_node_properties(self, id: str):
@@ -1199,6 +1201,41 @@ class _GuiCoreContext(CoreEventConsumerBase):
         self.__lazy_start()
         return "" if (reason := can_create()) else f"Cannot create scenario: {_get_reason(reason)}"
 
+    def on_file_action(self, state: State, id: str, payload: t.Dict[str, t.Any]):
+        args = t.cast(list, payload.get("args"))
+        act_payload = t.cast(t.Dict[str, str], args[0])
+        dn_id = t.cast(DataNodeId, act_payload.get("id"))
+        error_id = act_payload.get("error_id", "")
+        if reason := is_readable(dn_id):
+            try:
+                dn = t.cast(_FileDataNodeMixin, core_get(dn_id))
+                if act_payload.get("action") == "export":
+                    path = dn._get_downloadable_path()
+                    if path:
+                        self.gui._download(Path(path), dn_id)
+                    else:
+                        reason = dn.is_downloadable()
+                        state.assign(
+                            error_id,
+                            "Data unavailable: "
+                            + ("The data node has never been written." if reason else reason.reasons),
+                        )
+                else:
+                    checker_name = act_payload.get("upload_check")
+                    checker = self.gui._get_user_function(checker_name) if checker_name else None
+                    if not (
+                        reason := dn._upload(
+                            act_payload.get("path", ""),
+                            t.cast(t.Callable[[str, t.Any], bool], checker) if callable(checker) else None,
+                        )
+                    ):
+                        state.assign(error_id, f"Data unavailable: {reason.reasons}")
+
+            except Exception as e:
+                state.assign(error_id, f"Data node download error: {e}")
+        else:
+            state.assign(error_id, reason.reasons)
+
 
 def _get_reason(reason: t.Union[bool, ReasonCollection]):
     return reason.reasons if isinstance(reason, ReasonCollection) else " "

+ 29 - 0
tests/core/data/test_csv_data_node.py

@@ -30,6 +30,7 @@ from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data.csv import CSVDataNode
 from taipy.core.data.data_node_id import DataNodeId
 from taipy.core.exceptions.exceptions import InvalidExposedType
+from taipy.core.reason import NoFileToDownload, NotAFile
 
 
 @pytest.fixture(scope="function", autouse=True)
@@ -194,6 +195,29 @@ class TestCSVDataNode:
         assert ".data" not in dn.path
         assert os.path.exists(dn.path)
 
+    def test_is_downloadable(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.csv")
+        dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert reasons
+        assert reasons.reasons == ""
+
+    def test_is_not_downloadable_no_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/wrong_example.csv")
+        dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+
+    def test_is_not_downloadable_not_a_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
+        dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NotAFile(path, dn.id)) in reasons.reasons
+
     def test_get_downloadable_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.csv")
         dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
@@ -203,6 +227,11 @@ class TestCSVDataNode:
         dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": "NOT_EXISTING.csv", "exposed_type": "pandas"})
         assert dn._get_downloadable_path() == ""
 
+    def is_uploadable(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.csv")
+        dn = CSVDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        assert dn.is_uploadable()
+
     def test_upload(self, csv_file, tmpdir_factory):
         old_csv_path = tmpdir_factory.mktemp("data").join("df.csv").strpath
         old_data = pd.DataFrame([{"a": 0, "b": 1, "c": 2}, {"a": 3, "b": 4, "c": 5}])

+ 24 - 0
tests/core/data/test_excel_data_node.py

@@ -33,6 +33,7 @@ from taipy.core.exceptions.exceptions import (
     InvalidExposedType,
     NonExistingExcelSheet,
 )
+from taipy.core.reason import NoFileToDownload, NotAFile
 
 
 @pytest.fixture(scope="function", autouse=True)
@@ -409,6 +410,29 @@ class TestExcelDataNode:
         assert ".data" not in dn.path
         assert os.path.exists(dn.path)
 
+    def test_is_downloadable(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.xlsx")
+        dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert reasons
+        assert reasons.reasons == ""
+
+    def test_is_not_downloadable_no_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/wrong_path.xlsx")
+        dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+
+    def test_is_not_downloadable_not_a_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
+        dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NotAFile(path, dn.id)) in reasons.reasons
+
     def test_get_download_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.xlsx")
         dn = ExcelDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})

+ 24 - 0
tests/core/data/test_json_data_node.py

@@ -32,6 +32,7 @@ from taipy.core.data.data_node_id import DataNodeId
 from taipy.core.data.json import JSONDataNode
 from taipy.core.data.operator import JoinOperator, Operator
 from taipy.core.exceptions.exceptions import NoData
+from taipy.core.reason import NoFileToDownload, NotAFile
 
 
 @pytest.fixture(scope="function", autouse=True)
@@ -391,6 +392,29 @@ class TestJSONDataNode:
         assert ".data" not in dn.path
         assert os.path.exists(dn.path)
 
+    def test_is_downloadable(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json/example_dict.json")
+        dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": path})
+        reasons = dn.is_downloadable()
+        assert reasons
+        assert reasons.reasons == ""
+
+    def test_is_not_downloadable_no_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json/wrong_path.json")
+        dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": path})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+
+    def is_not_downloadable_not_a_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json")
+        dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": path})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NotAFile(path, dn.id)) in reasons.reasons
+
     def test_get_download_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/json/example_dict.json")
         dn = JSONDataNode("foo", Scope.SCENARIO, properties={"path": path})

+ 24 - 0
tests/core/data/test_parquet_data_node.py

@@ -34,6 +34,7 @@ from taipy.core.exceptions.exceptions import (
     UnknownCompressionAlgorithm,
     UnknownParquetEngine,
 )
+from taipy.core.reason import NoFileToDownload, NotAFile
 
 
 @pytest.fixture(scope="function", autouse=True)
@@ -234,6 +235,29 @@ class TestParquetDataNode:
         assert ".data" not in dn.path
         assert os.path.exists(dn.path)
 
+    def test_is_downloadable(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.parquet")
+        dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert reasons
+        assert reasons.reasons == ""
+
+    def test_is_not_downloadable_no_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/wrong_path.parquet")
+        dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+
+    def test_is_not_downloadable_not_a_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
+        dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NotAFile(path, dn.id)) in reasons.reasons
+
     def test_get_downloadable_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.parquet")
         dn = ParquetDataNode("foo", Scope.SCENARIO, properties={"path": path, "exposed_type": "pandas"})

+ 25 - 0
tests/core/data/test_pickle_data_node.py

@@ -27,6 +27,7 @@ from taipy.core.data._data_manager import _DataManager
 from taipy.core.data._data_manager_factory import _DataManagerFactory
 from taipy.core.data.pickle import PickleDataNode
 from taipy.core.exceptions.exceptions import NoData
+from taipy.core.reason import NoFileToDownload, NotAFile
 
 
 @pytest.fixture(scope="function", autouse=True)
@@ -205,6 +206,30 @@ class TestPickleDataNodeEntity:
         assert ".data" not in dn.path
         assert os.path.exists(dn.path)
 
+    def test_is_downloadable(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.p")
+        dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": path})
+        reasons = dn.is_downloadable()
+        assert reasons
+        assert reasons.reasons == ""
+
+    def test_is_not_downloadable_no_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/wrong_path.p")
+        dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": path})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NoFileToDownload(path, dn.id)) in reasons.reasons
+
+    def test_is_not_downloadable_not_a_file(self):
+        path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample")
+        dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": path})
+        reasons = dn.is_downloadable()
+        assert not reasons
+        assert len(reasons._reasons) == 1
+        assert str(NotAFile(path, dn.id)) in reasons.reasons
+
     def test_get_download_path(self):
         path = os.path.join(pathlib.Path(__file__).parent.resolve(), "data_sample/example.p")
         dn = PickleDataNode("foo", Scope.SCENARIO, properties={"path": path})

+ 2 - 1
tests/gui/control/test_date_range.py

@@ -45,7 +45,8 @@ def test_date_range_md_2(gui: Gui, test_client, helpers):
     helpers.test_control_md(gui, md_string, expected_list)
 
 
-def test_date_range_md_width(gui: Gui, helpers):
+def test_date_range_md_width(gui: Gui, test_client, helpers):
+    # do not remove test_client: it brings an app context needed for this test
     gui._bind_var_val(
         "dates", [datetime.strptime("15 Dec 2020", "%d %b %Y"), datetime.strptime("31 Dec 2020", "%d %b %Y")]
     )

+ 1 - 1
tests/gui/control/test_text.py

@@ -21,7 +21,7 @@ def test_text_md_1(gui: Gui, test_client, helpers):
 
 def test_text_md_width(gui: Gui, test_client, helpers):
     gui._bind_var_val("x", 10)
-    md_string = "<|{x}|width=70%|>"
+    md_string = "<|{x}|text|width=70%|>"
     expected_list = ["<Field", 'dataType="int"', 'defaultValue="10"', "value={tpec_TpExPr_x_TPMDL_0}", 'width="70%"']
     helpers.test_control_md(gui, md_string, expected_list)
 

+ 1 - 4
tests/gui_core/test_context_is_editable.py

@@ -282,10 +282,7 @@ class TestGuiCoreContext_is_editable:
             )
             assign.assert_called_once()
             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."
-            )
+            assert "tabular value: type does not support at[] indexer" in assign.call_args_list[0].args[1]
             assign.reset_mock()
 
             with patch("taipy.gui_core._context.is_editable", side_effect=mock_is_editable_false):

+ 1 - 4
tests/gui_core/test_context_is_readable.py

@@ -420,10 +420,7 @@ class TestGuiCoreContext_is_readable:
             )
             assign.assert_called_once()
             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."
-            )
+            assert "tabular value: type does not support at[] indexer" in assign.call_args_list[0].args[1]
             assign.reset_mock()
 
             with patch("taipy.gui_core._context.is_readable", side_effect=mock_is_readable_false):