@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,247 @@
|
|
|
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 { ApiRequestDataTypeInterface } from "../../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
6
|
+
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
7
|
+
import { configureI18n } from "../../../../../i18n";
|
|
8
|
+
import { HandbookPageService } from "../../../data/HandbookPageService";
|
|
9
|
+
import { HandbookSectionService } from "../../../data/HandbookSectionService";
|
|
10
|
+
import HandbookContents from "../HandbookContents";
|
|
11
|
+
|
|
12
|
+
// `vi.mock` factories are hoisted above every module-scope const, so the rows
|
|
13
|
+
// come from `vi.hoisted()` rather than from a plain const.
|
|
14
|
+
const { pages, sections } = vi.hoisted(() => ({
|
|
15
|
+
pages: [
|
|
16
|
+
{
|
|
17
|
+
id: "p1",
|
|
18
|
+
title: "Reading order",
|
|
19
|
+
section: "00-start-here",
|
|
20
|
+
order: "00-start-here/reading-order.md",
|
|
21
|
+
path: "00-start-here/reading-order.md",
|
|
22
|
+
summary: "The three paths.",
|
|
23
|
+
aiStatus: "completed",
|
|
24
|
+
wordCount: 10,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "p2",
|
|
28
|
+
title: "Testing the API",
|
|
29
|
+
section: "03-backend",
|
|
30
|
+
order: "03-backend/testing.md",
|
|
31
|
+
path: "03-backend/testing.md",
|
|
32
|
+
summary: undefined,
|
|
33
|
+
aiStatus: "failed",
|
|
34
|
+
wordCount: 20,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "p3",
|
|
38
|
+
title: "Frontend models",
|
|
39
|
+
section: "02-framework",
|
|
40
|
+
order: "02-framework/rulebook/frontend/01-models.md",
|
|
41
|
+
path: "02-framework/rulebook/frontend/01-models.md",
|
|
42
|
+
summary: "AbstractApiData.",
|
|
43
|
+
aiStatus: "completed",
|
|
44
|
+
wordCount: 30,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
sections: [
|
|
48
|
+
{ id: "s1", key: "00-start-here", title: "Start here", summary: "Get the stack running.", order: "00-start-here" },
|
|
49
|
+
{ id: "s2", key: "02-framework", title: "Framework", summary: undefined, order: "02-framework" },
|
|
50
|
+
{ id: "s3", key: "03-backend", title: "Backend", summary: "The API.", order: "03-backend" },
|
|
51
|
+
],
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock("../../../data/HandbookPageService", () => ({
|
|
55
|
+
HandbookPageService: { findMany: vi.fn(), sync: vi.fn() },
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
vi.mock("../../../data/HandbookSectionService", () => ({
|
|
59
|
+
HandbookSectionService: { findMany: vi.fn() },
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// The sheet is the ask surface's (Task I). It is not under test here, and it
|
|
63
|
+
// needs providers this spec has no reason to mount.
|
|
64
|
+
vi.mock("../../containers/HandbookAskSheet", () => ({
|
|
65
|
+
HandbookAskSheet: () => <div data-testid="handbook-ask-sheet" />,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// The suite-wide next-intl stub returns the key and drops the values, which
|
|
69
|
+
// would hide the two numbers the status line exists to show. This one keeps
|
|
70
|
+
// them, so `{count} pagine · sincronizzato {date}` is still asserted on the
|
|
71
|
+
// numbers rather than on a translated string this package does not own.
|
|
72
|
+
vi.mock("next-intl", () => {
|
|
73
|
+
const makeT = () => {
|
|
74
|
+
const t = (key: string, values?: Record<string, unknown>) =>
|
|
75
|
+
values ? `${key} ${Object.values(values).join(" ")}`.trimEnd() : key;
|
|
76
|
+
(t as any).has = () => true;
|
|
77
|
+
return t;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
useTranslations: () => makeT(),
|
|
81
|
+
useLocale: () => "en",
|
|
82
|
+
useNow: () => new Date(),
|
|
83
|
+
useTimeZone: () => "UTC",
|
|
84
|
+
useFormatter: () => ({
|
|
85
|
+
dateTime: () => "sync-date",
|
|
86
|
+
number: (value: unknown) => String(value),
|
|
87
|
+
relativeTime: () => "",
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// The header is the provider's job (Task F). Stubbed as
|
|
93
|
+
// HandbookPageContainer.spec.tsx stubs it, except that this stub renders
|
|
94
|
+
// `functions`: the ask launcher, Sincronizza and the status line are handed to
|
|
95
|
+
// the header through that prop, so dropping it would delete half of this
|
|
96
|
+
// component's output from the test.
|
|
97
|
+
vi.mock("../../../contexts/HandbookContext", () => ({
|
|
98
|
+
HandbookProvider: ({ children, functions }: { children?: ReactNode; functions?: ReactNode }) => (
|
|
99
|
+
<>
|
|
100
|
+
<div data-testid="page-functions">{functions}</div>
|
|
101
|
+
{children}
|
|
102
|
+
</>
|
|
103
|
+
),
|
|
104
|
+
useHandbookContext: () => ({}),
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
// RoundPageContainer is page chrome — header, details panel, url rewriting.
|
|
108
|
+
// Stubbed exactly as HandbookAskContainer.spec.tsx stubs it.
|
|
109
|
+
vi.mock("../../../../../components/containers/RoundPageContainer", () => ({
|
|
110
|
+
RoundPageContainer: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
|
|
114
|
+
try {
|
|
115
|
+
ModuleRegistry.get(key as any);
|
|
116
|
+
} catch {
|
|
117
|
+
ModuleRegistry.register(key, module);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
beforeAll(() => {
|
|
122
|
+
registerIfAbsent("HandbookPage", {
|
|
123
|
+
name: "handbookpages",
|
|
124
|
+
pageUrl: "/administration/handbook",
|
|
125
|
+
} as any);
|
|
126
|
+
|
|
127
|
+
// The package `Link` resolves the app's next-intl Link at runtime; without a
|
|
128
|
+
// configured i18n it throws rather than rendering an anchor.
|
|
129
|
+
configureI18n({
|
|
130
|
+
useRouter: () => ({
|
|
131
|
+
push: () => {},
|
|
132
|
+
replace: () => {},
|
|
133
|
+
back: () => {},
|
|
134
|
+
forward: () => {},
|
|
135
|
+
refresh: () => {},
|
|
136
|
+
prefetch: () => {},
|
|
137
|
+
}),
|
|
138
|
+
useTranslations: () => (key: string) => key,
|
|
139
|
+
usePathname: () => "/administration/handbook",
|
|
140
|
+
Link: ({ href, children, ...props }: any) => (
|
|
141
|
+
<a href={href} {...props}>
|
|
142
|
+
{children}
|
|
143
|
+
</a>
|
|
144
|
+
),
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("HandbookContents", () => {
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
vi.mocked(HandbookPageService.findMany)
|
|
151
|
+
.mockReset()
|
|
152
|
+
.mockResolvedValue(pages as any);
|
|
153
|
+
vi.mocked(HandbookSectionService.findMany)
|
|
154
|
+
.mockReset()
|
|
155
|
+
.mockResolvedValue(sections as any);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("renders sections in order with their blurbs", async () => {
|
|
159
|
+
render(<HandbookContents />);
|
|
160
|
+
const headings = await screen.findAllByRole("heading", { level: 2 });
|
|
161
|
+
expect(headings.map((h) => h.textContent)).toEqual(["Start here", "Framework", "Backend"]);
|
|
162
|
+
expect(screen.getByText("Get the stack running.")).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("renders a page summary when it has one and nothing when it does not", async () => {
|
|
166
|
+
render(<HandbookContents />);
|
|
167
|
+
expect(await screen.findByText("The three paths.")).toBeInTheDocument();
|
|
168
|
+
const row = screen.getByRole("link", { name: /Testing the API/ });
|
|
169
|
+
expect(within(row).queryByText("—")).not.toBeInTheDocument();
|
|
170
|
+
expect(row.textContent).toBe("Testing the API");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("groups a nested page under its intermediate directory", async () => {
|
|
174
|
+
render(<HandbookContents />);
|
|
175
|
+
expect(await screen.findByText("rulebook / frontend")).toBeInTheDocument();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("counts failures in the status line", async () => {
|
|
179
|
+
render(<HandbookContents />);
|
|
180
|
+
expect(await screen.findByText(/1/)).toBeInTheDocument();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("renders no table", async () => {
|
|
184
|
+
render(<HandbookContents />);
|
|
185
|
+
await screen.findAllByRole("heading", { level: 2 });
|
|
186
|
+
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The manual is INDEXED in English and SHOWN translated: every string on this
|
|
191
|
+
* surface prefers the translation that travels beside the English original,
|
|
192
|
+
* and falls back to the original when the ingest matched none.
|
|
193
|
+
*
|
|
194
|
+
* The fixtures are the suite's own, with a translation added to exactly one
|
|
195
|
+
* page and one section — so the same render proves both halves of the rule.
|
|
196
|
+
*/
|
|
197
|
+
describe("display translation", () => {
|
|
198
|
+
const translatedPages = pages.map((page) =>
|
|
199
|
+
page.id === "p1" ? { ...page, displayTitle: "Ordine di lettura", displaySummary: "I tre percorsi." } : page,
|
|
200
|
+
);
|
|
201
|
+
const translatedSections = sections.map((section) =>
|
|
202
|
+
section.key === "00-start-here"
|
|
203
|
+
? { ...section, displayTitle: "Da qui si parte", displaySummary: "Avvia lo stack." }
|
|
204
|
+
: section,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
beforeEach(() => {
|
|
208
|
+
vi.mocked(HandbookPageService.findMany).mockResolvedValue(translatedPages as any);
|
|
209
|
+
vi.mocked(HandbookSectionService.findMany).mockResolvedValue(translatedSections as any);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("renders a translated section heading and blurb, and the English ones where there is no translation", async () => {
|
|
213
|
+
render(<HandbookContents />);
|
|
214
|
+
const headings = await screen.findAllByRole("heading", { level: 2 });
|
|
215
|
+
expect(headings.map((h) => h.textContent)).toEqual(["Da qui si parte", "Framework", "Backend"]);
|
|
216
|
+
expect(screen.getByText("Avvia lo stack.")).toBeInTheDocument();
|
|
217
|
+
expect(screen.queryByText("Get the stack running.")).not.toBeInTheDocument();
|
|
218
|
+
// The untranslated section still reads in English.
|
|
219
|
+
expect(screen.getByText("The API.")).toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("renders a translated page row and falls back to English for an untranslated one", async () => {
|
|
223
|
+
render(<HandbookContents />);
|
|
224
|
+
expect(await screen.findByRole("link", { name: /Ordine di lettura/ })).toBeInTheDocument();
|
|
225
|
+
expect(screen.queryByRole("link", { name: /Reading order/ })).not.toBeInTheDocument();
|
|
226
|
+
expect(screen.getByText("I tre percorsi.")).toBeInTheDocument();
|
|
227
|
+
expect(screen.getByRole("link", { name: /Frontend models/ })).toBeInTheDocument();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("orders sections and pages by the untranslated keys", async () => {
|
|
231
|
+
render(<HandbookContents />);
|
|
232
|
+
const headings = await screen.findAllByRole("heading", { level: 2 });
|
|
233
|
+
// `order` and `section` stay English, so the translated section keeps its
|
|
234
|
+
// place rather than sorting under its Italian initial.
|
|
235
|
+
expect(headings.map((h) => h.textContent)).toEqual(["Da qui si parte", "Framework", "Backend"]);
|
|
236
|
+
expect(await screen.findByText("rulebook / frontend")).toBeInTheDocument();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("falls back to raw section keys when the section load fails", async () => {
|
|
241
|
+
vi.mocked(HandbookSectionService.findMany).mockRejectedValueOnce(new Error("500"));
|
|
242
|
+
render(<HandbookContents />);
|
|
243
|
+
const headings = await screen.findAllByRole("heading", { level: 2 });
|
|
244
|
+
expect(headings.map((h) => h.textContent)).toEqual(["00-start-here", "02-framework", "03-backend"]);
|
|
245
|
+
expect(screen.getByRole("link", { name: /Reading order/ })).toBeInTheDocument();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useFormatter, useTranslations } from "next-intl";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
|
|
6
|
+
import { HandbookPageInterface } from "../../data/HandbookPageInterface";
|
|
7
|
+
|
|
8
|
+
type HandbookIndexStatusProps = {
|
|
9
|
+
pages: HandbookPageInterface[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `aiStatus` values written by the ingest. Only `failed` means the page is not
|
|
14
|
+
* answerable; `pending` and `in_progress` are a sync still running and say
|
|
15
|
+
* nothing about the index being wrong.
|
|
16
|
+
*/
|
|
17
|
+
const FAILURE_STATUSES = new Set(["failed", "error"]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The one line of index bookkeeping the reading surface keeps.
|
|
21
|
+
*
|
|
22
|
+
* Word counts and per-page AI badges left the contents page; how many pages
|
|
23
|
+
* there are and when they were last synced did not, because that is the one
|
|
24
|
+
* question the administrator who pressed Sincronizza actually has. Both values
|
|
25
|
+
* are derived from the loaded pages — nothing new is stored for either, and
|
|
26
|
+
* `updatedAt` is framework-managed.
|
|
27
|
+
*/
|
|
28
|
+
export function HandbookIndexStatus({ pages }: HandbookIndexStatusProps) {
|
|
29
|
+
const t = useTranslations();
|
|
30
|
+
const format = useFormatter();
|
|
31
|
+
|
|
32
|
+
const lastSynced = useMemo(() => {
|
|
33
|
+
let latest: Date | undefined = undefined;
|
|
34
|
+
|
|
35
|
+
for (const page of pages) {
|
|
36
|
+
const updatedAt = page.updatedAt;
|
|
37
|
+
if (!(updatedAt instanceof Date) || Number.isNaN(updatedAt.getTime())) continue;
|
|
38
|
+
if (!latest || updatedAt.getTime() > latest.getTime()) latest = updatedAt;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return latest;
|
|
42
|
+
}, [pages]);
|
|
43
|
+
|
|
44
|
+
const failures = useMemo(
|
|
45
|
+
() => pages.filter((page) => FAILURE_STATUSES.has((page.aiStatus ?? "").toLowerCase())).length,
|
|
46
|
+
[pages],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<span className="text-muted-foreground text-xs tabular-nums">
|
|
51
|
+
{t("handbook.contents.status", {
|
|
52
|
+
count: pages.length,
|
|
53
|
+
date: lastSynced ? format.dateTime(lastSynced, { dateStyle: "medium" }) : "",
|
|
54
|
+
})}
|
|
55
|
+
{failures > 0 ? (
|
|
56
|
+
<span className="text-destructive ms-2 tabular-nums">
|
|
57
|
+
{t("handbook.contents.failures", { count: failures })}
|
|
58
|
+
</span>
|
|
59
|
+
) : null}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BookOpenIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
6
|
+
|
|
7
|
+
import { MicroLabel } from "../../../../components";
|
|
8
|
+
import { Modules } from "../../../../core";
|
|
9
|
+
import { usePageUrlGenerator } from "../../../../hooks";
|
|
10
|
+
import { Link } from "../../../../shadcnui";
|
|
11
|
+
import { cn } from "../../../../utils/cn";
|
|
12
|
+
import { handbookPageTitle, handbookSectionTitle } from "../../data/handbookDisplay";
|
|
13
|
+
import { HandbookPageInterface } from "../../data/HandbookPageInterface";
|
|
14
|
+
import { HandbookSectionInterface } from "../../data/HandbookSectionInterface";
|
|
15
|
+
|
|
16
|
+
type HandbookPageNavigatorProps = {
|
|
17
|
+
pages: HandbookPageInterface[];
|
|
18
|
+
sections: HandbookSectionInterface[];
|
|
19
|
+
currentId: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The manual's section tree, beside the page being read.
|
|
24
|
+
*
|
|
25
|
+
* A list of links, NOT `RoundPageContainer`'s `layout="rail"`: the rail renders
|
|
26
|
+
* a `TabsContent` per tab, so 120 pages would mean 120 content nodes and a
|
|
27
|
+
* client-side tab switch. A manual wants 120 real, shareable URLs and a working
|
|
28
|
+
* back button, which is what an anchor gives and a tab does not.
|
|
29
|
+
*
|
|
30
|
+
* Degrades the same way the contents surface does: a page whose `section`
|
|
31
|
+
* matches no loaded section — or has none at all — lands in a trailing group,
|
|
32
|
+
* and if no sections loaded at all the distinct page keys are used as written.
|
|
33
|
+
*/
|
|
34
|
+
export function HandbookPageNavigator({ pages, sections, currentId }: HandbookPageNavigatorProps) {
|
|
35
|
+
const t = useTranslations();
|
|
36
|
+
const generateUrl = usePageUrlGenerator();
|
|
37
|
+
|
|
38
|
+
const groups = useMemo(() => {
|
|
39
|
+
const byKey = new Map<string, HandbookPageInterface[]>();
|
|
40
|
+
for (const page of pages) {
|
|
41
|
+
const key = page.section ?? "";
|
|
42
|
+
const bucket = byKey.get(key);
|
|
43
|
+
if (bucket) bucket.push(page);
|
|
44
|
+
else byKey.set(key, [page]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const bucket of byKey.values()) bucket.sort((a, b) => (a.order ?? "").localeCompare(b.order ?? ""));
|
|
48
|
+
|
|
49
|
+
const ordered: { key: string; title: string; pages: HandbookPageInterface[] }[] = [];
|
|
50
|
+
const consumed = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const section of sections) {
|
|
53
|
+
const sectionPages = byKey.get(section.key);
|
|
54
|
+
if (!sectionPages) continue;
|
|
55
|
+
consumed.add(section.key);
|
|
56
|
+
ordered.push({ key: section.key, title: handbookSectionTitle(section), pages: sectionPages });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Whatever the loaded sections did not describe: an unknown key, an empty
|
|
60
|
+
// one, or every key when the section load failed outright.
|
|
61
|
+
const leftovers = [...byKey.keys()].filter((key) => !consumed.has(key)).sort((a, b) => a.localeCompare(b));
|
|
62
|
+
|
|
63
|
+
for (const key of leftovers)
|
|
64
|
+
ordered.push({
|
|
65
|
+
key: key.length > 0 ? key : "__untitled",
|
|
66
|
+
title: key.length > 0 ? key : t("handbook.sections.untitled"),
|
|
67
|
+
pages: byKey.get(key) ?? [],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return ordered;
|
|
71
|
+
}, [pages, sections, t]);
|
|
72
|
+
|
|
73
|
+
const currentRef = useRef<HTMLAnchorElement | null>(null);
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
Bring the current page into view inside the rail — 120 entries means it is
|
|
77
|
+
usually below the fold, and a position marker nobody can see marks nothing.
|
|
78
|
+
|
|
79
|
+
Deliberately NOT `scrollIntoView`: that walks up every scrollable ancestor,
|
|
80
|
+
so it would scroll the page itself and land the reader halfway down the
|
|
81
|
+
article they just opened. Setting `scrollTop` on the rail moves the rail and
|
|
82
|
+
only the rail.
|
|
83
|
+
*/
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const link = currentRef.current;
|
|
86
|
+
if (!link) return;
|
|
87
|
+
|
|
88
|
+
const rail = link.closest<HTMLElement>(".overflow-y-auto");
|
|
89
|
+
if (!rail) return;
|
|
90
|
+
|
|
91
|
+
rail.scrollTop = link.offsetTop - rail.clientHeight / 2 + link.clientHeight / 2;
|
|
92
|
+
}, [currentId, groups]);
|
|
93
|
+
|
|
94
|
+
if (groups.length === 0) return null;
|
|
95
|
+
|
|
96
|
+
// No `max-h` / `overflow` on the nav: the reader's rail wrapper is the sticky
|
|
97
|
+
// scroll container, and a second one nested inside it would trap the scroll
|
|
98
|
+
// in the wrong element.
|
|
99
|
+
/*
|
|
100
|
+
Deliberately the same treatment as `HandbookPageToc`: an icon and a
|
|
101
|
+
micro-label at the top, entries at `text-xs` on a `border-s-2` rail, and the
|
|
102
|
+
current one marked by turning that rail `border-primary` rather than by
|
|
103
|
+
growing.
|
|
104
|
+
|
|
105
|
+
The previous version gave the current page `text-primary font-medium` with
|
|
106
|
+
no size class, so it inherited the base size and rendered far larger than
|
|
107
|
+
its neighbours — the entry moved instead of being marked, and there was no
|
|
108
|
+
rail to show position at all. The two rails of this page are the same kind
|
|
109
|
+
of object and now read as one.
|
|
110
|
+
*/
|
|
111
|
+
return (
|
|
112
|
+
<div className="flex flex-col gap-y-2">
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<BookOpenIcon className="text-muted-foreground h-4 w-4" />
|
|
115
|
+
<MicroLabel>{t("handbook.reader.contents")}</MicroLabel>
|
|
116
|
+
</div>
|
|
117
|
+
<nav aria-label={t("handbook.reader.contents")} className="flex flex-col gap-y-3">
|
|
118
|
+
{groups.map((group) => (
|
|
119
|
+
<div key={group.key} className="flex flex-col gap-y-0.5">
|
|
120
|
+
<MicroLabel className="ps-3">{group.title}</MicroLabel>
|
|
121
|
+
{group.pages.map((page) => {
|
|
122
|
+
const isCurrent = page.id === currentId;
|
|
123
|
+
return (
|
|
124
|
+
<Link
|
|
125
|
+
key={page.id}
|
|
126
|
+
ref={isCurrent ? currentRef : undefined}
|
|
127
|
+
href={generateUrl({ page: Modules.HandbookPage, id: page.id })}
|
|
128
|
+
aria-current={isCurrent ? "page" : undefined}
|
|
129
|
+
className={cn(
|
|
130
|
+
"hover:text-primary block border-s-2 py-1 ps-3 text-start text-xs leading-tight transition-colors",
|
|
131
|
+
isCurrent ? "border-primary text-primary font-medium" : "text-muted-foreground border-transparent",
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
{handbookPageTitle(page)}
|
|
135
|
+
</Link>
|
|
136
|
+
);
|
|
137
|
+
})}
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
</nav>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { createContext, ReactNode, useContext } from "react";
|
|
5
|
+
import { SharedProvider } from "../../../contexts";
|
|
6
|
+
import { Modules } from "../../../core";
|
|
7
|
+
import { usePageUrlGenerator } from "../../../hooks";
|
|
8
|
+
import { BreadcrumbItemData } from "../../../interfaces";
|
|
9
|
+
import type { HandbookPageInterface, HandbookSectionInterface } from "../data";
|
|
10
|
+
import { handbookPageTitle, handbookSectionTitle } from "../data/handbookDisplay";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The handbook's page context.
|
|
14
|
+
*
|
|
15
|
+
* The reader's header was an empty bar with a book icon in it: the feature
|
|
16
|
+
* shipped no provider, and `RoundPageContainerTitle` reads its heading from
|
|
17
|
+
* `useSharedContext().title`
|
|
18
|
+
* (components/containers/RoundPageContainerTitle.tsx). With nothing supplying
|
|
19
|
+
* that context there was no title, no breadcrumb and no way back — so the page
|
|
20
|
+
* title was printed a second time inside the body instead.
|
|
21
|
+
*
|
|
22
|
+
* This is the provider every other feature already has, copied from
|
|
23
|
+
* `RoleProvider` (features/role/contexts/RoleContext.tsx): a `breadcrumb()`, a
|
|
24
|
+
* `title()`, and `SharedProvider` wrapping the feature's own context. Every
|
|
25
|
+
* handbook surface — contents, reader, ask — mounts it, so all three get the
|
|
26
|
+
* same header for free.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
interface HandbookContextType {
|
|
30
|
+
page: HandbookPageInterface | undefined;
|
|
31
|
+
section: HandbookSectionInterface | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HandbookContext = createContext<HandbookContextType | undefined>(undefined);
|
|
35
|
+
|
|
36
|
+
type HandbookProviderProps = {
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
/** Undefined on the contents and ask surfaces. */
|
|
39
|
+
page?: HandbookPageInterface;
|
|
40
|
+
/** The page's section, when one is loaded and resolvable. */
|
|
41
|
+
section?: HandbookSectionInterface;
|
|
42
|
+
/** Rendered into `title.functions`. */
|
|
43
|
+
functions?: ReactNode;
|
|
44
|
+
/** Overrides the default `title.type`. Used by the ask surface. */
|
|
45
|
+
titleType?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const HandbookProvider = ({ children, page, section, functions, titleType }: HandbookProviderProps) => {
|
|
49
|
+
const generateUrl = usePageUrlGenerator();
|
|
50
|
+
const t = useTranslations();
|
|
51
|
+
|
|
52
|
+
const breadcrumb = () => {
|
|
53
|
+
const response: BreadcrumbItemData[] = [];
|
|
54
|
+
|
|
55
|
+
response.push({
|
|
56
|
+
name: t(`handbook.title`),
|
|
57
|
+
href: generateUrl({ page: Modules.HandbookPage }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (page)
|
|
61
|
+
response.push({
|
|
62
|
+
name: handbookPageTitle(page),
|
|
63
|
+
href: generateUrl({ page: Modules.HandbookPage, id: page.id }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return response;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const title = () => {
|
|
70
|
+
const response: any = {
|
|
71
|
+
// The reader is titled by where the page sits, not by the manual it
|
|
72
|
+
// belongs to: the section is the `type` and the page is the `element`,
|
|
73
|
+
// the same split every entity detail page in the product uses. Sections
|
|
74
|
+
// are loaded as their own resource, so a page whose section has not
|
|
75
|
+
// resolved falls back to the raw key it carries — and the key is the
|
|
76
|
+
// directory name, which is not translated and never was.
|
|
77
|
+
type: titleType ?? (page ? (section ? handbookSectionTitle(section) : page.section) : t(`handbook.title`)),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (page) {
|
|
81
|
+
response.element = handbookPageTitle(page);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (functions) response.functions = functions;
|
|
85
|
+
|
|
86
|
+
return response;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
|
|
91
|
+
<HandbookContext.Provider
|
|
92
|
+
value={{
|
|
93
|
+
page: page,
|
|
94
|
+
section: section,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</HandbookContext.Provider>
|
|
99
|
+
</SharedProvider>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const useHandbookContext = (): HandbookContextType => {
|
|
104
|
+
const context = useContext(HandbookContext);
|
|
105
|
+
if (context === undefined) {
|
|
106
|
+
throw new Error("useHandbookContext must be used within a HandbookProvider");
|
|
107
|
+
}
|
|
108
|
+
return context;
|
|
109
|
+
};
|