Bläddra i källkod

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 år sedan
förälder
incheckning
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 DataSaverOn from "@mui/icons-material/DataSaverOn";
 import DataSaverOff from "@mui/icons-material/DataSaverOff";
+import Download from "@mui/icons-material/Download";
 
 import {
     createRequestInfiniteTableUpdateAction,
@@ -61,6 +62,7 @@ import {
     getTooltip,
     defaultColumns,
     OnRowClick,
+    DownloadAction,
 } from "./tableUtils";
 import {
     useClassNames,
@@ -181,6 +183,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
         onAction = "",
         size = DEFAULT_SIZE,
         userData,
+        downloadable = false,
     } = props;
     const [rows, setRows] = useState<RowType[]>([]);
     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;
                     }
                 });
-                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 styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
                     if (baseColumns[col].style) {
@@ -305,7 +308,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
             hNan,
             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]);
 
@@ -405,6 +408,17 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
         [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 onCellValidation: OnCellValidation = useCallback(
@@ -550,6 +564,17 @@ const AutoLoadingTable = (props: TaipyTableProps) => {
                                                             className={className}
                                                         />
                                                     ) : null,
+                                                    active && downloadable ? (
+                                                        <Tooltip title="Download as CSV" key="downloadCsv">
+                                                            <IconButton
+                                                                onClick={onDownload}
+                                                                size="small"
+                                                                sx={iconInRowSx}
+                                                            >
+                                                                <Download fontSize="inherit" />
+                                                            </IconButton>
+                                                        </Tooltip>
+                                                    ) : null,
                                                 ]
                                             ) : (
                                                 <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 DataSaverOn from "@mui/icons-material/DataSaverOn";
 import DataSaverOff from "@mui/icons-material/DataSaverOff";
+import Download from "@mui/icons-material/Download";
 
 import { createRequestTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers";
 import {
@@ -67,6 +68,7 @@ import {
     getRowIndex,
     getTooltip,
     OnRowClick,
+    DownloadAction,
 } from "./tableUtils";
 import {
     useClassNames,
@@ -102,6 +104,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
         width = "100%",
         size = DEFAULT_SIZE,
         userData,
+        downloadable = false,
     } = props;
     const pageSize = props.pageSize === undefined || props.pageSize < 1 ? 100 : Math.round(props.pageSize);
     const [value, setValue] = useState<Record<string, unknown>>({});
@@ -142,7 +145,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                         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 styTt = colsOrder.reduce<Record<string, Record<string, string>>>((pv, col) => {
                     if (baseColumns[col].style) {
@@ -173,7 +176,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
             hNan,
             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);
 
@@ -311,6 +314,17 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
         [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 pso = useMemo(() => {
@@ -441,6 +455,17 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => {
                                                             className={className}
                                                         />
                                                     ) : null,
+                                                    active && downloadable ? (
+                                                        <Tooltip title="Download as CSV" key="downloadCsv">
+                                                            <IconButton
+                                                                onClick={onDownload}
+                                                                size="small"
+                                                                sx={iconInRowSx}
+                                                            >
+                                                                <Download fontSize="inherit" />
+                                                            </IconButton>
+                                                        </Tooltip>
+                                                    ) : null,
                                                 ]
                                             ) : (
                                                 <TableSortLabel

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

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

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

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

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

@@ -262,6 +262,7 @@ class _PandasDataAccessor(_DataAccessor):
             except Exception as e:
                 _warn(f"Dataframe filtering: invalid query '{query}' on {value.head()}", e)
 
+        dictret: t.Optional[t.Dict[str, t.Any]]
         if paged:
             aggregates = payload.get("aggregates")
             applies = payload.get("applies")
@@ -375,7 +376,11 @@ class _PandasDataAccessor(_DataAccessor):
                         except Exception as 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)
-            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
         return ret_payload
 

+ 45 - 9
taipy/gui/gui.py

@@ -17,7 +17,6 @@ import inspect
 import json
 import math
 import os
-import pathlib
 import re
 import sys
 import tempfile
@@ -26,6 +25,8 @@ import typing as t
 import warnings
 from importlib import metadata, util
 from importlib.util import find_spec
+from pathlib import Path
+from tempfile import mkstemp
 from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
 from urllib.parse import unquote, urlencode, urlparse
 
@@ -224,6 +225,8 @@ class Gui:
     _HTML_CONTENT_KEY = "__taipy_html_content"
     __USER_CONTENT_CB = "custom_user_content_cb"
     __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_MD = re.compile(r"(.*?)\.md$")
@@ -342,7 +345,7 @@ class Gui:
 
         # get taipy version
         try:
-            gui_file = pathlib.Path(__file__ or ".").resolve()
+            gui_file = Path(__file__ or ".").resolve()
             with open(gui_file.parent / "version.json") as version_file:
                 self.__version = json.load(version_file)
         except Exception as e:  # pragma: no cover
@@ -898,7 +901,7 @@ class Gui:
                     elts.append(elt_dict)
         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 "")}
         if self._get_config("extended_status", False):
             base_json.update(
@@ -942,7 +945,7 @@ class Gui:
                 suffix = f".part.{part}"
                 complete = part == total - 1
         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.save(str(upload_path / (file_path.name + suffix)))
             if complete:
@@ -1320,6 +1323,31 @@ class Gui:
             cls = self.__locals_context.get_default().get(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:
         if isinstance(payload, dict):
             action = payload.get("action")
@@ -1327,7 +1355,15 @@ class Gui:
             action = str(payload)
             payload = {"action": 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
             else:  # pragma: no cover
                 _warn(f"on_action(): '{action}' is not a valid function.")
@@ -1911,7 +1947,7 @@ class Gui:
             else:
                 _warn("download() on_action is invalid.")
         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(
         self,
@@ -2133,7 +2169,7 @@ class Gui:
 
     def _set_css_file(self, css_file: t.Optional[str] = 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():
                 css_file = f"{script_file.stem}.css"
             elif script_file.is_dir() and (script_file / "taipy.css").exists():
@@ -2146,9 +2182,9 @@ class Gui:
 
     def _get_webapp_path(self):
         _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.is_dir():
                 _webapp_path = str(_conf_webapp_path.resolve())

+ 5 - 0
taipy/gui/viselements.json

@@ -1124,6 +1124,11 @@
             "name": "lov[<i>column_name</i>]",
             "type": "list[str]|str",
             "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."
           }
         ]
       }