@object-ui/app-shell 4.4.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/README.md +21 -0
- package/dist/console/AppContent.js +6 -39
- package/dist/console/ConsoleShell.js +45 -4
- package/dist/console/home/AppCard.d.ts +2 -1
- package/dist/console/home/AppCard.js +24 -5
- package/dist/console/home/HomePage.js +29 -7
- package/dist/console/home/QuickActions.js +13 -7
- package/dist/console/home/RecentApps.js +12 -5
- package/dist/console/home/StarredApps.js +12 -5
- package/dist/context/FavoritesProvider.d.ts +9 -12
- package/dist/context/FavoritesProvider.js +83 -37
- package/dist/context/RecentItemsProvider.d.ts +45 -0
- package/dist/context/RecentItemsProvider.js +139 -0
- package/dist/context/UserStateAdapters.d.ts +61 -0
- package/dist/context/UserStateAdapters.js +141 -0
- package/dist/context/index.d.ts +4 -0
- package/dist/context/index.js +2 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/useRecentItems.d.ts +9 -17
- package/dist/hooks/useRecentItems.js +9 -46
- package/dist/hooks/useTrackRouteAsRecent.d.ts +18 -0
- package/dist/hooks/useTrackRouteAsRecent.js +91 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/layout/PageHeader.d.ts +29 -2
- package/dist/layout/PageHeader.js +20 -2
- package/dist/utils/index.js +17 -7
- package/dist/views/ActionParamDialog.js +16 -2
- package/dist/views/ObjectView.js +63 -164
- package/dist/views/RecordDetailView.d.ts +20 -1
- package/dist/views/RecordDetailView.js +92 -12
- package/package.json +24 -24
|
@@ -5,39 +5,38 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
5
5
|
* React Context + Provider for shared favorites state across all consumers
|
|
6
6
|
* (HomePage, AppCard, AppSidebar, UnifiedSidebar, StarredApps).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `load(): Promise<FavoriteItem[]>` and `save(items: FavoriteItem[]): Promise<void>`.
|
|
18
|
-
* When the adapter is provided, localStorage should be used only as a fallback
|
|
19
|
-
* during the initial load while the server response is in-flight.
|
|
8
|
+
* Persistence is **localStorage-first** with optional backend hydration:
|
|
9
|
+
* - On mount we render synchronously from localStorage (no flash of empty UI).
|
|
10
|
+
* - If a `UserDataAdapter<FavoriteItem>` is attached via `UserStateAdaptersProvider`
|
|
11
|
+
* (see `./UserStateAdapters`), the provider hydrates from the backend and
|
|
12
|
+
* writes-through every mutation through a debounced `adapter.save()`.
|
|
13
|
+
* - localStorage stays in sync so offline / pre-auth sessions still work and
|
|
14
|
+
* gives an instant first paint after sign-in.
|
|
15
|
+
* - The local key is scoped per `user.id` so different accounts on the same
|
|
16
|
+
* browser don't cross-contaminate.
|
|
20
17
|
*
|
|
21
18
|
* @module
|
|
22
19
|
*/
|
|
23
|
-
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
20
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
21
|
+
import { useAuth } from '@object-ui/auth';
|
|
22
|
+
import { createDebouncedFlush, scopedKey, useStorageSync, useUserStateAdapter, } from './UserStateAdapters';
|
|
24
23
|
// ---------------------------------------------------------------------------
|
|
25
24
|
// Storage helpers
|
|
26
25
|
// ---------------------------------------------------------------------------
|
|
27
|
-
const
|
|
26
|
+
const STORAGE_BASE_KEY = 'objectui-favorites';
|
|
28
27
|
const MAX_FAVORITES = 20;
|
|
29
|
-
function loadFavorites() {
|
|
28
|
+
function loadFavorites(userId) {
|
|
30
29
|
try {
|
|
31
|
-
const raw = localStorage.getItem(
|
|
30
|
+
const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
|
|
32
31
|
return raw ? JSON.parse(raw) : [];
|
|
33
32
|
}
|
|
34
33
|
catch {
|
|
35
34
|
return [];
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
|
-
function saveFavorites(items) {
|
|
37
|
+
function saveFavorites(items, userId) {
|
|
39
38
|
try {
|
|
40
|
-
localStorage.setItem(
|
|
39
|
+
localStorage.setItem(scopedKey(STORAGE_BASE_KEY, userId), JSON.stringify(items));
|
|
41
40
|
}
|
|
42
41
|
catch {
|
|
43
42
|
// Storage full — silently ignore
|
|
@@ -48,12 +47,64 @@ function saveFavorites(items) {
|
|
|
48
47
|
// ---------------------------------------------------------------------------
|
|
49
48
|
const FavoritesContext = createContext(null);
|
|
50
49
|
export function FavoritesProvider({ children }) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
const { user } = useAuth();
|
|
51
|
+
const userId = user?.id ?? null;
|
|
52
|
+
const adapter = useUserStateAdapter('favorites');
|
|
53
|
+
const [favorites, setFavorites] = useState(() => loadFavorites(userId));
|
|
54
|
+
// Re-load from localStorage whenever the active user changes (sign-in /
|
|
55
|
+
// account switch). Prevents one account from seeing another's favorites.
|
|
54
56
|
useEffect(() => {
|
|
55
|
-
setFavorites(loadFavorites());
|
|
56
|
-
}, []);
|
|
57
|
+
setFavorites(loadFavorites(userId));
|
|
58
|
+
}, [userId]);
|
|
59
|
+
// Cross-tab sync: when another tab writes the same scoped key, mirror the
|
|
60
|
+
// change locally. `storage` events do not fire in the tab that wrote them,
|
|
61
|
+
// so this never echoes our own mutations.
|
|
62
|
+
useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => {
|
|
63
|
+
setFavorites(Array.isArray(value)
|
|
64
|
+
? value.filter(it => it && typeof it.id === 'string').slice(0, MAX_FAVORITES)
|
|
65
|
+
: []);
|
|
66
|
+
});
|
|
67
|
+
// Hydrate from backend whenever an adapter is attached (or user changes).
|
|
68
|
+
// Backend wins on conflict; we then write through to localStorage so the
|
|
69
|
+
// next reload is instant.
|
|
70
|
+
const hydrationToken = useRef(0);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!adapter)
|
|
73
|
+
return;
|
|
74
|
+
const token = ++hydrationToken.current;
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
void (async () => {
|
|
77
|
+
try {
|
|
78
|
+
const remote = await adapter.load();
|
|
79
|
+
if (cancelled || token !== hydrationToken.current)
|
|
80
|
+
return;
|
|
81
|
+
// Defensive sanitize: drop unparseable shapes, enforce cap.
|
|
82
|
+
const sane = (Array.isArray(remote) ? remote : [])
|
|
83
|
+
.filter(it => it && typeof it.id === 'string')
|
|
84
|
+
.slice(0, MAX_FAVORITES);
|
|
85
|
+
setFavorites(sane);
|
|
86
|
+
saveFavorites(sane, userId);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Keep localStorage state — adapter degrade-to-noop is acceptable.
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
return () => {
|
|
93
|
+
cancelled = true;
|
|
94
|
+
};
|
|
95
|
+
}, [adapter, userId]);
|
|
96
|
+
// Debounced write-through to backend.
|
|
97
|
+
const flusher = useMemo(() => createDebouncedFlush(async (items) => {
|
|
98
|
+
if (adapter)
|
|
99
|
+
await adapter.save(items);
|
|
100
|
+
}, 500), [adapter]);
|
|
101
|
+
// Flush pending writes when the adapter detaches / user changes.
|
|
102
|
+
useEffect(() => () => { void flusher.flush(); }, [flusher]);
|
|
103
|
+
const commit = useCallback((next) => {
|
|
104
|
+
saveFavorites(next, userId);
|
|
105
|
+
if (adapter)
|
|
106
|
+
flusher.schedule(next);
|
|
107
|
+
}, [userId, adapter, flusher]);
|
|
57
108
|
const addFavorite = useCallback((item) => {
|
|
58
109
|
setFavorites(prev => {
|
|
59
110
|
if (prev.some(f => f.id === item.id))
|
|
@@ -62,44 +113,39 @@ export function FavoritesProvider({ children }) {
|
|
|
62
113
|
{ ...item, favoritedAt: new Date().toISOString() },
|
|
63
114
|
...prev,
|
|
64
115
|
].slice(0, MAX_FAVORITES);
|
|
65
|
-
|
|
116
|
+
commit(updated);
|
|
66
117
|
return updated;
|
|
67
118
|
});
|
|
68
|
-
}, []);
|
|
119
|
+
}, [commit]);
|
|
69
120
|
const removeFavorite = useCallback((id) => {
|
|
70
121
|
setFavorites(prev => {
|
|
71
122
|
const updated = prev.filter(f => f.id !== id);
|
|
72
|
-
|
|
123
|
+
commit(updated);
|
|
73
124
|
return updated;
|
|
74
125
|
});
|
|
75
|
-
}, []);
|
|
126
|
+
}, [commit]);
|
|
76
127
|
const toggleFavorite = useCallback((item) => {
|
|
77
128
|
setFavorites(prev => {
|
|
78
129
|
const exists = prev.some(f => f.id === item.id);
|
|
79
130
|
const updated = exists
|
|
80
131
|
? prev.filter(f => f.id !== item.id)
|
|
81
132
|
: [{ ...item, favoritedAt: new Date().toISOString() }, ...prev].slice(0, MAX_FAVORITES);
|
|
82
|
-
|
|
133
|
+
commit(updated);
|
|
83
134
|
return updated;
|
|
84
135
|
});
|
|
85
|
-
}, []);
|
|
136
|
+
}, [commit]);
|
|
86
137
|
const clearFavorites = useCallback(() => {
|
|
87
138
|
setFavorites([]);
|
|
88
|
-
|
|
89
|
-
}, []);
|
|
139
|
+
commit([]);
|
|
140
|
+
}, [commit]);
|
|
90
141
|
const value = useMemo(() => ({
|
|
91
142
|
favorites,
|
|
92
143
|
addFavorite,
|
|
93
144
|
removeFavorite,
|
|
94
145
|
toggleFavorite,
|
|
95
|
-
// Inlined here so useMemo sees the freshest `favorites` without needing
|
|
96
|
-
// a separate useCallback([favorites]) entry in the deps array.
|
|
97
146
|
isFavorite: (id) => favorites.some(f => f.id === id),
|
|
98
147
|
clearFavorites,
|
|
99
|
-
}),
|
|
100
|
-
// addFavorite / removeFavorite / toggleFavorite / clearFavorites are all
|
|
101
|
-
// stable (useCallback with [] deps) and never cause extra re-renders.
|
|
102
|
-
[favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites]);
|
|
148
|
+
}), [favorites, addFavorite, removeFavorite, toggleFavorite, clearFavorites]);
|
|
103
149
|
return (_jsx(FavoritesContext.Provider, { value: value, children: children }));
|
|
104
150
|
}
|
|
105
151
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RecentItemsProvider
|
|
3
|
+
*
|
|
4
|
+
* Shared "recently-accessed" state with optional backend persistence. Mirrors
|
|
5
|
+
* the design of `FavoritesProvider`:
|
|
6
|
+
*
|
|
7
|
+
* - localStorage-first: instant first paint, works offline / pre-auth.
|
|
8
|
+
* - Hydrates from `UserDataAdapter<RecentItem>` when one is attached via
|
|
9
|
+
* `UserStateAdaptersProvider` (typically by `ConnectedShell`'s bridge).
|
|
10
|
+
* - Writes are debounced and pushed to the adapter; localStorage stays in
|
|
11
|
+
* sync as a cold-start cache.
|
|
12
|
+
* - Storage key is scoped by `user.id` to avoid cross-account leakage.
|
|
13
|
+
*
|
|
14
|
+
* The legacy `useRecentItems()` import path (`hooks/useRecentItems`) is
|
|
15
|
+
* preserved via a re-export shim so existing call sites keep working.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
import { type ReactNode } from 'react';
|
|
20
|
+
export interface RecentItem {
|
|
21
|
+
/** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
href: string;
|
|
25
|
+
type: 'object' | 'dashboard' | 'page' | 'report' | 'record';
|
|
26
|
+
/** ISO timestamp of last visit */
|
|
27
|
+
visitedAt: string;
|
|
28
|
+
}
|
|
29
|
+
interface RecentItemsContextValue {
|
|
30
|
+
recentItems: RecentItem[];
|
|
31
|
+
addRecentItem: (item: Omit<RecentItem, 'visitedAt'>) => void;
|
|
32
|
+
clearRecentItems: () => void;
|
|
33
|
+
}
|
|
34
|
+
export declare function RecentItemsProvider({ children }: {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
37
|
+
/**
|
|
38
|
+
* Access shared "recently-accessed" state.
|
|
39
|
+
*
|
|
40
|
+
* Falls back to a no-op implementation when used outside a
|
|
41
|
+
* `<RecentItemsProvider>` (e.g. unit tests that only render presentational
|
|
42
|
+
* components).
|
|
43
|
+
*/
|
|
44
|
+
export declare function useRecentItems(): RecentItemsContextValue;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* RecentItemsProvider
|
|
4
|
+
*
|
|
5
|
+
* Shared "recently-accessed" state with optional backend persistence. Mirrors
|
|
6
|
+
* the design of `FavoritesProvider`:
|
|
7
|
+
*
|
|
8
|
+
* - localStorage-first: instant first paint, works offline / pre-auth.
|
|
9
|
+
* - Hydrates from `UserDataAdapter<RecentItem>` when one is attached via
|
|
10
|
+
* `UserStateAdaptersProvider` (typically by `ConnectedShell`'s bridge).
|
|
11
|
+
* - Writes are debounced and pushed to the adapter; localStorage stays in
|
|
12
|
+
* sync as a cold-start cache.
|
|
13
|
+
* - Storage key is scoped by `user.id` to avoid cross-account leakage.
|
|
14
|
+
*
|
|
15
|
+
* The legacy `useRecentItems()` import path (`hooks/useRecentItems`) is
|
|
16
|
+
* preserved via a re-export shim so existing call sites keep working.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
21
|
+
import { useAuth } from '@object-ui/auth';
|
|
22
|
+
import { createDebouncedFlush, scopedKey, useStorageSync, useUserStateAdapter, } from './UserStateAdapters';
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Storage helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const STORAGE_BASE_KEY = 'objectui-recent-items';
|
|
27
|
+
const MAX_RECENT = 8;
|
|
28
|
+
function loadRecent(userId) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = localStorage.getItem(scopedKey(STORAGE_BASE_KEY, userId));
|
|
31
|
+
return raw ? JSON.parse(raw) : [];
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function saveRecent(items, userId) {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem(scopedKey(STORAGE_BASE_KEY, userId), JSON.stringify(items));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Context
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
const RecentItemsContext = createContext(null);
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Provider
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
export function RecentItemsProvider({ children }) {
|
|
53
|
+
const { user } = useAuth();
|
|
54
|
+
const userId = user?.id ?? null;
|
|
55
|
+
const adapter = useUserStateAdapter('recent');
|
|
56
|
+
const [recentItems, setRecentItems] = useState(() => loadRecent(userId));
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setRecentItems(loadRecent(userId));
|
|
59
|
+
}, [userId]);
|
|
60
|
+
// Cross-tab sync — see FavoritesProvider for the rationale.
|
|
61
|
+
useStorageSync(scopedKey(STORAGE_BASE_KEY, userId), value => {
|
|
62
|
+
setRecentItems(Array.isArray(value)
|
|
63
|
+
? value.filter(it => it && typeof it.id === 'string').slice(0, MAX_RECENT)
|
|
64
|
+
: []);
|
|
65
|
+
});
|
|
66
|
+
const hydrationToken = useRef(0);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!adapter)
|
|
69
|
+
return;
|
|
70
|
+
const token = ++hydrationToken.current;
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
void (async () => {
|
|
73
|
+
try {
|
|
74
|
+
const remote = await adapter.load();
|
|
75
|
+
if (cancelled || token !== hydrationToken.current)
|
|
76
|
+
return;
|
|
77
|
+
const sane = (Array.isArray(remote) ? remote : [])
|
|
78
|
+
.filter(it => it && typeof it.id === 'string')
|
|
79
|
+
.slice(0, MAX_RECENT);
|
|
80
|
+
setRecentItems(sane);
|
|
81
|
+
saveRecent(sane, userId);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// ignore — degrade to localStorage
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
return () => {
|
|
88
|
+
cancelled = true;
|
|
89
|
+
};
|
|
90
|
+
}, [adapter, userId]);
|
|
91
|
+
const flusher = useMemo(() => createDebouncedFlush(async (items) => {
|
|
92
|
+
if (adapter)
|
|
93
|
+
await adapter.save(items);
|
|
94
|
+
}, 500), [adapter]);
|
|
95
|
+
useEffect(() => () => { void flusher.flush(); }, [flusher]);
|
|
96
|
+
const commit = useCallback((next) => {
|
|
97
|
+
saveRecent(next, userId);
|
|
98
|
+
if (adapter)
|
|
99
|
+
flusher.schedule(next);
|
|
100
|
+
}, [userId, adapter, flusher]);
|
|
101
|
+
const addRecentItem = useCallback((item) => {
|
|
102
|
+
setRecentItems(prev => {
|
|
103
|
+
const filtered = prev.filter(r => r.id !== item.id);
|
|
104
|
+
const updated = [
|
|
105
|
+
{ ...item, visitedAt: new Date().toISOString() },
|
|
106
|
+
...filtered,
|
|
107
|
+
].slice(0, MAX_RECENT);
|
|
108
|
+
commit(updated);
|
|
109
|
+
return updated;
|
|
110
|
+
});
|
|
111
|
+
}, [commit]);
|
|
112
|
+
const clearRecentItems = useCallback(() => {
|
|
113
|
+
setRecentItems([]);
|
|
114
|
+
commit([]);
|
|
115
|
+
}, [commit]);
|
|
116
|
+
const value = useMemo(() => ({ recentItems, addRecentItem, clearRecentItems }), [recentItems, addRecentItem, clearRecentItems]);
|
|
117
|
+
return (_jsx(RecentItemsContext.Provider, { value: value, children: children }));
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Hook
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/**
|
|
123
|
+
* Access shared "recently-accessed" state.
|
|
124
|
+
*
|
|
125
|
+
* Falls back to a no-op implementation when used outside a
|
|
126
|
+
* `<RecentItemsProvider>` (e.g. unit tests that only render presentational
|
|
127
|
+
* components).
|
|
128
|
+
*/
|
|
129
|
+
export function useRecentItems() {
|
|
130
|
+
const ctx = useContext(RecentItemsContext);
|
|
131
|
+
if (!ctx) {
|
|
132
|
+
return {
|
|
133
|
+
recentItems: [],
|
|
134
|
+
addRecentItem: () => { },
|
|
135
|
+
clearRecentItems: () => { },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return ctx;
|
|
139
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserStateAdapters
|
|
3
|
+
*
|
|
4
|
+
* Injection point for backend persistence of per-user UI state (favorites,
|
|
5
|
+
* recently-accessed items, …). Keeps `FavoritesProvider` / `RecentItemsProvider`
|
|
6
|
+
* decoupled from any specific backend (REST, ObjectQL, GraphQL).
|
|
7
|
+
*
|
|
8
|
+
* The provider is stateful: a bridge component mounted lower in the tree
|
|
9
|
+
* (where the data adapter + authenticated user are available) calls
|
|
10
|
+
* `useAttachUserStateAdapters()` to inject adapters at runtime. While no
|
|
11
|
+
* adapter is attached, hosting providers transparently fall back to
|
|
12
|
+
* localStorage-only behaviour.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import { type ReactNode } from 'react';
|
|
17
|
+
/**
|
|
18
|
+
* Generic persistence adapter for a user-scoped list.
|
|
19
|
+
*
|
|
20
|
+
* Implementations must be safe to call concurrently and should *never*
|
|
21
|
+
* throw — failures should be swallowed so the hosting provider can degrade
|
|
22
|
+
* to localStorage-only mode without crashing the UI.
|
|
23
|
+
*/
|
|
24
|
+
export interface UserDataAdapter<T> {
|
|
25
|
+
/** Load the persisted list for the current user. Resolve to [] when absent. */
|
|
26
|
+
load(): Promise<T[]>;
|
|
27
|
+
/** Persist the full list (debounced upstream). Errors are silently ignored. */
|
|
28
|
+
save(items: T[]): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export type UserStateKind = 'favorites' | 'recent';
|
|
31
|
+
export declare function UserStateAdaptersProvider({ children }: {
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
34
|
+
/** Read the currently-attached adapter for a given kind. */
|
|
35
|
+
export declare function useUserStateAdapter<T>(kind: UserStateKind): UserDataAdapter<T> | null;
|
|
36
|
+
/**
|
|
37
|
+
* Imperative API used by bridge components to plug an adapter in/out.
|
|
38
|
+
* Returns a stable function across re-renders.
|
|
39
|
+
*/
|
|
40
|
+
export declare function useAttachUserStateAdapters(): (kind: UserStateKind, adapter: UserDataAdapter<any> | null) => void;
|
|
41
|
+
/**
|
|
42
|
+
* Build a user-scoped localStorage key. Falls back to the unscoped key for
|
|
43
|
+
* unauthenticated / preview sessions to preserve current behaviour.
|
|
44
|
+
*/
|
|
45
|
+
export declare function scopedKey(base: string, userId?: string | null): string;
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to cross-tab updates of a specific localStorage key. The browser
|
|
48
|
+
* fires `storage` events only in *other* tabs, so we never echo our own
|
|
49
|
+
* writes. `onValue` receives the parsed JSON payload (or null if the key was
|
|
50
|
+
* removed / unparseable).
|
|
51
|
+
*/
|
|
52
|
+
export declare function useStorageSync<T>(key: string, onValue: (value: T | null) => void): void;
|
|
53
|
+
/**
|
|
54
|
+
* Tiny debounced flush helper. Returns `{ schedule, flush, cancel }`.
|
|
55
|
+
* Designed for the "save full list to backend" pattern.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createDebouncedFlush<T>(fn: (value: T) => Promise<void> | void, delayMs?: number): {
|
|
58
|
+
schedule: (value: T) => void;
|
|
59
|
+
flush: () => Promise<void>;
|
|
60
|
+
cancel: () => void;
|
|
61
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* UserStateAdapters
|
|
4
|
+
*
|
|
5
|
+
* Injection point for backend persistence of per-user UI state (favorites,
|
|
6
|
+
* recently-accessed items, …). Keeps `FavoritesProvider` / `RecentItemsProvider`
|
|
7
|
+
* decoupled from any specific backend (REST, ObjectQL, GraphQL).
|
|
8
|
+
*
|
|
9
|
+
* The provider is stateful: a bridge component mounted lower in the tree
|
|
10
|
+
* (where the data adapter + authenticated user are available) calls
|
|
11
|
+
* `useAttachUserStateAdapters()` to inject adapters at runtime. While no
|
|
12
|
+
* adapter is attached, hosting providers transparently fall back to
|
|
13
|
+
* localStorage-only behaviour.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Contexts (split read / write so consumers don't re-render unnecessarily)
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const ReadCtx = createContext({ favorites: null, recent: null });
|
|
22
|
+
const WriteCtx = createContext(null);
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Provider
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
export function UserStateAdaptersProvider({ children }) {
|
|
27
|
+
const [adapters, setAdapters] = useState({
|
|
28
|
+
favorites: null,
|
|
29
|
+
recent: null,
|
|
30
|
+
});
|
|
31
|
+
// Stable attach API so bridge useEffect deps don't churn.
|
|
32
|
+
const attachRef = useRef({
|
|
33
|
+
attach: (kind, adapter) => {
|
|
34
|
+
setAdapters(prev => (prev[kind] === adapter ? prev : { ...prev, [kind]: adapter }));
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return (_jsx(WriteCtx.Provider, { value: attachRef.current, children: _jsx(ReadCtx.Provider, { value: adapters, children: children }) }));
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Hooks
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** Read the currently-attached adapter for a given kind. */
|
|
43
|
+
export function useUserStateAdapter(kind) {
|
|
44
|
+
return useContext(ReadCtx)[kind];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Imperative API used by bridge components to plug an adapter in/out.
|
|
48
|
+
* Returns a stable function across re-renders.
|
|
49
|
+
*/
|
|
50
|
+
export function useAttachUserStateAdapters() {
|
|
51
|
+
const ctx = useContext(WriteCtx);
|
|
52
|
+
return useCallback((kind, adapter) => {
|
|
53
|
+
ctx?.attach(kind, adapter);
|
|
54
|
+
}, [ctx]);
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Shared helpers (used by FavoritesProvider / RecentItemsProvider)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
/**
|
|
60
|
+
* Build a user-scoped localStorage key. Falls back to the unscoped key for
|
|
61
|
+
* unauthenticated / preview sessions to preserve current behaviour.
|
|
62
|
+
*/
|
|
63
|
+
export function scopedKey(base, userId) {
|
|
64
|
+
return userId ? `${base}:u:${userId}` : base;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Subscribe to cross-tab updates of a specific localStorage key. The browser
|
|
68
|
+
* fires `storage` events only in *other* tabs, so we never echo our own
|
|
69
|
+
* writes. `onValue` receives the parsed JSON payload (or null if the key was
|
|
70
|
+
* removed / unparseable).
|
|
71
|
+
*/
|
|
72
|
+
export function useStorageSync(key, onValue) {
|
|
73
|
+
// Keep the latest callback in a ref so re-renders of the caller don't
|
|
74
|
+
// tear down and re-add the window listener.
|
|
75
|
+
const cbRef = useRef(onValue);
|
|
76
|
+
cbRef.current = onValue;
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (typeof window === 'undefined')
|
|
79
|
+
return;
|
|
80
|
+
const handler = (e) => {
|
|
81
|
+
if (e.key !== key || e.storageArea !== localStorage)
|
|
82
|
+
return;
|
|
83
|
+
if (e.newValue == null) {
|
|
84
|
+
cbRef.current(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
cbRef.current(JSON.parse(e.newValue));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
cbRef.current(null);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
window.addEventListener('storage', handler);
|
|
95
|
+
return () => window.removeEventListener('storage', handler);
|
|
96
|
+
}, [key]);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Tiny debounced flush helper. Returns `{ schedule, flush, cancel }`.
|
|
100
|
+
* Designed for the "save full list to backend" pattern.
|
|
101
|
+
*/
|
|
102
|
+
export function createDebouncedFlush(fn, delayMs = 500) {
|
|
103
|
+
let timer = null;
|
|
104
|
+
let pending = null;
|
|
105
|
+
let hasPending = false;
|
|
106
|
+
const flush = async () => {
|
|
107
|
+
if (timer) {
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
timer = null;
|
|
110
|
+
}
|
|
111
|
+
if (!hasPending)
|
|
112
|
+
return;
|
|
113
|
+
const payload = pending;
|
|
114
|
+
hasPending = false;
|
|
115
|
+
pending = null;
|
|
116
|
+
try {
|
|
117
|
+
await fn(payload);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Swallow — adapter is responsible for its own error handling.
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const schedule = (value) => {
|
|
124
|
+
pending = value;
|
|
125
|
+
hasPending = true;
|
|
126
|
+
if (timer)
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
timer = setTimeout(() => {
|
|
129
|
+
timer = null;
|
|
130
|
+
void flush();
|
|
131
|
+
}, delayMs);
|
|
132
|
+
};
|
|
133
|
+
const cancel = () => {
|
|
134
|
+
if (timer)
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
timer = null;
|
|
137
|
+
hasPending = false;
|
|
138
|
+
pending = null;
|
|
139
|
+
};
|
|
140
|
+
return { schedule, flush, cancel };
|
|
141
|
+
}
|
package/dist/context/index.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { NavigationProvider, useNavigationContext } from './NavigationContext';
|
|
2
2
|
export { FavoritesProvider, useFavorites } from './FavoritesProvider';
|
|
3
3
|
export type { FavoriteItem } from './FavoritesProvider';
|
|
4
|
+
export { RecentItemsProvider, useRecentItems } from './RecentItemsProvider';
|
|
5
|
+
export type { RecentItem } from './RecentItemsProvider';
|
|
6
|
+
export { UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './UserStateAdapters';
|
|
7
|
+
export type { UserDataAdapter, UserStateKind } from './UserStateAdapters';
|
package/dist/context/index.js
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
export { NavigationProvider, useNavigationContext } from './NavigationContext';
|
|
2
2
|
export { FavoritesProvider, useFavorites } from './FavoritesProvider';
|
|
3
|
+
export { RecentItemsProvider, useRecentItems } from './RecentItemsProvider';
|
|
4
|
+
export { UserStateAdaptersProvider, useUserStateAdapter, useAttachUserStateAdapters, } from './UserStateAdapters';
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { useObjectActions } from './useObjectActions';
|
|
|
6
6
|
export { useRecentItems, type RecentItem } from './useRecentItems';
|
|
7
7
|
export { useRecordApprovals, type ApprovalProcessLite, type ApprovalRequestLite } from './useRecordApprovals';
|
|
8
8
|
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
|
9
|
+
export { useTrackRouteAsRecent, type UseTrackRouteAsRecentOptions } from './useTrackRouteAsRecent';
|
package/dist/hooks/index.js
CHANGED
|
@@ -6,3 +6,4 @@ export { useObjectActions } from './useObjectActions';
|
|
|
6
6
|
export { useRecentItems } from './useRecentItems';
|
|
7
7
|
export { useRecordApprovals } from './useRecordApprovals';
|
|
8
8
|
export { useResponsiveSidebar } from './useResponsiveSidebar';
|
|
9
|
+
export { useTrackRouteAsRecent } from './useTrackRouteAsRecent';
|
|
@@ -1,21 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useRecentItems
|
|
2
|
+
* useRecentItems — re-export shim
|
|
3
|
+
*
|
|
4
|
+
* The recent-items state has been migrated to a React Context so all consumers
|
|
5
|
+
* share a single state instance and can be backed by an optional backend
|
|
6
|
+
* persistence adapter. See `../context/RecentItemsProvider`.
|
|
7
|
+
*
|
|
8
|
+
* All existing imports of `useRecentItems` and `RecentItem` from this path
|
|
9
|
+
* continue to work without any changes at the call sites.
|
|
3
10
|
*
|
|
4
|
-
* Tracks recently visited items (objects, dashboards, pages) with
|
|
5
|
-
* localStorage persistence. Exposes helpers to add and retrieve items.
|
|
6
11
|
* @module
|
|
7
12
|
*/
|
|
8
|
-
export
|
|
9
|
-
/** Unique key, e.g. "object:contact" or "dashboard:sales_overview" */
|
|
10
|
-
id: string;
|
|
11
|
-
label: string;
|
|
12
|
-
href: string;
|
|
13
|
-
type: 'object' | 'dashboard' | 'page' | 'report' | 'record';
|
|
14
|
-
/** ISO timestamp of last visit */
|
|
15
|
-
visitedAt: string;
|
|
16
|
-
}
|
|
17
|
-
export declare function useRecentItems(): {
|
|
18
|
-
recentItems: RecentItem[];
|
|
19
|
-
addRecentItem: (item: Omit<RecentItem, "visitedAt">) => void;
|
|
20
|
-
clearRecentItems: () => void;
|
|
21
|
-
};
|
|
13
|
+
export { useRecentItems, type RecentItem } from '../context/RecentItemsProvider';
|