@goplusvn/core 0.1.32 → 0.1.33

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.33 — Tab navigation: giữ bộ lọc + dữ liệu tươi; MainLayout `notificationSlot`
4
+
5
+ **Tab navigation (PageTabs / TabNavigationProvider):**
6
+
7
+ - Tab lưu **path đầy đủ gồm query string** — quay lại tab khôi phục đúng bộ lọc/
8
+ trang đang đứng. Tab id vẫn key theo pathname (đổi bộ lọc không đẻ tab mới);
9
+ query mới nhất được cập nhật vào `tab.path` (action `UPDATE_TAB_PATH`).
10
+ `useSearchParams` nằm trong leaf `SearchParamsChangeSignal` bọc `Suspense`
11
+ (tránh lỗi build trên route static).
12
+ - **Bỏ prefetch-all-tabs** (mỗi lần đổi tab bắn full RSC render mọi tab còn lại
13
+ — nặng server) và **bỏ hover-prefetch** (payload FULL-prefetch bị client cache
14
+ ~5 phút → quay lại tab thấy dữ liệu cũ). Chuyển tab giờ luôn fetch tươi.
15
+
16
+ **MainLayout: prop `notificationSlot` (chuông thông báo do app cấp)**
17
+
18
+ Thêm prop optional `notificationSlot?: ReactNode` cho `MainLayout` (chảy xuống
19
+ `Vertical/HorizontalLayout` → header). Khi truyền, nó thay `<NotificationDropdown>`
20
+ tĩnh mặc định trong header — để app tự cấp chuông self-fetch dữ liệu của mình.
21
+ Bỏ trống → giữ nguyên dropdown tĩnh cũ (**backward-compat**, consumer khác không
22
+ phải sửa gì).
23
+
3
24
  ## 0.1.32 — Gỡ NextAuth khỏi core (BREAKING cho app chưa migrate)
4
25
 
5
26
  Core không còn phụ thuộc next-auth (bỏ peer dependency):
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.32",
4
+ "version": "0.1.33",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
5
5
  import { Settings } from "lucide-react";
6
6
 
7
7
  import type { DictionaryType } from "../../hooks";
8
+ import type { ReactNode } from "react";
8
9
  import type { LocaleType, NavigationType } from "../../types";
9
10
 
10
11
  import { ensureLocalizedPathname } from "../../utils";
@@ -23,9 +24,11 @@ import { TopBarHeaderMenubar } from "./top-bar-header-menubar";
23
24
  export function HorizontalLayoutHeader({
24
25
  dictionary,
25
26
  navigation,
27
+ notificationSlot,
26
28
  }: {
27
29
  dictionary: DictionaryType;
28
30
  navigation?: NavigationType[];
31
+ notificationSlot?: ReactNode;
29
32
  }) {
30
33
  const params = useParams();
31
34
  const locale = params.lang as LocaleType;
@@ -56,7 +59,9 @@ export function HorizontalLayoutHeader({
56
59
  navigation={navigation ?? []}
57
60
  />
58
61
  <div className="hidden sm:flex items-center gap-2">
59
- <NotificationDropdown dictionary={dictionary} />
62
+ {notificationSlot ?? (
63
+ <NotificationDropdown dictionary={dictionary} />
64
+ )}
60
65
  <FullScreenToggle />
61
66
  </div>
62
67
  <Customizer
@@ -16,6 +16,7 @@ interface HorizontalLayoutProps {
16
16
  onGlobalSearch?: (query: string) => void;
17
17
  searchResults?: any[];
18
18
  searchLoading?: boolean;
19
+ notificationSlot?: ReactNode;
19
20
  }
20
21
 
21
22
  export function HorizontalLayout({
@@ -25,6 +26,7 @@ export function HorizontalLayout({
25
26
  onGlobalSearch,
26
27
  searchResults,
27
28
  searchLoading,
29
+ notificationSlot,
28
30
  }: HorizontalLayoutProps) {
29
31
  return (
30
32
  <SidebarProvider>
@@ -39,6 +41,7 @@ export function HorizontalLayout({
39
41
  <HorizontalLayoutHeader
40
42
  dictionary={dictionary}
41
43
  navigation={navigation}
44
+ notificationSlot={notificationSlot}
42
45
  />
43
46
  <main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4">
44
47
  {children}
@@ -15,6 +15,12 @@ interface LayoutProps {
15
15
  onGlobalSearch?: (query: string) => void;
16
16
  searchResults?: any[];
17
17
  searchLoading?: boolean;
18
+ /**
19
+ * Chuông thông báo do app tự cấp (self-fetch dữ liệu app). Khi truyền, thay
20
+ * cho <NotificationDropdown> tĩnh mặc định trong header. Bỏ trống → giữ
21
+ * dropdown tĩnh cũ (backward-compat cho consumer chưa nối dữ liệu).
22
+ */
23
+ notificationSlot?: ReactNode;
18
24
  }
19
25
 
20
26
  export function MainLayout({
@@ -24,6 +30,7 @@ export function MainLayout({
24
30
  onGlobalSearch,
25
31
  searchResults,
26
32
  searchLoading,
33
+ notificationSlot,
27
34
  }: LayoutProps) {
28
35
  const mounted = useMounted();
29
36
  const isVertical = useIsVertical();
@@ -40,6 +47,7 @@ export function MainLayout({
40
47
  onGlobalSearch={onGlobalSearch}
41
48
  searchResults={searchResults}
42
49
  searchLoading={searchLoading}
50
+ notificationSlot={notificationSlot}
43
51
  >
44
52
  {children}
45
53
  </VerticalLayout>
@@ -50,6 +58,7 @@ export function MainLayout({
50
58
  onGlobalSearch={onGlobalSearch}
51
59
  searchResults={searchResults}
52
60
  searchLoading={searchLoading}
61
+ notificationSlot={notificationSlot}
53
62
  >
54
63
  {children}
55
64
  </HorizontalLayout>
@@ -146,12 +146,9 @@ export function PageTabs({
146
146
  tabIndex={0}
147
147
  onClick={() => handleTabClick(tab.id)}
148
148
  onContextMenu={() => handleContextMenu(tab.id)}
149
- onMouseEnter={() => {
150
- // Prefetch route when hovering over tab
151
- if (!isActive && tab.path) {
152
- router.prefetch(tab.path);
153
- }
154
- }}
149
+ // No hover prefetch: imperative prefetch is FULL-kind and its
150
+ // payload is client-cached ~5 minutes, so switching back to a
151
+ // tab would render stale data on these force-dynamic pages.
155
152
  onKeyDown={(e) => {
156
153
  if (e.key === "Enter" || e.key === " ") {
157
154
  e.preventDefault();
@@ -7,8 +7,10 @@ import {
7
7
  useReducer,
8
8
  useCallback,
9
9
  useRef,
10
+ useState,
11
+ Suspense,
10
12
  } from "react";
11
- import { usePathname, useRouter } from "next/navigation";
13
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
12
14
  import type { ReactNode } from "react";
13
15
 
14
16
  import type { DynamicIconNameType, NavigationType } from "../../types";
@@ -43,6 +45,7 @@ type TabNavigationAction =
43
45
  }
44
46
  | { type: "REMOVE_TAB"; payload: { id: string } }
45
47
  | { type: "SET_ACTIVE_TAB"; payload: { id: string } }
48
+ | { type: "UPDATE_TAB_PATH"; payload: { id: string; path: string } }
46
49
  | { type: "LOAD_STATE"; payload: TabNavigationState }
47
50
  | { type: "CLEAR_TABS" }
48
51
  | { type: "REMOVE_OTHER_TABS"; payload: { id: string } }
@@ -58,7 +61,9 @@ function tabNavigationReducer(
58
61
  switch (action.type) {
59
62
  case "ADD_TAB": {
60
63
  const { path, title, iconName } = action.payload;
61
- const normalizedPath = normalizePathname(path);
64
+ // path may carry a query string; the tab id is keyed by pathname only
65
+ // so the same page with different filters stays a single tab.
66
+ const normalizedPath = normalizePathname(path.split("?")[0]);
62
67
  const tabId = normalizedPath;
63
68
 
64
69
  // Check if tab already exists
@@ -119,6 +124,19 @@ function tabNavigationReducer(
119
124
  };
120
125
  }
121
126
 
127
+ case "UPDATE_TAB_PATH": {
128
+ // Tab id stays keyed by pathname; path stores the full URL (incl. query
129
+ // string) so re-activating a tab restores its filters/pagination.
130
+ const { id, path } = action.payload;
131
+ const tab = state.tabs.find((t) => t.id === id);
132
+ if (!tab || tab.path === path) return state;
133
+
134
+ return {
135
+ ...state,
136
+ tabs: state.tabs.map((t) => (t.id === id ? { ...t, path } : t)),
137
+ };
138
+ }
139
+
122
140
  case "LOAD_STATE": {
123
141
  return action.payload;
124
142
  }
@@ -194,6 +212,25 @@ const TabNavigationContext = createContext<
194
212
  TabNavigationContextValue | undefined
195
213
  >(undefined);
196
214
 
215
+ // useSearchParams requires a Suspense boundary on statically rendered routes,
216
+ // so it lives in this leaf instead of the provider itself. It only signals
217
+ // "the query string changed" — the provider reads the actual value from
218
+ // window.location so both pathname- and search-triggered runs agree.
219
+ function SearchParamsChangeSignal({
220
+ onChange,
221
+ }: {
222
+ onChange: (search: string) => void;
223
+ }) {
224
+ const searchParams = useSearchParams();
225
+ const search = searchParams?.toString() ?? "";
226
+
227
+ useEffect(() => {
228
+ onChange(search);
229
+ }, [search, onChange]);
230
+
231
+ return null;
232
+ }
233
+
197
234
  export function TabNavigationProvider({
198
235
  children,
199
236
  navigations,
@@ -205,6 +242,7 @@ export function TabNavigationProvider({
205
242
  const pathname = usePathname();
206
243
  const router = useRouter();
207
244
  const isInitialized = useRef(false);
245
+ const [searchSignal, setSearchSignal] = useState("");
208
246
 
209
247
  // Load state from sessionStorage on mount
210
248
  useEffect(() => {
@@ -233,7 +271,7 @@ export function TabNavigationProvider({
233
271
  }
234
272
  }, [state]);
235
273
 
236
- // Auto-add tab when pathname changes
274
+ // Auto-add tab when pathname or query string changes
237
275
  useEffect(() => {
238
276
  if (!pathname || !isInitialized.current) return;
239
277
 
@@ -247,9 +285,22 @@ export function TabNavigationProvider({
247
285
  const iconName = findRouteIcon(pathname, navigations) || undefined;
248
286
  const normalizedPath = normalizePathname(pathname);
249
287
 
288
+ // Full URL incl. query string — window.location is already updated by
289
+ // effect time, and reading it here keeps pathname-triggered and
290
+ // searchSignal-triggered runs consistent with each other.
291
+ const search = typeof window !== "undefined" ? window.location.search : "";
292
+ const fullPath = `${pathname}${search}`;
293
+
250
294
  // Check if tab already exists
251
295
  const existingTab = state.tabs.find((tab) => tab.id === normalizedPath);
252
296
  if (existingTab) {
297
+ // Remember the latest filters/pagination for this tab
298
+ if (existingTab.path !== fullPath) {
299
+ dispatch({
300
+ type: "UPDATE_TAB_PATH",
301
+ payload: { id: normalizedPath, path: fullPath },
302
+ });
303
+ }
253
304
  // Just set as active if not already active
254
305
  if (state.activeTabId !== normalizedPath) {
255
306
  dispatch({ type: "SET_ACTIVE_TAB", payload: { id: normalizedPath } });
@@ -258,23 +309,11 @@ export function TabNavigationProvider({
258
309
  // Add new tab
259
310
  dispatch({
260
311
  type: "ADD_TAB",
261
- payload: { path: pathname, title, iconName },
312
+ payload: { path: fullPath, title, iconName },
262
313
  });
263
314
  }
264
315
  // eslint-disable-next-line react-hooks/exhaustive-deps
265
- }, [pathname, navigations]);
266
-
267
- // Prefetch all tab routes when tabs change
268
- useEffect(() => {
269
- if (!isInitialized.current) return;
270
-
271
- // Prefetch all tab routes in background
272
- state.tabs.forEach((tab) => {
273
- if (tab.path && tab.id !== state.activeTabId) {
274
- router.prefetch(tab.path);
275
- }
276
- });
277
- }, [state.tabs, state.activeTabId, router]);
316
+ }, [pathname, searchSignal, navigations]);
278
317
 
279
318
  const addTab = useCallback(
280
319
  (path: string, title?: string) => {
@@ -510,6 +549,9 @@ export function TabNavigationProvider({
510
549
  closeAndGoToParent,
511
550
  }}
512
551
  >
552
+ <Suspense fallback={null}>
553
+ <SearchParamsChangeSignal onChange={setSearchSignal} />
554
+ </Suspense>
513
555
  {children}
514
556
  </TabNavigationContext.Provider>
515
557
  );
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
4
4
  import { Settings } from "lucide-react";
5
5
 
6
6
  import type { DictionaryType } from "../../hooks";
7
+ import type { ReactNode } from "react";
7
8
  import type { LocaleType } from "../../types";
8
9
 
9
10
  import { Button } from "../index";
@@ -17,8 +18,10 @@ import { ToggleMobileSidebar } from "./toggle-mobile-sidebar";
17
18
 
18
19
  export function VerticalLayoutHeader({
19
20
  dictionary,
21
+ notificationSlot,
20
22
  }: {
21
23
  dictionary: DictionaryType;
24
+ notificationSlot?: ReactNode;
22
25
  }) {
23
26
  const params = useParams();
24
27
  const locale = params.lang as LocaleType;
@@ -44,7 +47,7 @@ export function VerticalLayoutHeader({
44
47
  {/* Right: user actions */}
45
48
  <div className="flex items-center gap-2 flex-shrink-0">
46
49
  <div className="hidden sm:flex items-center gap-2">
47
- <NotificationDropdown dictionary={dictionary} />
50
+ {notificationSlot ?? <NotificationDropdown dictionary={dictionary} />}
48
51
  <FullScreenToggle />
49
52
  </div>
50
53
  <Customizer
@@ -16,6 +16,7 @@ interface VerticalLayoutProps {
16
16
  onGlobalSearch?: (query: string) => void;
17
17
  searchResults?: any[];
18
18
  searchLoading?: boolean;
19
+ notificationSlot?: ReactNode;
19
20
  }
20
21
 
21
22
  export function VerticalLayout({
@@ -25,6 +26,7 @@ export function VerticalLayout({
25
26
  onGlobalSearch,
26
27
  searchResults,
27
28
  searchLoading,
29
+ notificationSlot,
28
30
  }: VerticalLayoutProps) {
29
31
  return (
30
32
  <SidebarProvider>
@@ -36,7 +38,10 @@ export function VerticalLayout({
36
38
  searchLoading={searchLoading}
37
39
  />
38
40
  <SidebarInset className="w-full min-w-0 overflow-hidden h-screen flex flex-col">
39
- <VerticalLayoutHeader dictionary={dictionary} />
41
+ <VerticalLayoutHeader
42
+ dictionary={dictionary}
43
+ notificationSlot={notificationSlot}
44
+ />
40
45
  <main className="w-full mx-auto flex-1 min-h-0 px-4 md:px-8 py-4 overflow-auto">
41
46
  {children}
42
47
  </main>