浏览代码

use taipy-assets favicon (#1453)

* use taipy-assets favicon
resolves #1327
allow to change favicon dynamically
resolves #1244

* with doc

* doc

* test

* Fab's comments

* allow to change favicon for one state

* fab's comments

* favicon

* mypy

* trying to fix cross-env ...

* trying to fix cross-env ...

* trying to fix cross-env ...

* do not run test if cache hit

* noncoverage either if cache hit

* class name

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Fred Lefévère-Laoide 10 月之前
父节点
当前提交
16e90fba5b

+ 7 - 5
.github/workflows/frontend.yml

@@ -59,13 +59,15 @@ jobs:
       - if: steps.cache-gui-fe-build.outputs.cache-hit != 'true'
         run: npm run build --if-present
 
-      - run: npm test
+      - if: steps.cache-gui-fe-build.outputs.cache-hit != 'true'
+        run: npm test
 
       - name: Code coverage
-        if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'
-        uses: artiomtr/jest-coverage-report-action@v2.2.6
+        if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request' && steps.cache-gui-fe-build.outputs.cache-hit != 'true'
+        uses: artiomtr/jest-coverage-report-action@v2.3.0
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           threshold: "80"
-          working-directory: "frontend/taipy-gui"
-          skip-step: "install"
+          working-directory: "./frontend/taipy-gui"
+          skip-step: install
+          annotations: failed-tests

二进制
frontend/taipy-gui/public/favicon.ico


二进制
frontend/taipy-gui/public/favicon.png


+ 1 - 1
frontend/taipy-gui/public/index.html

@@ -5,7 +5,7 @@
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <link rel="manifest" href="manifest.json" />
-    <link rel="icon" type="image/png" href="{{favicon}}" />
+    <link rel="icon" type="image/png" href="favicon.png" class="taipy-favicon" data-url="{{favicon}}" />
     <link rel="apple-touch-icon" href="favicon.png" />
     <title>{{title}}</title>
     <script>{%- if config -%}

+ 4 - 1
frontend/taipy-gui/src/context/taipyReducers.ts

@@ -22,7 +22,7 @@ import { stylekitModeThemes, stylekitTheme } from "../themes/stylekit";
 import { getBaseURL, TIMEZONE_CLIENT } from "../utils";
 import { parseData } from "../utils/dataFormat";
 import { MenuProps } from "../utils/lov";
-import { getLocalStorageValue, IdMessage, storeClientId } from "./utils";
+import { changeFavicon, getLocalStorageValue, IdMessage, storeClientId } from "./utils";
 import { ligthenPayload, sendWsMessage, TAIPY_CLIENT_ID, WsMessage } from "./wsUtils";
 
 enum Types {
@@ -228,6 +228,8 @@ const messageToAction = (message: WsMessage) => {
             return createPartialAction((message as unknown as Record<string, string>).name, true);
         } else if (message.type === "ACK") {
             return createAckAction((message as unknown as IdMessage).id);
+        } else if (message.type === "FV") {
+            changeFavicon((message.payload as Record<string, string>)?.value);
         }
     }
     return {} as TaipyBaseAction;
@@ -278,6 +280,7 @@ export const initializeWebSocket = (socket: Socket | undefined, dispatch: Dispat
         socket.on("message", getWsMessageListener(dispatch));
         // only now does the socket tries to open/connect
         socket.connect();
+        changeFavicon();
     }
 };
 

+ 20 - 0
frontend/taipy-gui/src/context/utils.ts

@@ -1,3 +1,4 @@
+import axios from "axios";
 import { TAIPY_CLIENT_ID } from "./wsUtils";
 
 export const getLocalStorageValue = <T = string>(key: string, defaultValue: T, values?: T[]) => {
@@ -10,3 +11,22 @@ export const storeClientId = (id: string) => localStorage && localStorage.setIte
 export interface IdMessage {
     id: string;
 }
+
+export const changeFavicon = (url?: string) => {
+    const link: HTMLLinkElement | null = document.querySelector("link.taipy-favicon");
+    if (link) {
+        const { url: taipyUrl } = link.dataset;
+        const fetchUrl = url || (taipyUrl as string);
+        axios
+            .get(fetchUrl)
+            .then(() => {
+                link.href = fetchUrl;
+            })
+            .catch((error) => {
+                if (fetchUrl !== taipyUrl) {
+                    link.href = taipyUrl as string;
+                }
+                console.log(error);
+            });
+    }
+};

+ 2 - 1
frontend/taipy-gui/src/context/wsUtils.ts

@@ -20,7 +20,8 @@ export type WsMessageType =
     | "GMC"
     | "GDT"
     | "AID"
-    | "GR";
+    | "GR"
+    | "FV";
 
 export interface WsMessage {
     type: WsMessageType;

+ 45 - 15
taipy/gui/gui.py

@@ -223,6 +223,7 @@ class Gui:
     __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"
+    __DEFAULT_FAVICON_URL = "https://raw.githubusercontent.com/Avaiga/taipy-assets/develop/favicon.png"
 
     __RE_HTML = re.compile(r"(.*?)\.html$")
     __RE_MD = re.compile(r"(.*?)\.md$")
@@ -322,6 +323,7 @@ class Gui:
         self.__evaluator: _Evaluator = None  # type: ignore
         self.__adapter = _Adapter()
         self.__directory_name_of_pages: t.List[str] = []
+        self.__favicon: t.Optional[t.Union[str, Path]] = None
 
         # default actions
         self.on_action: t.Optional[t.Callable] = None
@@ -1117,9 +1119,7 @@ class Gui:
             # Get Module Context
             if mc := self._get_page_context(page_path):
                 page_renderer = self._get_page(page_path)._renderer
-                self._bind_custom_page_variables(
-                    page_renderer, self._get_client_id()
-                )
+                self._bind_custom_page_variables(page_renderer, self._get_client_id())
                 # get metadata if there is one
                 metadata: t.Dict[str, t.Any] = {}
                 if hasattr(page_renderer, "_metadata"):
@@ -1227,7 +1227,7 @@ class Gui:
     def __broadcast_ws(self, payload: dict, client_id: t.Optional[str] = None):
         try:
             to = list(self.__get_sids(client_id)) if client_id else []
-            self._server._ws.emit("message", payload, to=to if to else None)
+            self._server._ws.emit("message", payload, to=to if to else None, include_self=True)
             time.sleep(0.001)
         except Exception as e:  # pragma: no cover
             _warn(f"Exception raised in WebSocket communication in '{self.__frame.f_code.co_name}'", e)
@@ -1315,9 +1315,19 @@ class Gui:
         else:
             self.__send_ws({"type": _WsType.MULTIPLE_UPDATE.value, "payload": payload})
 
-    def __send_ws_broadcast(self, var_name: str, var_value: t.Any, client_id: t.Optional[str] = None):
+    def __send_ws_broadcast(
+        self,
+        var_name: str,
+        var_value: t.Any,
+        client_id: t.Optional[str] = None,
+        message_type: t.Optional[_WsType] = None,
+    ):
         self.__broadcast_ws(
-            {"type": _WsType.UPDATE.value, "name": _get_broadcast_var_name(var_name), "payload": {"value": var_value}},
+            {
+                "type": _WsType.UPDATE.value if message_type is None else message_type.value,
+                "name": _get_broadcast_var_name(var_name),
+                "payload": {"value": var_value},
+            },
             client_id,
         )
 
@@ -1977,7 +1987,13 @@ class Gui:
     def load_config(self, config: Config) -> None:
         self._config._load(config)
 
-    def _broadcast(self, name: str, value: t.Any, client_id: t.Optional[str] = None):
+    def _broadcast(
+        self,
+        name: str,
+        value: t.Any,
+        client_id: t.Optional[str] = None,
+        message_type: t.Optional[_WsType] = None,
+    ):
         """NOT DOCUMENTED
         Send the new value of a variable to all connected clients.
 
@@ -1986,7 +2002,7 @@ class Gui:
             value: The value (must be serializable to the JSON format).
             client_id: The client id (broadcast to all client if None)
         """
-        self.__send_ws_broadcast(name, value, client_id)
+        self.__send_ws_broadcast(name, value, client_id, message_type)
 
     def _broadcast_all_clients(self, name: str, value: t.Any):
         try:
@@ -2411,7 +2427,7 @@ class Gui:
                 static_folder=_webapp_path,
                 template_folder=_webapp_path,
                 title=self._get_config("title", "Taipy App"),
-                favicon=self._get_config("favicon", "favicon.png"),
+                favicon=self._get_config("favicon", Gui.__DEFAULT_FAVICON_URL),
                 root_margin=self._get_config("margin", None),
                 scripts=scripts,
                 styles=styles,
@@ -2440,8 +2456,7 @@ class Gui:
         async_mode: str = "gevent",
         **kwargs,
     ) -> t.Optional[Flask]:
-        """
-        Start the server that delivers pages to web clients.
+        """Start the server that delivers pages to web clients.
 
         Once you enter `run()`, users can run web browsers and point to the web server
         URL that `Gui` serves. The default is to listen to the *localhost* address
@@ -2593,8 +2608,7 @@ class Gui:
         )
 
     def reload(self):  # pragma: no cover
-        """
-        Reload the web server.
+        """Reload the web server.
 
         This function reloads the underlying web server only in the situation where
         it was run in a separated thread: the *run_in_thread* parameter to the
@@ -2607,8 +2621,7 @@ class Gui:
             _TaipyLogger._get_logger().info("Gui server has been reloaded.")
 
     def stop(self):
-        """
-        Stop the web server.
+        """Stop the web server.
 
         This function stops the underlying web server only in the situation where
         it was run in a separated thread: the *run_in_thread* parameter to the
@@ -2621,3 +2634,20 @@ class Gui:
 
     def _get_autorization(self, client_id: t.Optional[str] = None, system: t.Optional[bool] = False):
         return contextlib.nullcontext()
+
+    def set_favicon(self, favicon_path: t.Union[str, Path], state: t.Optional[State] = None):
+        """Change the favicon for all clients.
+
+        This function dynamically changes the favicon of Taipy GUI pages for all connected client.
+        favicon_path can be an URL (relative or not) or a file path.
+        TODO The *favicon* parameter to `(Gui.)run()^` can also be used to change
+         the favicon when the application starts.
+
+        """
+        if state or self.__favicon != favicon_path:
+            if not state:
+                self.__favicon = favicon_path
+            url = self._get_content("__taipy_favicon", favicon_path, True)
+            self._broadcast(
+                "taipy_favicon", url, self._get_client_id() if state else None, message_type=_WsType.FAVICON
+            )

+ 12 - 0
taipy/gui/state.py

@@ -13,6 +13,7 @@ import inspect
 import typing as t
 from contextlib import nullcontext
 from operator import attrgetter
+from pathlib import Path
 from types import FrameType
 
 from flask import has_app_context
@@ -83,6 +84,7 @@ class State:
         "broadcast",
         "get_gui",
         "refresh",
+        "set_favicon",
         "_set_context",
         "_notebook_context",
         "_get_placeholder",
@@ -243,3 +245,13 @@ class State:
 
     def __exit__(self, exc_type, exc_value, traceback):
         return super().__getattribute__(State.__attrs[0]).__exit__(exc_type, exc_value, traceback)
+
+    def set_favicon(self, favicon_path: t.Union[str, Path]):
+        """Change the favicon for the client of this state.
+
+        This function dynamically changes the favicon of Taipy GUI pages for a specific client.
+        favicon_path can be an URL (relative or not) or a file path.
+        TODO The *favicon* parameter to `(Gui.)run()^` can also be used to change
+         the favicon when the application starts.
+        """
+        super().__getattribute__(State.__gui_attr).set_favicon(favicon_path, self)

+ 1 - 0
taipy/gui/types.py

@@ -50,6 +50,7 @@ class _WsType(Enum):
     GET_MODULE_CONTEXT = "GMC"
     GET_DATA_TREE = "GDT"
     GET_ROUTES = "GR"
+    FAVICON = "FV"
 
 
 NumberTypes = {"int", "int64", "float", "float64"}

+ 35 - 0
tests/gui/gui_specific/test_favicon.py

@@ -0,0 +1,35 @@
+# Copyright 2021-2024 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 warnings
+
+from taipy.gui import Gui, Markdown
+
+
+def test_favicon(gui: Gui, helpers):
+
+    with warnings.catch_warnings(record=True):
+        gui._set_frame(inspect.currentframe())
+        gui.add_page("test", Markdown("#This is a page"))
+        gui.run(run_server=False)
+        client = gui._server.test_client()
+        # WS client and emit
+        ws_client = gui._server._ws.test_client(gui._server.get_flask())
+        # Get the jsx once so that the page will be evaluated -> variable will be registered
+        sid = helpers.create_scope_and_get_sid(gui)
+        client.get(f"/taipy-jsx/test/?client_id={sid}")
+        gui.set_favicon("https://newfavicon.com/favicon.png")
+        # assert for received message (message that would be sent to the front-end client)
+        msgs = ws_client.get_received()
+        assert msgs
+        assert msgs[0].get("args", {}).get("type", None) == "FV"
+        assert msgs[0].get("args", {}).get("payload", {}).get("value", None) == "https://newfavicon.com/favicon.png"