@carlonicora/nextjs-jsonapi 3.12.0 → 3.13.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/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
- package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
- package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
- package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
- package/dist/{BlockNoteEditor-6G27S6P3.js → BlockNoteEditor-CFVSHS46.js} +19 -19
- package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
- package/dist/{BlockNoteEditor-CR5KH4AI.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
- package/dist/{BlockNoteViewer-5MITJ7AD.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
- package/dist/{BlockNoteViewer-5MITJ7AD.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
- package/dist/{BlockNoteViewer-NJKFUNXP.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
- package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
- package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
- package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
- package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
- package/dist/billing/index.js +401 -401
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
- package/dist/chunk-IB7DTXWG.js.map +1 -0
- package/dist/{chunk-OKGGYCO5.js → chunk-LVE3BJ6K.js} +2088 -1134
- package/dist/chunk-LVE3BJ6K.js.map +1 -0
- package/dist/{chunk-Q2BREXH3.mjs → chunk-OSFYCSTW.mjs} +724 -7
- package/dist/chunk-OSFYCSTW.mjs.map +1 -0
- package/dist/{chunk-JEGL7KHK.mjs → chunk-UQXE3SME.mjs} +2081 -1127
- package/dist/chunk-UQXE3SME.mjs.map +1 -0
- package/dist/{chunk-HVNEWDRF.mjs → chunk-VZKJIAND.mjs} +2 -2
- package/dist/{chunk-4ETVCRQD.js → chunk-XH4IURUD.js} +7 -7
- package/dist/{chunk-4ETVCRQD.js.map → chunk-XH4IURUD.js.map} +1 -1
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +258 -7
- package/dist/components/index.d.ts +258 -7
- package/dist/components/index.js +30 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +29 -3
- package/dist/contexts/index.d.mts +17 -5
- package/dist/contexts/index.d.ts +17 -5
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +491 -11
- package/dist/core/index.d.ts +491 -11
- package/dist/core/index.js +38 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +37 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +7 -7
- package/dist/index.d.ts +7 -7
- package/dist/index.js +39 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +38 -2
- package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
- package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
- package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
- package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
- package/dist/server/index.d.mts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
- package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
- package/package.json +1 -1
- package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
- package/src/components/index.ts +1 -0
- package/src/core/index.ts +5 -0
- package/src/core/registry/ModuleRegistry.ts +4 -0
- package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
- package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
- package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
- package/src/features/assistant/data/Assistant.ts +2 -0
- package/src/features/assistant/data/AssistantInterface.ts +2 -0
- package/src/features/assistant/data/AssistantService.ts +4 -0
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
- package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
- package/src/features/content/data/content.interface.ts +1 -1
- package/src/features/handbook/HandbookPageModule.ts +41 -0
- package/src/features/handbook/HandbookSectionModule.ts +35 -0
- package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
- package/src/features/handbook/HandbookThreadModule.ts +29 -0
- package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
- package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
- package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
- package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
- package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
- package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
- package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
- package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
- package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
- package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
- package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
- package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
- package/src/features/handbook/components/index.ts +12 -0
- package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
- package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
- package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
- package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
- package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
- package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
- package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
- package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
- package/src/features/handbook/data/HandbookPage.ts +99 -0
- package/src/features/handbook/data/HandbookPageFields.ts +31 -0
- package/src/features/handbook/data/HandbookPageInput.ts +8 -0
- package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
- package/src/features/handbook/data/HandbookPageService.ts +60 -0
- package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
- package/src/features/handbook/data/HandbookSection.ts +73 -0
- package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
- package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
- package/src/features/handbook/data/HandbookSectionService.ts +26 -0
- package/src/features/handbook/data/HandbookThread.ts +114 -0
- package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
- package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
- package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
- package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
- package/src/features/handbook/data/HandbookThreadService.ts +86 -0
- package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
- package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
- package/src/features/handbook/data/handbookDisplay.ts +55 -0
- package/src/features/handbook/data/index.ts +16 -0
- package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
- package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
- package/src/features/handbook/i18n-keys.ts +46 -0
- package/src/features/handbook/index.ts +7 -0
- package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
- package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
- package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
- package/src/features/how-to/components/index.ts +1 -0
- package/src/features/how-to/components/lists/HowToList.tsx +5 -1
- package/src/features/how-to/data/HowTo.spec.ts +61 -1
- package/src/features/how-to/data/HowToService.ts +13 -0
- package/dist/chunk-JEGL7KHK.mjs.map +0 -1
- package/dist/chunk-L7DZSTFU.js.map +0 -1
- package/dist/chunk-OKGGYCO5.js.map +0 -1
- package/dist/chunk-Q2BREXH3.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-CR5KH4AI.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-NJKFUNXP.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
- /package/dist/{chunk-HVNEWDRF.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
6
|
+
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
7
|
+
import { HandbookAskSheet } from "../HandbookAskSheet";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The conversation itself is `useHandbookAsk`'s, and its own spec covers it.
|
|
11
|
+
* What this file proves is the sheet: what it opens with, what the recent menu
|
|
12
|
+
* holds, and what the page chip does to the next question. So the hook is
|
|
13
|
+
* mocked — but with real React state behind `ask`, because "the expand link
|
|
14
|
+
* appears once a thread exists" is a transition, not a snapshot.
|
|
15
|
+
*/
|
|
16
|
+
const fixtures = vi.hoisted(() => ({
|
|
17
|
+
ask: vi.fn(),
|
|
18
|
+
selectThread: vi.fn(),
|
|
19
|
+
startNew: vi.fn(),
|
|
20
|
+
threads: [] as { id: string; title: string }[],
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("../../../hooks/useHandbookAsk", async () => {
|
|
24
|
+
const { useState } = await import("react");
|
|
25
|
+
return {
|
|
26
|
+
useHandbookAsk: () => {
|
|
27
|
+
const [thread, setThread] = useState<{ id: string; title: string } | undefined>(undefined);
|
|
28
|
+
return {
|
|
29
|
+
threads: fixtures.threads,
|
|
30
|
+
thread,
|
|
31
|
+
messages: [],
|
|
32
|
+
sending: false,
|
|
33
|
+
ask: async (question: string, options?: { handbookPageId?: string }) => {
|
|
34
|
+
fixtures.ask(question, options);
|
|
35
|
+
setThread({ id: "t1", title: "Filtri di azienda" });
|
|
36
|
+
},
|
|
37
|
+
selectThread: fixtures.selectThread,
|
|
38
|
+
startNew: fixtures.startNew,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The sheet builds its links with `usePageUrlGenerator`, which reads
|
|
45
|
+
// `Modules.HandbookPage.pageUrl` through the registry.
|
|
46
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
47
|
+
try {
|
|
48
|
+
ModuleRegistry.get(key as any);
|
|
49
|
+
} catch {
|
|
50
|
+
ModuleRegistry.register(key, module);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
beforeAll(() => {
|
|
55
|
+
registerIfAbsent("HandbookPage", { name: "handbookpages", pageUrl: "/administration/handbook" } as any);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const open = async () => {
|
|
59
|
+
await userEvent.click(screen.getByRole("button", { name: /handbook\.ask\.open/ }));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("HandbookAskSheet", () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
fixtures.ask.mockReset();
|
|
65
|
+
fixtures.selectThread.mockReset();
|
|
66
|
+
fixtures.startNew.mockReset();
|
|
67
|
+
fixtures.threads = [];
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("opens on a new thread", async () => {
|
|
71
|
+
render(<HandbookAskSheet handbookPageId="p2" />);
|
|
72
|
+
|
|
73
|
+
await open();
|
|
74
|
+
|
|
75
|
+
expect(await screen.findByText("handbook.chat.empty_state.title")).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByPlaceholderText("handbook.chat.placeholder")).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("lists the five most recent threads and links to the full route", async () => {
|
|
80
|
+
fixtures.threads = Array.from({ length: 7 }, (_, index) => ({
|
|
81
|
+
id: `t${index}`,
|
|
82
|
+
title: `Conversazione ${index}`,
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
render(<HandbookAskSheet />);
|
|
86
|
+
await open();
|
|
87
|
+
await userEvent.click(await screen.findByRole("button", { name: "handbook.ask.recent" }));
|
|
88
|
+
|
|
89
|
+
// Five threads plus the "all conversations" row.
|
|
90
|
+
expect(await screen.findAllByRole("menuitem")).toHaveLength(6);
|
|
91
|
+
expect(screen.getByText("Conversazione 4")).toBeInTheDocument();
|
|
92
|
+
expect(screen.queryByText("Conversazione 5")).not.toBeInTheDocument();
|
|
93
|
+
expect(screen.getByRole("link", { name: "handbook.ask.all" })).toHaveAttribute(
|
|
94
|
+
"href",
|
|
95
|
+
"/administration/handbook/chat",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("opens a thread picked from the recent menu", async () => {
|
|
100
|
+
fixtures.threads = [{ id: "t3", title: "Filtri di azienda" }];
|
|
101
|
+
|
|
102
|
+
render(<HandbookAskSheet />);
|
|
103
|
+
await open();
|
|
104
|
+
await userEvent.click(await screen.findByRole("button", { name: "handbook.ask.recent" }));
|
|
105
|
+
await userEvent.click(await screen.findByRole("menuitem", { name: "Filtri di azienda" }));
|
|
106
|
+
|
|
107
|
+
expect(fixtures.selectThread).toHaveBeenCalledWith("t3");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("scopes the question to the page only when the chip is on", async () => {
|
|
111
|
+
render(<HandbookAskSheet handbookPageId="p2" />);
|
|
112
|
+
await open();
|
|
113
|
+
|
|
114
|
+
await userEvent.type(await screen.findByRole("textbox"), "Why?{Enter}");
|
|
115
|
+
expect(fixtures.ask).toHaveBeenLastCalledWith("Why?", { handbookPageId: undefined });
|
|
116
|
+
|
|
117
|
+
await userEvent.click(screen.getByRole("switch", { name: "handbook.ask.thisPageOnly" }));
|
|
118
|
+
await userEvent.type(screen.getByRole("textbox"), "Again?{Enter}");
|
|
119
|
+
expect(fixtures.ask).toHaveBeenLastCalledWith("Again?", { handbookPageId: "p2" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("offers no page chip when no page is open", async () => {
|
|
123
|
+
render(<HandbookAskSheet />);
|
|
124
|
+
await open();
|
|
125
|
+
|
|
126
|
+
await screen.findByRole("textbox");
|
|
127
|
+
expect(screen.queryByRole("switch", { name: "handbook.ask.thisPageOnly" })).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("expands to the route on the open thread", async () => {
|
|
131
|
+
render(<HandbookAskSheet />);
|
|
132
|
+
await open();
|
|
133
|
+
|
|
134
|
+
expect(screen.queryByRole("link", { name: "handbook.ask.expand" })).toBeNull();
|
|
135
|
+
|
|
136
|
+
await userEvent.type(await screen.findByRole("textbox"), "Why?{Enter}");
|
|
137
|
+
|
|
138
|
+
expect(await screen.findByRole("link", { name: "handbook.ask.expand" })).toHaveAttribute(
|
|
139
|
+
"href",
|
|
140
|
+
"/administration/handbook/chat/t1",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { configureI18n } from "../../../../../i18n";
|
|
6
|
+
import { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
7
|
+
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
8
|
+
import { HandbookPageService } from "../../../data/HandbookPageService";
|
|
9
|
+
import { HandbookSectionService } from "../../../data/HandbookSectionService";
|
|
10
|
+
import HandbookPageContainer from "../HandbookPageContainer";
|
|
11
|
+
|
|
12
|
+
// The fixtures are the contents surface's own (Task G), plus `content` for the
|
|
13
|
+
// page under test. `vi.mock` factories are hoisted above every module-scope
|
|
14
|
+
// const, so the rows come from `vi.hoisted()` rather than from a plain const.
|
|
15
|
+
const { pages, sections } = vi.hoisted(() => ({
|
|
16
|
+
pages: [
|
|
17
|
+
{
|
|
18
|
+
id: "p1",
|
|
19
|
+
title: "Reading order",
|
|
20
|
+
section: "00-start-here",
|
|
21
|
+
order: "00-start-here/reading-order.md",
|
|
22
|
+
path: "00-start-here/reading-order.md",
|
|
23
|
+
summary: "The three paths.",
|
|
24
|
+
aiStatus: "completed",
|
|
25
|
+
wordCount: 10,
|
|
26
|
+
content: "# Reading order\n",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "p2",
|
|
30
|
+
title: "Testing the API",
|
|
31
|
+
section: "03-backend",
|
|
32
|
+
order: "03-backend/testing.md",
|
|
33
|
+
path: "03-backend/testing.md",
|
|
34
|
+
summary: undefined,
|
|
35
|
+
aiStatus: "failed",
|
|
36
|
+
wordCount: 20,
|
|
37
|
+
content: "# Testing the API\n\n## What is mocked\n\nEverything.\n",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "p3",
|
|
41
|
+
title: "Frontend models",
|
|
42
|
+
section: "02-framework",
|
|
43
|
+
order: "02-framework/rulebook/frontend/01-models.md",
|
|
44
|
+
path: "02-framework/rulebook/frontend/01-models.md",
|
|
45
|
+
summary: "AbstractApiData.",
|
|
46
|
+
aiStatus: "completed",
|
|
47
|
+
wordCount: 30,
|
|
48
|
+
content: "# Frontend models\n",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
sections: [
|
|
52
|
+
{ id: "s1", key: "00-start-here", title: "Start here", summary: "Get the stack running.", order: "00-start-here" },
|
|
53
|
+
{ id: "s2", key: "02-framework", title: "Framework", summary: undefined, order: "02-framework" },
|
|
54
|
+
{ id: "s3", key: "03-backend", title: "Backend", summary: "The API.", order: "03-backend" },
|
|
55
|
+
],
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock("../../../data/HandbookPageService", () => ({
|
|
59
|
+
HandbookPageService: { findOne: vi.fn(), findMany: vi.fn() },
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
vi.mock("../../../data/HandbookSectionService", () => ({
|
|
63
|
+
HandbookSectionService: { findMany: vi.fn() },
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// The header is the provider's job (Task F) and the sheet is the ask surface's
|
|
67
|
+
// (Task I). Neither is under test here, and both need providers this spec has
|
|
68
|
+
// no reason to mount.
|
|
69
|
+
vi.mock("../../../contexts/HandbookContext", () => ({
|
|
70
|
+
HandbookProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
|
71
|
+
useHandbookContext: () => ({}),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
vi.mock("../HandbookAskSheet", () => ({
|
|
75
|
+
HandbookAskSheet: () => <div data-testid="handbook-ask-sheet" />,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// RoundPageContainer is page chrome — header, details panel, url rewriting.
|
|
79
|
+
// Stubbed exactly as HandbookAskContainer.spec.tsx stubs it.
|
|
80
|
+
vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
|
|
81
|
+
RoundPageContainer: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
85
|
+
try {
|
|
86
|
+
ModuleRegistry.get(key as any);
|
|
87
|
+
} catch {
|
|
88
|
+
ModuleRegistry.register(key, module);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
beforeAll(() => {
|
|
93
|
+
registerIfAbsent("HandbookPage", {
|
|
94
|
+
name: "handbookpages",
|
|
95
|
+
pageUrl: "/administration/handbook",
|
|
96
|
+
} as any);
|
|
97
|
+
|
|
98
|
+
// The package `Link` resolves the app's next-intl Link at runtime; without a
|
|
99
|
+
// configured i18n it throws rather than rendering an anchor.
|
|
100
|
+
configureI18n({
|
|
101
|
+
useRouter: () => ({
|
|
102
|
+
push: () => {},
|
|
103
|
+
replace: () => {},
|
|
104
|
+
back: () => {},
|
|
105
|
+
forward: () => {},
|
|
106
|
+
refresh: () => {},
|
|
107
|
+
prefetch: () => {},
|
|
108
|
+
}),
|
|
109
|
+
useTranslations: () => (key: string) => key,
|
|
110
|
+
usePathname: () => "/administration/handbook",
|
|
111
|
+
Link: ({ href, children, ...props }: any) => (
|
|
112
|
+
<a href={href} {...props}>
|
|
113
|
+
{children}
|
|
114
|
+
</a>
|
|
115
|
+
),
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("HandbookPageContainer", () => {
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
vi.mocked(HandbookPageService.findOne)
|
|
122
|
+
.mockReset()
|
|
123
|
+
.mockImplementation(async ({ id }: { id: string }) => pages.find((page) => page.id === id) as any);
|
|
124
|
+
vi.mocked(HandbookPageService.findMany)
|
|
125
|
+
.mockReset()
|
|
126
|
+
.mockResolvedValue(pages as any);
|
|
127
|
+
vi.mocked(HandbookSectionService.findMany)
|
|
128
|
+
.mockReset()
|
|
129
|
+
.mockResolvedValue(sections as any);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("renders the page title in the body once, and no path line", async () => {
|
|
133
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
134
|
+
|
|
135
|
+
// The markdown's own H1 stays; the body's SectionHeader and path line are gone.
|
|
136
|
+
expect(await screen.findByRole("heading", { level: 1, name: "Testing the API" })).toBeInTheDocument();
|
|
137
|
+
expect(screen.queryByText("03-backend/testing.md")).not.toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("lists every page in the navigator with the current one marked", async () => {
|
|
141
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
142
|
+
|
|
143
|
+
const nav = await screen.findByRole("navigation", { name: /contents/i });
|
|
144
|
+
expect(within(nav).getAllByRole("link")).toHaveLength(3);
|
|
145
|
+
expect(within(nav).getByRole("link", { current: "page" })).toHaveTextContent("Testing the API");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("orders the navigator by section then order", async () => {
|
|
149
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
150
|
+
|
|
151
|
+
const nav = await screen.findByRole("navigation", { name: /contents/i });
|
|
152
|
+
expect(
|
|
153
|
+
within(nav)
|
|
154
|
+
.getAllByRole("link")
|
|
155
|
+
.map((link) => link.textContent),
|
|
156
|
+
).toEqual(["Reading order", "Frontend models", "Testing the API"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("links the ordered neighbours", async () => {
|
|
160
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
161
|
+
|
|
162
|
+
// "Frontend models" is the page before this one, so it renders twice: once
|
|
163
|
+
// in the navigator, once as the `previous` neighbour at the foot.
|
|
164
|
+
const links = await screen.findAllByRole("link", { name: /Frontend models/ });
|
|
165
|
+
expect(links).toHaveLength(2);
|
|
166
|
+
const previous = links.find((link) => link.textContent?.includes("handbook.reader.previous"));
|
|
167
|
+
expect(previous).toBeDefined();
|
|
168
|
+
expect(previous).toHaveAttribute("href", "/administration/handbook/p3");
|
|
169
|
+
// p2 is the last page in the order, so there is no `next` at all.
|
|
170
|
+
expect(screen.queryByText("handbook.reader.next")).not.toBeInTheDocument();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("builds a heading index from the rendered markdown", async () => {
|
|
174
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
175
|
+
|
|
176
|
+
// The visible label and the landmark name are the same key, which
|
|
177
|
+
// next-intl's spec mock renders verbatim.
|
|
178
|
+
const toc = await screen.findByRole("navigation", { name: /onThisPage/i });
|
|
179
|
+
expect(within(toc).getByRole("link", { name: "What is mocked" })).toHaveAttribute("href", "#what-is-mocked");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Indexed in English, shown translated. The reader has three surfaces that
|
|
184
|
+
* print handbook words — the body, the navigator and the prev/next pair — and
|
|
185
|
+
* all three resolve the same way.
|
|
186
|
+
*
|
|
187
|
+
* Only p2 (the page under test) and p3 (its `previous`) carry a translation,
|
|
188
|
+
* so one render proves both the translated and the fallback branch.
|
|
189
|
+
*/
|
|
190
|
+
describe("display translation", () => {
|
|
191
|
+
const translated = pages.map((page) => {
|
|
192
|
+
if (page.id === "p2")
|
|
193
|
+
return {
|
|
194
|
+
...page,
|
|
195
|
+
displayTitle: "Testare le API",
|
|
196
|
+
displayContent: "# Testare le API\n\n## Cosa è mockato\n\nTutto.\n",
|
|
197
|
+
};
|
|
198
|
+
if (page.id === "p3") return { ...page, displayTitle: "Modelli frontend" };
|
|
199
|
+
return page;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
vi.mocked(HandbookPageService.findOne).mockImplementation(
|
|
204
|
+
async ({ id }: { id: string }) => translated.find((page) => page.id === id) as any,
|
|
205
|
+
);
|
|
206
|
+
vi.mocked(HandbookPageService.findMany).mockResolvedValue(translated as any);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("renders the translated body", async () => {
|
|
210
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
211
|
+
|
|
212
|
+
expect(await screen.findByRole("heading", { level: 1, name: "Testare le API" })).toBeInTheDocument();
|
|
213
|
+
expect(screen.queryByRole("heading", { level: 1, name: "Testing the API" })).not.toBeInTheDocument();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("renders the English body for a page with no translation", async () => {
|
|
217
|
+
render(<HandbookPageContainer handbookPageId="p1" />);
|
|
218
|
+
|
|
219
|
+
expect(await screen.findByRole("heading", { level: 1, name: "Reading order" })).toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("labels the navigator entries with the translation, falling back per page", async () => {
|
|
223
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
224
|
+
|
|
225
|
+
const nav = await screen.findByRole("navigation", { name: /contents/i });
|
|
226
|
+
// Still ordered by `section` then `order`, which stay English: only the
|
|
227
|
+
// labels changed.
|
|
228
|
+
expect(
|
|
229
|
+
within(nav)
|
|
230
|
+
.getAllByRole("link")
|
|
231
|
+
.map((link) => link.textContent),
|
|
232
|
+
).toEqual(["Reading order", "Modelli frontend", "Testare le API"]);
|
|
233
|
+
expect(within(nav).getByRole("link", { current: "page" })).toHaveTextContent("Testare le API");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("labels the previous neighbour with its translation", async () => {
|
|
237
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
238
|
+
|
|
239
|
+
const links = await screen.findAllByRole("link", { name: /Modelli frontend/ });
|
|
240
|
+
const previous = links.find((link) => link.textContent?.includes("handbook.reader.previous"));
|
|
241
|
+
expect(previous).toBeDefined();
|
|
242
|
+
// The link still points at the same page: the translation changes the
|
|
243
|
+
// words, never the destination.
|
|
244
|
+
expect(previous).toHaveAttribute("href", "/administration/handbook/p3");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("labels the navigator sections with their translation, falling back per section", async () => {
|
|
248
|
+
vi.mocked(HandbookSectionService.findMany).mockResolvedValue(
|
|
249
|
+
sections.map((section) =>
|
|
250
|
+
section.key === "03-backend" ? { ...section, displayTitle: "Backend (API)" } : section,
|
|
251
|
+
) as any,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
255
|
+
|
|
256
|
+
const nav = await screen.findByRole("navigation", { name: /contents/i });
|
|
257
|
+
expect(
|
|
258
|
+
within(nav)
|
|
259
|
+
.getAllByRole("heading")
|
|
260
|
+
.map((heading) => heading.textContent),
|
|
261
|
+
).toEqual(["Start here", "Framework", "Backend (API)"]);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("keeps the not-found branch when the id resolves to nothing", async () => {
|
|
266
|
+
vi.mocked(HandbookPageService.findOne).mockResolvedValue(undefined as any);
|
|
267
|
+
|
|
268
|
+
render(<HandbookPageContainer handbookPageId="ghost" />);
|
|
269
|
+
|
|
270
|
+
expect(await screen.findByText("handbook.notFound")).toBeInTheDocument();
|
|
271
|
+
expect(screen.queryByRole("navigation", { name: /contents/i })).not.toBeInTheDocument();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("still renders the navigator when the section load fails", async () => {
|
|
275
|
+
vi.mocked(HandbookSectionService.findMany).mockRejectedValue(new Error("500"));
|
|
276
|
+
|
|
277
|
+
render(<HandbookPageContainer handbookPageId="p2" />);
|
|
278
|
+
|
|
279
|
+
const nav = await screen.findByRole("navigation", { name: /contents/i });
|
|
280
|
+
expect(within(nav).getAllByRole("link")).toHaveLength(3);
|
|
281
|
+
// Raw section keys as headings, in key order.
|
|
282
|
+
expect(
|
|
283
|
+
within(nav)
|
|
284
|
+
.getAllByRole("heading")
|
|
285
|
+
.map((heading) => heading.textContent),
|
|
286
|
+
).toEqual(["00-start-here", "02-framework", "03-backend"]);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactMarkdownContainer } from "../../../../components";
|
|
4
|
+
import { handbookPageContent } from "../../data/handbookDisplay";
|
|
5
|
+
import { HandbookPageInterface } from "../../data/HandbookPageInterface";
|
|
6
|
+
|
|
7
|
+
type HandbookPageDetailsProps = {
|
|
8
|
+
handbookPage: HandbookPageInterface;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `content` is markdown read from a file and rendered read-only. It is NOT a
|
|
13
|
+
* BlockNote field: nothing edits it in the app, so no editor and no
|
|
14
|
+
* blocks-to-text conversion is involved.
|
|
15
|
+
*
|
|
16
|
+
* The title and the file path are NOT rendered here. The reader's header owns
|
|
17
|
+
* both — it names the section and the page — and repeating them above the
|
|
18
|
+
* markdown's own H1 printed the title twice on every page.
|
|
19
|
+
*
|
|
20
|
+
* The body comes from `handbookPageContent`, so a page whose translation the
|
|
21
|
+
* ingest matched renders in the reader's language and one without it falls back
|
|
22
|
+
* to the English file. This is the detail read, which is the only read that
|
|
23
|
+
* carries `displayContent` at all.
|
|
24
|
+
*/
|
|
25
|
+
export function HandbookPageDetails({ handbookPage }: HandbookPageDetailsProps) {
|
|
26
|
+
return <ReactMarkdownContainer content={handbookPageContent(handbookPage)} />;
|
|
27
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
|
|
6
|
+
import { MicroLabel } from "../../../../components";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
8
|
+
import { usePageUrlGenerator } from "../../../../hooks";
|
|
9
|
+
import { Link } from "../../../../shadcnui";
|
|
10
|
+
import { handbookPageTitle } from "../../data/handbookDisplay";
|
|
11
|
+
import { HandbookPageInterface } from "../../data/HandbookPageInterface";
|
|
12
|
+
|
|
13
|
+
type HandbookPageNeighboursProps = {
|
|
14
|
+
pages: HandbookPageInterface[];
|
|
15
|
+
currentId: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Previous and next, at the foot of the page.
|
|
20
|
+
*
|
|
21
|
+
* Walks the same ordering the navigator renders — section key, then `order`,
|
|
22
|
+
* which is the repo-relative path — so reading straight through the manual with
|
|
23
|
+
* these two links reproduces the contents page from top to bottom. Below `md`
|
|
24
|
+
* the navigator is hidden and this pair is the only way forward that is not the
|
|
25
|
+
* breadcrumb.
|
|
26
|
+
*
|
|
27
|
+
* Nothing renders at either end of the list: no disabled control, no empty slot.
|
|
28
|
+
*
|
|
29
|
+
* The ordering is read off `section` and `order`, which stay English; only the
|
|
30
|
+
* two labels are translated.
|
|
31
|
+
*/
|
|
32
|
+
export function HandbookPageNeighbours({ pages, currentId }: HandbookPageNeighboursProps) {
|
|
33
|
+
const t = useTranslations();
|
|
34
|
+
const generateUrl = usePageUrlGenerator();
|
|
35
|
+
|
|
36
|
+
const { previous, next } = useMemo(() => {
|
|
37
|
+
const ordered = [...pages].sort((a, b) =>
|
|
38
|
+
`${a.section ?? ""}/${a.order ?? ""}`.localeCompare(`${b.section ?? ""}/${b.order ?? ""}`),
|
|
39
|
+
);
|
|
40
|
+
const index = ordered.findIndex((page) => page.id === currentId);
|
|
41
|
+
if (index === -1) return { previous: undefined, next: undefined };
|
|
42
|
+
|
|
43
|
+
return { previous: ordered[index - 1], next: ordered[index + 1] };
|
|
44
|
+
}, [pages, currentId]);
|
|
45
|
+
|
|
46
|
+
if (!previous && !next) return null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="mt-8 grid gap-4 border-t pt-4 md:grid-cols-2">
|
|
50
|
+
{previous ? (
|
|
51
|
+
<Link
|
|
52
|
+
href={generateUrl({ page: Modules.HandbookPage, id: previous.id })}
|
|
53
|
+
className="flex flex-col gap-y-1 text-start"
|
|
54
|
+
>
|
|
55
|
+
<MicroLabel>{t("handbook.reader.previous")}</MicroLabel>
|
|
56
|
+
<span className="text-primary text-sm font-medium">{handbookPageTitle(previous)}</span>
|
|
57
|
+
</Link>
|
|
58
|
+
) : (
|
|
59
|
+
<span />
|
|
60
|
+
)}
|
|
61
|
+
{next ? (
|
|
62
|
+
<Link
|
|
63
|
+
href={generateUrl({ page: Modules.HandbookPage, id: next.id })}
|
|
64
|
+
className="flex flex-col gap-y-1 text-end md:items-end"
|
|
65
|
+
>
|
|
66
|
+
<MicroLabel>{t("handbook.reader.next")}</MicroLabel>
|
|
67
|
+
<span className="text-primary text-sm font-medium">{handbookPageTitle(next)}</span>
|
|
68
|
+
</Link>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|