@carlonicora/nextjs-jsonapi 1.110.0 → 1.111.1

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 (146) hide show
  1. package/dist/{BlockNoteEditor-K7IIWM3S.mjs → BlockNoteEditor-A5LNFWHW.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-7OKW6HHN.js → BlockNoteEditor-HNQNKCFP.js} +9 -9
  3. package/dist/{BlockNoteEditor-7OKW6HHN.js.map → BlockNoteEditor-HNQNKCFP.js.map} +1 -1
  4. package/dist/billing/index.js +299 -299
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-AKXUNE3A.js → chunk-DRJUN4R6.js} +22 -19
  7. package/dist/chunk-DRJUN4R6.js.map +1 -0
  8. package/dist/{chunk-TXVVZLUQ.mjs → chunk-LYLGU2BK.mjs} +67 -64
  9. package/dist/chunk-LYLGU2BK.mjs.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.js +2 -2
  13. package/dist/components/index.mjs +1 -1
  14. package/dist/contexts/index.js +2 -2
  15. package/dist/contexts/index.mjs +1 -1
  16. package/dist/features/help/index.js +30 -30
  17. package/dist/features/help/index.mjs +1 -1
  18. package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts +2 -0
  19. package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts.map +1 -0
  20. package/dist/scripts/generate-web-module/__tests__/container.spec.js +54 -0
  21. package/dist/scripts/generate-web-module/__tests__/container.spec.js.map +1 -0
  22. package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts +2 -0
  23. package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts.map +1 -0
  24. package/dist/scripts/generate-web-module/__tests__/data-service.spec.js +19 -0
  25. package/dist/scripts/generate-web-module/__tests__/data-service.spec.js.map +1 -0
  26. package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts +2 -0
  27. package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts.map +1 -0
  28. package/dist/scripts/generate-web-module/__tests__/editor.spec.js +35 -0
  29. package/dist/scripts/generate-web-module/__tests__/editor.spec.js.map +1 -0
  30. package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts +4 -0
  31. package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts.map +1 -0
  32. package/dist/scripts/generate-web-module/__tests__/fixtures.js +45 -0
  33. package/dist/scripts/generate-web-module/__tests__/fixtures.js.map +1 -0
  34. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts +2 -0
  35. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts.map +1 -0
  36. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js +74 -0
  37. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js.map +1 -0
  38. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts +2 -0
  39. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts.map +1 -0
  40. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js +21 -0
  41. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js.map +1 -0
  42. package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts +2 -0
  43. package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts.map +1 -0
  44. package/dist/scripts/generate-web-module/__tests__/selector.spec.js +25 -0
  45. package/dist/scripts/generate-web-module/__tests__/selector.spec.js.map +1 -0
  46. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts +2 -0
  47. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts.map +1 -0
  48. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js +55 -0
  49. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js.map +1 -0
  50. package/dist/scripts/generate-web-module/generator.d.ts.map +1 -1
  51. package/dist/scripts/generate-web-module/generator.js +14 -11
  52. package/dist/scripts/generate-web-module/generator.js.map +1 -1
  53. package/dist/scripts/generate-web-module/templates/components/container.template.d.ts +5 -1
  54. package/dist/scripts/generate-web-module/templates/components/container.template.d.ts.map +1 -1
  55. package/dist/scripts/generate-web-module/templates/components/container.template.js +43 -63
  56. package/dist/scripts/generate-web-module/templates/components/container.template.js.map +1 -1
  57. package/dist/scripts/generate-web-module/templates/components/content.template.d.ts +7 -4
  58. package/dist/scripts/generate-web-module/templates/components/content.template.d.ts.map +1 -1
  59. package/dist/scripts/generate-web-module/templates/components/content.template.js +103 -14
  60. package/dist/scripts/generate-web-module/templates/components/content.template.js.map +1 -1
  61. package/dist/scripts/generate-web-module/templates/components/editor.template.d.ts +1 -1
  62. package/dist/scripts/generate-web-module/templates/components/editor.template.js +139 -150
  63. package/dist/scripts/generate-web-module/templates/components/editor.template.js.map +1 -1
  64. package/dist/scripts/generate-web-module/templates/components/index.d.ts +0 -1
  65. package/dist/scripts/generate-web-module/templates/components/index.d.ts.map +1 -1
  66. package/dist/scripts/generate-web-module/templates/components/index.js +1 -3
  67. package/dist/scripts/generate-web-module/templates/components/index.js.map +1 -1
  68. package/dist/scripts/generate-web-module/templates/components/list-container.template.js +2 -2
  69. package/dist/scripts/generate-web-module/templates/components/list.template.d.ts.map +1 -1
  70. package/dist/scripts/generate-web-module/templates/components/list.template.js +6 -1
  71. package/dist/scripts/generate-web-module/templates/components/list.template.js.map +1 -1
  72. package/dist/scripts/generate-web-module/templates/components/selector.template.d.ts.map +1 -1
  73. package/dist/scripts/generate-web-module/templates/components/selector.template.js +48 -52
  74. package/dist/scripts/generate-web-module/templates/components/selector.template.js.map +1 -1
  75. package/dist/scripts/generate-web-module/templates/data/interface.template.d.ts.map +1 -1
  76. package/dist/scripts/generate-web-module/templates/data/interface.template.js +38 -11
  77. package/dist/scripts/generate-web-module/templates/data/interface.template.js.map +1 -1
  78. package/dist/scripts/generate-web-module/templates/data/model.template.d.ts.map +1 -1
  79. package/dist/scripts/generate-web-module/templates/data/model.template.js +83 -26
  80. package/dist/scripts/generate-web-module/templates/data/model.template.js.map +1 -1
  81. package/dist/scripts/generate-web-module/templates/data/service.template.d.ts.map +1 -1
  82. package/dist/scripts/generate-web-module/templates/data/service.template.js +21 -1
  83. package/dist/scripts/generate-web-module/templates/data/service.template.js.map +1 -1
  84. package/dist/scripts/generate-web-module/templates/index.d.ts +1 -1
  85. package/dist/scripts/generate-web-module/templates/index.d.ts.map +1 -1
  86. package/dist/scripts/generate-web-module/templates/index.js +1 -2
  87. package/dist/scripts/generate-web-module/templates/index.js.map +1 -1
  88. package/dist/scripts/generate-web-module/templates/module.template.d.ts.map +1 -1
  89. package/dist/scripts/generate-web-module/templates/module.template.js +11 -3
  90. package/dist/scripts/generate-web-module/templates/module.template.js.map +1 -1
  91. package/dist/scripts/generate-web-module/templates/table-hook.template.js +31 -1
  92. package/dist/scripts/generate-web-module/templates/table-hook.template.js.map +1 -1
  93. package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts +0 -1
  94. package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts.map +1 -1
  95. package/dist/scripts/generate-web-module/transformers/import-resolver.js +0 -1
  96. package/dist/scripts/generate-web-module/transformers/import-resolver.js.map +1 -1
  97. package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts +5 -0
  98. package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts.map +1 -0
  99. package/dist/scripts/generate-web-module/transformers/relationship-key.js +21 -0
  100. package/dist/scripts/generate-web-module/transformers/relationship-key.js.map +1 -0
  101. package/dist/scripts/generate-web-module/transformers/relationship-resolver.d.ts.map +1 -1
  102. package/dist/scripts/generate-web-module/transformers/relationship-resolver.js +2 -0
  103. package/dist/scripts/generate-web-module/transformers/relationship-resolver.js.map +1 -1
  104. package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts +21 -0
  105. package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts.map +1 -1
  106. package/dist/scripts/generate-web-module/types/template-data.interface.d.ts +17 -0
  107. package/dist/scripts/generate-web-module/types/template-data.interface.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/scripts/generate-web-module/__tests__/container.spec.ts +58 -0
  110. package/scripts/generate-web-module/__tests__/data-service.spec.ts +18 -0
  111. package/scripts/generate-web-module/__tests__/editor.spec.ts +36 -0
  112. package/scripts/generate-web-module/__tests__/fixtures.ts +43 -0
  113. package/scripts/generate-web-module/__tests__/model-interface.spec.ts +77 -0
  114. package/scripts/generate-web-module/__tests__/plumbing.spec.ts +21 -0
  115. package/scripts/generate-web-module/__tests__/selector.spec.ts +24 -0
  116. package/scripts/generate-web-module/__tests__/table-module-list.spec.ts +59 -0
  117. package/scripts/generate-web-module/generator.ts +14 -13
  118. package/scripts/generate-web-module/templates/components/container.template.ts +51 -66
  119. package/scripts/generate-web-module/templates/components/content.template.ts +107 -15
  120. package/scripts/generate-web-module/templates/components/editor.template.ts +140 -152
  121. package/scripts/generate-web-module/templates/components/index.ts +0 -1
  122. package/scripts/generate-web-module/templates/components/list-container.template.ts +2 -2
  123. package/scripts/generate-web-module/templates/components/list.template.ts +6 -1
  124. package/scripts/generate-web-module/templates/components/selector.template.ts +48 -52
  125. package/scripts/generate-web-module/templates/data/interface.template.ts +44 -12
  126. package/scripts/generate-web-module/templates/data/model.template.ts +88 -28
  127. package/scripts/generate-web-module/templates/data/service.template.ts +23 -1
  128. package/scripts/generate-web-module/templates/index.ts +0 -1
  129. package/scripts/generate-web-module/templates/module.template.ts +13 -3
  130. package/scripts/generate-web-module/templates/table-hook.template.ts +31 -2
  131. package/scripts/generate-web-module/transformers/import-resolver.ts +0 -2
  132. package/scripts/generate-web-module/transformers/relationship-key.ts +18 -0
  133. package/scripts/generate-web-module/transformers/relationship-resolver.ts +2 -0
  134. package/scripts/generate-web-module/types/json-schema.interface.ts +9 -0
  135. package/scripts/generate-web-module/types/template-data.interface.ts +7 -0
  136. package/src/features/notification/components/modals/NotificationModal.tsx +2 -5
  137. package/src/features/notification/contexts/NotificationContext.tsx +29 -13
  138. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +70 -0
  139. package/dist/chunk-AKXUNE3A.js.map +0 -1
  140. package/dist/chunk-TXVVZLUQ.mjs.map +0 -1
  141. package/dist/scripts/generate-web-module/templates/components/details.template.d.ts +0 -14
  142. package/dist/scripts/generate-web-module/templates/components/details.template.d.ts.map +0 -1
  143. package/dist/scripts/generate-web-module/templates/components/details.template.js +0 -112
  144. package/dist/scripts/generate-web-module/templates/components/details.template.js.map +0 -1
  145. package/scripts/generate-web-module/templates/components/details.template.ts +0 -116
  146. /package/dist/{BlockNoteEditor-K7IIWM3S.mjs.map → BlockNoteEditor-A5LNFWHW.mjs.map} +0 -0
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { FrontendTemplateData, FrontendField } from "../types/template-data.interface";
8
- import { toCamelCase } from "../transformers/name-transformer";
8
+ import { toCamelCase, pluralize } from "../transformers/name-transformer";
9
9
 
10
10
  /**
11
11
  * Generate the table structure hook file content
@@ -96,7 +96,7 @@ registerTableGenerator(Modules.${names.pascalCase}, use${names.pascalCase}TableS
96
96
  * Generate field column map entries
97
97
  */
98
98
  function generateFieldColumnEntries(data: FrontendTemplateData): string {
99
- const { names, fields, extendsContent } = data;
99
+ const { names, fields, extendsContent, relationships } = data;
100
100
  const entries: string[] = [];
101
101
 
102
102
  // Name field (with link and tooltip for tldr if Content-extending)
@@ -183,8 +183,37 @@ function generateFieldColumnEntries(data: FrontendTemplateData): string {
183
183
  enableSorting: false,
184
184
  enableHiding: false,
185
185
  }),`);
186
+ } else if (field.type === "date" || field.type === "datetime") {
187
+ entries.push(` [${names.pascalCase}Fields.${field.name}]: () =>
188
+ cellDate({
189
+ name: "${field.name}",
190
+ title: t(\`features.${names.camelCase}.fields.${field.name}.label\`),
191
+ }),`);
186
192
  }
187
193
  });
188
194
 
195
+ // Relationship columns (rendered when flagged for table display)
196
+ relationships
197
+ .filter((rel) => rel.showInTable)
198
+ .forEach((rel) => {
199
+ const key = rel.single
200
+ ? toCamelCase(rel.alias || rel.variant || rel.name)
201
+ : pluralize(toCamelCase(rel.alias || rel.name));
202
+ const accessor = rel.single
203
+ ? `${names.camelCase}.${key}?.name ?? ""`
204
+ : `${names.camelCase}.${key}.map((r) => r.name).join(", ")`;
205
+ entries.push(` [${names.pascalCase}Fields.${key}]: () => ({
206
+ id: "${key}",
207
+ accessorKey: "${key}",
208
+ header: t(\`generic.relationships.${key}.label\`),
209
+ cell: ({ row }: { row: TableContent<${names.pascalCase}Interface> }) => {
210
+ const ${names.camelCase}: ${names.pascalCase}Interface = row.original.jsonApiData;
211
+ return <span>{${accessor}}</span>;
212
+ },
213
+ enableSorting: false,
214
+ enableHiding: false,
215
+ }),`);
216
+ });
217
+
189
218
  return entries.join("\n");
190
219
  }
@@ -177,7 +177,6 @@ export interface ModuleFilePaths {
177
177
  selector: string;
178
178
  multiSelector: string;
179
179
  list: string;
180
- details: string;
181
180
  content: string;
182
181
  container: string;
183
182
  listContainer: string;
@@ -220,7 +219,6 @@ export function buildFilePaths(
220
219
  selector: `${basePath}/components/forms/${names.pascalCase}Selector.tsx`,
221
220
  multiSelector: `${basePath}/components/forms/${names.pascalCase}MultiSelector.tsx`,
222
221
  list: `${basePath}/components/lists/${names.pascalCase}List.tsx`,
223
- details: `${basePath}/components/details/${names.pascalCase}Details.tsx`,
224
222
  content: `${basePath}/components/details/${names.pascalCase}Content.tsx`,
225
223
  container: `${basePath}/components/containers/${names.pascalCase}Container.tsx`,
226
224
  listContainer: `${basePath}/components/containers/${names.pascalCase}ListContainer.tsx`,
@@ -0,0 +1,18 @@
1
+ import { FrontendRelationship } from "../types/template-data.interface";
2
+ import { toKebabCase, pluralize } from "./name-transformer";
3
+
4
+ /** JSON:API wire key for a relationship. Schema dtoKey wins; otherwise derive
5
+ * identically to the backend mapper so both sides of the wire agree. */
6
+ export function resolveRelationshipKey(rel: FrontendRelationship): string {
7
+ if (rel.dtoKey) return rel.dtoKey;
8
+ // NOTE: Author relationships are normally NOT serialized through this helper — the backend
9
+ // special-cases Author via contextKey/currentUser and the model template skips Author
10
+ // serialization, so callers must not rely on this helper for Author wire keys.
11
+ if (rel.variant) {
12
+ return rel.single ? rel.variant.toLowerCase() : pluralize(rel.variant.toLowerCase());
13
+ }
14
+ if (rel.alias) {
15
+ return rel.single ? toKebabCase(rel.alias) : pluralize(toKebabCase(rel.alias));
16
+ }
17
+ return rel.single ? rel.name.toLowerCase() : pluralize(rel.name.toLowerCase());
18
+ }
@@ -122,6 +122,8 @@ export function resolveRelationship(rel: JsonRelationshipDefinition, targetHasNa
122
122
  modelKebab,
123
123
  fields,
124
124
  targetHasName: targetHasNameMap?.get(rel.name) ?? true, // Foundation entities default to true
125
+ dtoKey: rel.dtoKey ?? "",
126
+ showInTable: rel.showInTable === true,
125
127
  };
126
128
  }
127
129
 
@@ -13,6 +13,7 @@ export interface JsonFieldDefinition {
13
13
  name: string;
14
14
  type: string;
15
15
  nullable: boolean;
16
+ readOnly?: boolean;
16
17
  }
17
18
 
18
19
  /**
@@ -29,6 +30,10 @@ export interface JsonRelationshipDefinition {
29
30
  toNode: boolean; // Backend-specific, ignored in frontend
30
31
  nullable: boolean;
31
32
  fields?: JsonFieldDefinition[]; // Relationship property fields (stored on edges)
33
+ /** Explicit JSON:API wire key. When absent it is derived identically to the backend mapper. */
34
+ dtoKey?: string;
35
+ description?: string;
36
+ showInTable?: boolean;
32
37
  }
33
38
 
34
39
  /**
@@ -44,4 +49,8 @@ export interface JsonModuleDefinition {
44
49
  relationships: JsonRelationshipDefinition[];
45
50
  /** Explicitly set whether this module extends Content. If not set, auto-detects based on fields. */
46
51
  extendsContent?: boolean;
52
+ featureId?: string;
53
+ displayProp?: string;
54
+ containerTabs?: { activity?: boolean; relations?: { module: string; listProp: string; directory: string }[] };
55
+ inclusions?: { related?: { endpoint: string; fields: string[] }[] };
47
56
  }
@@ -30,6 +30,7 @@ export interface FrontendField {
30
30
  formComponent: FormComponentType;
31
31
  nullable: boolean;
32
32
  isContentField: boolean; // Indicates BlockNoteEditor
33
+ readOnly?: boolean;
33
34
  }
34
35
 
35
36
  /**
@@ -68,6 +69,8 @@ export interface FrontendRelationship {
68
69
  modelKebab: string; // e.g., "user"
69
70
  fields?: FrontendField[]; // Relationship property fields (stored on edges)
70
71
  targetHasName: boolean; // Whether the target entity has a "name" field (or extends Content)
72
+ dtoKey: string; // resolved wire key; "" means derive at use-site
73
+ showInTable: boolean;
71
74
  }
72
75
 
73
76
  /**
@@ -153,6 +156,10 @@ export interface FrontendTemplateData {
153
156
 
154
157
  // For service generation
155
158
  relationshipServiceMethods: RelationshipServiceMethod[];
159
+ featureId?: string;
160
+ displayProp: string;
161
+ containerTabs: { activity: boolean; relations: { module: string; listProp: string; directory: string }[] };
162
+ relatedInclusions: { endpoint: string; fields: string[] }[];
156
163
  }
157
164
 
158
165
  /**
@@ -36,7 +36,6 @@ function NotificationModalContent({ isOpen, setIsOpen }: NotificationModalProps)
36
36
  error,
37
37
  loadNotifications,
38
38
  shouldRefresh,
39
- lastLoaded,
40
39
  } = useNotificationContext();
41
40
  const {
42
41
  socketNotifications,
@@ -87,10 +86,8 @@ function NotificationModalContent({ isOpen, setIsOpen }: NotificationModalProps)
87
86
  setNewNotifications(unreadCount > 0);
88
87
  }, [unreadCount]);
89
88
 
90
- // Load notifications from API on mount if never loaded
91
- useEffect(() => {
92
- if (lastLoaded === 0) loadNotifications();
93
- }, [lastLoaded, loadNotifications]);
89
+ // The initial load is owned by NotificationContextProvider's mount effect.
90
+ // The bell still refreshes on open when the cache is stale (see handleOpenChange).
94
91
 
95
92
  const processSocketNotificationsRef = useRef<NodeJS.Timeout | null>(null);
96
93
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import React, { createContext, ReactElement, useCallback, useContext, useEffect, useState } from "react";
4
+ import React, { createContext, ReactElement, useCallback, useContext, useEffect, useRef, useState } from "react";
5
5
  import { SharedProvider } from "../../../contexts";
6
6
  import { Modules } from "../../../core";
7
7
  import { useI18nRouter } from "../../../i18n";
@@ -55,6 +55,11 @@ export const NotificationContextProvider = ({ children }: NotificationContextPro
55
55
  const [lastLoaded, setLastLoaded] = useState(0);
56
56
  const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false);
57
57
 
58
+ // Tracks an in-flight loadNotifications() request so concurrent callers
59
+ // (the provider's mount effect + any consumer that also loads on mount)
60
+ // share a single GET /notifications instead of each firing their own.
61
+ const inFlightRef = useRef<Promise<void> | null>(null);
62
+
58
63
  // Access current user for initial load check
59
64
  const { currentUser } = useCurrentUserContext();
60
65
 
@@ -80,19 +85,30 @@ export const NotificationContextProvider = ({ children }: NotificationContextPro
80
85
  }, []);
81
86
 
82
87
  const loadNotifications = useCallback(async () => {
83
- setIsLoading(true);
84
- setError(null);
88
+ // Coalesce concurrent callers into one request. Without this, the several
89
+ // mount-time loaders fire before the first fetch resolves (lastLoaded is
90
+ // still 0), so each issues its own GET /notifications.
91
+ if (inFlightRef.current) return inFlightRef.current;
92
+
93
+ const request = (async () => {
94
+ setIsLoading(true);
95
+ setError(null);
96
+
97
+ try {
98
+ const fetchedNotifications = await NotificationService.findMany({});
99
+ setNotifications(fetchedNotifications);
100
+ setLastLoaded(Date.now());
101
+ } catch (error) {
102
+ const errorMessage = error instanceof Error ? error.message : "Failed to load notifications";
103
+ setError(errorMessage);
104
+ } finally {
105
+ setIsLoading(false);
106
+ inFlightRef.current = null;
107
+ }
108
+ })();
85
109
 
86
- try {
87
- const fetchedNotifications = await NotificationService.findMany({});
88
- setNotifications(fetchedNotifications);
89
- setLastLoaded(Date.now());
90
- } catch (error) {
91
- const errorMessage = error instanceof Error ? error.message : "Failed to load notifications";
92
- setError(errorMessage);
93
- } finally {
94
- setIsLoading(false);
95
- }
110
+ inFlightRef.current = request;
111
+ return request;
96
112
  }, []);
97
113
 
98
114
  // Initial load - runs once per session when user is available and not admin
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
2
+ import { renderHook, act } from "@testing-library/react";
3
+ import { configureI18n } from "../../../../i18n";
4
+
5
+ // currentUser undefined → the provider's initial-load effect is a no-op
6
+ // (it returns early on `!currentUser`), so the ONLY fetches in this test come
7
+ // from the explicit loadNotifications() calls below. This isolates the
8
+ // in-flight de-duplication behaviour from the mount-time auto-load.
9
+ vi.mock("../../../user/contexts/CurrentUserContext", () => ({
10
+ useCurrentUserContext: () => ({ currentUser: undefined }),
11
+ }));
12
+ vi.mock("../../data/notification.service", () => ({
13
+ NotificationService: { findMany: vi.fn(), markAsRead: vi.fn() },
14
+ }));
15
+
16
+ import { NotificationContextProvider, useNotificationContext } from "../NotificationContext";
17
+ import { NotificationService } from "../../data/notification.service";
18
+
19
+ const stubRouter = {
20
+ push: vi.fn(),
21
+ replace: vi.fn(),
22
+ back: vi.fn(),
23
+ forward: vi.fn(),
24
+ refresh: vi.fn(),
25
+ prefetch: vi.fn(),
26
+ };
27
+
28
+ function wrapper({ children }: { children: React.ReactNode }) {
29
+ return <NotificationContextProvider>{children}</NotificationContextProvider>;
30
+ }
31
+
32
+ describe("NotificationContext.loadNotifications", () => {
33
+ beforeAll(() => {
34
+ // useI18nRouter() throws unless i18n is configured; the provider calls it at mount.
35
+ configureI18n({
36
+ useRouter: () => stubRouter,
37
+ useTranslations: () => (key: string) => key,
38
+ usePathname: () => "/",
39
+ Link: (({ children }: { children: React.ReactNode }) => children) as any,
40
+ });
41
+ });
42
+
43
+ beforeEach(() => {
44
+ vi.mocked(NotificationService.findMany).mockReset();
45
+ });
46
+
47
+ it("coalesces concurrent loadNotifications calls into a single request", async () => {
48
+ let release: (() => void) | undefined;
49
+ vi.mocked(NotificationService.findMany).mockImplementation(
50
+ () =>
51
+ new Promise((resolve) => {
52
+ release = () => resolve([] as never);
53
+ }),
54
+ );
55
+
56
+ const { result } = renderHook(() => useNotificationContext(), { wrapper });
57
+
58
+ // Three concurrent callers, mirroring the three mount-time triggers
59
+ // (provider + LayoutDetails + NotificationModal) firing before the first
60
+ // request resolves.
61
+ await act(async () => {
62
+ result.current.loadNotifications();
63
+ result.current.loadNotifications();
64
+ result.current.loadNotifications();
65
+ release?.();
66
+ });
67
+
68
+ expect(NotificationService.findMany).toHaveBeenCalledTimes(1);
69
+ });
70
+ });