@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.
- package/dist/{BlockNoteEditor-K7IIWM3S.mjs → BlockNoteEditor-A5LNFWHW.mjs} +2 -2
- package/dist/{BlockNoteEditor-7OKW6HHN.js → BlockNoteEditor-HNQNKCFP.js} +9 -9
- package/dist/{BlockNoteEditor-7OKW6HHN.js.map → BlockNoteEditor-HNQNKCFP.js.map} +1 -1
- package/dist/billing/index.js +299 -299
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-AKXUNE3A.js → chunk-DRJUN4R6.js} +22 -19
- package/dist/chunk-DRJUN4R6.js.map +1 -0
- package/dist/{chunk-TXVVZLUQ.mjs → chunk-LYLGU2BK.mjs} +67 -64
- package/dist/chunk-LYLGU2BK.mjs.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.js +2 -2
- package/dist/components/index.mjs +1 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/features/help/index.js +30 -30
- package/dist/features/help/index.mjs +1 -1
- package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts +2 -0
- package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/container.spec.js +54 -0
- package/dist/scripts/generate-web-module/__tests__/container.spec.js.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts +2 -0
- package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/data-service.spec.js +19 -0
- package/dist/scripts/generate-web-module/__tests__/data-service.spec.js.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts +2 -0
- package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/editor.spec.js +35 -0
- package/dist/scripts/generate-web-module/__tests__/editor.spec.js.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts +4 -0
- package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/fixtures.js +45 -0
- package/dist/scripts/generate-web-module/__tests__/fixtures.js.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts +2 -0
- package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js +74 -0
- package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts +2 -0
- package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js +21 -0
- package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts +2 -0
- package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/selector.spec.js +25 -0
- package/dist/scripts/generate-web-module/__tests__/selector.spec.js.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts +2 -0
- package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js +55 -0
- package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js.map +1 -0
- package/dist/scripts/generate-web-module/generator.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/generator.js +14 -11
- package/dist/scripts/generate-web-module/generator.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/container.template.d.ts +5 -1
- package/dist/scripts/generate-web-module/templates/components/container.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/container.template.js +43 -63
- package/dist/scripts/generate-web-module/templates/components/container.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/content.template.d.ts +7 -4
- package/dist/scripts/generate-web-module/templates/components/content.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/content.template.js +103 -14
- package/dist/scripts/generate-web-module/templates/components/content.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/editor.template.d.ts +1 -1
- package/dist/scripts/generate-web-module/templates/components/editor.template.js +139 -150
- package/dist/scripts/generate-web-module/templates/components/editor.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/index.d.ts +0 -1
- package/dist/scripts/generate-web-module/templates/components/index.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/index.js +1 -3
- package/dist/scripts/generate-web-module/templates/components/index.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/list-container.template.js +2 -2
- package/dist/scripts/generate-web-module/templates/components/list.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/list.template.js +6 -1
- package/dist/scripts/generate-web-module/templates/components/list.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/selector.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/components/selector.template.js +48 -52
- package/dist/scripts/generate-web-module/templates/components/selector.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/data/interface.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/data/interface.template.js +38 -11
- package/dist/scripts/generate-web-module/templates/data/interface.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/data/model.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/data/model.template.js +83 -26
- package/dist/scripts/generate-web-module/templates/data/model.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/data/service.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/data/service.template.js +21 -1
- package/dist/scripts/generate-web-module/templates/data/service.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/index.d.ts +1 -1
- package/dist/scripts/generate-web-module/templates/index.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/index.js +1 -2
- package/dist/scripts/generate-web-module/templates/index.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/module.template.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/templates/module.template.js +11 -3
- package/dist/scripts/generate-web-module/templates/module.template.js.map +1 -1
- package/dist/scripts/generate-web-module/templates/table-hook.template.js +31 -1
- package/dist/scripts/generate-web-module/templates/table-hook.template.js.map +1 -1
- package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts +0 -1
- package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/transformers/import-resolver.js +0 -1
- package/dist/scripts/generate-web-module/transformers/import-resolver.js.map +1 -1
- package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts +5 -0
- package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts.map +1 -0
- package/dist/scripts/generate-web-module/transformers/relationship-key.js +21 -0
- package/dist/scripts/generate-web-module/transformers/relationship-key.js.map +1 -0
- package/dist/scripts/generate-web-module/transformers/relationship-resolver.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/transformers/relationship-resolver.js +2 -0
- package/dist/scripts/generate-web-module/transformers/relationship-resolver.js.map +1 -1
- package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts +21 -0
- package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts.map +1 -1
- package/dist/scripts/generate-web-module/types/template-data.interface.d.ts +17 -0
- package/dist/scripts/generate-web-module/types/template-data.interface.d.ts.map +1 -1
- package/package.json +1 -1
- package/scripts/generate-web-module/__tests__/container.spec.ts +58 -0
- package/scripts/generate-web-module/__tests__/data-service.spec.ts +18 -0
- package/scripts/generate-web-module/__tests__/editor.spec.ts +36 -0
- package/scripts/generate-web-module/__tests__/fixtures.ts +43 -0
- package/scripts/generate-web-module/__tests__/model-interface.spec.ts +77 -0
- package/scripts/generate-web-module/__tests__/plumbing.spec.ts +21 -0
- package/scripts/generate-web-module/__tests__/selector.spec.ts +24 -0
- package/scripts/generate-web-module/__tests__/table-module-list.spec.ts +59 -0
- package/scripts/generate-web-module/generator.ts +14 -13
- package/scripts/generate-web-module/templates/components/container.template.ts +51 -66
- package/scripts/generate-web-module/templates/components/content.template.ts +107 -15
- package/scripts/generate-web-module/templates/components/editor.template.ts +140 -152
- package/scripts/generate-web-module/templates/components/index.ts +0 -1
- package/scripts/generate-web-module/templates/components/list-container.template.ts +2 -2
- package/scripts/generate-web-module/templates/components/list.template.ts +6 -1
- package/scripts/generate-web-module/templates/components/selector.template.ts +48 -52
- package/scripts/generate-web-module/templates/data/interface.template.ts +44 -12
- package/scripts/generate-web-module/templates/data/model.template.ts +88 -28
- package/scripts/generate-web-module/templates/data/service.template.ts +23 -1
- package/scripts/generate-web-module/templates/index.ts +0 -1
- package/scripts/generate-web-module/templates/module.template.ts +13 -3
- package/scripts/generate-web-module/templates/table-hook.template.ts +31 -2
- package/scripts/generate-web-module/transformers/import-resolver.ts +0 -2
- package/scripts/generate-web-module/transformers/relationship-key.ts +18 -0
- package/scripts/generate-web-module/transformers/relationship-resolver.ts +2 -0
- package/scripts/generate-web-module/types/json-schema.interface.ts +9 -0
- package/scripts/generate-web-module/types/template-data.interface.ts +7 -0
- package/src/features/notification/components/modals/NotificationModal.tsx +2 -5
- package/src/features/notification/contexts/NotificationContext.tsx +29 -13
- package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +70 -0
- package/dist/chunk-AKXUNE3A.js.map +0 -1
- package/dist/chunk-TXVVZLUQ.mjs.map +0 -1
- package/dist/scripts/generate-web-module/templates/components/details.template.d.ts +0 -14
- package/dist/scripts/generate-web-module/templates/components/details.template.d.ts.map +0 -1
- package/dist/scripts/generate-web-module/templates/components/details.template.js +0 -112
- package/dist/scripts/generate-web-module/templates/components/details.template.js.map +0 -1
- package/scripts/generate-web-module/templates/components/details.template.ts +0 -116
- /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
|
-
//
|
|
91
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
+
});
|