Przeglądaj źródła

Backport/custom-frontend (#1205)

* Remove uploaded part file after merging (#1030)

* Decouple preview bundle (#1013)

* Decouple preview bundle

* per Fred

* Add name to shared bundle for easier import (#1027)

* Extensible CustomPage class (#962)

* GUI: Allow dict update on custom frontend (#932) (#935)

* Allow dict update on custom frontend

* fix codespell

* per Fred

* Trigger Build

* fix ruff

* Decouple onReload (#1203) (#1204)

* onReload

* update on reload

* per Fred
Dinh Long Nguyen 1 rok temu
rodzic
commit
e02c591345

+ 20 - 4
frontend/taipy-gui/base/src/app.ts

@@ -3,18 +3,20 @@ import { sendWsMessage, TAIPY_CLIENT_ID } from "../../src/context/wsUtils";
 import { uploadFile } from "../../src/workers/fileupload";
 
 import { Socket, io } from "socket.io-client";
-import { DataManager } from "./dataManager";
+import { DataManager, ModuleData } from "./dataManager";
 import { initSocket } from "./utils";
 
 export type OnInitHandler = (appManager: TaipyApp) => void;
 export type OnChangeHandler = (appManager: TaipyApp, encodedName: string, value: unknown) => void;
 export type OnNotifyHandler = (appManager: TaipyApp, type: string, message: string) => void;
+export type onReloadHandler = (appManager: TaipyApp, removedChanges: ModuleData) => void;
 
 export class TaipyApp {
     socket: Socket;
     _onInit: OnInitHandler | undefined;
     _onChange: OnChangeHandler | undefined;
     _onNotify: OnNotifyHandler | undefined;
+    _onReload: onReloadHandler | undefined;
     variableData: DataManager | undefined;
     functionData: DataManager | undefined;
     appId: string;
@@ -46,7 +48,7 @@ export class TaipyApp {
         return this._onInit;
     }
     set onInit(handler: OnInitHandler | undefined) {
-        if (handler !== undefined && handler?.length !== 1) {
+        if (handler !== undefined && handler.length !== 1) {
             throw new Error("onInit() requires one parameter");
         }
         this._onInit = handler;
@@ -56,7 +58,7 @@ export class TaipyApp {
         return this._onChange;
     }
     set onChange(handler: OnChangeHandler | undefined) {
-        if (handler !== undefined && handler?.length !== 3) {
+        if (handler !== undefined && handler.length !== 3) {
             throw new Error("onChange() requires three parameters");
         }
         this._onChange = handler;
@@ -66,12 +68,22 @@ export class TaipyApp {
         return this._onNotify;
     }
     set onNotify(handler: OnNotifyHandler | undefined) {
-        if (handler !== undefined && handler?.length !== 3) {
+        if (handler !== undefined && handler.length !== 3) {
             throw new Error("onNotify() requires three parameters");
         }
         this._onNotify = handler;
     }
 
+    get onReload() {
+        return this._onReload;
+    }
+    set onReload(handler: onReloadHandler | undefined) {
+        if (handler !== undefined && handler?.length !== 2) {
+            throw new Error("_onReload() requires two parameters");
+        }
+        this._onReload = handler;
+    }
+
     // Utility methods
     init() {
         this.clientId = "";
@@ -141,6 +153,10 @@ export class TaipyApp {
     upload(encodedName: string, files: FileList, progressCallback: (val: number) => void) {
         return uploadFile(encodedName, files, progressCallback, this.clientId);
     }
+
+    getPageMetadata() {
+        return JSON.parse(localStorage.getItem("tp_cp_meta") || "{}");
+    }
 }
 
 export const createApp = (onInit?: OnInitHandler, onChange?: OnChangeHandler, path?: string, socket?: Socket) => {

+ 1 - 0
frontend/taipy-gui/base/src/dataManager.ts

@@ -50,6 +50,7 @@ export class DataManager {
                 this._data[vData["encoded_name"]] = vData.value;
             }
         }
+        return changes;
     }
 
     getEncodedName(varName: string, module: string): string | undefined {

+ 6 - 0
frontend/taipy-gui/base/src/index-preview.ts

@@ -0,0 +1,6 @@
+import { TaipyApp, createApp, OnChangeHandler, OnInitHandler } from "./app";
+import { ModuleData } from "./dataManager";
+
+export default TaipyApp;
+export { TaipyApp, createApp };
+export type { OnChangeHandler, OnInitHandler, ModuleData };

+ 1 - 0
frontend/taipy-gui/base/src/index.ts

@@ -7,4 +7,5 @@ export type { OnChangeHandler, OnInitHandler, ModuleData };
 
 window.addEventListener("beforeunload", () => {
     document.cookie = "tprh=;path=/;Max-Age=-99999999;";
+    localStorage.removeItem("tp_cp_meta");
 });

+ 7 - 2
frontend/taipy-gui/base/src/utils.ts

@@ -1,3 +1,4 @@
+import merge from "lodash/merge";
 import { Socket } from "socket.io-client";
 import { IdMessage, storeClientId } from "../../src/context/utils";
 import { WsMessage, sendWsMessage } from "../../src/context/wsUtils";
@@ -74,8 +75,12 @@ const processWsMessage = (message: WsMessage, appManager: TaipyApp) => {
             const variableData = payload.variable;
             const functionData = payload.function;
             if (appManager.variableData && appManager.functionData) {
-                appManager.variableData.init(variableData);
-                appManager.functionData.init(functionData);
+                const varChanges = appManager.variableData.init(variableData);
+                const functionChanges = appManager.functionData.init(functionData);
+                const changes = merge(varChanges, functionChanges);
+                if (varChanges || functionChanges) {
+                    appManager.onReload && appManager.onReload(appManager, changes);
+                }
             } else {
                 appManager.variableData = new DataManager(variableData);
                 appManager.functionData = new DataManager(functionData);

+ 17 - 7
frontend/taipy-gui/base/webpack.config.js

@@ -8,9 +8,18 @@ const webAppPath = resolveApp(basePath);
 
 module.exports = {
     target: "web",
-    entry: "./base/src/index.ts",
+    entry: {
+        "default": "./base/src/index.ts",
+        "preview": "./base/src/index-preview.ts",
+    },
     output: {
-        filename: "taipy-gui-base.js",
+        filename: (arg) => {
+            if (arg.chunk.name === "default") {
+                return "taipy-gui-base.js";
+            }
+            return "[name].taipy-gui-base.js";
+        },
+        chunkFilename: "[name].taipy-gui-base.js",
         path: webAppPath,
         globalObject: "this",
         library: {
@@ -18,11 +27,12 @@ module.exports = {
             type: "umd",
         },
     },
-    plugins: [
-        new webpack.optimize.LimitChunkCountPlugin({
-            maxChunks: 1,
-        }),
-    ],
+    optimization: {
+        splitChunks: {
+            chunks: 'all',
+            name: "shared",
+        },
+    },
     module: {
         rules: [
             {

+ 8 - 0
frontend/taipy-gui/src/components/Taipy/Navigate.tsx

@@ -34,9 +34,14 @@ const Navigate = ({ to, params, tab, force }: NavigateProps) => {
             const searchParams = new URLSearchParams(params || "");
             // Handle Resource Handler Id
             let tprh: string | null = null;
+            let meta: string | null = null;
             if (searchParams.has("tprh")) {
                 tprh = searchParams.get("tprh");
                 searchParams.delete("tprh");
+                if (searchParams.has("tp_cp_meta")) {
+                    meta = searchParams.get("tp_cp_meta");
+                    searchParams.delete("tp_cp_meta");
+                }
             }
             if (Object.keys(state.locations || {}).some((route) => tos === route)) {
                 const searchParamsLocation = new URLSearchParams(location.search);
@@ -47,6 +52,9 @@ const Navigate = ({ to, params, tab, force }: NavigateProps) => {
                     if (tprh !== null) {
                         // Add a session cookie for the resource handler id
                         document.cookie = `tprh=${tprh};path=/;`;
+                        if (meta !== null) {
+                            localStorage.setItem("tp_cp_meta", meta);
+                        }
                         navigate(0);
                     }
                 }

+ 18 - 7
frontend/taipy-gui/webpack.config.js

@@ -171,9 +171,19 @@ module.exports = (env, options) => {
     },
     {
         mode: options.mode,
-        entry: ["./base/src/index.ts"],
+        target: "web",
+        entry: {
+            "default": "./base/src/index.ts",
+            "preview": "./base/src/index-preview.ts",
+        },
         output: {
-            filename: "taipy-gui-base.js",
+            filename: (arg) => {
+                if (arg.chunk.name === "default") {
+                    return "taipy-gui-base.js";
+                }
+                return "[name].taipy-gui-base.js";
+            },
+            chunkFilename: "[name].taipy-gui-base.js",
             path: webAppPath,
             globalObject: "this",
             library: {
@@ -181,11 +191,12 @@ module.exports = (env, options) => {
                 type: "umd",
             },
         },
-        plugins: [
-            new webpack.optimize.LimitChunkCountPlugin({
-                maxChunks: 1,
-            }),
-        ],
+        optimization: {
+            splitChunks: {
+                chunks: 'all',
+                name: "shared",
+            },
+        },
         module: {
             rules: [
                 {

+ 8 - 1
taipy/gui/custom/_page.py

@@ -23,13 +23,20 @@ class Page(BasePage):
     A custom page for external application that can be added to Taipy GUI"""
 
     def __init__(
-        self, resource_handler: ResourceHandler, binding_variables: t.Optional[t.List[str]] = None, **kwargs
+        self,
+        resource_handler: ResourceHandler,
+        binding_variables: t.Optional[t.List[str]] = None,
+        metadata: t.Optional[t.Dict[str, t.Any]] = None,
+        **kwargs,
     ) -> None:
         if binding_variables is None:
             binding_variables = []
+        if metadata is None:
+            metadata = {}
         super().__init__(**kwargs)
         self._resource_handler = resource_handler
         self._binding_variables = binding_variables
+        self._metadata: t.Dict[str, t.Any] = metadata
 
 
 class ResourceHandler(ABC):

+ 28 - 4
taipy/gui/gui.py

@@ -31,7 +31,17 @@ from urllib.parse import unquote, urlencode, urlparse
 
 import markdown as md_lib
 import tzlocal
-from flask import Blueprint, Flask, g, has_app_context, jsonify, request, send_file, send_from_directory
+from flask import (
+    Blueprint,
+    Flask,
+    g,
+    has_app_context,
+    has_request_context,
+    jsonify,
+    request,
+    send_file,
+    send_from_directory,
+)
 from werkzeug.utils import secure_filename
 
 import __main__  # noqa: F401
@@ -732,7 +742,8 @@ class Gui:
             return f"{var_name_decode}.{suffix_var_name}" if suffix_var_name else var_name_decode, module_name
         if module_name == current_context:
             var_name = var_name_decode
-        else:
+        # only strict checking for cross-context linked variable when the context has been properly set
+        elif self._has_set_context():
             if var_name not in self.__var_dir._var_head:
                 raise NameError(f"Can't find matching variable for {var_name} on context: {current_context}")
             _found = False
@@ -940,8 +951,11 @@ class Gui:
                     try:
                         with open(file_path, "wb") as grouped_file:
                             for nb in range(part + 1):
-                                with open(upload_path / f"{file_path.name}.part.{nb}", "rb") as part_file:
+                                part_file_path = upload_path / f"{file_path.name}.part.{nb}"
+                                with open(part_file_path, "rb") as part_file:
                                     grouped_file.write(part_file.read())
+                                # remove file_path after it is merged
+                                part_file_path.unlink()
                     except EnvironmentError as ee:  # pragma: no cover
                         _warn("Cannot group file after chunk upload", ee)
                         return
@@ -1002,7 +1016,13 @@ class Gui:
                 elif isinstance(newvalue, _TaipyToJson):
                     newvalue = newvalue.get()
                 if isinstance(newvalue, (dict, _MapDict)):
-                    continue  # this var has no transformer
+                    # Skip in taipy-gui, available in custom frontend
+                    resource_handler_id = None
+                    with contextlib.suppress(Exception):
+                        if has_request_context():
+                            resource_handler_id = request.cookies.get(_Server._RESOURCE_HANDLER_ARG, None)
+                    if resource_handler_id is None:
+                        continue  # this var has no transformer
                 if isinstance(newvalue, float) and math.isnan(newvalue):
                     # do not let NaN go through json, it is not handle well (dies silently through websocket)
                     newvalue = None
@@ -1568,6 +1588,9 @@ class Gui:
     def _set_locals_context(self, context: t.Optional[str]) -> t.ContextManager[None]:
         return self.__locals_context.set_locals_context(context)
 
+    def _has_set_context(self):
+        return self.__locals_context.get_context() is not None
+
     def _get_page_context(self, page_name: str) -> str | None:
         if page_name not in self._config.routes:
             return None
@@ -2062,6 +2085,7 @@ class Gui:
                 to=page_name,
                 params={
                     _Server._RESOURCE_HANDLER_ARG: pr._resource_handler.get_id(),
+                    _Server._CUSTOM_PAGE_META_ARG: json.dumps(pr._metadata, cls=_TaipyJsonEncoder)
                 },
             ):
                 # Proactively handle the bindings of custom page variables

+ 3 - 0
taipy/gui/page.py

@@ -46,6 +46,9 @@ class Page:
             self._frame = self._renderer._frame
         elif isinstance(self, CustomPage):
             self._frame = t.cast(FrameType, t.cast(FrameType, inspect.stack()[2].frame))
+            # Allow CustomPage class to be inherited
+            if len(inspect.stack()) > 3 and inspect.stack()[2].function != "<module>":
+                self._frame = t.cast(FrameType, t.cast(FrameType, inspect.stack()[3].frame))
         elif len(inspect.stack()) < 4:
             raise RuntimeError(f"Can't resolve module. Page '{type(self).__name__}' is not registered.")
         else:

+ 1 - 0
taipy/gui/server.py

@@ -48,6 +48,7 @@ class _Server:
     __OPENING_CURLY = r"\1&#x7B;"
     __CLOSING_CURLY = r"&#x7D;\2"
     _RESOURCE_HANDLER_ARG = "tprh"
+    _CUSTOM_PAGE_META_ARG = "tp_cp_meta"
 
     def __init__(
         self,

+ 0 - 4
tests/gui/server/http/test_file_upload.py

@@ -87,16 +87,12 @@ def test_file_upload_multi_part(gui: Gui, helpers):
         content_type="multipart/form-data",
     )
     assert ret.status_code == 200
-    file0_path = upload_path / f"{file_name}.part.0"
-    assert file0_path.exists()
     ret = flask_client.post(
         f"/taipy-uploads?client_id={sid}",
         data={"var_name": "varname", "blob": file1, "total": "2", "part": "1"},
         content_type="multipart/form-data",
     )
     assert ret.status_code == 200
-    file1_path = upload_path / f"{file_name}.part.1"
-    assert file1_path.exists()
     file_path = upload_path / file_name
     assert file_path.exists()