@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,213 @@
1
+ "use client";
2
+
3
+ import { useTranslations } from "next-intl";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { v4 } from "uuid";
6
+
7
+ import { Modules, rehydrate } from "../../../core";
8
+ import { usePageUrlGenerator } from "../../../hooks";
9
+ import { HandbookThreadMessage } from "../data/HandbookThreadMessage";
10
+ import type { HandbookThreadInterface } from "../data/HandbookThreadInterface";
11
+ import type { HandbookThreadMessageInterface } from "../data/HandbookThreadMessageInterface";
12
+ import { HandbookThreadMessageService } from "../data/HandbookThreadMessageService";
13
+ import { HandbookThreadService } from "../data/HandbookThreadService";
14
+
15
+ /**
16
+ * The state of one handbook conversation, extracted verbatim from
17
+ * `HandbookAskContainer`.
18
+ *
19
+ * Two surfaces now hold a conversation — the full `/chat` route and the side
20
+ * sheet reachable from contents and reader — and they must behave identically:
21
+ * same optimistic user message, same read-back through
22
+ * `HandbookThreadService.findOne`, same error branch. Duplicating that in two
23
+ * components is how the two drift, so it lives here and both consume it.
24
+ *
25
+ * The one difference is the address bar. The route keeps the url on the open
26
+ * thread; opening a sheet is not a navigation, so the sheet passes
27
+ * `syncUrl: false` and nothing is written to history.
28
+ */
29
+
30
+ /**
31
+ * Sources ride in the markdown rather than in a bespoke block: the message
32
+ * renderer already renders markdown, and a list of file paths is exactly that.
33
+ *
34
+ * The message is rebuilt through `dehydrate()` + `rehydrate()` rather than a
35
+ * fresh `buildLocal`, so it keeps its id and React does not remount the bubble
36
+ * on every render. Mirrors `withPatchedTitle` in `AssistantContext`.
37
+ */
38
+ function withSources(messages: HandbookThreadMessageInterface[], label: string): HandbookThreadMessageInterface[] {
39
+ return messages.map((message) => withSourcesInContent(message, label));
40
+ }
41
+
42
+ function withSourcesInContent(message: HandbookThreadMessageInterface, label: string): HandbookThreadMessageInterface {
43
+ if (message.role !== "assistant" || message.sources.length === 0) return message;
44
+
45
+ const dehydrated = message.dehydrate();
46
+ const content = `${message.content}\n\n**${label}**\n\n${message.sources.map((source) => `- \`${source}\``).join("\n")}`;
47
+
48
+ return rehydrate<HandbookThreadMessageInterface>(Modules.HandbookThreadMessage, {
49
+ jsonApi: {
50
+ ...dehydrated.jsonApi,
51
+ attributes: { ...(dehydrated.jsonApi?.attributes ?? {}), content },
52
+ },
53
+ included: dehydrated.included,
54
+ });
55
+ }
56
+
57
+ export type UseHandbookAskParams = {
58
+ /**
59
+ * Keep the address bar on the open thread. Defaults to `true`, the route's
60
+ * behaviour; the sheet passes `false`.
61
+ */
62
+ syncUrl?: boolean;
63
+ /** Thread to open on mount, when the route carries one. */
64
+ handbookThreadId?: string;
65
+ };
66
+
67
+ export type UseHandbookAskResult = {
68
+ threads: HandbookThreadInterface[];
69
+ thread: HandbookThreadInterface | undefined;
70
+ messages: HandbookThreadMessageInterface[];
71
+ sending: boolean;
72
+ ask: (question: string, options?: { handbookPageId?: string }) => Promise<void>;
73
+ selectThread: (id: string) => Promise<void>;
74
+ startNew: () => void;
75
+ };
76
+
77
+ export function useHandbookAsk(params?: UseHandbookAskParams): UseHandbookAskResult {
78
+ const t = useTranslations();
79
+ const generateUrl = usePageUrlGenerator();
80
+
81
+ const shouldSyncUrl = params?.syncUrl ?? true;
82
+ const handbookThreadId = params?.handbookThreadId;
83
+
84
+ const [threads, setThreads] = useState<HandbookThreadInterface[]>([]);
85
+ const [thread, setThread] = useState<HandbookThreadInterface | undefined>(undefined);
86
+ const [messages, setMessages] = useState<HandbookThreadMessageInterface[]>([]);
87
+ const [sending, setSending] = useState(false);
88
+
89
+ const sourcesLabel = t("handbook.chat.sources");
90
+
91
+ useEffect(() => {
92
+ let cancelled = false;
93
+ (async () => {
94
+ const loaded = await HandbookThreadService.findMany();
95
+ if (!cancelled) setThreads(loaded);
96
+ })();
97
+ return () => {
98
+ cancelled = true;
99
+ };
100
+ }, []);
101
+
102
+ // A routed thread id opens that thread on mount, so the url survives a reload
103
+ // or a pasted link. Runs once per id; selecting from the list takes the other
104
+ // path and only replaces the url.
105
+ useEffect(() => {
106
+ if (!handbookThreadId) return;
107
+ let cancelled = false;
108
+ (async () => {
109
+ const selected = await HandbookThreadService.findOne({ id: handbookThreadId });
110
+ if (cancelled) return;
111
+ setThread(selected);
112
+ setMessages(withSources(selected.messages, sourcesLabel));
113
+ })();
114
+ return () => {
115
+ cancelled = true;
116
+ };
117
+ }, [handbookThreadId]);
118
+
119
+ /**
120
+ * Keep the address bar on the open thread, the way ConversationListItem does
121
+ * (`window.history.replaceState` with the generated url) — selecting a thread
122
+ * is not a navigation, so this replaces rather than pushes.
123
+ *
124
+ * A no-op when the caller asked for no url sync: the sheet floats over a page
125
+ * that is itself the navigation, and rewriting the url under it would send a
126
+ * reader who closes the sheet somewhere they never went.
127
+ */
128
+ const syncUrl = useCallback(
129
+ (id?: string) => {
130
+ if (!shouldSyncUrl) return;
131
+ const base = `${generateUrl({ page: Modules.HandbookPage })}/chat`;
132
+ window.history.replaceState(null, "", id ? `${base}/${id}` : base);
133
+ },
134
+ [generateUrl, shouldSyncUrl],
135
+ );
136
+
137
+ const selectThread = useCallback(
138
+ async (id: string) => {
139
+ const selected = await HandbookThreadService.findOne({ id });
140
+ setThread(selected);
141
+ setMessages(withSources(selected.messages, sourcesLabel));
142
+ syncUrl(id);
143
+ },
144
+ [syncUrl],
145
+ );
146
+
147
+ const startNew = useCallback(() => {
148
+ setThread(undefined);
149
+ setMessages([]);
150
+ syncUrl(undefined);
151
+ }, [syncUrl]);
152
+
153
+ const ask = useCallback(
154
+ async (question: string, options?: { handbookPageId?: string }) => {
155
+ const trimmed = question.trim();
156
+ if (!trimmed || sending) return;
157
+
158
+ const position = messages.length;
159
+ setMessages((current) => [
160
+ ...current,
161
+ HandbookThreadMessage.buildLocal({ role: "user", content: trimmed, position, isOptimistic: true }),
162
+ ]);
163
+ setSending(true);
164
+
165
+ try {
166
+ // Both branches end in the same read: only the single-thread endpoint
167
+ // side-loads the messages, so the transcript always comes from there.
168
+ let id = thread?.id;
169
+ if (id === undefined) {
170
+ const created = await HandbookThreadService.create({
171
+ id: v4(),
172
+ question: trimmed,
173
+ handbookPageId: options?.handbookPageId,
174
+ });
175
+ id = created.id;
176
+ setThreads((current) => [created, ...current]);
177
+ } else {
178
+ await HandbookThreadMessageService.ask({
179
+ id: v4(),
180
+ threadId: id,
181
+ question: trimmed,
182
+ handbookPageId: options?.handbookPageId,
183
+ });
184
+ }
185
+
186
+ const loaded = await HandbookThreadService.findOne({ id });
187
+ setThread(loaded);
188
+ setMessages(withSources(loaded.messages, sourcesLabel));
189
+ // The server derives the title from the first question, so the list row
190
+ // created a moment ago has to pick it up.
191
+ setThreads((current) => current.map((item) => (item.id === loaded.id ? loaded : item)));
192
+ syncUrl(loaded.id);
193
+ } catch {
194
+ // 400 when the installation has no usable AI configuration, 403 for a
195
+ // non-administrator. Neither is actionable from here, so both surface
196
+ // as one assistant-side message rather than a bespoke error pane.
197
+ setMessages((current) => [
198
+ ...current,
199
+ HandbookThreadMessage.buildLocal({
200
+ role: "assistant",
201
+ content: t("handbook.chat.failed"),
202
+ position: position + 1,
203
+ }),
204
+ ]);
205
+ } finally {
206
+ setSending(false);
207
+ }
208
+ },
209
+ [messages.length, sending, syncUrl, t, thread?.id],
210
+ );
211
+
212
+ return { threads, thread, messages, sending, ask, selectThread, startNew };
213
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The fixed namespace of i18n keys the handbook feature reads via
3
+ * useTranslations. Consuming apps must define each entry in their
4
+ * messages/<locale>.json — this list is the contract between the package and
5
+ * the app, and the names are frozen: an app that wants different wording
6
+ * changes its own translation, never the key.
7
+ *
8
+ * Mirrors the contract shipped by the administration feature
9
+ * (features/administration/i18n-keys.ts).
10
+ */
11
+ export const handbookI18nKeys = [
12
+ "handbook.title",
13
+ "handbook.subtitle",
14
+ "handbook.sync",
15
+ "handbook.syncing",
16
+ "handbook.synced",
17
+ "handbook.syncFailed",
18
+ "handbook.empty",
19
+ "handbook.notConfigured",
20
+ "handbook.chat.title",
21
+ "handbook.chat.placeholder",
22
+ "handbook.chat.ask",
23
+ "handbook.chat.pending",
24
+ "handbook.chat.failed",
25
+ "handbook.chat.sources",
26
+ "handbook.chat.list_title",
27
+ "handbook.chat.new",
28
+ "handbook.chat.empty_sidebar",
29
+ "handbook.chat.rename_placeholder",
30
+ "handbook.chat.delete_confirm",
31
+ "handbook.chat.empty_state.title",
32
+ "handbook.chat.empty_state.subtitle",
33
+ "handbook.contents.title",
34
+ "handbook.contents.status",
35
+ "handbook.contents.failures",
36
+ "handbook.reader.contents",
37
+ "handbook.reader.onThisPage",
38
+ "handbook.reader.previous",
39
+ "handbook.reader.next",
40
+ "handbook.ask.open",
41
+ "handbook.ask.recent",
42
+ "handbook.ask.all",
43
+ "handbook.ask.expand",
44
+ "handbook.ask.thisPageOnly",
45
+ "handbook.sections.untitled",
46
+ ] as const;
@@ -0,0 +1,7 @@
1
+ export * from "./HandbookPageModule";
2
+ export * from "./HandbookSectionModule";
3
+ export * from "./HandbookThreadMessageModule";
4
+ export * from "./HandbookThreadModule";
5
+ export * from "./components";
6
+ export * from "./contexts/HandbookContext";
7
+ export * from "./data";
@@ -239,7 +239,6 @@ function HowToEditorInternal({
239
239
  const payload: HowToInput = {
240
240
  id: values.id,
241
241
  name: values.name,
242
- authorId: "",
243
242
  description: values.description,
244
243
  pages: HowTo.serializePagesToString(values.pages),
245
244
  howToType: values.howToType,
@@ -0,0 +1,84 @@
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 { showError, showToast } from "../../../../utils/toast";
6
+ import { HowToService } from "../../data/HowToService";
7
+ import HowToReindexButton from "./HowToReindexButton";
8
+
9
+ vi.mock("../../data/HowToService", () => ({
10
+ HowToService: { reindex: vi.fn() },
11
+ }));
12
+
13
+ vi.mock("../../../../utils/toast", () => ({
14
+ showToast: vi.fn(),
15
+ showError: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("next-intl", () => ({
19
+ useTranslations: () => (key: string) => key,
20
+ }));
21
+
22
+ describe("HowToReindexButton", () => {
23
+ beforeEach(() => {
24
+ vi.mocked(HowToService.reindex).mockReset().mockResolvedValue(undefined);
25
+ vi.mocked(showToast).mockReset();
26
+ vi.mocked(showError).mockReset();
27
+ });
28
+
29
+ it("renders the action label", () => {
30
+ render(<HowToReindexButton refresh={vi.fn().mockResolvedValue(undefined)} />);
31
+
32
+ expect(screen.getByRole("button")).toHaveTextContent("howto.reindex.action");
33
+ });
34
+
35
+ it("reindexes, then refreshes the list", async () => {
36
+ const refresh = vi.fn().mockResolvedValue(undefined);
37
+ render(<HowToReindexButton refresh={refresh} />);
38
+
39
+ await userEvent.click(screen.getByRole("button"));
40
+
41
+ await waitFor(() => expect(HowToService.reindex).toHaveBeenCalledTimes(1));
42
+ await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
43
+ });
44
+
45
+ it("shows the running state while the reindex is in flight", async () => {
46
+ let resolveReindex: () => void = () => {};
47
+ vi.mocked(HowToService.reindex).mockReturnValue(
48
+ new Promise<void>((resolve) => {
49
+ resolveReindex = resolve;
50
+ }),
51
+ );
52
+ render(<HowToReindexButton refresh={vi.fn().mockResolvedValue(undefined)} />);
53
+
54
+ await userEvent.click(screen.getByRole("button"));
55
+
56
+ await waitFor(() => expect(screen.getByRole("button")).toHaveTextContent("howto.reindex.running"));
57
+ expect(screen.getByRole("button")).toBeDisabled();
58
+
59
+ resolveReindex();
60
+ await waitFor(() => expect(screen.getByRole("button")).toHaveTextContent("howto.reindex.action"));
61
+ });
62
+
63
+ it("shows the done state once the reindex succeeds", async () => {
64
+ render(<HowToReindexButton refresh={vi.fn().mockResolvedValue(undefined)} />);
65
+
66
+ await userEvent.click(screen.getByRole("button"));
67
+
68
+ await waitFor(() => expect(showToast).toHaveBeenCalledWith("howto.reindex.done"));
69
+ expect(showError).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("shows the failed state when the reindex rejects, and re-enables itself", async () => {
73
+ vi.mocked(HowToService.reindex).mockRejectedValue(new Error("boom"));
74
+ const refresh = vi.fn().mockResolvedValue(undefined);
75
+ render(<HowToReindexButton refresh={refresh} />);
76
+
77
+ await userEvent.click(screen.getByRole("button"));
78
+
79
+ await waitFor(() => expect(showError).toHaveBeenCalledWith("howto.reindex.failed"));
80
+ expect(showToast).not.toHaveBeenCalled();
81
+ expect(refresh).not.toHaveBeenCalled();
82
+ await waitFor(() => expect(screen.getByRole("button")).not.toBeDisabled());
83
+ });
84
+ });
@@ -0,0 +1,46 @@
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 { HowToService } from "../../data/HowToService";
9
+
10
+ type HowToReindexButtonProps = {
11
+ refresh: () => Promise<void>;
12
+ };
13
+
14
+ /**
15
+ * Re-queues every guide for chunking.
16
+ *
17
+ * Guides are chunked on create and update only, so one written before chunking
18
+ * existed — or one whose chunks were dropped — is invisible to the help chat
19
+ * with no way back in. This is the way back in.
20
+ *
21
+ * The control is a plain Button, not a trigger: it opens nothing, so the
22
+ * render-prop composition rule for Base UI triggers does not apply here.
23
+ */
24
+ export default function HowToReindexButton({ refresh }: HowToReindexButtonProps) {
25
+ const t = useTranslations();
26
+ const [isReindexing, setIsReindexing] = useState(false);
27
+
28
+ const onReindex = async () => {
29
+ setIsReindexing(true);
30
+ try {
31
+ await HowToService.reindex();
32
+ await refresh();
33
+ showToast(t(`howto.reindex.done`));
34
+ } catch {
35
+ showError(t(`howto.reindex.failed`));
36
+ } finally {
37
+ setIsReindexing(false);
38
+ }
39
+ };
40
+
41
+ return (
42
+ <Button variant="outline" onClick={onReindex} disabled={isReindexing}>
43
+ {isReindexing ? t(`howto.reindex.running`) : t(`howto.reindex.action`)}
44
+ </Button>
45
+ );
46
+ }
@@ -7,5 +7,6 @@ export { default as HowToDetails } from "./details/HowToDetails";
7
7
  export { default as HowToDeleter } from "./forms/HowToDeleter";
8
8
  export { default as HowToEditor } from "./forms/HowToEditor";
9
9
  export { default as HowToMultiSelector } from "./forms/HowToMultiSelector";
10
+ export { default as HowToReindexButton } from "./forms/HowToReindexButton";
10
11
  export { default as HowToSelector } from "./forms/HowToSelector";
11
12
  export { default as HowToList } from "./lists/HowToList";
@@ -10,6 +10,7 @@ import { HowToInterface } from "../../data/HowToInterface";
10
10
  import { HowToService } from "../../data/HowToService";
11
11
  import "../../hooks/useHowToTableStructure";
12
12
  import HowToEditor from "../forms/HowToEditor";
13
+ import HowToReindexButton from "../forms/HowToReindexButton";
13
14
 
14
15
  type HowToListProps = {
15
16
  fullWidth?: boolean;
@@ -24,7 +25,10 @@ export default function HowToList({ fullWidth }: HowToListProps) {
24
25
  retrieverParams: {},
25
26
  });
26
27
 
27
- const functions: ReactNode[] = [<HowToEditor key="create-how-to" />];
28
+ const functions: ReactNode[] = [
29
+ <HowToReindexButton key="reindex-how-tos" refresh={data.refresh} />,
30
+ <HowToEditor key="create-how-to" />,
31
+ ];
28
32
 
29
33
  return (
30
34
  <ContentListTable
@@ -11,16 +11,34 @@ const howToModule: ApiRequestDataTypeInterface = {
11
11
  model: HowTo,
12
12
  } as any;
13
13
 
14
+ const userModule: ApiRequestDataTypeInterface = {
15
+ name: "users",
16
+ model: HowTo,
17
+ } as any;
18
+
14
19
  beforeAll(() => {
15
20
  ModuleRegistry.register("HowTo", howToModule);
21
+ ModuleRegistry.register("User", userModule);
16
22
  });
17
23
 
24
+ /** Collect every `id` value at any depth of a serialised payload. */
25
+ function collectIds(value: any, ids: string[] = []): string[] {
26
+ if (Array.isArray(value)) {
27
+ for (const item of value) collectIds(item, ids);
28
+ } else if (value && typeof value === "object") {
29
+ for (const [key, child] of Object.entries(value)) {
30
+ if (key === "id") ids.push(child as string);
31
+ else collectIds(child, ids);
32
+ }
33
+ }
34
+ return ids;
35
+ }
36
+
18
37
  describe("HowTo.createJsonApi", () => {
19
38
  it("serialises the new attributes", () => {
20
39
  const res = new HowTo().createJsonApi({
21
40
  id: "11111111-1111-4111-8111-111111111111",
22
41
  name: "Add an NPC",
23
- authorId: "",
24
42
  description: [{ type: "paragraph", content: [] }],
25
43
  howToType: "how-to",
26
44
  slug: "add-an-npc",
@@ -40,4 +58,46 @@ describe("HowTo.createJsonApi", () => {
40
58
  expect(a.draft).toBe(false);
41
59
  expect(typeof a.description).toBe("string"); // existing: BlockNote JSON stringified
42
60
  });
61
+
62
+ it("emits no author relationship when the how-to has no author", () => {
63
+ // The backend HowTo descriptor declares `relationships: {}` — a how-to has no
64
+ // author on the wire, so the editor must not invent one.
65
+ const res = new HowTo().createJsonApi({
66
+ id: "11111111-1111-4111-8111-111111111111",
67
+ name: "Add an NPC",
68
+ description: [{ type: "paragraph", content: [] }],
69
+ });
70
+
71
+ expect(res.data.relationships).toEqual({});
72
+ expect(res.data.relationships.author).toBeUndefined();
73
+ expect("author" in res.data.relationships).toBe(false);
74
+ });
75
+
76
+ it("emits no empty-string id anywhere in the payload", () => {
77
+ const res = new HowTo().createJsonApi({
78
+ id: "11111111-1111-4111-8111-111111111111",
79
+ name: "Add an NPC",
80
+ description: [{ type: "paragraph", content: [] }],
81
+ });
82
+
83
+ const ids = collectIds(res);
84
+ expect(ids).toEqual(["11111111-1111-4111-8111-111111111111"]);
85
+ expect(ids).not.toContain("");
86
+ expect(JSON.stringify(res)).not.toContain('"id":""');
87
+ });
88
+
89
+ it("still emits the author relationship when a real author id is supplied", () => {
90
+ // Guards the ContentInput widening: content types that DO carry an author
91
+ // keep serialising it through Content.addContentInput.
92
+ const res = new HowTo().createJsonApi({
93
+ id: "11111111-1111-4111-8111-111111111111",
94
+ name: "Add an NPC",
95
+ authorId: "22222222-2222-4222-8222-222222222222",
96
+ description: [{ type: "paragraph", content: [] }],
97
+ });
98
+
99
+ expect(res.data.relationships.author).toEqual({
100
+ data: { type: "users", id: "22222222-2222-4222-8222-222222222222" },
101
+ });
102
+ });
43
103
  });
@@ -59,6 +59,19 @@ export class HowToService extends AbstractService {
59
59
  });
60
60
  }
61
61
 
62
+ /**
63
+ * POST /howtos/reindex — re-queues every guide for chunking. Replies 204.
64
+ * Chunking otherwise only happens on create and update, so a guide that was
65
+ * never chunked stays invisible to help-mode retrieval with no way back.
66
+ */
67
+ static async reindex(): Promise<void> {
68
+ await this.callApi({
69
+ type: Modules.HowTo,
70
+ method: HttpMethod.POST,
71
+ endpoint: new EndpointCreator({ endpoint: Modules.HowTo, id: "reindex" }).generate(),
72
+ });
73
+ }
74
+
62
75
  static async findPublished(params: { howToType?: string } = {}): Promise<HowToInterface[]> {
63
76
  const endpoint = new EndpointCreator({ endpoint: `public/${Modules.HowTo.name}` });
64
77
  if (params.howToType) endpoint.addAdditionalParam("type", params.howToType);