@carlonicora/nextjs-jsonapi 3.11.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.
Files changed (156) hide show
  1. package/dist/{AssistantInterface-DwdBzS9f.d.ts → AssistantInterface-RvZzlcA_.d.ts} +2 -0
  2. package/dist/{AssistantInterface-DMh-zApr.d.mts → AssistantInterface-jL56dyNc.d.mts} +2 -0
  3. package/dist/{AssistantMessageInterface-0zyVingf.d.ts → AssistantMessageInterface-CuaMDs2i.d.ts} +1 -1
  4. package/dist/{AssistantMessageInterface-ddyG114R.d.mts → AssistantMessageInterface-Du3ShThp.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-XOVNTXDY.js → BlockNoteEditor-CFVSHS46.js} +19 -19
  6. package/dist/{BlockNoteEditor-XOVNTXDY.js.map → BlockNoteEditor-CFVSHS46.js.map} +1 -1
  7. package/dist/{BlockNoteEditor-NBHDEZ7W.mjs → BlockNoteEditor-SERY337S.mjs} +4 -4
  8. package/dist/{BlockNoteViewer-C2UUOBAF.js → BlockNoteViewer-CWFPYYIU.js} +3 -3
  9. package/dist/{BlockNoteViewer-C2UUOBAF.js.map → BlockNoteViewer-CWFPYYIU.js.map} +1 -1
  10. package/dist/{BlockNoteViewer-TYLVTIUD.mjs → BlockNoteViewer-P2VKEFRN.mjs} +2 -2
  11. package/dist/{ai-connection.module-28f-hux0.d.mts → HandbookSectionInterface-BOxIdBdd.d.mts} +54 -1
  12. package/dist/{ai-connection.module-Bqwh_DnR.d.ts → HandbookSectionInterface-CAXxbDw0.d.ts} +54 -1
  13. package/dist/{auth.interface-B6ZQLGjS.d.ts → auth.interface-BC1jp_ni.d.ts} +1 -1
  14. package/dist/{auth.interface-jYD8Gc4P.d.mts → auth.interface-dR2-6gPM.d.mts} +1 -1
  15. package/dist/billing/index.d.mts +27 -0
  16. package/dist/billing/index.d.ts +27 -0
  17. package/dist/billing/index.js +697 -450
  18. package/dist/billing/index.js.map +1 -1
  19. package/dist/billing/index.mjs +282 -35
  20. package/dist/billing/index.mjs.map +1 -1
  21. package/dist/{chunk-6J2SQZ3G.js → chunk-IB7DTXWG.js} +847 -129
  22. package/dist/chunk-IB7DTXWG.js.map +1 -0
  23. package/dist/{chunk-YN4LOVL4.js → chunk-LVE3BJ6K.js} +2106 -1109
  24. package/dist/chunk-LVE3BJ6K.js.map +1 -0
  25. package/dist/{chunk-M5QAHSCY.mjs → chunk-OSFYCSTW.mjs} +725 -7
  26. package/dist/chunk-OSFYCSTW.mjs.map +1 -0
  27. package/dist/{chunk-XNGI4HDZ.mjs → chunk-UQXE3SME.mjs} +2100 -1103
  28. package/dist/chunk-UQXE3SME.mjs.map +1 -0
  29. package/dist/{chunk-IMSIVIIC.mjs → chunk-VZKJIAND.mjs} +2 -2
  30. package/dist/{chunk-V6CGCARD.js → chunk-XH4IURUD.js} +7 -7
  31. package/dist/{chunk-V6CGCARD.js.map → chunk-XH4IURUD.js.map} +1 -1
  32. package/dist/client/index.d.mts +2 -2
  33. package/dist/client/index.d.ts +2 -2
  34. package/dist/client/index.js +4 -4
  35. package/dist/client/index.mjs +3 -3
  36. package/dist/components/index.d.mts +258 -7
  37. package/dist/components/index.d.ts +258 -7
  38. package/dist/components/index.js +30 -4
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/index.mjs +29 -3
  41. package/dist/contexts/index.d.mts +17 -5
  42. package/dist/contexts/index.d.ts +17 -5
  43. package/dist/contexts/index.js +4 -4
  44. package/dist/contexts/index.mjs +3 -3
  45. package/dist/core/index.d.mts +493 -12
  46. package/dist/core/index.d.ts +493 -12
  47. package/dist/core/index.js +38 -2
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/index.mjs +37 -1
  50. package/dist/features/help/index.js +37 -37
  51. package/dist/features/help/index.mjs +3 -3
  52. package/dist/features/tokenusage/index.js +101 -101
  53. package/dist/features/tokenusage/index.mjs +3 -3
  54. package/dist/index.d.mts +7 -7
  55. package/dist/index.d.ts +7 -7
  56. package/dist/index.js +39 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +38 -2
  59. package/dist/{notification.interface-DYF56CtE.d.ts → notification.interface-CjzV_KII.d.ts} +1 -1
  60. package/dist/{notification.interface-BWOCvKRP.d.mts → notification.interface-MnRt1Tjm.d.mts} +1 -1
  61. package/dist/{s3.service-D_dswjgz.d.mts → s3.service-C0Yq2Sx3.d.mts} +2 -2
  62. package/dist/{s3.service-DserU9VK.d.ts → s3.service-CUGarZs3.d.ts} +2 -2
  63. package/dist/server/index.d.mts +3 -3
  64. package/dist/server/index.d.ts +3 -3
  65. package/dist/server/index.js +3 -3
  66. package/dist/server/index.mjs +1 -1
  67. package/dist/{useSocket-BZG9oX-L.d.mts → useSocket-BcH7l7B8.d.mts} +1 -1
  68. package/dist/{useSocket-CiyTtYFj.d.ts → useSocket-DCZd_eJk.d.ts} +1 -1
  69. package/package.json +1 -1
  70. package/src/components/containers/ReactMarkdownContainer.tsx +61 -11
  71. package/src/components/index.ts +1 -0
  72. package/src/core/index.ts +5 -0
  73. package/src/core/registry/ModuleRegistry.ts +4 -0
  74. package/src/features/assistant/components/parts/AssistantComposer.tsx +22 -3
  75. package/src/features/assistant/components/parts/AssistantSidebar.tsx +13 -3
  76. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +10 -3
  77. package/src/features/assistant/contexts/AssistantContext.tsx +30 -3
  78. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +32 -0
  79. package/src/features/assistant/data/Assistant.ts +2 -0
  80. package/src/features/assistant/data/AssistantInterface.ts +2 -0
  81. package/src/features/assistant/data/AssistantService.ts +4 -0
  82. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +5 -1
  83. package/src/features/assistant-message/data/AssistantMessage.ts +27 -2
  84. package/src/features/billing/i18n-keys.ts +7 -0
  85. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +108 -2
  86. package/src/features/billing/stripe-price/components/forms/__tests__/PriceEditor.spec.tsx +182 -0
  87. package/src/features/billing/stripe-price/components/lists/PricesList.tsx +152 -25
  88. package/src/features/billing/stripe-price/components/lists/__tests__/PricesList.spec.tsx +268 -0
  89. package/src/features/billing/stripe-price/data/__tests__/stripe-price.transfer.spec.ts +265 -0
  90. package/src/features/billing/stripe-price/data/stripe-price.fields.ts +1 -0
  91. package/src/features/billing/stripe-price/data/stripe-price.transfer.ts +194 -0
  92. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +66 -2
  93. package/src/features/content/data/content.interface.ts +1 -1
  94. package/src/features/handbook/HandbookPageModule.ts +41 -0
  95. package/src/features/handbook/HandbookSectionModule.ts +35 -0
  96. package/src/features/handbook/HandbookThreadMessageModule.ts +21 -0
  97. package/src/features/handbook/HandbookThreadModule.ts +29 -0
  98. package/src/features/handbook/components/containers/HandbookAdminContainer.tsx +17 -0
  99. package/src/features/handbook/components/containers/HandbookAskContainer.tsx +142 -0
  100. package/src/features/handbook/components/containers/HandbookAskSheet.tsx +149 -0
  101. package/src/features/handbook/components/containers/HandbookPageContainer.tsx +143 -0
  102. package/src/features/handbook/components/containers/__tests__/HandbookAskContainer.spec.tsx +268 -0
  103. package/src/features/handbook/components/containers/__tests__/HandbookAskSheet.spec.tsx +143 -0
  104. package/src/features/handbook/components/containers/__tests__/HandbookPageContainer.spec.tsx +288 -0
  105. package/src/features/handbook/components/details/HandbookPageDetails.tsx +27 -0
  106. package/src/features/handbook/components/details/HandbookPageNeighbours.tsx +72 -0
  107. package/src/features/handbook/components/details/HandbookPageToc.tsx +165 -0
  108. package/src/features/handbook/components/forms/HandbookSyncButton.tsx +43 -0
  109. package/src/features/handbook/components/forms/__tests__/HandbookSyncButton.spec.tsx +42 -0
  110. package/src/features/handbook/components/index.ts +12 -0
  111. package/src/features/handbook/components/lists/HandbookContents.tsx +196 -0
  112. package/src/features/handbook/components/lists/HandbookSectionGroup.tsx +153 -0
  113. package/src/features/handbook/components/lists/__tests__/HandbookContents.spec.tsx +247 -0
  114. package/src/features/handbook/components/parts/HandbookIndexStatus.tsx +62 -0
  115. package/src/features/handbook/components/parts/HandbookPageNavigator.tsx +143 -0
  116. package/src/features/handbook/contexts/HandbookContext.tsx +109 -0
  117. package/src/features/handbook/contexts/__tests__/HandbookContext.spec.tsx +131 -0
  118. package/src/features/handbook/data/HandbookPage.spec.ts +136 -0
  119. package/src/features/handbook/data/HandbookPage.ts +99 -0
  120. package/src/features/handbook/data/HandbookPageFields.ts +31 -0
  121. package/src/features/handbook/data/HandbookPageInput.ts +8 -0
  122. package/src/features/handbook/data/HandbookPageInterface.ts +29 -0
  123. package/src/features/handbook/data/HandbookPageService.ts +60 -0
  124. package/src/features/handbook/data/HandbookSection.spec.ts +58 -0
  125. package/src/features/handbook/data/HandbookSection.ts +73 -0
  126. package/src/features/handbook/data/HandbookSectionInput.ts +12 -0
  127. package/src/features/handbook/data/HandbookSectionInterface.ts +26 -0
  128. package/src/features/handbook/data/HandbookSectionService.ts +26 -0
  129. package/src/features/handbook/data/HandbookThread.ts +114 -0
  130. package/src/features/handbook/data/HandbookThreadInterface.ts +31 -0
  131. package/src/features/handbook/data/HandbookThreadMessage.ts +144 -0
  132. package/src/features/handbook/data/HandbookThreadMessageInterface.ts +35 -0
  133. package/src/features/handbook/data/HandbookThreadMessageService.ts +30 -0
  134. package/src/features/handbook/data/HandbookThreadService.ts +86 -0
  135. package/src/features/handbook/data/__tests__/HandbookThreadService.spec.ts +112 -0
  136. package/src/features/handbook/data/handbookDisplay.spec.ts +92 -0
  137. package/src/features/handbook/data/handbookDisplay.ts +55 -0
  138. package/src/features/handbook/data/index.ts +16 -0
  139. package/src/features/handbook/hooks/__tests__/useHandbookAsk.spec.ts +198 -0
  140. package/src/features/handbook/hooks/useHandbookAsk.ts +213 -0
  141. package/src/features/handbook/i18n-keys.ts +46 -0
  142. package/src/features/handbook/index.ts +7 -0
  143. package/src/features/how-to/components/forms/HowToEditor.tsx +0 -1
  144. package/src/features/how-to/components/forms/HowToReindexButton.spec.tsx +84 -0
  145. package/src/features/how-to/components/forms/HowToReindexButton.tsx +46 -0
  146. package/src/features/how-to/components/index.ts +1 -0
  147. package/src/features/how-to/components/lists/HowToList.tsx +5 -1
  148. package/src/features/how-to/data/HowTo.spec.ts +61 -1
  149. package/src/features/how-to/data/HowToService.ts +13 -0
  150. package/dist/chunk-6J2SQZ3G.js.map +0 -1
  151. package/dist/chunk-M5QAHSCY.mjs.map +0 -1
  152. package/dist/chunk-XNGI4HDZ.mjs.map +0 -1
  153. package/dist/chunk-YN4LOVL4.js.map +0 -1
  154. /package/dist/{BlockNoteEditor-NBHDEZ7W.mjs.map → BlockNoteEditor-SERY337S.mjs.map} +0 -0
  155. /package/dist/{BlockNoteViewer-TYLVTIUD.mjs.map → BlockNoteViewer-P2VKEFRN.mjs.map} +0 -0
  156. /package/dist/{chunk-IMSIVIIC.mjs.map → chunk-VZKJIAND.mjs.map} +0 -0
@@ -0,0 +1,86 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef, PreviousRef } from "../../../core";
2
+ import { HandbookThread } from "./HandbookThread";
3
+ import { HandbookThreadInput, HandbookThreadInterface } from "./HandbookThreadInterface";
4
+
5
+ export class HandbookThreadService extends AbstractService {
6
+ /**
7
+ * `next` / `prev` are not optional extras: `useDataListRetriever` drives
8
+ * pagination by handing the cursor back here, so a findMany that drops them
9
+ * renders exactly one page and no navigation, however many rows exist.
10
+ * Mirrors HowToService.findMany.
11
+ */
12
+ static async findMany(
13
+ params: {
14
+ search?: string;
15
+ fetchAll?: boolean;
16
+ next?: NextRef;
17
+ prev?: PreviousRef;
18
+ } = {},
19
+ ): Promise<HandbookThreadInterface[]> {
20
+ const endpoint = new EndpointCreator({ endpoint: Modules.HandbookThread });
21
+
22
+ if (params.fetchAll) endpoint.addAdditionalParam("fetchAll", "true");
23
+ if (params.search) endpoint.addAdditionalParam("search", params.search);
24
+ if (Modules.HandbookThread.inclusions?.lists?.fields)
25
+ endpoint.limitToFields(Modules.HandbookThread.inclusions.lists.fields);
26
+ if (Modules.HandbookThread.inclusions?.lists?.types)
27
+ endpoint.limitToType(Modules.HandbookThread.inclusions.lists.types);
28
+
29
+ return this.callApi({
30
+ type: Modules.HandbookThread,
31
+ method: HttpMethod.GET,
32
+ endpoint: endpoint.generate(),
33
+ next: params.next,
34
+ });
35
+ }
36
+
37
+ /** Returns the thread with its messages side-loaded — the sidebar's select. */
38
+ static async findOne(params: { id: string }): Promise<HandbookThreadInterface> {
39
+ return this.callApi<HandbookThreadInterface>({
40
+ type: Modules.HandbookThread,
41
+ method: HttpMethod.GET,
42
+ endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread, id: params.id }).generate(),
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Opens a thread with its first question. The response is the thread (title
48
+ * included); the messages are read back with `findOne`, because only the
49
+ * single-resource endpoint side-loads them.
50
+ *
51
+ * The API answers 400 when the installation has no usable AI configuration
52
+ * and 403 for a non-administrator; both reach the caller as a thrown error.
53
+ *
54
+ * `HandbookThreadInput.handbookPageId` scopes retrieval to one page. It rides
55
+ * through the model's `createJsonApi()`, exactly as `question` does — there is
56
+ * no `overridesJsonApiCreation` on this path.
57
+ */
58
+ static async create(params: HandbookThreadInput): Promise<HandbookThreadInterface> {
59
+ return this.callApi<HandbookThreadInterface>({
60
+ type: Modules.HandbookThread,
61
+ method: HttpMethod.POST,
62
+ endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread }).generate(),
63
+ input: params,
64
+ suppressGlobalError: true,
65
+ });
66
+ }
67
+
68
+ static async rename(params: { id: string; title: string }): Promise<void> {
69
+ const thread = new HandbookThread();
70
+ await this.callApi({
71
+ type: Modules.HandbookThread,
72
+ method: HttpMethod.PATCH,
73
+ endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread, id: params.id }).generate(),
74
+ input: thread.createRenameJsonApi({ id: params.id, title: params.title }),
75
+ overridesJsonApiCreation: true,
76
+ });
77
+ }
78
+
79
+ static async delete(params: { id: string }): Promise<void> {
80
+ await this.callApi({
81
+ type: Modules.HandbookThread,
82
+ method: HttpMethod.DELETE,
83
+ endpoint: new EndpointCreator({ endpoint: Modules.HandbookThread, id: params.id }).generate(),
84
+ });
85
+ }
86
+ }
@@ -0,0 +1,112 @@
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { AbstractService } from "../../../../core/abstracts/AbstractService";
4
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
5
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
6
+ import { HandbookThread } from "../HandbookThread";
7
+ import { HandbookThreadMessage } from "../HandbookThreadMessage";
8
+ import { HandbookThreadMessageService } from "../HandbookThreadMessageService";
9
+ import { HandbookThreadService } from "../HandbookThreadService";
10
+
11
+ // ModuleRegistry is backed by a globalThis symbol shared across test files in a
12
+ // worker, hence the register-if-absent guard (same idiom as
13
+ // Assistant.scope.spec.ts).
14
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
15
+ try {
16
+ ModuleRegistry.get(key as any);
17
+ } catch {
18
+ ModuleRegistry.register(key, module);
19
+ }
20
+ };
21
+
22
+ beforeAll(() => {
23
+ registerIfAbsent("HandbookThread", { name: "handbookthreads", model: HandbookThread } as any);
24
+ registerIfAbsent("HandbookThreadMessage", {
25
+ name: "handbookthreadmessages",
26
+ model: HandbookThreadMessage,
27
+ } as any);
28
+ });
29
+
30
+ // `callApi` is a protected static on AbstractService; the services inherit it,
31
+ // so spying on the base class intercepts every call without touching the HTTP
32
+ // layer.
33
+ type CallApiParams = {
34
+ method: string;
35
+ endpoint: string;
36
+ input?: unknown;
37
+ next?: unknown;
38
+ overridesJsonApiCreation?: boolean;
39
+ };
40
+ const callApi = () => vi.spyOn(AbstractService as any, "callApi");
41
+ const firstCall = (spy: ReturnType<typeof callApi>): CallApiParams => spy.mock.calls[0][0] as CallApiParams;
42
+
43
+ describe("HandbookThreadService.findMany", () => {
44
+ beforeEach(() => vi.restoreAllMocks());
45
+
46
+ it("forwards the pagination cursor — `useDataListRetriever` pages through it", async () => {
47
+ const spy = callApi().mockResolvedValue([]);
48
+
49
+ await HandbookThreadService.findMany({ next: { next: "handbookthreads?page[cursor]=42" } });
50
+
51
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({ next: { next: "handbookthreads?page[cursor]=42" } }));
52
+ });
53
+
54
+ it("still calls with an undefined cursor on the first page", async () => {
55
+ const spy = callApi().mockResolvedValue([]);
56
+
57
+ await HandbookThreadService.findMany();
58
+
59
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining({ next: undefined }));
60
+ });
61
+
62
+ it("passes search and fetchAll through to the endpoint", async () => {
63
+ const spy = callApi().mockResolvedValue([]);
64
+
65
+ await HandbookThreadService.findMany({ search: "cypher", fetchAll: true });
66
+
67
+ const endpoint = firstCall(spy).endpoint;
68
+ expect(endpoint).toContain("handbookthreads");
69
+ expect(endpoint).toContain("cypher");
70
+ expect(endpoint).toContain("fetchAll");
71
+ });
72
+ });
73
+
74
+ describe("HandbookThreadService mutations", () => {
75
+ beforeEach(() => vi.restoreAllMocks());
76
+
77
+ it("creates a thread from the question alone and lets the model build the envelope", async () => {
78
+ const spy = callApi().mockResolvedValue({});
79
+
80
+ await HandbookThreadService.create({ id: "client-1", question: " " });
81
+
82
+ const call = firstCall(spy);
83
+ expect(call.method).toBe("POST");
84
+ expect(call.input).toEqual({ id: "client-1", question: " " });
85
+ expect(call.overridesJsonApiCreation).toBeUndefined();
86
+ });
87
+
88
+ it("renames through the model's dedicated envelope, never a hand-built one", async () => {
89
+ const spy = callApi().mockResolvedValue(undefined);
90
+
91
+ await HandbookThreadService.rename({ id: "t1", title: "Filtri di azienda" });
92
+
93
+ const call = firstCall(spy);
94
+ expect(call.method).toBe("PATCH");
95
+ expect(call.input).toEqual(new HandbookThread().createRenameJsonApi({ id: "t1", title: "Filtri di azienda" }));
96
+ });
97
+ });
98
+
99
+ describe("HandbookThreadMessageService.ask", () => {
100
+ beforeEach(() => vi.restoreAllMocks());
101
+
102
+ it("posts the question to the thread's message collection", async () => {
103
+ const spy = callApi().mockResolvedValue(undefined);
104
+
105
+ await HandbookThreadMessageService.ask({ id: "m1", threadId: "t1", question: "Come funziona?" });
106
+
107
+ const call = firstCall(spy);
108
+ expect(call.method).toBe("POST");
109
+ expect(call.endpoint).toContain("handbookthreads/t1/handbookthreadmessages");
110
+ expect(call.input).toEqual({ id: "m1", question: "Come funziona?" });
111
+ });
112
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ handbookPageContent,
4
+ handbookPageSummary,
5
+ handbookPageTitle,
6
+ handbookSectionSummary,
7
+ handbookSectionTitle,
8
+ } from "./handbookDisplay";
9
+
10
+ /**
11
+ * The one rule the whole feature depends on: indexed in English, shown
12
+ * translated, and nothing but the rendered words changes.
13
+ */
14
+ describe("handbookDisplay", () => {
15
+ describe("a page with a translation", () => {
16
+ const page = {
17
+ title: "Module anatomy",
18
+ displayTitle: "Anatomia di un modulo",
19
+ summary: "What a feature module contains.",
20
+ displaySummary: "Cosa contiene un modulo.",
21
+ content: "# Module anatomy\n",
22
+ displayContent: "# Anatomia di un modulo\n",
23
+ };
24
+
25
+ it("renders the translated title", () => {
26
+ expect(handbookPageTitle(page)).toBe("Anatomia di un modulo");
27
+ });
28
+
29
+ it("renders the translated summary", () => {
30
+ expect(handbookPageSummary(page)).toBe("Cosa contiene un modulo.");
31
+ });
32
+
33
+ it("renders the translated body", () => {
34
+ expect(handbookPageContent(page)).toBe("# Anatomia di un modulo\n");
35
+ });
36
+ });
37
+
38
+ describe("a page without a translation", () => {
39
+ const page = {
40
+ title: "Module anatomy",
41
+ displayTitle: undefined,
42
+ summary: "What a feature module contains.",
43
+ displaySummary: undefined,
44
+ content: "# Module anatomy\n",
45
+ displayContent: undefined,
46
+ };
47
+
48
+ it("falls back to the English title", () => {
49
+ expect(handbookPageTitle(page)).toBe("Module anatomy");
50
+ });
51
+
52
+ it("falls back to the English summary", () => {
53
+ expect(handbookPageSummary(page)).toBe("What a feature module contains.");
54
+ });
55
+
56
+ it("falls back to the English body", () => {
57
+ expect(handbookPageContent(page)).toBe("# Module anatomy\n");
58
+ });
59
+
60
+ it("leaves a summary that exists in neither language undefined", () => {
61
+ expect(handbookPageSummary({ summary: undefined, displaySummary: undefined })).toBeUndefined();
62
+ });
63
+ });
64
+
65
+ describe("sections", () => {
66
+ it("renders the translated title and summary when there is one", () => {
67
+ const section = {
68
+ title: "Backend",
69
+ displayTitle: "Backend (API)",
70
+ summary: "The API.",
71
+ displaySummary: "Le API.",
72
+ };
73
+ expect(handbookSectionTitle(section)).toBe("Backend (API)");
74
+ expect(handbookSectionSummary(section)).toBe("Le API.");
75
+ });
76
+
77
+ it("falls back to English when there is none", () => {
78
+ const section = { title: "Backend", displayTitle: undefined, summary: "The API.", displaySummary: undefined };
79
+ expect(handbookSectionTitle(section)).toBe("Backend");
80
+ expect(handbookSectionSummary(section)).toBe("The API.");
81
+ });
82
+ });
83
+
84
+ /**
85
+ * An empty string is a translation the ingest wrote, not a missing one, so
86
+ * `??` is the correct operator and `||` would be wrong: the fallback must
87
+ * only fire on `undefined`.
88
+ */
89
+ it("treats an empty translation as a translation", () => {
90
+ expect(handbookPageTitle({ title: "Module anatomy", displayTitle: "" })).toBe("");
91
+ });
92
+ });
@@ -0,0 +1,55 @@
1
+ import { HandbookPageInterface } from "./HandbookPageInterface";
2
+ import { HandbookSectionInterface } from "./HandbookSectionInterface";
3
+
4
+ /**
5
+ * The handbook is INDEXED in English and SHOWN translated.
6
+ *
7
+ * A page's translation lives on the same node as its English original — matched
8
+ * by `path` at ingest time — and arrives as ordinary attributes beside it.
9
+ * There is no second id, no locale in any URL and no second resource, so
10
+ * nothing about ordering, grouping or section membership changes: `path`,
11
+ * `order` and `section` stay English and are still what every sort reads. Only
12
+ * the rendered words are resolved here.
13
+ *
14
+ * These five functions are the ONE place that resolution happens. A component
15
+ * reaching for `page.title` directly renders English to an Italian reader, and
16
+ * a per-component `??` would be five copies of the same rule to keep in step.
17
+ *
18
+ * Each parameter is a `Pick` of the interface rather than the whole of it, so a
19
+ * caller holding a projection — a row from a sparse fieldset, a spec fixture —
20
+ * passes it without a cast, while a rename on either interface still breaks
21
+ * this file rather than drifting from it.
22
+ */
23
+
24
+ /** The page title to render: the translation when there is one, else English. */
25
+ export function handbookPageTitle(page: Pick<HandbookPageInterface, "title" | "displayTitle">): string {
26
+ return page.displayTitle ?? page.title;
27
+ }
28
+
29
+ /** The page's one-line description, translated when the ingest matched one. */
30
+ export function handbookPageSummary(
31
+ page: Pick<HandbookPageInterface, "summary" | "displaySummary">,
32
+ ): string | undefined {
33
+ return page.displaySummary ?? page.summary;
34
+ }
35
+
36
+ /**
37
+ * The markdown body to render. Detail-only, exactly as `content` is:
38
+ * `displayContent` is the whole file and is absent from every list payload, so
39
+ * this is never called on a row that came out of a list read.
40
+ */
41
+ export function handbookPageContent(page: Pick<HandbookPageInterface, "content" | "displayContent">): string {
42
+ return page.displayContent ?? page.content;
43
+ }
44
+
45
+ /** The section heading to render, translated when the ingest matched one. */
46
+ export function handbookSectionTitle(section: Pick<HandbookSectionInterface, "title" | "displayTitle">): string {
47
+ return section.displayTitle ?? section.title;
48
+ }
49
+
50
+ /** The section blurb to render, translated when the ingest matched one. */
51
+ export function handbookSectionSummary(
52
+ section: Pick<HandbookSectionInterface, "summary" | "displaySummary">,
53
+ ): string | undefined {
54
+ return section.displaySummary ?? section.summary;
55
+ }
@@ -0,0 +1,16 @@
1
+ export * from "./handbookDisplay";
2
+ export * from "./HandbookPage";
3
+ export * from "./HandbookPageFields";
4
+ export * from "./HandbookPageInput";
5
+ export * from "./HandbookPageInterface";
6
+ export * from "./HandbookPageService";
7
+ export * from "./HandbookSection";
8
+ export * from "./HandbookSectionInput";
9
+ export * from "./HandbookSectionInterface";
10
+ export * from "./HandbookSectionService";
11
+ export * from "./HandbookThread";
12
+ export * from "./HandbookThreadInterface";
13
+ export * from "./HandbookThreadMessage";
14
+ export * from "./HandbookThreadMessageInterface";
15
+ export * from "./HandbookThreadMessageService";
16
+ export * from "./HandbookThreadService";
@@ -0,0 +1,198 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
5
+ import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
6
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
7
+ import { HandbookThread } from "../../data/HandbookThread";
8
+ import { HandbookThreadMessage } from "../../data/HandbookThreadMessage";
9
+ import { HandbookThreadMessageService } from "../../data/HandbookThreadMessageService";
10
+ import { HandbookThreadService } from "../../data/HandbookThreadService";
11
+ import { useHandbookAsk } from "../useHandbookAsk";
12
+
13
+ vi.mock("../../data/HandbookThreadService", () => ({
14
+ HandbookThreadService: {
15
+ findMany: vi.fn(),
16
+ findOne: vi.fn(),
17
+ create: vi.fn(),
18
+ rename: vi.fn(),
19
+ delete: vi.fn(),
20
+ },
21
+ }));
22
+
23
+ vi.mock("../../data/HandbookThreadMessageService", () => ({
24
+ HandbookThreadMessageService: { ask: vi.fn() },
25
+ }));
26
+
27
+ // The models resolve Modules.HandbookThread / Modules.HandbookThreadMessage
28
+ // lazily through ModuleRegistry, and `_readIncluded` resolves the CONSTRUCTOR
29
+ // through DataClassRegistry. Registering both is the package's spec convention
30
+ // (see HandbookAskContainer.spec.tsx) and is preferred to mocking the core
31
+ // barrel the hook also reads `rehydrate` from.
32
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
33
+ try {
34
+ ModuleRegistry.get(key as any);
35
+ } catch {
36
+ ModuleRegistry.register(key, module);
37
+ }
38
+ };
39
+
40
+ beforeAll(() => {
41
+ registerIfAbsent("HandbookPage", { name: "handbookpages", pageUrl: "/administration/handbook" } as any);
42
+ const threadModule = { name: "handbookthreads", model: HandbookThread } as any;
43
+ const messageModule = { name: "handbookthreadmessages", model: HandbookThreadMessage } as any;
44
+ registerIfAbsent("HandbookThread", threadModule);
45
+ registerIfAbsent("HandbookThreadMessage", messageModule);
46
+ DataClassRegistry.registerObjectClass(threadModule, HandbookThread as any);
47
+ DataClassRegistry.registerObjectClass(messageModule, HandbookThreadMessage as any);
48
+ });
49
+
50
+ function buildThread(params: {
51
+ id: string;
52
+ title: string;
53
+ messages?: { id: string; role: "user" | "assistant"; content: string; position: number; sources?: string[] }[];
54
+ }): HandbookThread {
55
+ const messages = params.messages ?? [];
56
+ return new HandbookThread().rehydrate({
57
+ jsonApi: {
58
+ id: params.id,
59
+ type: "handbookthreads",
60
+ attributes: { title: params.title },
61
+ meta: { updatedAt: new Date().toISOString() },
62
+ relationships: {
63
+ messages: { data: messages.map((m) => ({ id: m.id, type: "handbookthreadmessages" })) },
64
+ },
65
+ },
66
+ included: messages.map((m) => ({
67
+ id: m.id,
68
+ type: "handbookthreadmessages",
69
+ attributes: { role: m.role, content: m.content, position: m.position, sources: m.sources ?? [] },
70
+ })),
71
+ } as any);
72
+ }
73
+
74
+ const answered = (id: string) =>
75
+ buildThread({
76
+ id,
77
+ title: "Filtri di azienda",
78
+ messages: [
79
+ { id: "m1", role: "user", content: "Perché?", position: 0 },
80
+ { id: "m2", role: "assistant", content: "Inietta il filtro di azienda.", position: 1 },
81
+ ],
82
+ });
83
+
84
+ describe("useHandbookAsk", () => {
85
+ beforeEach(() => {
86
+ vi.mocked(HandbookThreadService.findMany).mockReset().mockResolvedValue([]);
87
+ vi.mocked(HandbookThreadService.findOne).mockReset().mockResolvedValue(answered("t1"));
88
+ vi.mocked(HandbookThreadService.create)
89
+ .mockReset()
90
+ .mockResolvedValue(buildThread({ id: "t1", title: "" }));
91
+ vi.mocked(HandbookThreadMessageService.ask).mockReset().mockResolvedValue(undefined);
92
+ });
93
+
94
+ it("adds an optimistic user message then replaces the transcript from the read-back", async () => {
95
+ const { result } = renderHook(() => useHandbookAsk());
96
+
97
+ await act(async () => {
98
+ await result.current.ask("How do migrations run?");
99
+ });
100
+
101
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
102
+ expect(HandbookThreadService.findOne).toHaveBeenCalledWith({ id: "t1" });
103
+ expect(result.current.thread?.id).toBe("t1");
104
+ });
105
+
106
+ it("passes handbookPageId to the create call when one is supplied", async () => {
107
+ const { result } = renderHook(() => useHandbookAsk({ syncUrl: false }));
108
+
109
+ await act(async () => {
110
+ await result.current.ask("Why?", { handbookPageId: "p2" });
111
+ });
112
+
113
+ expect(HandbookThreadService.create).toHaveBeenCalledWith(expect.objectContaining({ handbookPageId: "p2" }));
114
+ });
115
+
116
+ it("passes handbookPageId to the append call on an open thread", async () => {
117
+ const { result } = renderHook(() => useHandbookAsk({ syncUrl: false, handbookThreadId: "t1" }));
118
+ await waitFor(() => expect(result.current.thread?.id).toBe("t1"));
119
+
120
+ await act(async () => {
121
+ await result.current.ask("Why?", { handbookPageId: "p2" });
122
+ });
123
+
124
+ expect(HandbookThreadMessageService.ask).toHaveBeenCalledWith(
125
+ expect.objectContaining({ threadId: "t1", handbookPageId: "p2" }),
126
+ );
127
+ expect(HandbookThreadService.create).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("keeps the url on the open thread by default", async () => {
131
+ const replaceState = vi.spyOn(window.history, "replaceState");
132
+ const { result } = renderHook(() => useHandbookAsk());
133
+
134
+ await act(async () => {
135
+ await result.current.ask("Why?");
136
+ });
137
+
138
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/administration/handbook/chat/t1");
139
+ replaceState.mockRestore();
140
+ });
141
+
142
+ it("does not touch the URL when syncUrl is false", async () => {
143
+ const replaceState = vi.spyOn(window.history, "replaceState");
144
+ const { result } = renderHook(() => useHandbookAsk({ syncUrl: false }));
145
+
146
+ await act(async () => {
147
+ await result.current.ask("Why?");
148
+ });
149
+
150
+ expect(replaceState).not.toHaveBeenCalled();
151
+ replaceState.mockRestore();
152
+ });
153
+
154
+ it("appends one assistant-side failure message when the API rejects", async () => {
155
+ vi.mocked(HandbookThreadService.create).mockRejectedValueOnce(new Error("400"));
156
+ const { result } = renderHook(() => useHandbookAsk());
157
+
158
+ await act(async () => {
159
+ await result.current.ask("Why?");
160
+ });
161
+
162
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
163
+ expect(result.current.messages.at(-1)?.role).toBe("assistant");
164
+ expect(result.current.messages.at(-1)?.content).toBe("handbook.chat.failed");
165
+ // The asked question stays in the transcript.
166
+ expect(result.current.messages[0].content).toBe("Why?");
167
+ });
168
+
169
+ it("opens the routed thread on mount without rewriting the url", async () => {
170
+ const replaceState = vi.spyOn(window.history, "replaceState");
171
+
172
+ const { result } = renderHook(() => useHandbookAsk({ handbookThreadId: "t1" }));
173
+
174
+ await waitFor(() => expect(result.current.messages).toHaveLength(2));
175
+ expect(HandbookThreadService.findOne).toHaveBeenCalledWith({ id: "t1" });
176
+ expect(replaceState).not.toHaveBeenCalled();
177
+ replaceState.mockRestore();
178
+ });
179
+
180
+ it("loads the thread list and reloads the transcript on select", async () => {
181
+ vi.mocked(HandbookThreadService.findMany).mockResolvedValue([
182
+ buildThread({ id: "t1", title: "Filtri di azienda" }),
183
+ ]);
184
+ const { result } = renderHook(() => useHandbookAsk());
185
+
186
+ await waitFor(() => expect(result.current.threads).toHaveLength(1));
187
+
188
+ await act(async () => {
189
+ await result.current.selectThread("t1");
190
+ });
191
+
192
+ expect(result.current.messages).toHaveLength(2);
193
+
194
+ act(() => result.current.startNew());
195
+ expect(result.current.thread).toBeUndefined();
196
+ expect(result.current.messages).toHaveLength(0);
197
+ });
198
+ });