Pārlūkot izejas kodu

table csv download (#944)

* table csv download
resolves #427

* mypy

* mypy

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 1 gadu atpakaļ
vecāks
revīzija
bdbaf83f68

+ 27 - 2
frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx

@@ -30,6 +30,7 @@ import Tooltip from "@mui/material/Tooltip";
 import AddIcon from "@mui/icons-material/Add";
 import AddIcon from "@mui/icons-material/Add";
 import DataSaverOn from "@mui/icons-material/DataSaverOn";
 import DataSaverOn from "@mui/icons-material/DataSaverOn";
 import DataSaverOff from "@mui/icons-material/DataSaverOff";
 import DataSaverOff from "@mui/icons-material/DataSaverOff";
+import Download from "@mui/icons-material/Download";
 
 
 import {
 import {
     createRequestInfiniteTableUpdateAction,
     createRequestInfiniteTableUpdateAction,
@@ -61,6 +62,7 @@ import {
     getTooltip,
     getTooltip,
     defaultColumns,
     defaultColumns,
     OnRowClick,
     OnRowClick,
+    DownloadAction,
 } from "./tableUtils";
 } from "./tableUtils";
 import {
 import {
     useClassNames,
     useClassNames,
@@ -181,6 +183,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
         onAction = "",
         onAction = "",
         size = DEFAULT_SIZE,
         size = DEFAULT_SIZE,
         userData,
         userData,
+        downloadable = false,
     } = props;
     } = props;
     const [rows, setRows] = useState<RowType[]>([]);
     const [rows, setRows] = useState<RowType[]>([]);
     const [rowCount, setRowCount] = useState(1000); // need something > 0 to bootstrap the infinite loader
     const [rowCount, setRowCount] = useState(1000); // need something > 0 to bootstrap the infinite loader
@@ -274,7 +277,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                         col.tooltip = props.tooltip;
                         col.tooltip = props.tooltip;
                     }
                     }
                 });
                 });
-                addDeleteColumn((active && (onAdd || onDelete) ? 1 : 0) + (active && filter ? 1 : 0), baseColumns);
+                addDeleteColumn((active && (onAdd || onDelete) ? 1 : 0) + (active && filter ? 1 : 0) + (active && downloadable ? 1 : 0), baseColumns);
                 const colsOrder = Object.keys(baseColumns).sort(getsortByIndex(baseColumns));
                 const colsOrder = Object.keys(baseColumns).sort(getsortByIndex(baseColumns));
                 const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
                 const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
                     if (baseColumns[col].style) {
                     if (baseColumns[col].style) {
@@ -305,7 +308,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             hNan,
             hNan,
             false,
             false,
         ];
         ];
-    }, [active, editable, onAdd, onDelete, baseColumns, props.lineStyle, props.tooltip, props.nanValue, props.filter]);
+    }, [active, editable, onAdd, onDelete, baseColumns, props.lineStyle, props.tooltip, props.nanValue, props.filter, downloadable]);
 
 
     const boxBodySx = useMemo(() => ({ height: height }), [height]);
     const boxBodySx = useMemo(() => ({ height: height }), [height]);
 
 
@@ -405,6 +408,17 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
         [visibleStartIndex, dispatch, updateVarName, onAdd, module, userData]
         [visibleStartIndex, dispatch, updateVarName, onAdd, module, userData]
     );
     );
 
 
+    const onDownload = useCallback(
+        () =>
+            dispatch(
+                createSendActionNameAction(updateVarName, module, {
+                    action: DownloadAction,
+                    user_data: userData,
+                })
+            ),
+        [dispatch, updateVarName, module, userData]
+    );
+
     const isItemLoaded = useCallback((index: number) => index < rows.length && !!rows[index], [rows]);
     const isItemLoaded = useCallback((index: number) => index < rows.length && !!rows[index], [rows]);
 
 
     const onCellValidation: OnCellValidation = useCallback(
     const onCellValidation: OnCellValidation = useCallback(
@@ -550,6 +564,17 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                                                             className={className}
                                                             className={className}
                                                         />
                                                         />
                                                     ) : null,
                                                     ) : null,
+                                                    active && downloadable ? (
+                                                        <Tooltip title="Download as CSV" key="downloadCsv">
+                                                            <IconButton
+                                                                onClick={onDownload}
+                                                                size="small"
+                                                                sx={iconInRowSx}
+                                                            >
+                                                                <Download fontSize="inherit" />
+                                                            </IconButton>
+                                                        </Tooltip>
+                                                    ) : null,
                                                 ]
                                                 ]
                                             ) : (
                                             ) : (
                                                 <TableSortLabel
                                                 <TableSortLabel

+ 27 - 2
frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx

@@ -39,6 +39,7 @@ import IconButton from "@mui/material/IconButton";
 import AddIcon from "@mui/icons-material/Add";
 import AddIcon from "@mui/icons-material/Add";
 import DataSaverOn from "@mui/icons-material/DataSaverOn";
 import DataSaverOn from "@mui/icons-material/DataSaverOn";
 import DataSaverOff from "@mui/icons-material/DataSaverOff";
 import DataSaverOff from "@mui/icons-material/DataSaverOff";
+import Download from "@mui/icons-material/Download";
 
 
 import { createRequestTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
 import { createRequestTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
 import {
 import {
@@ -67,6 +68,7 @@ import {
     getRowIndex,
     getRowIndex,
     getTooltip,
     getTooltip,
     OnRowClick,
     OnRowClick,
+    DownloadAction,
 } from "./tableUtils";
 } from "./tableUtils";
 import {
 import {
     useClassNames,
     useClassNames,
@@ -102,6 +104,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
         width = "100%",
         width = "100%",
         size = DEFAULT_SIZE,
         size = DEFAULT_SIZE,
         userData,
         userData,
+        downloadable = false,
     } = props;
     } = props;
     const pageSize = props.pageSize === undefined || props.pageSize < 1 ? 100 : Math.round(props.pageSize);
     const pageSize = props.pageSize === undefined || props.pageSize < 1 ? 100 : Math.round(props.pageSize);
     const [value, setValue] = useState<Record<string, unknown>>({});
     const [value, setValue] = useState<Record<string, unknown>>({});
@@ -142,7 +145,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                         col.tooltip = props.tooltip;
                         col.tooltip = props.tooltip;
                     }
                     }
                 });
                 });
-                addDeleteColumn((active && (onAdd || onDelete) ? 1 : 0) + (active && filter ? 1 : 0), baseColumns);
+                addDeleteColumn((active && (onAdd || onDelete) ? 1 : 0) + (active && filter ? 1 : 0) + (active && downloadable ? 1 : 0), baseColumns);
                 const colsOrder = Object.keys(baseColumns).sort(getsortByIndex(baseColumns));
                 const colsOrder = Object.keys(baseColumns).sort(getsortByIndex(baseColumns));
                 const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
                 const styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
                     if (baseColumns[col].style) {
                     if (baseColumns[col].style) {
@@ -173,7 +176,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
             hNan,
             hNan,
             false,
             false,
         ];
         ];
-    }, [active, editable, onAdd, onDelete, baseColumns, props.lineStyle, props.tooltip, props.nanValue, props.filter]);
+    }, [active, editable, onAdd, onDelete, baseColumns, props.lineStyle, props.tooltip, props.nanValue, props.filter, downloadable]);
 
 
     useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars);
     useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars);
 
 
@@ -311,6 +314,17 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
         [startIndex, dispatch, updateVarName, onAdd, module, userData]
         [startIndex, dispatch, updateVarName, onAdd, module, userData]
     );
     );
 
 
+    const onDownload = useCallback(
+        () =>
+            dispatch(
+                createSendActionNameAction(updateVarName, module, {
+                    action: DownloadAction,
+                    user_data: userData,
+                })
+            ),
+        [dispatch, updateVarName, module, userData]
+    );
+
     const tableContainerSx = useMemo(() => ({ maxHeight: height }), [height]);
     const tableContainerSx = useMemo(() => ({ maxHeight: height }), [height]);
 
 
     const pso = useMemo(() => {
     const pso = useMemo(() => {
@@ -441,6 +455,17 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                                             className={className}
                                                             className={className}
                                                         />
                                                         />
                                                     ) : null,
                                                     ) : null,
+                                                    active && downloadable ? (
+                                                        <Tooltip title="Download as CSV" key="downloadCsv">
+                                                            <IconButton
+                                                                onClick={onDownload}
+                                                                size="small"
+                                                                sx={iconInRowSx}
+                                                            >
+                                                                <Download fontSize="inherit" />
+                                                            </IconButton>
+                                                        </Tooltip>
+                                                    ) : null,
                                                 ]
                                                 ]
                                             ) : (
                                             ) : (
                                                 <TableSortLabel
                                                 <TableSortLabel

+ 3 - 0
frontend/taipy-gui/src/components/Taipy/tableUtils.tsx

@@ -128,8 +128,11 @@ export interface TaipyTableProps extends TaipyActiveProps, TaipyMultiSelectProps
     size?: "small" | "medium";
     size?: "small" | "medium";
     defaultKey?: string; // for testing purposes only
     defaultKey?: string; // for testing purposes only
     userData?: unknown;
     userData?: unknown;
+    downloadable?: boolean;
 }
 }
 
 
+export const DownloadAction = "__Taipy__download_csv";
+
 export type PageSizeOptionsType = (
 export type PageSizeOptionsType = (
     | number
     | number
     | {
     | {

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

@@ -490,6 +490,7 @@ class _Factory:
                 ("filter", PropertyType.boolean),
                 ("filter", PropertyType.boolean),
                 ("hover_text", PropertyType.dynamic_string),
                 ("hover_text", PropertyType.dynamic_string),
                 ("size",),
                 ("size",),
+                ("downloadable", PropertyType.boolean),
             ]
             ]
         )
         )
         ._set_propagate()
         ._set_propagate()

+ 6 - 1
taipy/gui/data/pandas_data_accessor.py

@@ -262,6 +262,7 @@ class _PandasDataAccessor(_DataAccessor):
             except Exception as e:
             except Exception as e:
                 _warn(f"Dataframe filtering: invalid query '{query}' on {value.head()}", e)
                 _warn(f"Dataframe filtering: invalid query '{query}' on {value.head()}", e)
 
 
+        dictret: t.Optional[t.Dict[str, t.Any]]
         if paged:
         if paged:
             aggregates = payload.get("aggregates")
             aggregates = payload.get("aggregates")
             applies = payload.get("applies")
             applies = payload.get("applies")
@@ -375,7 +376,11 @@ class _PandasDataAccessor(_DataAccessor):
                         except Exception as e:
                         except Exception as e:
                             _warn(f"Limit rows error with {decimator} for Dataframe", e)
                             _warn(f"Limit rows error with {decimator} for Dataframe", e)
             value = self.__build_transferred_cols(gui, columns, t.cast(pd.DataFrame, value), is_copied=is_copied)
             value = self.__build_transferred_cols(gui, columns, t.cast(pd.DataFrame, value), is_copied=is_copied)
-            dictret = self.__format_data(value, data_format, "list", data_extraction=True)
+            if payload.get("csv") is True:
+                ret_payload["df"] = value
+                dictret = None
+            else:
+                dictret = self.__format_data(value, data_format, "list", data_extraction=True)
         ret_payload["value"] = dictret
         ret_payload["value"] = dictret
         return ret_payload
         return ret_payload
 
 

+ 45 - 9
taipy/gui/gui.py

@@ -17,7 +17,6 @@ import inspect
 import json
 import json
 import math
 import math
 import os
 import os
-import pathlib
 import re
 import re
 import sys
 import sys
 import tempfile
 import tempfile
@@ -26,6 +25,8 @@ import typing as t
 import warnings
 import warnings
 from importlib import metadata, util
 from importlib import metadata, util
 from importlib.util import find_spec
 from importlib.util import find_spec
+from pathlib import Path
+from tempfile import mkstemp
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from urllib.parse import unquote, urlencode, urlparse
 from urllib.parse import unquote, urlencode, urlparse
 
 
@@ -224,6 +225,8 @@ class Gui:
     _HTML_CONTENT_KEY = "__taipy_html_content"
     _HTML_CONTENT_KEY = "__taipy_html_content"
     __USER_CONTENT_CB = "custom_user_content_cb"
     __USER_CONTENT_CB = "custom_user_content_cb"
     __ROBOTO_FONT = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
     __ROBOTO_FONT = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
+    __DOWNLOAD_ACTION = "__Taipy__download_csv"
+    __DOWNLOAD_DELETE_ACTION = "__Taipy__download_delete_csv"
 
 
     __RE_HTML = re.compile(r"(.*?)\.html$")
     __RE_HTML = re.compile(r"(.*?)\.html$")
     __RE_MD = re.compile(r"(.*?)\.md$")
     __RE_MD = re.compile(r"(.*?)\.md$")
@@ -342,7 +345,7 @@ class Gui:
 
 
         # get taipy version
         # get taipy version
         try:
         try:
-            gui_file = pathlib.Path(__file__ or ".").resolve()
+            gui_file = Path(__file__ or ".").resolve()
             with open(gui_file.parent / "version.json") as version_file:
             with open(gui_file.parent / "version.json") as version_file:
                 self.__version = json.load(version_file)
                 self.__version = json.load(version_file)
         except Exception as e:  # pragma: no cover
         except Exception as e:  # pragma: no cover
@@ -898,7 +901,7 @@ class Gui:
                     elts.append(elt_dict)
                     elts.append(elt_dict)
         status.update({"libraries": libraries})
         status.update({"libraries": libraries})
 
 
-    def _serve_status(self, template: pathlib.Path) -> t.Dict[str, t.Dict[str, str]]:
+    def _serve_status(self, template: Path) -> t.Dict[str, t.Dict[str, str]]:
         base_json: t.Dict[str, t.Any] = {"user_status": str(self.__call_on_status() or "")}
         base_json: t.Dict[str, t.Any] = {"user_status": str(self.__call_on_status() or "")}
         if self._get_config("extended_status", False):
         if self._get_config("extended_status", False):
             base_json.update(
             base_json.update(
@@ -942,7 +945,7 @@ class Gui:
                 suffix = f".part.{part}"
                 suffix = f".part.{part}"
                 complete = part == total - 1
                 complete = part == total - 1
         if file:  # and allowed_file(file.filename)
         if file:  # and allowed_file(file.filename)
-            upload_path = pathlib.Path(self._get_config("upload_folder", tempfile.gettempdir())).resolve()
+            upload_path = Path(self._get_config("upload_folder", tempfile.gettempdir())).resolve()
             file_path = _get_non_existent_file_path(upload_path, secure_filename(file.filename))
             file_path = _get_non_existent_file_path(upload_path, secure_filename(file.filename))
             file.save(str(upload_path / (file_path.name + suffix)))
             file.save(str(upload_path / (file_path.name + suffix)))
             if complete:
             if complete:
@@ -1320,6 +1323,31 @@ class Gui:
             cls = self.__locals_context.get_default().get(class_name)
             cls = self.__locals_context.get_default().get(class_name)
         return cls if isinstance(cls, class_type) else class_name
         return cls if isinstance(cls, class_type) else class_name
 
 
+    def __download_csv(self, state: State, var_name: str, payload: dict):
+        holder_name = t.cast(str, payload.get("var_name"))
+        ret = self._accessors._get_data(
+            self,
+            holder_name,
+            _getscopeattr(self, holder_name, None),
+            {"alldata": True, "csv": True},
+        )
+        if isinstance(ret, dict):
+            df = ret.get("df")
+            try:
+                fd, temp_path = mkstemp(".csv", var_name, text=True)
+                with os.fdopen(fd, "wt", newline="") as csv_file:
+                    df.to_csv(csv_file, index=False)  # type:ignore
+                self._download(temp_path, "data.csv", Gui.__DOWNLOAD_DELETE_ACTION)
+            except Exception as e:  # pragma: no cover
+                if not self._call_on_exception("download_csv", e):
+                    _warn("download_csv(): Exception raised", e)
+
+    def __delete_csv(self, state: State, var_name: str, payload: dict):
+        try:
+            (Path(tempfile.gettempdir()) / t.cast(str, payload.get("args", [])[-1]).split("/")[-1]).unlink(True)
+        except Exception:
+            pass
+
     def __on_action(self, id: t.Optional[str], payload: t.Any) -> None:
     def __on_action(self, id: t.Optional[str], payload: t.Any) -> None:
         if isinstance(payload, dict):
         if isinstance(payload, dict):
             action = payload.get("action")
             action = payload.get("action")
@@ -1327,7 +1355,15 @@ class Gui:
             action = str(payload)
             action = str(payload)
             payload = {"action": action}
             payload = {"action": action}
         if action:
         if action:
-            if self.__call_function_with_args(action_function=self._get_user_function(action), id=id, payload=payload):
+            action_fn: t.Union[t.Callable, str]
+            if Gui.__DOWNLOAD_ACTION == action:
+                action_fn = self.__download_csv
+                payload["var_name"] = id
+            elif Gui.__DOWNLOAD_DELETE_ACTION == action:
+                action_fn = self.__delete_csv
+            else:
+                action_fn = self._get_user_function(action)
+            if self.__call_function_with_args(action_function=action_fn, id=id, payload=payload):
                 return
                 return
             else:  # pragma: no cover
             else:  # pragma: no cover
                 _warn(f"on_action(): '{action}' is not a valid function.")
                 _warn(f"on_action(): '{action}' is not a valid function.")
@@ -1911,7 +1947,7 @@ class Gui:
             else:
             else:
                 _warn("download() on_action is invalid.")
                 _warn("download() on_action is invalid.")
         content_str = self._get_content("Gui.download", content, False)
         content_str = self._get_content("Gui.download", content, False)
-        self.__send_ws_download(content_str, str(name), str(on_action))
+        self.__send_ws_download(content_str, str(name), str(on_action) if on_action is not None else "")
 
 
     def _notify(
     def _notify(
         self,
         self,
@@ -2133,7 +2169,7 @@ class Gui:
 
 
     def _set_css_file(self, css_file: t.Optional[str] = None):
     def _set_css_file(self, css_file: t.Optional[str] = None):
         if css_file is None:
         if css_file is None:
-            script_file = pathlib.Path(self.__frame.f_code.co_filename or ".").resolve()
+            script_file = Path(self.__frame.f_code.co_filename or ".").resolve()
             if script_file.with_suffix(".css").exists():
             if script_file.with_suffix(".css").exists():
                 css_file = f"{script_file.stem}.css"
                 css_file = f"{script_file.stem}.css"
             elif script_file.is_dir() and (script_file / "taipy.css").exists():
             elif script_file.is_dir() and (script_file / "taipy.css").exists():
@@ -2146,9 +2182,9 @@ class Gui:
 
 
     def _get_webapp_path(self):
     def _get_webapp_path(self):
         _conf_webapp_path = (
         _conf_webapp_path = (
-            pathlib.Path(self._get_config("webapp_path", None)) if self._get_config("webapp_path", None) else None
+            Path(self._get_config("webapp_path", None)) if self._get_config("webapp_path", None) else None
         )
         )
-        _webapp_path = str((pathlib.Path(__file__).parent / "webapp").resolve())
+        _webapp_path = str((Path(__file__).parent / "webapp").resolve())
         if _conf_webapp_path:
         if _conf_webapp_path:
             if _conf_webapp_path.is_dir():
             if _conf_webapp_path.is_dir():
                 _webapp_path = str(_conf_webapp_path.resolve())
                 _webapp_path = str(_conf_webapp_path.resolve())

+ 5 - 0
taipy/gui/viselements.json

@@ -1124,6 +1124,11 @@
             "name": "lov[<i>column_name</i>]",
             "name": "lov[<i>column_name</i>]",
             "type": "list[str]|str",
             "type": "list[str]|str",
             "doc": "The list of values of the indicated column."
             "doc": "The list of values of the indicated column."
+          },
+          {
+            "name": "downloadable",
+            "type": "boolean",
+            "doc": "The indicator that would show the icon to allow the user to download the data as a csv file if True."
           }
           }
         ]
         ]
       }
       }