From 074daee0df04975c22dbd3d5cb947b194602ead8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 30 Jun 2024 12:44:51 +0300 Subject: [PATCH] persist tabs --- src/components/AppLayout/AppLayout.tsx | 42 ++++++++++- src/lib/storage/create-storage-driver.ts | 46 ++++++++++++ src/lib/storage/local-storage.ts | 9 +++ src/lib/storage/memory-storage.ts | 32 ++++++++ src/lib/tabs/persist.ts | 37 ++++++++++ src/lib/tabs/tabbed-navigation-2.tsx | 65 +++++++--------- src/lib/tabs/tabbed-navigation.tsx | 56 +++++++------- src/routes/CategoriesRoute.tsx | 94 ++++++++++++++++-------- src/routes/ProductsRoute.tsx | 54 +++++++++++--- 9 files changed, 321 insertions(+), 114 deletions(-) create mode 100644 src/lib/storage/create-storage-driver.ts create mode 100644 src/lib/storage/local-storage.ts create mode 100644 src/lib/storage/memory-storage.ts create mode 100644 src/lib/tabs/persist.ts diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index 3f60a2e..20e9249 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -5,16 +5,37 @@ import { Sidebar } from "src/components/Sidebar/Sidebar"; import { Tabs, TabsApi } from "src/components/Tabs/Tabs.tsx"; import { TabStoreKey } from "src/constants/tabs.constants.ts"; -import { useTabbedNavigation } from "src/lib/tabs"; +import { TabbedNavigationMeta, useTabbedNavigation } from "src/lib/tabs"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import * as routes from "src/constants/routes.constants.ts"; +import { usePersistTabs } from "src/lib/tabs/persist.ts"; +import { localStorageDriver } from "src/lib/storage/local-storage.ts"; + +const persistStoreKey = { + name: "main-tabs", + version: "1.0", +}; + export function AppLayout() { const apiRef = useRef(); const navigate = useNavigate(); - const { getTabsProps } = useTabbedNavigation({ + const { getTabsFromStorage, persistTabs } = + usePersistTabs({ + storage: localStorageDriver, + storageKey: persistStoreKey, + }); + + const [tabs, setTabs] = useState(getTabsFromStorage() || []); + + const [startPinnedTabs, setStartPinnedTabsChange] = useState([]); + + const { activeTabId, setActiveTabId } = useTabbedNavigation({ + tabs, + onTabsChange: setTabs, + startPinnedTabs, key: TabStoreKey.Main, onCloseAllTabs: () => { navigate(routes.homeRoute); @@ -22,12 +43,25 @@ export function AppLayout() { resolveTabMeta: useCallback(() => ({}), []), }); + useEffect(() => { + return persistTabs(tabs); + }, [tabs, persistTabs]); + return (
John Doe
- +
diff --git a/src/lib/storage/create-storage-driver.ts b/src/lib/storage/create-storage-driver.ts new file mode 100644 index 0000000..411627b --- /dev/null +++ b/src/lib/storage/create-storage-driver.ts @@ -0,0 +1,46 @@ +export type StorageKey = { + version: string; + name: string; +}; + +export const createStorageDriver = (storage: Storage) => { + const get = (key: StorageKey): V | null => { + const value = storage.getItem(key.name); + if (!value) { + return null; + } + try { + const result = JSON.parse(value); + if (result?.version !== key.version) { + remove(key); + return null; + } + return result?.state; + } catch (e) { + // todo: log this + return null; + } + }; + + const set = (key: StorageKey, value: any) => { + storage.setItem( + key.name, + JSON.stringify({ + state: value, + version: key.version, + }), + ); + }; + + const remove = (key: StorageKey) => { + storage.removeItem(key.name); + }; + + return { + get, + set, + remove, + }; +}; + +export type StorageDriver = ReturnType; diff --git a/src/lib/storage/local-storage.ts b/src/lib/storage/local-storage.ts new file mode 100644 index 0000000..b4bcb6b --- /dev/null +++ b/src/lib/storage/local-storage.ts @@ -0,0 +1,9 @@ +import { createStorageDriver } from "src/lib/storage/create-storage-driver.ts"; + +export const localStorageDriver = createStorageDriver(localStorage); + +export const { + get: getFromLocalStorage, + set: setToLocalStorage, + remove: removeFromLocalStorage, +} = localStorageDriver; diff --git a/src/lib/storage/memory-storage.ts b/src/lib/storage/memory-storage.ts new file mode 100644 index 0000000..7d5c015 --- /dev/null +++ b/src/lib/storage/memory-storage.ts @@ -0,0 +1,32 @@ +import { createStorageDriver } from "src/lib/storage/create-storage-driver.ts"; + +const memoryStorageMap = new Map(); + +export const memoryStorage: Storage = { + getItem: (storageKey: string) => { + return memoryStorageMap.get(storageKey); + }, + setItem(key: string, value: string) { + return memoryStorageMap.set(key, value); + }, + removeItem(key: string) { + memoryStorageMap.delete(key); + }, + clear() { + memoryStorageMap.clear(); + }, + get length() { + return memoryStorageMap.size; + }, + key(index: number) { + return [...memoryStorageMap.keys()][index]; + }, +}; + +export const memoryStorageDriver = createStorageDriver(memoryStorage); + +export const { + get: getFromMemoryStorage, + set: setToMemoryStorage, + remove: removeFromMemoryStorage, +} = memoryStorageDriver; diff --git a/src/lib/tabs/persist.ts b/src/lib/tabs/persist.ts new file mode 100644 index 0000000..150219f --- /dev/null +++ b/src/lib/tabs/persist.ts @@ -0,0 +1,37 @@ +import { TabModel, ValidTabMeta } from "src/lib/tabs/tabs.types.ts"; +import { + StorageDriver, + StorageKey, +} from "src/lib/storage/create-storage-driver.ts"; +import { useCallback } from "react"; + +export const usePersistTabs = ({ + storageKey, + storage, +}: { + storageKey: StorageKey; + storage: StorageDriver; +}) => { + return { + getTabsFromStorage: useCallback( + () => storage.get[]>(storageKey) || [], + [storageKey, storage], + ), + persistTabs: useCallback( + (tabs: TabModel[]) => { + const onUnload = () => { + // save on window close + // it doesn't make sense for memory storage + storage.set(storageKey, tabs); + }; + window.addEventListener("unload", onUnload); + return () => { + window.removeEventListener("unload", onUnload); + // save on unmount + storage.set(storageKey, tabs); + }; + }, + [storageKey, storage], + ), + }; +}; diff --git a/src/lib/tabs/tabbed-navigation-2.tsx b/src/lib/tabs/tabbed-navigation-2.tsx index f63c04d..988f273 100644 --- a/src/lib/tabs/tabbed-navigation-2.tsx +++ b/src/lib/tabs/tabbed-navigation-2.tsx @@ -1,6 +1,6 @@ import { useDataRouterContext } from "../../hooks/useDataRouterContext.tsx"; import { useMatches, useNavigate } from "react-router-dom"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { RouterState, AgnosticDataRouteMatch } from "@remix-run/router"; import { last, replaceAt, insertAt } from "src/utils/array-utils.ts"; import { TabModel, ValidTabMeta } from "src/lib/tabs/tabs.types.ts"; @@ -17,7 +17,7 @@ type ValidID = | string | { (props: { params: Params }): string }; -type TabConfig< +export type TabConfig< Params extends ValidParams = ValidParams, ID extends ValidID = ValidID, > = { @@ -26,43 +26,36 @@ type TabConfig< title: ({ params }: { params: Params }) => string; }; -export class Tab< - Params extends ValidParams = ValidParams, - ID extends ValidID = ValidID, -> { - routeId: string; - id: ID; - title: ({ params }: { params: Params }) => string; - constructor(props: TabConfig) { - const { id, routeId, title } = props; - this.id = id; - this.routeId = routeId; - this.title = title; - } -} +type TabsChangeCallback = ( + tabs: + | TabModel[] + | { + ( + prevTabs: TabModel[], + ): TabModel[]; + }, +) => void; export const useTabbedNavigation2 = < Meta extends ValidTabMeta = ValidTabMeta, Params extends ValidParams = ValidParams, >(options: { - config: Tab[]; - initialTabs?: TabModel[]; - initialStartPinnedTabs?: string[]; + config: TabConfig[]; + onTabsChange?: TabsChangeCallback; + tabs: TabModel[]; + startPinnedTabs: string[]; onCloseAllTabs: () => void; resolveTabMeta: (match: AgnosticDataRouteMatch) => Meta; }) => { const { resolveTabMeta, onCloseAllTabs, - initialTabs = [], - initialStartPinnedTabs = [], + onTabsChange, + tabs = [], + startPinnedTabs, config, } = options; - const [tabs, setTabs] = - useState[]>(initialTabs); - const [startPinnedTabs, setStartPinnedTabs] = useState( - initialStartPinnedTabs, - ); + const { router } = useDataRouterContext(); const navigate = useNavigate(); @@ -91,10 +84,13 @@ export const useTabbedNavigation2 = < ); return [def, match]; }) - .filter(([def]) => !!def) as unknown as [Tab, AgnosticDataRouteMatch][]; + .filter(([def]) => !!def) as unknown as [ + TabConfig, + AgnosticDataRouteMatch, + ][]; pairs.forEach(([def, match]) => { - setTabs((prevTabs) => { + onTabsChange?.((prevTabs) => { const tab = prevTabs.find( (tab) => tab.id === @@ -134,7 +130,7 @@ export const useTabbedNavigation2 = < }); }); }, - [resolveTabMeta, startPinnedTabs, config], + [resolveTabMeta, startPinnedTabs, config, onTabsChange], ); useEffect(() => { @@ -153,18 +149,7 @@ export const useTabbedNavigation2 = < })?.pathname; return { - getTabsProps: () => ({ - activeTabId, - tabs, - onTabsChange: setTabs, - onActiveTabIdChange: setActiveTabId, - hasControlledActiveTabId: true, - startPinnedTabs, - onStartPinnedTabsChange: setStartPinnedTabs, - }), - setTabs, setActiveTabId, - tabs, activeTabId, }; }; diff --git a/src/lib/tabs/tabbed-navigation.tsx b/src/lib/tabs/tabbed-navigation.tsx index 3a4c295..2fc433f 100644 --- a/src/lib/tabs/tabbed-navigation.tsx +++ b/src/lib/tabs/tabbed-navigation.tsx @@ -6,7 +6,7 @@ import { useNavigate, } from "react-router-dom"; import { Handle, TabHandle } from "../../router.tsx"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { RouterState, AgnosticDataRouteMatch } from "@remix-run/router"; import { last, replaceAt, insertAt } from "src/utils/array-utils.ts"; import { TabModel, ValidTabMeta } from "src/lib/tabs/tabs.types.ts"; @@ -40,31 +40,38 @@ export const useActiveTabId = (key: string) => { return storeMatches[storeMatches.length - 1]?.pathname; }; +type TabsChangeCallback = ( + tabs: + | TabModel[] + | { + ( + tabs: TabModel[], + ): TabModel[]; + }, +) => void; + export const useTabbedNavigation = < - Meta1 extends ValidTabMeta = ValidTabMeta, + Meta extends ValidTabMeta = ValidTabMeta, >(options: { - initialTabs?: TabModel[]; - initialStartPinnedTabs?: string[]; + tabs: TabModel[]; + + onTabsChange?: TabsChangeCallback; + startPinnedTabs: string[]; key: string; onCloseAllTabs: () => void; - resolveTabMeta: ( - match: AgnosticDataRouteMatch, - tabHandle: TabHandle, - ) => Meta1; + resolveTabMeta: (match: AgnosticDataRouteMatch, tabHandle: TabHandle) => Meta; }) => { - type Meta = TabbedNavigationMeta & Meta1; + type CompoundMeta = TabbedNavigationMeta & Meta; const { key, resolveTabMeta, onCloseAllTabs, - initialTabs = [], - initialStartPinnedTabs = [], + tabs, + startPinnedTabs, + onTabsChange, } = options; - const [tabs, setTabs] = useState[]>(initialTabs); - const [startPinnedTabs, setStartPinnedTabs] = useState( - initialStartPinnedTabs, - ); + const { router } = useDataRouterContext(); const navigate = useNavigate(); @@ -90,8 +97,8 @@ export const useTabbedNavigation = < return; } - const updateTabsState = (prevTabs: TabModel[]) => { - const doesTabBelongToRouteMatch = (tab: TabModel) => { + const updateTabsState = (prevTabs: TabModel[]) => { + const doesTabBelongToRouteMatch = (tab: TabModel) => { return ( tab.meta.routeId === match.route.id && pathToLocation(tab.meta.path).pathname.startsWith(match.pathname) @@ -137,9 +144,9 @@ export const useTabbedNavigation = < } }; - setTabs(updateTabsState); + onTabsChange?.(updateTabsState); }, - [key, resolveTabMeta, startPinnedTabs], + [key, resolveTabMeta, startPinnedTabs, onTabsChange], ); useEffect(() => { @@ -151,18 +158,7 @@ export const useTabbedNavigation = < const activeTabId = useActiveTabId(key); return { - getTabsProps: () => ({ - activeTabId, - tabs, - onTabsChange: setTabs, - onActiveTabIdChange: setActiveTabId, - hasControlledActiveTabId: true, - startPinnedTabs, - onStartPinnedTabsChange: setStartPinnedTabs, - }), - setTabs, setActiveTabId, - tabs, activeTabId, }; }; diff --git a/src/routes/CategoriesRoute.tsx b/src/routes/CategoriesRoute.tsx index 4f154dc..88abc2e 100644 --- a/src/routes/CategoriesRoute.tsx +++ b/src/routes/CategoriesRoute.tsx @@ -6,57 +6,91 @@ import { homeRoute, } from "src/constants/routes.constants.ts"; import { routeIds } from "src/router.tsx"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Tabs } from "src/components/Tabs/Tabs.tsx"; import { - Tab, + TabbedNavigationMeta, + TabConfig, useTabbedNavigation2, } from "src/lib/tabs/tabbed-navigation-2.tsx"; +import { TabModel } from "src/lib/tabs"; +import { usePersistTabs } from "src/lib/tabs/persist.ts"; +import { localStorageDriver } from "src/lib/storage/local-storage.ts"; + type DetailTabParams = { id: string }; +const persistStoreKey = { + name: "category-tabs", + version: "1.0", +}; + export function CategoriesRoute() { - const [listTab] = useState( - () => - new Tab({ - title: () => "List", - id: categoriesListRoute, - routeId: routeIds.category.list, - }), - ); + const [listTab] = useState(() => ({ + title: () => "List", + id: categoriesListRoute, + routeId: routeIds.category.list, + })); + + const [detailTab] = useState>(() => ({ + title: ({ params }) => `Category ${params.id}`, + id: ({ params }) => categoryDetailRoute.replace(":id", params.id), + routeId: routeIds.category.detail, + })); + + const { getTabsFromStorage, persistTabs } = + usePersistTabs({ + storageKey: persistStoreKey, + storage: localStorageDriver, + }); + + const defaultTabs: TabModel[] = [ + { + id: listTab.id, + title: "List", + meta: { + routeId: listTab.id, + path: "", + }, + }, + ]; - const [detailTab] = useState( - () => - new Tab({ - title: ({ params }) => `Category ${params.id}`, - id: ({ params }) => categoryDetailRoute.replace(":id", params.id), - routeId: routeIds.category.detail, - }), + const [tabs, setTabs] = useState[]>( + getTabsFromStorage() || defaultTabs, ); + useEffect(() => { + return persistTabs(tabs); + }, [tabs, persistTabs]); + + const [startPinnedTabs, setStartPinnedTabsChange] = useState([ + listTab.id, + ]); + const navigate = useNavigate(); - const { getTabsProps } = useTabbedNavigation2({ + const { activeTabId, setActiveTabId } = useTabbedNavigation2({ config: useMemo(() => [listTab, detailTab], [listTab, detailTab]), onCloseAllTabs: () => { navigate(homeRoute); }, - initialStartPinnedTabs: [listTab.id], - initialTabs: [ - { - id: listTab.id, - title: "List", - meta: { - routeId: listTab.id, - path: "", - }, - }, - ], + startPinnedTabs, + tabs, + onTabsChange: setTabs, resolveTabMeta: useCallback(() => ({}), []), }); + //const {getTabsProps} = usePersistTabs(persistStoreKey, tabs) + return (
- +
); diff --git a/src/routes/ProductsRoute.tsx b/src/routes/ProductsRoute.tsx index cba06da..c7038bf 100644 --- a/src/routes/ProductsRoute.tsx +++ b/src/routes/ProductsRoute.tsx @@ -1,6 +1,6 @@ import { TabStoreKey } from "src/constants/tabs.constants.ts"; import { Tabs } from "src/components/Tabs/Tabs.tsx"; -import { useTabbedNavigation } from "src/lib/tabs"; +import { TabbedNavigationMeta, useTabbedNavigation } from "src/lib/tabs"; import { homeRoute, @@ -8,18 +8,26 @@ import { productsListRoute, } from "src/constants/routes.constants.ts"; import { Link, Outlet, useNavigate, useParams } from "react-router-dom"; -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { routeIds } from "src/router.tsx"; +import { usePersistTabs } from "src/lib/tabs/persist.ts"; +import { localStorageDriver } from "src/lib/storage/local-storage.ts"; + +const persistStoreKey = { + name: "product-tabs", + version: "1.0", +}; + export function ProductsRoute() { const navigate = useNavigate(); - const { getTabsProps } = useTabbedNavigation({ - key: TabStoreKey.Products, - onCloseAllTabs: () => { - navigate(homeRoute); - }, - initialStartPinnedTabs: [productsListRoute], - initialTabs: [ + const { getTabsFromStorage, persistTabs } = + usePersistTabs({ + storageKey: persistStoreKey, + storage: localStorageDriver, + }); + const [tabs, setTabs] = useState( + getTabsFromStorage || [ { id: productsListRoute, title: "List", @@ -29,12 +37,38 @@ export function ProductsRoute() { }, }, ], + ); + + const [startPinnedTabs, onStartPinnedTabsChange] = useState([ + productsListRoute, + ]); + + const { activeTabId, setActiveTabId } = useTabbedNavigation({ + key: TabStoreKey.Products, + onCloseAllTabs: () => { + navigate(homeRoute); + }, + startPinnedTabs: [productsListRoute], + tabs, + onTabsChange: setTabs, resolveTabMeta: useCallback(() => ({}), []), }); + useEffect(() => { + return persistTabs(tabs); + }, [tabs, persistTabs]); + return (
- +
);