@carlonicora/nextjs-jsonapi 3.28.2 → 4.0.1
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/dist/{BlockNoteEditor-36VL2TNM.js → BlockNoteEditor-BXMTGKDV.js} +21 -20
- package/dist/BlockNoteEditor-BXMTGKDV.js.map +1 -0
- package/dist/{BlockNoteEditor-4IDXT6ZU.mjs → BlockNoteEditor-FGVQP35E.mjs} +9 -8
- package/dist/{BlockNoteEditor-4IDXT6ZU.mjs.map → BlockNoteEditor-FGVQP35E.mjs.map} +1 -1
- package/dist/{BlockNoteViewer-KRTAFTUX.mjs → BlockNoteViewer-DQYT6IJW.mjs} +3 -3
- package/dist/{BlockNoteViewer-7BFNTN4E.js → BlockNoteViewer-YH7G66PS.js} +4 -4
- package/dist/{BlockNoteViewer-7BFNTN4E.js.map → BlockNoteViewer-YH7G66PS.js.map} +1 -1
- package/dist/{HandbookSectionInterface-DgYH--Ik.d.mts → HandbookSectionInterface-BYsmnPEz.d.mts} +1 -1
- package/dist/{HandbookSectionInterface-Ck1wZJ81.d.ts → HandbookSectionInterface-OePKPbqN.d.ts} +1 -1
- package/dist/{JsonApiRequest-UHOWNI3O.mjs → JsonApiRequest-MEP6AXDT.mjs} +2 -2
- package/dist/JsonApiRequest-OB6WL4LA.js +26 -0
- package/dist/{JsonApiRequest-TE5WD3WS.js.map → JsonApiRequest-OB6WL4LA.js.map} +1 -1
- package/dist/ServerSession-DFNNNkg7.d.ts +29 -0
- package/dist/ServerSession-DK1z0_Ql.d.mts +29 -0
- package/dist/{analytics.modules-CfczU12P.d.ts → analytics.modules-Bsacu6ik.d.ts} +1 -1
- package/dist/{analytics.modules-pzGwMASI.d.mts → analytics.modules-txdCLNG-.d.mts} +1 -1
- package/dist/billing/index.js +404 -402
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +10 -8
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-7B4E42R5.mjs → chunk-3CZ2GK5R.mjs} +33 -9
- package/dist/chunk-3CZ2GK5R.mjs.map +1 -0
- package/dist/{chunk-4Z45NYPR.js → chunk-5W5MN7JP.js} +14 -83
- package/dist/chunk-5W5MN7JP.js.map +1 -0
- package/dist/{chunk-CAKOFJZD.js → chunk-6OFANJTE.js} +195 -142
- package/dist/chunk-6OFANJTE.js.map +1 -0
- package/dist/{chunk-5TRFNKJS.js → chunk-6TUB7Y6T.js} +23 -25
- package/dist/chunk-6TUB7Y6T.js.map +1 -0
- package/dist/{chunk-37DMFVLN.mjs → chunk-7SCREMTX.mjs} +4 -3
- package/dist/{chunk-37DMFVLN.mjs.map → chunk-7SCREMTX.mjs.map} +1 -1
- package/dist/{chunk-Q6J2ICKO.mjs → chunk-ADZSH3MG.mjs} +3 -72
- package/dist/chunk-ADZSH3MG.mjs.map +1 -0
- package/dist/{chunk-E3I4MT53.mjs → chunk-AQZ3T4BR.mjs} +71 -18
- package/dist/chunk-AQZ3T4BR.mjs.map +1 -0
- package/dist/{chunk-SJLXPHK5.mjs → chunk-E2LENTUH.mjs} +22 -24
- package/dist/chunk-E2LENTUH.mjs.map +1 -0
- package/dist/chunk-EBEXWN3I.mjs +17 -0
- package/dist/chunk-EBEXWN3I.mjs.map +1 -0
- package/dist/chunk-G4JR3SFV.js +28 -0
- package/dist/chunk-G4JR3SFV.js.map +1 -0
- package/dist/chunk-IK65TDQH.js +15 -0
- package/dist/chunk-IK65TDQH.js.map +1 -0
- package/dist/{chunk-GHFMKH3M.js → chunk-JEQ34Z5E.js} +62 -38
- package/dist/chunk-JEQ34Z5E.js.map +1 -0
- package/dist/{chunk-KOTKKBJT.js → chunk-NFCXUAE6.js} +1328 -1183
- package/dist/chunk-NFCXUAE6.js.map +1 -0
- package/dist/chunk-OEOK6A3P.js +17 -0
- package/dist/chunk-OEOK6A3P.js.map +1 -0
- package/dist/chunk-PW42LRSL.mjs +28 -0
- package/dist/chunk-PW42LRSL.mjs.map +1 -0
- package/dist/{chunk-U65JPKO3.mjs → chunk-R6ZYB4VN.mjs} +2 -2
- package/dist/chunk-T7SAW3UW.mjs +15 -0
- package/dist/chunk-T7SAW3UW.mjs.map +1 -0
- package/dist/{chunk-GKDHYB2R.js → chunk-TWBZ2GSX.js} +3 -3
- package/dist/{chunk-GKDHYB2R.js.map → chunk-TWBZ2GSX.js.map} +1 -1
- package/dist/{chunk-26MQIJNB.js → chunk-WGVNJXZO.js} +4 -3
- package/dist/chunk-WGVNJXZO.js.map +1 -0
- package/dist/{chunk-HPUV6FBW.mjs → chunk-WJC4SYRL.mjs} +2131 -1986
- package/dist/chunk-WJC4SYRL.mjs.map +1 -0
- package/dist/client/index.d.mts +3 -3
- package/dist/client/index.d.ts +3 -3
- package/dist/client/index.js +6 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +13 -12
- package/dist/components/index.d.mts +16 -6
- package/dist/components/index.d.ts +16 -6
- package/dist/components/index.js +8 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +9 -6
- package/dist/{config-3N1LSrcC.d.ts → config-B6UB7j69.d.ts} +1 -1
- package/dist/{config-BmufyEj6.d.mts → config-CEAq4Ozy.d.mts} +10 -1
- package/dist/{config-AVtuvfM1.d.ts → config-DydDHCfb.d.ts} +10 -1
- package/dist/{config-BSKTYDoh.d.mts → config-n-ZpPmOL.d.mts} +1 -1
- package/dist/contexts/index.d.mts +28 -4
- package/dist/contexts/index.d.ts +28 -4
- package/dist/contexts/index.js +10 -5
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +9 -4
- package/dist/core/index.d.mts +6 -6
- package/dist/core/index.d.ts +6 -6
- package/dist/core/index.js +5 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +4 -4
- package/dist/features/analytics/index.d.mts +2 -2
- package/dist/features/analytics/index.d.ts +2 -2
- package/dist/features/analytics/index.js +124 -123
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +6 -5
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.d.mts +10 -2
- package/dist/features/analytics/server.d.ts +10 -2
- package/dist/features/analytics/server.js +8 -6
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +6 -4
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/help/index.d.mts +2 -2
- package/dist/features/help/index.d.ts +2 -2
- package/dist/features/help/index.js +39 -38
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +5 -4
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.d.mts +2 -2
- package/dist/features/tokenusage/index.d.ts +2 -2
- package/dist/features/tokenusage/index.js +127 -126
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +6 -5
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +8 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -9
- package/dist/next-config/index.d.mts +43 -0
- package/dist/next-config/index.d.ts +43 -0
- package/dist/next-config/index.js +230 -0
- package/dist/next-config/index.js.map +1 -0
- package/dist/next-config/index.mjs +230 -0
- package/dist/next-config/index.mjs.map +1 -0
- package/dist/{request-FXF63NSQ.mjs → request-3IUMNVOS.mjs} +2 -2
- package/dist/request-LZBAOT3Y.js +8 -0
- package/dist/request-LZBAOT3Y.js.map +1 -0
- package/dist/server/cachedGet.d.mts +28 -0
- package/dist/server/cachedGet.d.ts +28 -0
- package/dist/server/cachedGet.js +60 -0
- package/dist/server/cachedGet.js.map +1 -0
- package/dist/server/cachedGet.mjs +60 -0
- package/dist/server/cachedGet.mjs.map +1 -0
- package/dist/server/cachedGet.stub.d.mts +7 -0
- package/dist/server/cachedGet.stub.d.ts +7 -0
- package/dist/server/cachedGet.stub.js +17 -0
- package/dist/server/cachedGet.stub.js.map +1 -0
- package/dist/server/cachedGet.stub.mjs +17 -0
- package/dist/server/cachedGet.stub.mjs.map +1 -0
- package/dist/server/index.d.mts +27 -25
- package/dist/server/index.d.ts +27 -25
- package/dist/server/index.js +13 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +15 -8
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/index.mjs.map +1 -1
- package/dist/{types-D0uLYV2P.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
- package/dist/{types-Cn153-mH.d.ts → types-bsm8Fp55.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-B-3FRKBD.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-CCJ_cdQy.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
- package/package.json +12 -1
- package/src/components/forms/__tests__/EditorSheet.test.tsx +25 -1
- package/src/components/forms/__tests__/useEditorDialog.test.ts +164 -1
- package/src/components/forms/useEditorDialog.ts +69 -2
- package/src/contexts/SessionContext.tsx +20 -0
- package/src/contexts/SocketContext.tsx +30 -3
- package/src/contexts/ViewportContext.tsx +17 -2
- package/src/contexts/__tests__/SessionContext.test.tsx +58 -0
- package/src/contexts/__tests__/SocketContext.session.test.tsx +77 -0
- package/src/contexts/index.ts +1 -0
- package/src/core/abstracts/AbstractService.ts +4 -6
- package/src/core/abstracts/__tests__/AbstractService.locale.test.ts +64 -0
- package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +5 -1
- package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +5 -1
- package/src/features/notification/contexts/NotificationContext.tsx +5 -2
- package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +22 -0
- package/src/features/user/contexts/CurrentUserContext.tsx +7 -5
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +63 -3
- package/src/hooks/__tests__/useSocket.render.test.ts +22 -0
- package/src/hooks/useSocket.ts +0 -1
- package/src/i18n/__tests__/config.test.ts +27 -0
- package/src/i18n/config.ts +17 -0
- package/src/i18n/index.ts +2 -0
- package/src/next-config/__tests__/createNextConfig.test.ts +268 -0
- package/src/next-config/csp.ts +128 -0
- package/src/next-config/headers.ts +61 -0
- package/src/next-config/index.ts +114 -0
- package/src/server/JsonApiServer.ts +3 -1
- package/src/server/ServerSession.ts +29 -1
- package/src/server/__tests__/JsonApiServer.test.ts +41 -0
- package/src/server/__tests__/ServerSession.read.test.ts +67 -0
- package/src/server/__tests__/cache.test.ts +48 -0
- package/src/server/__tests__/cachedGet.test.ts +125 -0
- package/src/server/__tests__/request.test.ts +96 -1
- package/src/server/cache.ts +21 -4
- package/src/server/cacheDigest.ts +10 -0
- package/src/server/cacheProfiles.ts +15 -0
- package/src/server/cachedGet.stub.ts +15 -0
- package/src/server/cachedGet.ts +80 -0
- package/src/server/request.ts +33 -32
- package/src/shadcnui/ui/__tests__/sidebar.menu-skeleton.test.tsx +50 -0
- package/src/shadcnui/ui/__tests__/sidebar.session.test.tsx +133 -0
- package/src/shadcnui/ui/sidebar.tsx +121 -18
- package/src/testing/matchers/jsonApiMatchers.ts +1 -0
- package/src/unified/JsonApiRequest.ts +4 -1
- package/src/unified/__tests__/JsonApiGet.cache.test.ts +51 -0
- package/src/utils/__tests__/map-thenable.test.ts +29 -0
- package/src/utils/__tests__/use-mobile.test.tsx +102 -1
- package/src/utils/map-thenable.ts +15 -0
- package/src/utils/use-mobile.tsx +13 -4
- package/dist/BlockNoteEditor-36VL2TNM.js.map +0 -1
- package/dist/JsonApiRequest-TE5WD3WS.js +0 -26
- package/dist/chunk-26MQIJNB.js.map +0 -1
- package/dist/chunk-4Z45NYPR.js.map +0 -1
- package/dist/chunk-5TRFNKJS.js.map +0 -1
- package/dist/chunk-7B4E42R5.mjs.map +0 -1
- package/dist/chunk-CAKOFJZD.js.map +0 -1
- package/dist/chunk-E3I4MT53.mjs.map +0 -1
- package/dist/chunk-GHFMKH3M.js.map +0 -1
- package/dist/chunk-HPUV6FBW.mjs.map +0 -1
- package/dist/chunk-KOTKKBJT.js.map +0 -1
- package/dist/chunk-Q6J2ICKO.mjs.map +0 -1
- package/dist/chunk-SJLXPHK5.mjs.map +0 -1
- package/dist/request-ZTX6I63X.js +0 -8
- package/dist/request-ZTX6I63X.js.map +0 -1
- /package/dist/{BlockNoteViewer-KRTAFTUX.mjs.map → BlockNoteViewer-DQYT6IJW.mjs.map} +0 -0
- /package/dist/{JsonApiRequest-UHOWNI3O.mjs.map → JsonApiRequest-MEP6AXDT.mjs.map} +0 -0
- /package/dist/{chunk-U65JPKO3.mjs.map → chunk-R6ZYB4VN.mjs.map} +0 -0
- /package/dist/{request-FXF63NSQ.mjs.map → request-3IUMNVOS.mjs.map} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { act, render, screen } from "@testing-library/react";
|
|
3
|
+
import { Activity } from "react";
|
|
3
4
|
import userEvent from "@testing-library/user-event";
|
|
4
5
|
import { useForm } from "react-hook-form";
|
|
5
6
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
@@ -257,4 +258,27 @@ describe("EditorSheet", () => {
|
|
|
257
258
|
expect(submitButton).toBeDisabled();
|
|
258
259
|
});
|
|
259
260
|
});
|
|
261
|
+
|
|
262
|
+
describe("Activity hide and reveal (back/forward)", () => {
|
|
263
|
+
it("uncontrolled sheet is closed and the form reset after Activity reveal", async () => {
|
|
264
|
+
const page = (mode: "visible" | "hidden") => (
|
|
265
|
+
<Activity mode={mode}>
|
|
266
|
+
<TestEditor />
|
|
267
|
+
</Activity>
|
|
268
|
+
);
|
|
269
|
+
const r = render(page("visible"));
|
|
270
|
+
|
|
271
|
+
await userEvent.click(screen.getByText("ui.buttons.create"));
|
|
272
|
+
await userEvent.type(screen.getByTestId("name-input"), "Left behind");
|
|
273
|
+
expect(screen.getByTestId("name-input")).toHaveValue("Left behind");
|
|
274
|
+
|
|
275
|
+
act(() => r.rerender(page("hidden")));
|
|
276
|
+
act(() => r.rerender(page("visible")));
|
|
277
|
+
|
|
278
|
+
expect(screen.queryByTestId("name-input")).toBeNull();
|
|
279
|
+
|
|
280
|
+
await userEvent.click(screen.getByText("ui.buttons.create"));
|
|
281
|
+
expect(screen.getByTestId("name-input")).toHaveValue("");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
260
284
|
});
|
|
@@ -1,7 +1,80 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { renderHook, act } from "@testing-library/react";
|
|
2
|
+
import { renderHook, act, render } from "@testing-library/react";
|
|
3
|
+
import { Activity, StrictMode, createElement, useState } from "react";
|
|
3
4
|
import { useEditorDialog } from "../useEditorDialog";
|
|
4
5
|
|
|
6
|
+
type ActivityMode = "visible" | "hidden";
|
|
7
|
+
type HookApi = ReturnType<typeof useEditorDialog>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Mounts a sheet driven by `useEditorDialog` inside a React `<Activity>`, the
|
|
11
|
+
* way Next 16.4 keeps a route mounted but hidden across back/forward. The page
|
|
12
|
+
* component sits inside the Activity, so its own state (the parent's
|
|
13
|
+
* `dialogOpen`) survives the hide exactly as a route's state does.
|
|
14
|
+
*/
|
|
15
|
+
function renderInActivity(opts: {
|
|
16
|
+
controlled?: boolean;
|
|
17
|
+
initialParentOpen?: boolean;
|
|
18
|
+
forceShow?: boolean;
|
|
19
|
+
strict?: boolean;
|
|
20
|
+
isFormDirty?: () => boolean;
|
|
21
|
+
onClose?: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
const renders: boolean[] = [];
|
|
24
|
+
const parentSpy = vi.fn();
|
|
25
|
+
let api!: HookApi;
|
|
26
|
+
let parentOpen: boolean | undefined;
|
|
27
|
+
let setParentOpen!: (open: boolean) => void;
|
|
28
|
+
|
|
29
|
+
function Sheet(props: { dialogOpen?: boolean; onDialogOpenChange?: (open: boolean) => void }) {
|
|
30
|
+
api = useEditorDialog(opts.isFormDirty ?? (() => false), {
|
|
31
|
+
...props,
|
|
32
|
+
forceShow: opts.forceShow,
|
|
33
|
+
onClose: opts.onClose,
|
|
34
|
+
});
|
|
35
|
+
renders.push(api.open);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Page() {
|
|
40
|
+
const [open, setOpen] = useState(opts.initialParentOpen ?? false);
|
|
41
|
+
parentOpen = open;
|
|
42
|
+
setParentOpen = setOpen;
|
|
43
|
+
if (!opts.controlled) return createElement(Sheet);
|
|
44
|
+
return createElement(Sheet, {
|
|
45
|
+
dialogOpen: open,
|
|
46
|
+
onDialogOpenChange: (next: boolean) => {
|
|
47
|
+
parentSpy(next);
|
|
48
|
+
setOpen(next);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function App({ mode }: { mode: ActivityMode }) {
|
|
54
|
+
return createElement(Activity, { mode }, createElement(Page));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// StrictMode only double-invokes effects when it wraps the ROOT element.
|
|
58
|
+
const root = (mode: ActivityMode) =>
|
|
59
|
+
opts.strict ? createElement(StrictMode, null, createElement(App, { mode })) : createElement(App, { mode });
|
|
60
|
+
|
|
61
|
+
const r = render(root("visible"));
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
renders,
|
|
65
|
+
parentSpy,
|
|
66
|
+
get api() {
|
|
67
|
+
return api;
|
|
68
|
+
},
|
|
69
|
+
get parentOpen() {
|
|
70
|
+
return parentOpen;
|
|
71
|
+
},
|
|
72
|
+
openFromParent: () => act(() => setParentOpen(true)),
|
|
73
|
+
hide: () => act(() => r.rerender(root("hidden"))),
|
|
74
|
+
reveal: () => act(() => r.rerender(root("visible"))),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
5
78
|
describe("useEditorDialog", () => {
|
|
6
79
|
beforeEach(() => {
|
|
7
80
|
vi.clearAllMocks();
|
|
@@ -281,4 +354,94 @@ describe("useEditorDialog", () => {
|
|
|
281
354
|
expect(outer.result.current.open).toBe(true);
|
|
282
355
|
});
|
|
283
356
|
});
|
|
357
|
+
|
|
358
|
+
describe("Activity hide and reveal (back/forward)", () => {
|
|
359
|
+
it("controlled sheet does not reopen after Activity reveal", () => {
|
|
360
|
+
const t = renderInActivity({ controlled: true, initialParentOpen: true });
|
|
361
|
+
expect(t.api.open).toBe(true);
|
|
362
|
+
t.parentSpy.mockClear();
|
|
363
|
+
|
|
364
|
+
t.hide();
|
|
365
|
+
const revealedAt = t.renders.length;
|
|
366
|
+
t.reveal();
|
|
367
|
+
|
|
368
|
+
expect(t.parentSpy.mock.calls).toEqual([[false]]);
|
|
369
|
+
expect(t.parentOpen).toBe(false);
|
|
370
|
+
expect(t.api.open).toBe(false);
|
|
371
|
+
expect(t.renders.slice(revealedAt)).not.toContain(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("sheet opened after mount is closed after Activity reveal", () => {
|
|
375
|
+
const t = renderInActivity({});
|
|
376
|
+
expect(t.api.open).toBe(false);
|
|
377
|
+
act(() => t.api.setOpen(true));
|
|
378
|
+
expect(t.api.open).toBe(true);
|
|
379
|
+
|
|
380
|
+
t.hide();
|
|
381
|
+
t.reveal();
|
|
382
|
+
|
|
383
|
+
expect(t.api.open).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("forceShow sheet stays closed after Activity reveal", () => {
|
|
387
|
+
const t = renderInActivity({ forceShow: true });
|
|
388
|
+
expect(t.api.open).toBe(true);
|
|
389
|
+
|
|
390
|
+
t.hide();
|
|
391
|
+
t.reveal();
|
|
392
|
+
|
|
393
|
+
expect(t.api.open).toBe(false);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("stays closed under StrictMode reveal", () => {
|
|
397
|
+
const t = renderInActivity({ controlled: true, strict: true });
|
|
398
|
+
t.openFromParent();
|
|
399
|
+
expect(t.api.open).toBe(true);
|
|
400
|
+
|
|
401
|
+
t.hide();
|
|
402
|
+
const revealedAt = t.renders.length;
|
|
403
|
+
t.reveal();
|
|
404
|
+
|
|
405
|
+
// Strict Mode doubles the notify effect too, so parent calls are not counted.
|
|
406
|
+
expect(t.renders.slice(revealedAt)).not.toContain(true);
|
|
407
|
+
expect(t.api.open).toBe(false);
|
|
408
|
+
expect(t.parentOpen).toBe(false);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("controlled sheet still opens on first mount under StrictMode", () => {
|
|
412
|
+
const t = renderInActivity({ controlled: true, initialParentOpen: true, strict: true });
|
|
413
|
+
expect(t.api.open).toBe(true);
|
|
414
|
+
expect(t.parentOpen).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("discard confirmation is closed after Activity reveal", () => {
|
|
418
|
+
const t = renderInActivity({ isFormDirty: () => true });
|
|
419
|
+
act(() => t.api.setOpen(true));
|
|
420
|
+
act(() => t.api.handleOpenChange(false));
|
|
421
|
+
expect(t.api.discardDialogProps.open).toBe(true);
|
|
422
|
+
|
|
423
|
+
t.hide();
|
|
424
|
+
t.reveal();
|
|
425
|
+
|
|
426
|
+
expect(t.api.discardDialogProps.open).toBe(false);
|
|
427
|
+
expect(t.api.open).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("onClose is called once per hide and reveal", () => {
|
|
431
|
+
const onClose = vi.fn();
|
|
432
|
+
const t = renderInActivity({ onClose });
|
|
433
|
+
act(() => t.api.setOpen(true));
|
|
434
|
+
onClose.mockClear();
|
|
435
|
+
|
|
436
|
+
t.hide();
|
|
437
|
+
t.reveal();
|
|
438
|
+
|
|
439
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("forceShow sheet still opens on first mount under StrictMode", () => {
|
|
443
|
+
const t = renderInActivity({ forceShow: true, strict: true });
|
|
444
|
+
expect(t.api.open).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
284
447
|
});
|
|
@@ -62,11 +62,75 @@ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorD
|
|
|
62
62
|
// each other every render → "Maximum update depth exceeded".
|
|
63
63
|
const syncingFromProp = useRef(false);
|
|
64
64
|
|
|
65
|
+
// Mirrors `open` on every render. The reset effect below has an empty
|
|
66
|
+
// dependency list, so its cleanup closes over the FIRST render's `open`; it
|
|
67
|
+
// must read the current value from here instead.
|
|
68
|
+
const openRef = useRef(open);
|
|
69
|
+
openRef.current = open;
|
|
70
|
+
const showDiscardRef = useRef(showDiscardConfirm);
|
|
71
|
+
showDiscardRef.current = showDiscardConfirm;
|
|
72
|
+
|
|
73
|
+
// Set by the reset cleanup when the route was hidden with the sheet (or its
|
|
74
|
+
// discard confirmation) open; consumed by the reset effect on reveal.
|
|
75
|
+
const hiddenWhileOpen = useRef(false);
|
|
76
|
+
// Latch armed on reveal: the parent's `dialogOpen` is still the stale `true`
|
|
77
|
+
// it held when the route was hidden, and must not re-open the sheet. Cleared
|
|
78
|
+
// by the first `dialogOpen === false`.
|
|
79
|
+
const ignoreTrueFromParent = useRef(false);
|
|
80
|
+
|
|
81
|
+
// Reset when <Activity> hides the route (Next 16.4 keeps routes mounted across
|
|
82
|
+
// back/forward). After back/forward an editor sheet must be closed with its
|
|
83
|
+
// form reset, exactly as before 16.4. Measured in React 19.3:
|
|
84
|
+
// 1. Hiding runs every effect cleanup and revealing re-runs every effect with
|
|
85
|
+
// mount semantics, while `useState` survives. A setState in the cleanup is
|
|
86
|
+
// applied while hidden, so the sheet is already closed on reveal and
|
|
87
|
+
// EditorSheet's close branch resets the form.
|
|
88
|
+
// 2. An empty-dependency cleanup reads the first render's `open`, hence
|
|
89
|
+
// `openRef`.
|
|
90
|
+
// 3. Effects run in declaration order. This effect is declared BEFORE the
|
|
91
|
+
// dialogOpen sync effect, so the latch is armed before the sync effect sees
|
|
92
|
+
// the parent's stale `true`; declared after, the sheet re-opens.
|
|
93
|
+
// 4. It never calls `onDialogOpenChange`. The notify effect re-runs on reveal
|
|
94
|
+
// with `open === false` and tells the parent once; calling it here too
|
|
95
|
+
// would tell the parent twice.
|
|
96
|
+
// Strict Mode's fake unmount on first mount finds `openRef` still false, so
|
|
97
|
+
// the latch is never armed then and a controlled or forceShow sheet still
|
|
98
|
+
// opens on mount.
|
|
99
|
+
// A real unmount also runs the cleanup; the setStates on an unmounted
|
|
100
|
+
// component are no-ops.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (hiddenWhileOpen.current) {
|
|
103
|
+
hiddenWhileOpen.current = false;
|
|
104
|
+
ignoreTrueFromParent.current = true;
|
|
105
|
+
}
|
|
106
|
+
return () => {
|
|
107
|
+
const wasOpen = openRef.current || showDiscardRef.current;
|
|
108
|
+
hiddenWhileOpen.current = wasOpen;
|
|
109
|
+
// Only when something is open: on Strict Mode's fake unmount the
|
|
110
|
+
// forceShow effect's setOpen(true) is still pending (`openRef` is false),
|
|
111
|
+
// and an unconditional setOpen(false) here would cancel it for good — the
|
|
112
|
+
// forceShow guard below does not open again for an unchanged prop.
|
|
113
|
+
if (wasOpen) {
|
|
114
|
+
setOpen(false);
|
|
115
|
+
setShowDiscardConfirm(false);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
65
120
|
// Sync open state from external dialogOpen prop.
|
|
66
121
|
// `open` is intentionally NOT a dependency: this effect must run only when the
|
|
67
122
|
// prop changes. Adding `open` would re-fire on internal closes and immediately
|
|
68
123
|
// re-open the dialog (dialogOpen still true while open just went false).
|
|
124
|
+
// After an <Activity> reveal it also re-runs with mount semantics while the
|
|
125
|
+
// parent's `dialogOpen` is still the stale `true`: the `ignoreTrueFromParent`
|
|
126
|
+
// latch drops that `true` until the parent reports `false` (the notify effect
|
|
127
|
+
// tells it). A latch, not a one-shot flag: under Strict Mode the reveal runs
|
|
128
|
+
// this effect twice, and a one-shot flag let the second pass re-open the sheet.
|
|
69
129
|
useEffect(() => {
|
|
130
|
+
if (ignoreTrueFromParent.current) {
|
|
131
|
+
if (options?.dialogOpen === true) return;
|
|
132
|
+
if (options?.dialogOpen === false) ignoreTrueFromParent.current = false;
|
|
133
|
+
}
|
|
70
134
|
if (options?.dialogOpen !== undefined && options.dialogOpen !== open) {
|
|
71
135
|
syncingFromProp.current = true;
|
|
72
136
|
setOpen(options.dialogOpen);
|
|
@@ -94,9 +158,12 @@ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorD
|
|
|
94
158
|
onDialogOpenChangeRef.current?.(open);
|
|
95
159
|
}, [open]);
|
|
96
160
|
|
|
97
|
-
// Force show
|
|
161
|
+
// Force show — only on a change to `true`. The effect re-runs on an
|
|
162
|
+
// <Activity> reveal with the prop unchanged; opening then would undo the reset.
|
|
163
|
+
const lastForceShow = useRef<boolean | undefined>(undefined);
|
|
98
164
|
useEffect(() => {
|
|
99
|
-
if (options?.forceShow) setOpen(true);
|
|
165
|
+
if (options?.forceShow && lastForceShow.current !== true) setOpen(true);
|
|
166
|
+
lastForceShow.current = options?.forceShow;
|
|
100
167
|
}, [options?.forceShow]);
|
|
101
168
|
|
|
102
169
|
// Call onClose when dialog closes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, use, type ReactNode } from "react";
|
|
4
|
+
import type { SessionSnapshot } from "../server/ServerSession";
|
|
5
|
+
|
|
6
|
+
const SessionContext = createContext<Promise<SessionSnapshot> | null>(null);
|
|
7
|
+
|
|
8
|
+
export function SessionProvider({ session, children }: { session: Promise<SessionSnapshot>; children: ReactNode }) {
|
|
9
|
+
return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Unwraps the session promise with `use()`. Suspends the caller until the
|
|
14
|
+
* promise resolves, so call it only from a leaf behind its own `<Suspense>`.
|
|
15
|
+
*/
|
|
16
|
+
export function useSession(): SessionSnapshot {
|
|
17
|
+
const session = use(SessionContext);
|
|
18
|
+
if (!session) throw new Error("useSession must be used within a SessionProvider");
|
|
19
|
+
return use(session);
|
|
20
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { createContext, useContext } from "react";
|
|
3
|
+
import React, { createContext, useContext, useEffect, useState } from "react";
|
|
4
4
|
import { NotificationInterface } from "../features/notification/data";
|
|
5
5
|
import { useSocket } from "../hooks";
|
|
6
|
+
import type { SessionSnapshot } from "../server/ServerSession";
|
|
6
7
|
|
|
7
8
|
interface SocketContextProps {
|
|
8
9
|
socket: ReturnType<typeof useSocket>["socket"];
|
|
@@ -32,11 +33,37 @@ export const SocketContext = createContext<SocketContextProps>(defaultContextVal
|
|
|
32
33
|
|
|
33
34
|
interface SocketProviderProps {
|
|
34
35
|
token?: string;
|
|
36
|
+
/**
|
|
37
|
+
* The request's session snapshot as a promise. The token is resolved in an
|
|
38
|
+
* effect, so this provider never suspends and its children render at once;
|
|
39
|
+
* the socket connects when the promise settles.
|
|
40
|
+
*/
|
|
41
|
+
session?: Promise<SessionSnapshot>;
|
|
35
42
|
children: React.ReactNode;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
export const SocketProvider: React.FC<SocketProviderProps> = ({ token, children }) => {
|
|
39
|
-
const
|
|
45
|
+
export const SocketProvider: React.FC<SocketProviderProps> = ({ token, session, children }) => {
|
|
46
|
+
const [sessionToken, setSessionToken] = useState<string | undefined>(undefined);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!session) return;
|
|
50
|
+
let active = true;
|
|
51
|
+
// Callback form: a server-passed promise's then() returns undefined, so it
|
|
52
|
+
// cannot be chained (see utils/map-thenable).
|
|
53
|
+
session.then(
|
|
54
|
+
(snapshot) => {
|
|
55
|
+
if (active) setSessionToken(snapshot.token);
|
|
56
|
+
},
|
|
57
|
+
() => {},
|
|
58
|
+
);
|
|
59
|
+
return () => {
|
|
60
|
+
active = false;
|
|
61
|
+
};
|
|
62
|
+
}, [session]);
|
|
63
|
+
|
|
64
|
+
const resolvedToken = token ?? sessionToken;
|
|
65
|
+
// Called unconditionally: useSocket returns early while the token is falsy.
|
|
66
|
+
const socketHookData = useSocket({ token: resolvedToken as string });
|
|
40
67
|
|
|
41
68
|
const sendMessage = (event: string, data: any) => {
|
|
42
69
|
const currentSocket = socketHookData ? socketHookData.socket : defaultContextValue.socket;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import type { SessionSnapshot } from "../server/ServerSession";
|
|
5
|
+
import { mapThenable } from "../utils/map-thenable";
|
|
4
6
|
import { getViewportContext, measureViewport, persistViewport, subscribeViewport } from "../utils/use-mobile";
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -24,13 +26,26 @@ import { getViewportContext, measureViewport, persistViewport, subscribeViewport
|
|
|
24
26
|
*/
|
|
25
27
|
export function ViewportProvider({
|
|
26
28
|
initialIsMobile,
|
|
29
|
+
session,
|
|
27
30
|
children,
|
|
28
31
|
}: {
|
|
29
|
-
initialIsMobile
|
|
32
|
+
initialIsMobile?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Alternative to `initialIsMobile` (pass exactly one): the request's session
|
|
35
|
+
* snapshot as a promise. The provider never awaits it, so its children render
|
|
36
|
+
* at once; only `useIsMobile()` callers suspend until it settles. The first
|
|
37
|
+
* client measurement replaces it with a boolean, as with `initialIsMobile`.
|
|
38
|
+
*/
|
|
39
|
+
session?: Promise<SessionSnapshot>;
|
|
30
40
|
children: React.ReactNode;
|
|
31
41
|
}) {
|
|
32
42
|
const ViewportContext = getViewportContext();
|
|
33
|
-
const
|
|
43
|
+
const seed = React.useMemo<boolean | Promise<boolean>>(
|
|
44
|
+
() => (session ? mapThenable(session, (snapshot) => snapshot.isMobile) : (initialIsMobile ?? false)),
|
|
45
|
+
// Created once: later measurements own the value.
|
|
46
|
+
[],
|
|
47
|
+
);
|
|
48
|
+
const [isMobile, setIsMobile] = React.useState<boolean | Promise<boolean>>(seed);
|
|
34
49
|
|
|
35
50
|
React.useEffect(
|
|
36
51
|
() =>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { act, render, renderHook, screen } from "@testing-library/react";
|
|
2
|
+
import { Suspense } from "react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { SessionSnapshot } from "../../server/ServerSession";
|
|
5
|
+
import { SessionProvider, useSession } from "../SessionContext";
|
|
6
|
+
|
|
7
|
+
const snapshot: SessionSnapshot = {
|
|
8
|
+
isLogged: true,
|
|
9
|
+
isAdministrator: false,
|
|
10
|
+
token: "t1",
|
|
11
|
+
sidebarOpen: true,
|
|
12
|
+
isMobile: false,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function Leaf() {
|
|
16
|
+
return <span>{useSession().token}</span>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("SessionContext", () => {
|
|
20
|
+
it("renders children without waiting for the session", () => {
|
|
21
|
+
render(
|
|
22
|
+
<SessionProvider session={new Promise<SessionSnapshot>(() => {})}>
|
|
23
|
+
<p>child</p>
|
|
24
|
+
</SessionProvider>,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
expect(screen.getByText("child")).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("useSession suspends until the promise resolves and then returns it", async () => {
|
|
31
|
+
let resolve!: (value: SessionSnapshot) => void;
|
|
32
|
+
const session = new Promise<SessionSnapshot>((r) => {
|
|
33
|
+
resolve = r;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// An async act around the suspending render lets React schedule the retry
|
|
37
|
+
// when the promise resolves; a plain render() never re-renders the leaf.
|
|
38
|
+
await act(async () => {
|
|
39
|
+
render(
|
|
40
|
+
<SessionProvider session={session}>
|
|
41
|
+
<Suspense fallback="waiting">
|
|
42
|
+
<Leaf />
|
|
43
|
+
</Suspense>
|
|
44
|
+
</SessionProvider>,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText("waiting")).toBeInTheDocument();
|
|
49
|
+
|
|
50
|
+
await act(async () => resolve(snapshot));
|
|
51
|
+
|
|
52
|
+
expect(screen.getByText("t1")).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("useSession throws outside a provider", () => {
|
|
56
|
+
expect(() => renderHook(() => useSession())).toThrow(/SessionProvider/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { act, render, screen } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { SessionSnapshot } from "../../server/ServerSession";
|
|
4
|
+
|
|
5
|
+
const { useSocketMock } = vi.hoisted(() => ({
|
|
6
|
+
useSocketMock: vi.fn((_options: { token?: string }) => ({
|
|
7
|
+
socket: null,
|
|
8
|
+
isConnected: false,
|
|
9
|
+
messages: [],
|
|
10
|
+
socketNotifications: [],
|
|
11
|
+
sendMessage: () => {},
|
|
12
|
+
removeMessage: () => {},
|
|
13
|
+
removeSocketNotification: () => {},
|
|
14
|
+
clearMessages: () => {},
|
|
15
|
+
clearSocketNotifications: () => {},
|
|
16
|
+
})),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("../../hooks/useSocket", () => ({ useSocket: useSocketMock }));
|
|
20
|
+
|
|
21
|
+
import { SocketProvider } from "../SocketContext";
|
|
22
|
+
|
|
23
|
+
function deferred<T>() {
|
|
24
|
+
let resolve!: (value: T) => void;
|
|
25
|
+
const promise = new Promise<T>((r) => {
|
|
26
|
+
resolve = r;
|
|
27
|
+
});
|
|
28
|
+
return { promise, resolve };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const snapshot = (token: string): SessionSnapshot => ({
|
|
32
|
+
isLogged: true,
|
|
33
|
+
isAdministrator: false,
|
|
34
|
+
token,
|
|
35
|
+
sidebarOpen: true,
|
|
36
|
+
isMobile: false,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
useSocketMock.mockClear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("SocketProvider with a session", () => {
|
|
44
|
+
it("renders children before the session resolves", () => {
|
|
45
|
+
const pending = deferred<SessionSnapshot>();
|
|
46
|
+
render(
|
|
47
|
+
<SocketProvider session={pending.promise}>
|
|
48
|
+
<span>child</span>
|
|
49
|
+
</SocketProvider>,
|
|
50
|
+
);
|
|
51
|
+
expect(screen.getByText("child")).toBeInTheDocument();
|
|
52
|
+
expect(useSocketMock).toHaveBeenLastCalledWith({ token: undefined });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("connects with the token once the session resolves", async () => {
|
|
56
|
+
const pending = deferred<SessionSnapshot>();
|
|
57
|
+
render(
|
|
58
|
+
<SocketProvider session={pending.promise}>
|
|
59
|
+
<span>child</span>
|
|
60
|
+
</SocketProvider>,
|
|
61
|
+
);
|
|
62
|
+
await act(async () => {
|
|
63
|
+
pending.resolve(snapshot("t1"));
|
|
64
|
+
await pending.promise;
|
|
65
|
+
});
|
|
66
|
+
expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t1" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("still accepts a plain token", () => {
|
|
70
|
+
render(
|
|
71
|
+
<SocketProvider token="t2">
|
|
72
|
+
<span>child</span>
|
|
73
|
+
</SocketProvider>,
|
|
74
|
+
);
|
|
75
|
+
expect(useSocketMock.mock.calls[0][0]).toEqual({ token: "t2" });
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/contexts/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./HeaderChildrenContext";
|
|
|
15
15
|
export * from "./HeaderLeftContentContext";
|
|
16
16
|
export * from "./HeaderLogoContext";
|
|
17
17
|
export * from "./MobileNavigationContext";
|
|
18
|
+
export * from "./SessionContext";
|
|
18
19
|
export * from "./SharedContext";
|
|
19
20
|
export * from "./SocketContext";
|
|
20
21
|
export * from "./ViewportContext";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ApiRequestDataTypeInterface } from "../interfaces/ApiRequestDataTypeInterface";
|
|
2
2
|
import { ApiResponseInterface } from "../interfaces/ApiResponseInterface";
|
|
3
3
|
import { setLastApiResponse } from "./lastApiResponse";
|
|
4
|
+
import { getI18nServerLocale } from "../../i18n/config";
|
|
4
5
|
|
|
5
6
|
export enum HttpMethod {
|
|
6
7
|
GET = "GET",
|
|
@@ -158,8 +159,7 @@ export abstract class AbstractService {
|
|
|
158
159
|
// Get language based on environment
|
|
159
160
|
let language = "en";
|
|
160
161
|
if (typeof window === "undefined") {
|
|
161
|
-
|
|
162
|
-
language = (await getLocale()) ?? "en";
|
|
162
|
+
language = await getI18nServerLocale();
|
|
163
163
|
} else {
|
|
164
164
|
// Client-side: extract locale from URL pathname
|
|
165
165
|
language = this.getClientLocale();
|
|
@@ -279,8 +279,7 @@ export abstract class AbstractService {
|
|
|
279
279
|
// Get language based on environment
|
|
280
280
|
let language = "en";
|
|
281
281
|
if (typeof window === "undefined") {
|
|
282
|
-
|
|
283
|
-
language = (await getLocale()) ?? "en";
|
|
282
|
+
language = await getI18nServerLocale();
|
|
284
283
|
} else {
|
|
285
284
|
// Client-side: extract locale from URL pathname
|
|
286
285
|
language = this.getClientLocale();
|
|
@@ -378,8 +377,7 @@ export abstract class AbstractService {
|
|
|
378
377
|
let language = "en";
|
|
379
378
|
|
|
380
379
|
if (typeof window === "undefined") {
|
|
381
|
-
|
|
382
|
-
language = (await getLocale()) ?? "en";
|
|
380
|
+
language = await getI18nServerLocale();
|
|
383
381
|
} else {
|
|
384
382
|
// Client-side: extract locale from URL pathname
|
|
385
383
|
language = this.getClientLocale();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// The library must not import next-intl itself: instrumentation loads the library, and
|
|
4
|
+
// next-intl's getLocale pulls in the app's i18n/request.ts, which reads `next/root-params`
|
|
5
|
+
// (only allowed inside the app directory). The locale comes from configureI18n instead.
|
|
6
|
+
const { nextIntlServer, jsonApiGet } = vi.hoisted(() => ({
|
|
7
|
+
nextIntlServer: vi.fn(),
|
|
8
|
+
jsonApiGet: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("next-intl/server", () => {
|
|
12
|
+
nextIntlServer();
|
|
13
|
+
return { getLocale: vi.fn(async () => "xx") };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock("../../../unified/JsonApiRequest", () => ({
|
|
17
|
+
JsonApiGet: jsonApiGet,
|
|
18
|
+
JsonApiPost: vi.fn(),
|
|
19
|
+
JsonApiPut: vi.fn(),
|
|
20
|
+
JsonApiPatch: vi.fn(),
|
|
21
|
+
JsonApiDelete: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { configureI18n, type I18nConfig } from "../../../i18n/config";
|
|
25
|
+
import { AbstractService, HttpMethod } from "../AbstractService";
|
|
26
|
+
|
|
27
|
+
class TestService extends AbstractService {
|
|
28
|
+
static read() {
|
|
29
|
+
return this.callApi({ type: { name: "tests" } as any, method: HttpMethod.GET, endpoint: "https://api.test/tests" });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("AbstractService server locale", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vi.stubGlobal("window", undefined);
|
|
36
|
+
jsonApiGet.mockResolvedValue({ ok: true, response: 200, data: [], included: [] });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.unstubAllGlobals();
|
|
41
|
+
configureI18n(null as unknown as I18nConfig);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("reads the server locale from configureI18n, never from next-intl", async () => {
|
|
45
|
+
configureI18n({
|
|
46
|
+
useRouter: vi.fn(),
|
|
47
|
+
useTranslations: vi.fn(),
|
|
48
|
+
Link: vi.fn() as unknown as I18nConfig["Link"],
|
|
49
|
+
usePathname: vi.fn(),
|
|
50
|
+
getLocale: async () => "it",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await TestService.read().catch(() => undefined);
|
|
54
|
+
|
|
55
|
+
expect(typeof window).toBe("undefined");
|
|
56
|
+
expect(jsonApiGet).toHaveBeenCalledWith(expect.objectContaining({ language: "it" }));
|
|
57
|
+
expect(nextIntlServer).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("fails loudly when configureI18n has no getLocale", async () => {
|
|
61
|
+
await expect(TestService.read()).rejects.toThrow(/configureI18n/);
|
|
62
|
+
expect(jsonApiGet).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -18,7 +18,11 @@ type Props = {
|
|
|
18
18
|
deniedRedirect?: string;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Server page for the administrative analytics dashboard: role gate, then the client container.
|
|
23
|
+
*
|
|
24
|
+
* Reads the session: render inside a `<Suspense>` boundary (Cache Components).
|
|
25
|
+
*/
|
|
22
26
|
export async function AnalyticsAdminPage({ adminRoleId, pageUrl, sessionPageUrl, deniedRedirect = "/" }: Props) {
|
|
23
27
|
if (!(await ServerSession.hasRole(adminRoleId))) {
|
|
24
28
|
redirect(deniedRedirect);
|