@carlonicora/nextjs-jsonapi 3.12.0 → 3.14.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-CR5KH4AI.mjs → BlockNoteEditor-QG5OWH5K.mjs} +4 -4
- package/dist/{BlockNoteEditor-6G27S6P3.js → BlockNoteEditor-WP4K423H.js} +19 -19
- package/dist/{BlockNoteEditor-6G27S6P3.js.map → BlockNoteEditor-WP4K423H.js.map} +1 -1
- 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-JEGL7KHK.mjs → chunk-46BRB36R.mjs} +2847 -1803
- package/dist/chunk-46BRB36R.mjs.map +1 -0
- package/dist/{chunk-L7DZSTFU.js → chunk-IB7DTXWG.js} +846 -129
- package/dist/chunk-IB7DTXWG.js.map +1 -0
- package/dist/{chunk-OKGGYCO5.js → chunk-OH6GJY4B.js} +2162 -1118
- package/dist/chunk-OH6GJY4B.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-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 +270 -7
- package/dist/components/index.d.ts +270 -7
- package/dist/components/index.js +32 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +31 -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 +2 -1
- package/src/components/containers/MermaidDiagram.tsx +117 -0
- package/src/components/containers/ReactMarkdownContainer.tsx +98 -15
- package/src/components/containers/index.ts +1 -0
- 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 +52 -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-QG5OWH5K.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,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,52 @@
|
|
|
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
|
+
/**
|
|
26
|
+
* The reading column's type scale.
|
|
27
|
+
*
|
|
28
|
+
* `ReactMarkdownContainer`'s `size="normal"` puts NO size class on `p` or `li`,
|
|
29
|
+
* so they inherit — and the app's root is 14px while the browser default the
|
|
30
|
+
* container's own headings are sized against is 16px. The body therefore
|
|
31
|
+
* rendered a step larger than every other piece of body copy in the product,
|
|
32
|
+
* which role 7 of the typography system puts at `text-sm`.
|
|
33
|
+
*
|
|
34
|
+
* `text-sm` on the wrapper fixes the inherited elements by cascade. The heading
|
|
35
|
+
* ladder has explicit classes and cannot cascade, so it is restated one step
|
|
36
|
+
* down — a `text-3xl` H1 belongs to a page that has no header above it, and
|
|
37
|
+
* this one is already titled twice over by the reader's header and breadcrumb.
|
|
38
|
+
*
|
|
39
|
+
* Scoped HERE rather than in the shared container on purpose: 23 other surfaces
|
|
40
|
+
* render markdown through it — judgements, law articles, documents, legal
|
|
41
|
+
* research reports — and changing its default would restyle all of them
|
|
42
|
+
* silently. That alignment is worth making, but as its own change.
|
|
43
|
+
*/
|
|
44
|
+
const READING_SCALE = "text-sm [&_h1]:text-2xl [&_h2]:text-xl [&_h3]:text-base [&_h4]:text-sm [&_table]:text-sm";
|
|
45
|
+
|
|
46
|
+
export function HandbookPageDetails({ handbookPage }: HandbookPageDetailsProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div className={READING_SCALE}>
|
|
49
|
+
<ReactMarkdownContainer content={handbookPageContent(handbookPage)} />
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { List } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useCallback, useEffect, useState, type MouseEvent as ReactMouseEvent } from "react";
|
|
6
|
+
|
|
7
|
+
import { MicroLabel } from "../../../../components";
|
|
8
|
+
import { cn } from "../../../../utils/cn";
|
|
9
|
+
|
|
10
|
+
type HandbookPageTocProps = {
|
|
11
|
+
/** The `id` of the element holding the rendered markdown. */
|
|
12
|
+
containerId: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type TocHeading = {
|
|
16
|
+
id: string;
|
|
17
|
+
text: string;
|
|
18
|
+
level: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The heading index of the page being read.
|
|
23
|
+
*
|
|
24
|
+
* Follows `HowToTableOfContents` (apps/web how-to detail): the same
|
|
25
|
+
* IntersectionObserver tracking, the same rootMargin, the same
|
|
26
|
+
* topmost-visible-wins rule and the same smooth scroll on click. Only the
|
|
27
|
+
* heading SOURCE differs — the how-to reads BlockNote blocks and assigns the
|
|
28
|
+
* ids itself, this one reads the ids `rehype-slug` already put on the rendered
|
|
29
|
+
* markdown, so the entries are anchors with a real `href` rather than buttons.
|
|
30
|
+
*/
|
|
31
|
+
export function HandbookPageToc({ containerId }: HandbookPageTocProps) {
|
|
32
|
+
const t = useTranslations();
|
|
33
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
34
|
+
const [headings, setHeadings] = useState<TocHeading[]>([]);
|
|
35
|
+
const [headingsWithIds, setHeadingsWithIds] = useState<Map<string, Element>>(new Map());
|
|
36
|
+
|
|
37
|
+
// Read the headings out of the rendered markdown, and re-read them whenever
|
|
38
|
+
// the container's content changes — a different page renders into the same
|
|
39
|
+
// container without this component ever unmounting.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const contentPanel = document.getElementById(containerId);
|
|
42
|
+
if (!contentPanel) return;
|
|
43
|
+
|
|
44
|
+
const collect = () => {
|
|
45
|
+
const headingElements = contentPanel.querySelectorAll("h1, h2, h3");
|
|
46
|
+
const nextHeadings: TocHeading[] = [];
|
|
47
|
+
const nextMap = new Map<string, Element>();
|
|
48
|
+
|
|
49
|
+
headingElements.forEach((el) => {
|
|
50
|
+
if (!el.id) return;
|
|
51
|
+
nextHeadings.push({
|
|
52
|
+
id: el.id,
|
|
53
|
+
text: el.textContent ?? "",
|
|
54
|
+
level: Number(el.tagName.slice(1)),
|
|
55
|
+
});
|
|
56
|
+
nextMap.set(el.id, el);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
setHeadings(nextHeadings);
|
|
60
|
+
setHeadingsWithIds(nextMap);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
collect();
|
|
64
|
+
|
|
65
|
+
const observer = new MutationObserver(collect);
|
|
66
|
+
observer.observe(contentPanel, { childList: true, subtree: true });
|
|
67
|
+
|
|
68
|
+
return () => observer.disconnect();
|
|
69
|
+
}, [containerId]);
|
|
70
|
+
|
|
71
|
+
// Set up IntersectionObserver for scroll tracking
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (headingsWithIds.size === 0) return;
|
|
74
|
+
|
|
75
|
+
/*
|
|
76
|
+
`root: null` — the VIEWPORT, deliberately.
|
|
77
|
+
|
|
78
|
+
The previous version looked for a `.overflow-y-auto` descendant of the
|
|
79
|
+
content panel and used that as the root. There is none: the element that
|
|
80
|
+
actually scrolls is an ANCESTOR (the page container's scroll body), so the
|
|
81
|
+
root fell back to the content panel itself, which never moves relative to
|
|
82
|
+
its own children. The observer therefore fired once on mount and never
|
|
83
|
+
again, and the only thing that ever changed the highlight was a click.
|
|
84
|
+
|
|
85
|
+
Viewport-relative intersection needs no knowledge of which ancestor
|
|
86
|
+
scrolls — the headings move through the viewport either way.
|
|
87
|
+
*/
|
|
88
|
+
const observer = new IntersectionObserver(
|
|
89
|
+
(entries) => {
|
|
90
|
+
// Find the topmost visible heading
|
|
91
|
+
const visibleEntries = entries.filter((entry) => entry.isIntersecting);
|
|
92
|
+
|
|
93
|
+
if (visibleEntries.length > 0) {
|
|
94
|
+
// Sort by their position in the document and pick the first
|
|
95
|
+
const topEntry = visibleEntries.reduce((top, entry) => {
|
|
96
|
+
const topRect = top.boundingClientRect;
|
|
97
|
+
const entryRect = entry.boundingClientRect;
|
|
98
|
+
return entryRect.top < topRect.top ? entry : top;
|
|
99
|
+
});
|
|
100
|
+
setActiveId(topEntry.target.id);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
root: null,
|
|
105
|
+
// The active band is the top tenth to top third of the viewport: a
|
|
106
|
+
// heading is "where you are" once it has reached reading position, not
|
|
107
|
+
// when it is still at the bottom edge.
|
|
108
|
+
rootMargin: "-10% 0px -70% 0px",
|
|
109
|
+
threshold: 0,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
headingsWithIds.forEach((element) => {
|
|
114
|
+
observer.observe(element);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return () => observer.disconnect();
|
|
118
|
+
}, [containerId, headingsWithIds]);
|
|
119
|
+
|
|
120
|
+
const handleClick = useCallback(
|
|
121
|
+
(event: ReactMouseEvent<HTMLAnchorElement>, id: string) => {
|
|
122
|
+
const element = headingsWithIds.get(id);
|
|
123
|
+
if (!element) return;
|
|
124
|
+
|
|
125
|
+
event.preventDefault();
|
|
126
|
+
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
127
|
+
// Deliberately NOT setActiveId(id). The highlight answers "where am I",
|
|
128
|
+
// and the observer answers that as the scroll lands. Setting it here made
|
|
129
|
+
// it answer "where did I click" instead, and it stuck there.
|
|
130
|
+
},
|
|
131
|
+
[headingsWithIds],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Don't render if no headings
|
|
135
|
+
if (headings.length === 0) return null;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex flex-col gap-y-2">
|
|
139
|
+
<div className="flex items-center gap-2">
|
|
140
|
+
<List className="text-muted-foreground h-4 w-4" />
|
|
141
|
+
<MicroLabel>{t("handbook.reader.onThisPage")}</MicroLabel>
|
|
142
|
+
</div>
|
|
143
|
+
<nav aria-label={t("handbook.reader.onThisPage")} className="flex flex-col gap-y-0.5">
|
|
144
|
+
{headings.map((heading) => (
|
|
145
|
+
<a
|
|
146
|
+
key={heading.id}
|
|
147
|
+
href={`#${heading.id}`}
|
|
148
|
+
onClick={(event) => handleClick(event, heading.id)}
|
|
149
|
+
className={cn(
|
|
150
|
+
"hover:text-primary border-s-2 py-1 text-start text-xs transition-colors",
|
|
151
|
+
heading.level === 1 && "ps-3",
|
|
152
|
+
heading.level === 2 && "ps-5",
|
|
153
|
+
heading.level === 3 && "ps-7",
|
|
154
|
+
activeId === heading.id
|
|
155
|
+
? "border-primary text-primary font-medium"
|
|
156
|
+
: "text-muted-foreground border-transparent",
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
{heading.text}
|
|
160
|
+
</a>
|
|
161
|
+
))}
|
|
162
|
+
</nav>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "../../../../shadcnui";
|
|
7
|
+
import { showError, showToast } from "../../../../utils/toast";
|
|
8
|
+
import { HandbookPageService } from "../../data/HandbookPageService";
|
|
9
|
+
|
|
10
|
+
type HandbookSyncButtonProps = {
|
|
11
|
+
refresh: () => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The sync control is a plain Button, not a trigger: it opens nothing, so the
|
|
16
|
+
* render-prop composition rule for Base UI triggers does not apply here.
|
|
17
|
+
*/
|
|
18
|
+
export default function HandbookSyncButton({ refresh }: HandbookSyncButtonProps) {
|
|
19
|
+
const t = useTranslations();
|
|
20
|
+
const [isSyncing, setIsSyncing] = useState(false);
|
|
21
|
+
|
|
22
|
+
const onSync = async () => {
|
|
23
|
+
setIsSyncing(true);
|
|
24
|
+
try {
|
|
25
|
+
await HandbookPageService.sync();
|
|
26
|
+
await refresh();
|
|
27
|
+
showToast(t(`handbook.synced`));
|
|
28
|
+
} catch {
|
|
29
|
+
// The most common failure is a consumer with no `handbook.path`
|
|
30
|
+
// configured, which the API answers with 400. Surface it and leave the
|
|
31
|
+
// list untouched.
|
|
32
|
+
showError(t(`handbook.syncFailed`));
|
|
33
|
+
} finally {
|
|
34
|
+
setIsSyncing(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Button onClick={onSync} disabled={isSyncing}>
|
|
40
|
+
{isSyncing ? t(`handbook.syncing`) : t(`handbook.sync`)}
|
|
41
|
+
</Button>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { HandbookPageService } from "../../../data/HandbookPageService";
|
|
6
|
+
import HandbookSyncButton from "../HandbookSyncButton";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../../data/HandbookPageService", () => ({
|
|
9
|
+
HandbookPageService: { sync: vi.fn() },
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("next-intl", () => ({
|
|
13
|
+
useTranslations: () => (key: string) => key,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("HandbookSyncButton", () => {
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.mocked(HandbookPageService.sync).mockReset().mockResolvedValue(undefined);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("syncs, then refreshes the list", async () => {
|
|
22
|
+
const refresh = vi.fn().mockResolvedValue(undefined);
|
|
23
|
+
render(<HandbookSyncButton refresh={refresh} />);
|
|
24
|
+
|
|
25
|
+
await userEvent.click(screen.getByRole("button"));
|
|
26
|
+
|
|
27
|
+
await waitFor(() => expect(HandbookPageService.sync).toHaveBeenCalledTimes(1));
|
|
28
|
+
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not refresh when the sync fails, and re-enables itself", async () => {
|
|
32
|
+
vi.mocked(HandbookPageService.sync).mockRejectedValue(new Error("no handbook path configured"));
|
|
33
|
+
const refresh = vi.fn().mockResolvedValue(undefined);
|
|
34
|
+
render(<HandbookSyncButton refresh={refresh} />);
|
|
35
|
+
|
|
36
|
+
await userEvent.click(screen.getByRole("button"));
|
|
37
|
+
|
|
38
|
+
await waitFor(() => expect(HandbookPageService.sync).toHaveBeenCalledTimes(1));
|
|
39
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
40
|
+
await waitFor(() => expect(screen.getByRole("button")).not.toBeDisabled());
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { default as HandbookAdminContainer } from "./containers/HandbookAdminContainer";
|
|
2
|
+
export { default as HandbookPageContainer } from "./containers/HandbookPageContainer";
|
|
3
|
+
export * from "./containers/HandbookAskContainer";
|
|
4
|
+
export * from "./containers/HandbookAskSheet";
|
|
5
|
+
export * from "./details/HandbookPageDetails";
|
|
6
|
+
export * from "./details/HandbookPageNeighbours";
|
|
7
|
+
export * from "./details/HandbookPageToc";
|
|
8
|
+
export * from "./parts/HandbookPageNavigator";
|
|
9
|
+
export { default as HandbookSyncButton } from "./forms/HandbookSyncButton";
|
|
10
|
+
export { default as HandbookContents } from "./lists/HandbookContents";
|
|
11
|
+
export * from "./lists/HandbookSectionGroup";
|
|
12
|
+
export * from "./parts/HandbookIndexStatus";
|