diff --git a/frontend/taipy-gui/base/src/packaging/taipy-gui-base.d.ts b/frontend/taipy-gui/base/src/packaging/taipy-gui-base.d.ts index be8ce809a3..071886d454 100644 --- a/frontend/taipy-gui/base/src/packaging/taipy-gui-base.d.ts +++ b/frontend/taipy-gui/base/src/packaging/taipy-gui-base.d.ts @@ -76,7 +76,8 @@ export type WsMessageType = | "AID" | "GR" | "FV" - | "BC"; + | "BC" + | "LS" export interface WsMessage { type: WsMessageType | string; name: string; diff --git a/frontend/taipy-gui/src/components/Router.tsx b/frontend/taipy-gui/src/components/Router.tsx index b36e6ca52f..4252a1124f 100644 --- a/frontend/taipy-gui/src/components/Router.tsx +++ b/frontend/taipy-gui/src/components/Router.tsx @@ -44,6 +44,7 @@ import MainPage from "./pages/MainPage"; import TaipyRendered from "./pages/TaipyRendered"; import NotFound404 from "./pages/NotFound404"; import { getBaseURL } from "../utils"; +import { useLocalStorageWithEvent } from "../hooks"; interface AxiosRouter { router: string; @@ -63,6 +64,8 @@ const Router = () => { const themeClass = "taipy-" + state.theme.palette.mode; const baseURL = getBaseURL(); + useLocalStorageWithEvent(dispatch); + useEffect(() => { if (refresh) { // no need to access the backend again, the routes are static @@ -125,7 +128,7 @@ const Router = () => { path !== "/" + (path) => path !== "/", )} /> } diff --git a/frontend/taipy-gui/src/context/taipyReducers.ts b/frontend/taipy-gui/src/context/taipyReducers.ts index 9b985e6964..b0ac7c2574 100644 --- a/frontend/taipy-gui/src/context/taipyReducers.ts +++ b/frontend/taipy-gui/src/context/taipyReducers.ts @@ -16,7 +16,7 @@ import { createTheme, Theme } from "@mui/material/styles"; import merge from "lodash/merge"; import { Dispatch } from "react"; import { io, Socket } from "socket.io-client"; -import { nanoid } from 'nanoid'; +import { nanoid } from "nanoid"; import { FilterDesc } from "../components/Taipy/tableUtils"; import { stylekitModeThemes, stylekitTheme } from "../themes/stylekit"; @@ -48,6 +48,7 @@ export enum Types { Partial = "PARTIAL", Acknowledgement = "ACKNOWLEDGEMENT", Broadcast = "BROADCAST", + LocalStorage = "LOCAL_STORAGE", } /** @@ -180,7 +181,7 @@ const getUserTheme = (mode: PaletteMode) => { }, }, }, - }) + }), ); }; @@ -225,7 +226,7 @@ export const messageToAction = (message: WsMessage) => { (message as unknown as NavigateMessage).to, (message as unknown as NavigateMessage).params, (message as unknown as NavigateMessage).tab, - (message as unknown as NavigateMessage).force + (message as unknown as NavigateMessage).force, ); } else if (message.type === "ID") { return createIdAction((message as unknown as IdMessage).id); @@ -267,7 +268,8 @@ export const getWsMessageListener = (dispatch: Dispatch) => { // Broadcast const __BroadcastRepo: Record> = {}; -const stackBroadcast = (name: string, value: unknown) => (__BroadcastRepo[name] = __BroadcastRepo[name] || []).push(value); +const stackBroadcast = (name: string, value: unknown) => + (__BroadcastRepo[name] = __BroadcastRepo[name] || []).push(value); const broadcast_timeout = 250; @@ -495,7 +497,7 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta action.payload, state.id, action.context, - action.propagate + action.propagate, ); break; case Types.Action: @@ -507,6 +509,9 @@ export const taipyReducer = (state: TaipyState, baseAction: TaipyBaseAction): Ta case Types.RequestUpdate: ackId = sendWsMessage(state.socket, "RU", action.name, action.payload, state.id, action.context); break; + case Types.LocalStorage: + ackId = sendWsMessage(state.socket, "LS", action.name, action.payload, state.id, action.context); + break; } if (ackId) return { ...state, ackList: [...state.ackList, ackId] }; return state; @@ -545,7 +550,7 @@ export const createSendUpdateAction = ( context: string | undefined, onChange?: string, propagate = true, - relName?: string + relName?: string, ): TaipyAction => ({ type: Types.SendUpdate, name: name, @@ -598,7 +603,7 @@ export const createRequestChartUpdateAction = ( context: string | undefined, columns: string[], pageKey: string, - decimatorPayload: unknown | undefined + decimatorPayload: unknown | undefined, ): TaipyAction => createRequestDataUpdateAction( name, @@ -609,7 +614,7 @@ export const createRequestChartUpdateAction = ( { decimatorPayload: decimatorPayload, }, - true + true, ); export const createRequestTableUpdateAction = ( @@ -631,7 +636,7 @@ export const createRequestTableUpdateAction = ( filters?: Array, compare?: string, compareDatas?: string, - stateContext?: Record + stateContext?: Record, ): TaipyAction => createRequestDataUpdateAction( name, @@ -654,7 +659,7 @@ export const createRequestTableUpdateAction = ( compare, compare_datas: compareDatas, state_context: stateContext, - }) + }), ); export const createRequestInfiniteTableUpdateAction = ( @@ -677,7 +682,7 @@ export const createRequestInfiniteTableUpdateAction = ( compare?: string, compareDatas?: string, stateContext?: Record, - reverse?: boolean + reverse?: boolean, ): TaipyAction => createRequestDataUpdateAction( name, @@ -702,7 +707,7 @@ export const createRequestInfiniteTableUpdateAction = ( compare_datas: compareDatas, state_context: stateContext, reverse: !!reverse, - }) + }), ); /** @@ -733,7 +738,7 @@ export const createRequestDataUpdateAction = ( pageKey: string, payload: Record, allData = false, - library?: string + library?: string, ): TaipyAction => { payload = payload || {}; if (id !== undefined) { @@ -771,7 +776,7 @@ export const createRequestUpdateAction = ( context: string | undefined, names: string[], forceRefresh = false, - stateContext?: Record + stateContext?: Record, ): TaipyAction => ({ type: Types.RequestUpdate, name: "", @@ -846,7 +851,7 @@ export const createNavigateAction = ( to?: string, params?: Record, tab?: string, - force?: boolean + force?: boolean, ): TaipyNavigateAction => ({ type: Types.Navigate, to, @@ -882,3 +887,9 @@ export const createPartialAction = (name: string, create: boolean): TaipyPartial name, create, }); + +export const createLocalStorageAction = (localStorageData: Record): TaipyAction => ({ + type: Types.LocalStorage, + name: "", + payload: localStorageData, +}); diff --git a/frontend/taipy-gui/src/context/wsUtils.ts b/frontend/taipy-gui/src/context/wsUtils.ts index e5e8fd1ec1..fc1cc35b0c 100644 --- a/frontend/taipy-gui/src/context/wsUtils.ts +++ b/frontend/taipy-gui/src/context/wsUtils.ts @@ -22,7 +22,8 @@ export type WsMessageType = | "AID" | "GR" | "FV" - | "BC"; + | "BC" + | "LS"; export interface WsMessage { type: WsMessageType; diff --git a/frontend/taipy-gui/src/hooks/index.ts b/frontend/taipy-gui/src/hooks/index.ts new file mode 100644 index 0000000000..e4dbd62cab --- /dev/null +++ b/frontend/taipy-gui/src/hooks/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { useLocalStorageWithEvent } from "./useLocalStorageWithEvent"; + +export { useLocalStorageWithEvent }; diff --git a/frontend/taipy-gui/src/hooks/useLocalStorageWithEvent.ts b/frontend/taipy-gui/src/hooks/useLocalStorageWithEvent.ts new file mode 100644 index 0000000000..267dc7a253 --- /dev/null +++ b/frontend/taipy-gui/src/hooks/useLocalStorageWithEvent.ts @@ -0,0 +1,29 @@ +/* + * 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 { Dispatch, useEffect } from "react"; +import { createLocalStorageAction, TaipyBaseAction } from "../context/taipyReducers"; + +export const useLocalStorageWithEvent = (dispatch: Dispatch) => { + // send all localStorage data to backend on init + useEffect(() => { + const localStorageData: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key) { + localStorageData[key] = localStorage.getItem(key) || ""; + } + } + dispatch(createLocalStorageAction(localStorageData)); + }, [dispatch]); // Not necessary to add dispatch to the dependency array but comply with eslint warning anyway +}; diff --git a/taipy/gui/__init__.py b/taipy/gui/__init__.py index 03c03bd1d5..17ec6de3ef 100644 --- a/taipy/gui/__init__.py +++ b/taipy/gui/__init__.py @@ -77,6 +77,7 @@ from ._renderers.json import JsonAdapter from .gui_actions import ( broadcast_callback, + close_notification, download, get_module_context, get_module_name_from_state, @@ -87,6 +88,7 @@ invoke_long_callback, navigate, notify, + query_local_storage, resume_control, ) from .icon import Icon diff --git a/taipy/gui/data/data_scope.py b/taipy/gui/data/data_scope.py index e5c62d477a..53ee6fcac1 100644 --- a/taipy/gui/data/data_scope.py +++ b/taipy/gui/data/data_scope.py @@ -23,17 +23,24 @@ class _DataScopes: _GLOBAL_ID = "global" _META_PRE_RENDER = "pre_render" - _DEFAULT_METADATA = {_META_PRE_RENDER: False} + _META_LOCAL_STORAGE = "local_storage" + _DEFAULT_METADATA = {_META_PRE_RENDER: False, _META_LOCAL_STORAGE: {}} def __init__(self, gui: "Gui") -> None: self.__gui = gui self.__scopes: t.Dict[str, SimpleNamespace] = {_DataScopes._GLOBAL_ID: SimpleNamespace()} # { scope_name: { metadata: value } } self.__scopes_metadata: t.Dict[str, t.Dict[str, t.Any]] = { - _DataScopes._GLOBAL_ID: _DataScopes._DEFAULT_METADATA.copy() + _DataScopes._GLOBAL_ID: _DataScopes._get_new_default_metadata() } self.__single_client = True + @staticmethod + def _get_new_default_metadata() -> t.Dict[str, t.Any]: + metadata = _DataScopes._DEFAULT_METADATA.copy() + metadata[_DataScopes._META_LOCAL_STORAGE] = {} + return metadata + def set_single_client(self, value: bool) -> None: self.__single_client = value @@ -66,7 +73,7 @@ def create_scope(self, id: str) -> None: return if id not in self.__scopes: self.__scopes[id] = SimpleNamespace() - self.__scopes_metadata[id] = _DataScopes._DEFAULT_METADATA.copy() + self.__scopes_metadata[id] = _DataScopes._get_new_default_metadata() # Propagate shared variables to the new scope from the global scope for var in self.__gui._get_shared_variables(): if hasattr(self.__scopes[_DataScopes._GLOBAL_ID], var): diff --git a/taipy/gui/gui.py b/taipy/gui/gui.py index 641ef279dc..6183f25c3a 100644 --- a/taipy/gui/gui.py +++ b/taipy/gui/gui.py @@ -730,6 +730,8 @@ def _manage_message(self, msg_type: _WsType, message: dict) -> None: self.__handle_ws_app_id(message) elif msg_type == _WsType.GET_ROUTES.value: self.__handle_ws_get_routes() + elif msg_type == _WsType.LOCAL_STORAGE.value: + self.__handle_ws_local_storage(message) else: self._manage_external_message(msg_type, message) self.__send_ack(message.get("ack_id")) @@ -1368,6 +1370,31 @@ def __handle_ws_get_routes(self): send_back_only=True, ) + def __handle_ws_local_storage(self, message: t.Any): + if not isinstance(message, dict): + return + payload = message.get("payload", None) + scope_meta_ls = self._get_data_scope_metadata()[_DataScopes._META_LOCAL_STORAGE] + if payload is None: + return + for key, value in payload.items(): + if value is not None and scope_meta_ls.get(key) != value: + scope_meta_ls[key] = value + + def _query_local_storage(self, *keys: str) -> t.Optional[t.Union[str, t.Dict[str, str]]]: + if not keys: + return None + if len(keys) == 1: + if keys[0] in self._get_data_scope_metadata()[_DataScopes._META_LOCAL_STORAGE]: + return self._get_data_scope_metadata()[_DataScopes._META_LOCAL_STORAGE][keys[0]] + return None + # case of multiple keys + ls_items = {} + for key in keys: + if key in self._get_data_scope_metadata()[_DataScopes._META_LOCAL_STORAGE]: + ls_items[key] = self._get_data_scope_metadata()[_DataScopes._META_LOCAL_STORAGE][key] + return ls_items + def __send_ws(self, payload: dict, allow_grouping=True, send_back_only=False) -> None: grouping_message = self.__get_message_grouping() if allow_grouping else None if grouping_message is None: diff --git a/taipy/gui/gui_actions.py b/taipy/gui/gui_actions.py index 5c90272b39..70773924c2 100644 --- a/taipy/gui/gui_actions.py +++ b/taipy/gui/gui_actions.py @@ -226,8 +226,8 @@ def get_state_id(state: State) -> t.Optional[str]: state (State^): The current user state as received in any callback. Returns: - A string that uniquely identifies the state. If this value None, it indicates that *state* is not - handled by a `Gui^` instance. + A string that uniquely identifies the state.
+ 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() @@ -241,7 +241,7 @@ def get_module_context(state: State) -> t.Optional[str]: state (State^): The current user state as received in any callback. Returns: - The name of the current module + The name of the current module. """ if state and isinstance(state._gui, Gui): return state._gui._get_locals_context() @@ -442,3 +442,29 @@ def thread_status(name: str, period_s: float, count: int): thread.start() if isinstance(period, int) and period >= 500 and _is_function(user_status_function): thread_status(thread.name, period / 1000.0, 0) + + +def query_local_storage(state: State, *keys: str) -> t.Optional[t.Union[str, t.Dict[str, str]]]: + """Retrieve values from the browser's local storage. + + This function queries the local storage of the client identified by *state* and returns the + values associated with the specified keys. Local storage is a key-value store available in the + user's browser, typically manipulated by client-side code. + + Arguments: + state (State^): The current user state as received in any callback. + *keys (string): One or more keys to retrieve values for from the client's local storage. + + Returns: + The requested values from the browser's local storage. + + - If a single key is provided (*keys* has a single element), this function returns the + corresponding value as a string. + - If multiple keys are provided, this function returns a dictionary mapping each key to + its value in the client's local storage. + - If no value is found for a key, that key will not appear in the dictionary. + """ + if state and isinstance(state._gui, Gui): + return state._gui._query_local_storage(*keys) + _warn("'query_local_storage()' must be called in the context of a callback.") + return None diff --git a/taipy/gui/types.py b/taipy/gui/types.py index d6da3d22de..ae32cad644 100644 --- a/taipy/gui/types.py +++ b/taipy/gui/types.py @@ -53,6 +53,7 @@ class _WsType(Enum): GET_ROUTES = "GR" FAVICON = "FV" BROADCAST = "BC" + LOCAL_STORAGE = "LS" NumberTypes = {"int", "int64", "float", "float64"} @@ -158,8 +159,7 @@ class PropertyType(Enum): @t.overload # noqa: F811 -def _get_taipy_type(a_type: None) -> None: - ... +def _get_taipy_type(a_type: None) -> None: ... @t.overload @@ -175,8 +175,7 @@ def _get_taipy_type(a_type: PropertyType) -> t.Type[_TaipyBase]: # noqa: F811 @t.overload def _get_taipy_type( # noqa: F811 a_type: t.Optional[t.Union[t.Type[_TaipyBase], t.Type[Decimator], PropertyType]], -) -> t.Optional[t.Union[t.Type[_TaipyBase], t.Type[Decimator], PropertyType]]: - ... +) -> t.Optional[t.Union[t.Type[_TaipyBase], t.Type[Decimator], PropertyType]]: ... def _get_taipy_type( # noqa: F811