소스 검색

notify (or any gui_actions) with async state (#2647)

* notify (or any gui_actions) with async state
resolves #2641 #2642

* add key

* handle width

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 5 일 전
부모
커밋
441a186015
5개의 변경된 파일152개의 추가작업 그리고 64개의 파일을 삭제
  1. 70 52
      frontend/taipy-gui/src/components/Taipy/Selector.tsx
  2. 21 0
      taipy/gui/gui.py
  3. 10 10
      taipy/gui/gui_actions.py
  4. 11 2
      taipy/gui/state.py
  5. 40 0
      tests/gui/actions/test_action_with_async_state.py

+ 70 - 52
frontend/taipy-gui/src/components/Taipy/Selector.tsx

@@ -14,7 +14,6 @@
 import React, {
     ChangeEvent,
     CSSProperties,
-    Fragment,
     HTMLAttributes,
     MouseEvent,
     ReactNode,
@@ -50,7 +49,7 @@ import Select, { SelectChangeEvent } from "@mui/material/Select";
 import TextField from "@mui/material/TextField";
 import { Theme, useTheme } from "@mui/material";
 
-import { doNotPropagateEvent, expandSx, getSuffixedClassNames, getUpdateVar } from "./utils";
+import { doNotPropagateEvent, expandSx, getCssSize, getSuffixedClassNames, getUpdateVar } from "./utils";
 import { createSendActionNameAction, createSendUpdateAction } from "../../context/taipyReducers";
 import { ItemProps, LovImage, paperBaseSx, SelTreeProps, showItem, SingleItem, useLovListMemo } from "./lovUtils";
 import {
@@ -312,7 +311,18 @@ const Selector = (props: SelectorProps) => {
         () => ({
             my: 1,
             mx: 0,
-            width: width,
+            width: getCssSize(width),
+            maxWidth: "unset",
+            "& .MuiInputBase-root": { minHeight: 48, "& input": { minHeight: "unset" } },
+        }),
+        [width]
+    );
+
+    const autoCompleteSx = useMemo(
+        () => ({
+            my: 1,
+            mx: 0,
+            width: getCssSize(width),
             "& .MuiFormControl-root": {
                 maxWidth: "unset",
                 my: 0,
@@ -476,16 +486,12 @@ const Selector = (props: SelectorProps) => {
     const renderAutoInput = useCallback(
         (params: AutocompleteRenderInputParams) => {
             if (params.InputProps) {
-                if (!params.InputProps.startAdornment) {
-                    params.InputProps.startAdornment = [];
-                } else if (Array.isArray(params.InputProps.startAdornment)) {
-                    if (selectionMessage) {
-                        params.InputProps.startAdornment = [selectionMessage];
-                    }
+                if (selectionMessage) {
+                    params.InputProps.startAdornment = [<Chip key="selectionMessage" label={selectionMessage}></Chip>];
                 } else {
-                    if (selectionMessage) {
-                        params.InputProps.startAdornment = [selectionMessage];
-                    } else {
+                    if (!params.InputProps.startAdornment) {
+                        params.InputProps.startAdornment = [];
+                    } else if (!Array.isArray(params.InputProps.startAdornment)) {
                         params.InputProps.startAdornment = [params.InputProps.startAdornment];
                     }
                 }
@@ -498,15 +504,25 @@ const Selector = (props: SelectorProps) => {
                         lovListLength={lovList.length}
                         active={active}
                         handleCheckAllChange={handleCheckAllAutoChange}
+                        key="selectAll"
                     />
                 );
             } else {
-                console.log("selector autocomplete needs to updata params for slotProps.input.startAdornment");
+                console.log("selector autocomplete needs to update params for slotProps.input.startAdornment");
                 console.log("renderAutoInput", params);
             }
             return <TextField {...params} label={props.label} margin="dense" />;
         },
-        [selectionMessage, props.label, multiple, showSelectAll, selectedValue.length, lovList.length, active, handleCheckAllAutoChange]
+        [
+            selectionMessage,
+            props.label,
+            multiple,
+            showSelectAll,
+            selectedValue.length,
+            lovList.length,
+            active,
+            handleCheckAllAutoChange,
+        ]
     );
 
     const handleDelete = useCallback(
@@ -606,7 +622,7 @@ const Selector = (props: SelectorProps) => {
                             getOptionLabel={getOptionLabel}
                             getOptionKey={getOptionKey}
                             isOptionEqualToValue={isOptionEqualToValue}
-                            sx={controlSx}
+                            sx={autoCompleteSx}
                             className={`${className} ${getComponentClassName(props.children)}`}
                             renderInput={renderAutoInput}
                             renderOption={renderOption}
@@ -639,43 +655,45 @@ const Selector = (props: SelectorProps) => {
                                 disabled={!active}
                                 renderValue={(selected) => (
                                     <Box sx={renderBoxSx}>
-                                        {typeof selectionMessage === "string"
-                                            ? selectionMessage
-                                            : lovList
-                                                  .filter((it) =>
-                                                      Array.isArray(selected)
-                                                          ? selected.includes(it.id)
-                                                          : selected === it.id
-                                                  )
-                                                  .map((item, idx) => {
-                                                      if (multiple) {
-                                                          const chipProps = {} as Record<string, unknown>;
-                                                          if (typeof item.item === "string") {
-                                                              chipProps.label = item.item;
-                                                          } else {
-                                                              chipProps.label = item.item.text || "";
-                                                              chipProps.avatar = <Avatar src={item.item.path} />;
-                                                          }
-                                                          return (
-                                                              <Chip
-                                                                  key={item.id}
-                                                                  {...chipProps}
-                                                                  onDelete={handleDelete}
-                                                                  data-id={item.id}
-                                                                  onMouseDown={doNotPropagateEvent}
-                                                                  disabled={!active}
-                                                              />
-                                                          );
-                                                      } else if (idx === 0) {
-                                                          return typeof item.item === "string" ? (
-                                                              item.item
-                                                          ) : (
-                                                              <LovImage item={item.item} />
-                                                          );
-                                                      } else {
-                                                          return null;
-                                                      }
-                                                  })}
+                                        {typeof selectionMessage === "string" ? (
+                                            <Chip key="selectionMessage" label={selectionMessage} />
+                                        ) : (
+                                            lovList
+                                                .filter((it) =>
+                                                    Array.isArray(selected)
+                                                        ? selected.includes(it.id)
+                                                        : selected === it.id
+                                                )
+                                                .map((item, idx) => {
+                                                    if (multiple) {
+                                                        const chipProps = {} as Record<string, unknown>;
+                                                        if (typeof item.item === "string") {
+                                                            chipProps.label = item.item;
+                                                        } else {
+                                                            chipProps.label = item.item.text || "";
+                                                            chipProps.avatar = <Avatar src={item.item.path} />;
+                                                        }
+                                                        return (
+                                                            <Chip
+                                                                key={item.id}
+                                                                {...chipProps}
+                                                                onDelete={handleDelete}
+                                                                data-id={item.id}
+                                                                onMouseDown={doNotPropagateEvent}
+                                                                disabled={!active}
+                                                            />
+                                                        );
+                                                    } else if (idx === 0) {
+                                                        return typeof item.item === "string" ? (
+                                                            item.item
+                                                        ) : (
+                                                            <LovImage item={item.item} />
+                                                        );
+                                                    } else {
+                                                        return null;
+                                                    }
+                                                })
+                                        )}
                                     </Box>
                                 )}
                                 MenuProps={getMenuProps(height)}

+ 21 - 0
taipy/gui/gui.py

@@ -1684,6 +1684,27 @@ class Gui:
     def _set_module_context(self, module_context: t.Optional[str]) -> t.ContextManager[None]:
         return self._set_locals_context(module_context) if module_context is not None else contextlib.nullcontext()
 
+    def _invoke_method(self, state_id: str, method: t.Callable, *args):
+        this_sid = None
+        if request:
+            # avoid messing with the client_id => Set(ws id)
+            this_sid = getattr(request, "sid", None)
+            request.sid = None  # type: ignore[attr-defined]
+        try:
+            with self.get_flask_app().app_context():
+                setattr(g, Gui.__ARG_CLIENT_ID, state_id)
+                return method(self, *args)
+        except Exception as e:  # pragma: no cover
+            if not self._call_on_exception(method, e):
+                _warn(
+                    f"Gui._invoke_method(): Exception raised in {_function_name(method)}",
+                    e,
+                )
+        finally:
+            if this_sid:
+                request.sid = this_sid  # type: ignore[attr-defined]
+        return None
+
     def invoke_callback(
         self,
         state_id: str,

+ 10 - 10
taipy/gui/gui_actions.py

@@ -55,7 +55,7 @@ def download(state: State, content: t.Any, name: t.Optional[str] = "", on_action
                   and the second element reflects the server-side URL where the file is located.
     """
     if state and isinstance(state._gui, Gui):
-        state._gui._download(content, name, on_action)  # type: ignore[attr-defined]
+        state._invoke_on_gui(Gui._download, content, name, on_action)
     else:
         _warn("'download()' must be called in the context of a callback.")
 
@@ -100,7 +100,7 @@ def notify(
     displayed, but the in-app notification will still function.
     """
     if state and isinstance(state._gui, Gui):
-        return state._gui._notify(notification_type, message, system_notification, duration, id)  # type: ignore[attr-defined]
+        return state._invoke_on_gui(Gui._notify, notification_type, message, system_notification, duration, id)
     else:
         _warn("'notify()' must be called in the context of a callback.")
         return None
@@ -122,7 +122,7 @@ def close_notification(state: State, id: str) -> None:
     """
     if state and isinstance(state._gui, Gui):
         # Send the close command with the notification_id
-        state._gui._close_notification(id)  # type: ignore[attr-defined]
+        state._invoke_on_gui(Gui._close_notification, id)
     else:
         _warn("'close_notification()' must be called in the context of a callback.")
 
@@ -157,7 +157,7 @@ def hold_control(
         message: The message to show. The default value is the string "Work in Progress...".
     """
     if state and isinstance(state._gui, Gui):
-        state._gui._hold_actions(callback, message)  # type: ignore[attr-defined]
+        state._invoke_on_gui(Gui._hold_actions, callback, message)
     else:
         _warn("'hold_actions()' must be called in the context of a callback.")
 
@@ -172,7 +172,7 @@ def resume_control(state: State):
         state (State^): The current user state as received in any callback.
     """
     if state and isinstance(state._gui, Gui):
-        state._gui._resume_actions()  # type: ignore[attr-defined]
+        state._invoke_on_gui(Gui._resume_actions)
     else:
         _warn("'resume_actions()' must be called in the context of a callback.")
 
@@ -198,7 +198,7 @@ def navigate(
         force: When navigating to a known page, the content is refreshed even it the page is already shown.
     """
     if state and isinstance(state._gui, Gui):
-        state._gui._navigate(to, params, tab, force)  # type: ignore[attr-defined]
+        state._invoke_on_gui(Gui._navigate, to, params, tab, force)
     else:
         _warn("'navigate()' must be called in the context of a callback.")
 
@@ -223,7 +223,7 @@ def get_user_content_url(
         An URL that, when queried, triggers the *on_user_content* callback.
     """
     if state and isinstance(state._gui, Gui):
-        return state._gui._get_user_content_url(path, params)  # type: ignore[attr-defined]
+        return state._invoke_on_gui(Gui._get_user_content_url, path, params)
     _warn("'get_user_content_url()' must be called in the context of a callback.")
     return None
 
@@ -245,7 +245,7 @@ def get_state_id(state: State) -> t.Optional[str]:
             If this value None, it indicates that *state* is not handled by a `Gui^` instance.
     """
     if state and isinstance(state._gui, Gui):
-        return state._gui._get_client_id()  # type: ignore[attr-defined]
+        return state._invoke_on_gui(Gui._get_client_id)
     return None
 
 
@@ -259,7 +259,7 @@ def get_module_context(state: State) -> t.Optional[str]:
         The name of the current module.
     """
     if state and isinstance(state._gui, Gui):
-        return state._gui._get_locals_context()  # type: ignore[attr-defined]
+        return state._invoke_on_gui(Gui._get_locals_context)
     return None
 
 
@@ -289,7 +289,7 @@ def get_module_name_from_state(state: State) -> t.Optional[str]:
             that triggered the callback that was provided the *state* object.
     """
     if state and isinstance(state._gui, Gui):
-        return state._gui._get_locals_context()  # type: ignore[attr-defined]
+        return state._invoke_on_gui(Gui._get_locals_context)
     return None
 
 

+ 11 - 2
taipy/gui/state.py

@@ -150,8 +150,10 @@ class State(SimpleNamespace, metaclass=ABCMeta):
         self._gui.set_favicon(favicon_path, self)
 
     @abstractmethod
-    def __getitem__(self, key: str) -> "State":
-        ...
+    def __getitem__(self, key: str) -> "State": ...
+
+    @abstractmethod
+    def _invoke_on_gui(self, method: t.Callable, *args: t.Any) -> t.Any: ...
 
 
 class _GuiState(State):
@@ -174,6 +176,7 @@ class _GuiState(State):
         "_get_gui_attr",
         "_get_placeholder_attrs",
         "_add_attribute",
+        "_invoke_on_gui",
     )
     __placeholder_attrs = ("_taipy_p1", "_current_context", "__state_id")
     __excluded_attrs = __attrs + __methods + __placeholder_attrs
@@ -280,6 +283,9 @@ class _GuiState(State):
             return gui._bind_var_val(name, default_value)
         return False
 
+    def _invoke_on_gui(self, method: t.Callable, *args):
+        return method(self.get_gui(), *args)
+
 
 class _AsyncState(_GuiState):
     def __init__(self, state: State) -> None:
@@ -303,3 +309,6 @@ class _AsyncState(_GuiState):
         return self.get_gui().invoke_callback(
             t.cast(str, self._get_placeholder("__state_id")), _AsyncState.__get_var_from_state, [var_name]
         )
+
+    def _invoke_on_gui(self, method: t.Callable, *args):
+        return self.get_gui()._invoke_method(t.cast(str, self._get_placeholder("__state_id")), method, *args)

+ 40 - 0
tests/gui/actions/test_action_with_async_state.py

@@ -0,0 +1,40 @@
+# Copyright 2021-2025 Avaiga Private Limited
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations under the License.
+
+import inspect
+import typing as t
+
+from flask import g
+
+from taipy.gui import Gui, Markdown, notify
+from taipy.gui.state import _AsyncState, _GuiState
+
+
+def test_async_notify(gui: Gui, helpers):
+    # set gui frame
+    gui._set_frame(inspect.currentframe())
+
+    gui.add_page("test", Markdown("<|Hello|button|>"))
+    gui.run(run_server=False)
+    flask_client = gui._server.test_client()
+    # WS client and emit
+    ws_client = gui._server._ws.test_client(gui._server.get_flask())  # type: ignore[arg-type]
+    cid = helpers.create_scope_and_get_sid(gui)
+    flask_client.get(f"/taipy-jsx/test?client_id={cid}")
+    with gui.get_flask_app().test_request_context(f"/taipy-jsx/test/?client_id={cid}", data={"client_id": cid}):
+        g.client_id = cid
+        id = notify(_AsyncState(t.cast(_GuiState, gui._Gui__state)), "Info", "Message", id="id_async")  # type: ignore[attr-defined]
+        assert id == "id_async"
+    received_messages = ws_client.get_received()
+    assert len(received_messages) == 1
+    helpers.assert_outward_ws_simple_message(
+        received_messages[0], "AL", {"nType": "Info", "message": "Message", "notificationId": "id_async"}
+    )