@carlonicora/nextjs-jsonapi 3.24.2 → 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{AssistantMessageInterface-1Kbz8RVs.d.ts → AssistantMessageInterface-CubxuBIJ.d.ts} +1 -1
- package/dist/{AssistantMessageInterface-CoxJyuFM.d.mts → AssistantMessageInterface-sHjI2zPN.d.mts} +1 -1
- package/dist/{BlockNoteEditor-35D4R7HJ.js → BlockNoteEditor-4EIKMNY3.js} +31 -21
- package/dist/BlockNoteEditor-4EIKMNY3.js.map +1 -0
- package/dist/{BlockNoteEditor-65HR6ULV.mjs → BlockNoteEditor-AXYVDDXM.mjs} +16 -6
- package/dist/{BlockNoteEditor-65HR6ULV.mjs.map → BlockNoteEditor-AXYVDDXM.mjs.map} +1 -1
- package/dist/{BlockNoteViewer-QDXDVYEM.js → BlockNoteViewer-7BFNTN4E.js} +4 -4
- package/dist/{BlockNoteViewer-QDXDVYEM.js.map → BlockNoteViewer-7BFNTN4E.js.map} +1 -1
- package/dist/{BlockNoteViewer-KDKGUIG5.mjs → BlockNoteViewer-KRTAFTUX.mjs} +3 -3
- package/dist/{HandbookSectionInterface-OePKPbqN.d.ts → HandbookSectionInterface-Ck1wZJ81.d.ts} +1 -1
- package/dist/{HandbookSectionInterface-BYsmnPEz.d.mts → HandbookSectionInterface-DgYH--Ik.d.mts} +1 -1
- package/dist/JsonApiRequest-TE5WD3WS.js +26 -0
- package/dist/{JsonApiRequest-G625QAH5.js.map → JsonApiRequest-TE5WD3WS.js.map} +1 -1
- package/dist/{JsonApiRequest-RRMNV7YE.mjs → JsonApiRequest-UHOWNI3O.mjs} +2 -2
- package/dist/analytics.modules-CfczU12P.d.ts +25 -0
- package/dist/analytics.modules-pzGwMASI.d.mts +25 -0
- package/dist/{auth.interface-BC1jp_ni.d.ts → auth.interface-BzI4onI_.d.ts} +1 -1
- package/dist/{auth.interface-dR2-6gPM.d.mts → auth.interface-Df4vXbNz.d.mts} +1 -1
- package/dist/billing/index.js +402 -402
- package/dist/billing/index.mjs +4 -4
- package/dist/{chunk-YXRVPEEJ.js → chunk-26MQIJNB.js} +1 -1
- package/dist/{chunk-YXRVPEEJ.js.map → chunk-26MQIJNB.js.map} +1 -1
- package/dist/{chunk-T76WLDE5.mjs → chunk-37DMFVLN.mjs} +1 -1
- package/dist/{chunk-3AR4KA5D.js → chunk-4Z45NYPR.js} +13 -13
- package/dist/{chunk-3AR4KA5D.js.map → chunk-4Z45NYPR.js.map} +1 -1
- package/dist/chunk-7B4E42R5.mjs +280 -0
- package/dist/chunk-7B4E42R5.mjs.map +1 -0
- package/dist/{chunk-2XCUAHQT.js → chunk-ABVTYQIJ.js} +1563 -1336
- package/dist/chunk-ABVTYQIJ.js.map +1 -0
- package/dist/{chunk-U7QVISQS.js → chunk-CAKOFJZD.js} +450 -40
- package/dist/chunk-CAKOFJZD.js.map +1 -0
- package/dist/{chunk-IBVKPLHT.mjs → chunk-E3I4MT53.mjs} +422 -12
- package/dist/chunk-E3I4MT53.mjs.map +1 -0
- package/dist/chunk-GHFMKH3M.js +280 -0
- package/dist/chunk-GHFMKH3M.js.map +1 -0
- package/dist/chunk-GKDHYB2R.js +110 -0
- package/dist/chunk-GKDHYB2R.js.map +1 -0
- package/dist/{chunk-ZJI5ZZW5.mjs → chunk-Q6J2ICKO.mjs} +3 -3
- package/dist/{chunk-4O7R2Z2O.mjs → chunk-QWZAGQWJ.mjs} +2175 -1948
- package/dist/chunk-QWZAGQWJ.mjs.map +1 -0
- package/dist/chunk-U65JPKO3.mjs +110 -0
- package/dist/chunk-U65JPKO3.mjs.map +1 -0
- package/dist/client/index.d.mts +6 -5
- package/dist/client/index.d.ts +6 -5
- package/dist/client/index.js +5 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +4 -4
- package/dist/components/index.d.mts +33 -10
- package/dist/components/index.d.ts +33 -10
- package/dist/components/index.js +7 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +6 -4
- package/dist/{config-B6UB7j69.d.ts → config-3N1LSrcC.d.ts} +1 -1
- package/dist/{config-Cd5xTEfZ.d.ts → config-AVtuvfM1.d.ts} +1 -1
- package/dist/{config-n-ZpPmOL.d.mts → config-BSKTYDoh.d.mts} +1 -1
- package/dist/{config-YyhFHxkU.d.mts → config-BmufyEj6.d.mts} +1 -1
- package/dist/contexts/index.d.mts +5 -4
- package/dist/contexts/index.d.ts +5 -4
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +31 -14
- package/dist/core/index.d.ts +31 -14
- package/dist/core/index.js +9 -5
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +8 -4
- package/dist/features/analytics/index.css +30 -0
- package/dist/features/analytics/index.css.map +1 -0
- package/dist/features/analytics/index.d.mts +508 -0
- package/dist/features/analytics/index.d.ts +508 -0
- package/dist/features/analytics/index.js +1166 -0
- package/dist/features/analytics/index.js.map +1 -0
- package/dist/features/analytics/index.mjs +1166 -0
- package/dist/features/analytics/index.mjs.map +1 -0
- package/dist/features/analytics/server.d.mts +28 -0
- package/dist/features/analytics/server.d.ts +28 -0
- package/dist/features/analytics/server.js +41 -0
- package/dist/features/analytics/server.js.map +1 -0
- package/dist/features/analytics/server.mjs +41 -0
- package/dist/features/analytics/server.mjs.map +1 -0
- package/dist/features/help/index.d.mts +2 -2
- package/dist/features/help/index.d.ts +2 -2
- package/dist/features/help/index.js +43 -43
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +9 -9
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.d.mts +2 -2
- package/dist/features/tokenusage/index.d.ts +2 -2
- package/dist/features/tokenusage/index.js +141 -223
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +28 -110
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +11 -9
- package/dist/index.d.ts +11 -9
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9 -5
- package/dist/notification.interface-BNCvnVCW.d.ts +34 -0
- package/dist/notification.interface-P_pNfzZQ.d.mts +34 -0
- package/dist/{s3.service-Bqr-fQmi.d.ts → s3.service-CbUa6a-8.d.ts} +3 -2
- package/dist/{s3.service-Buiy1ncj.d.mts → s3.service-DAp8FbuG.d.mts} +3 -2
- package/dist/server/index.d.mts +5 -4
- package/dist/server/index.d.ts +5 -4
- package/dist/server/index.js +39 -279
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +20 -260
- package/dist/server/index.mjs.map +1 -1
- package/dist/{types-bsm8Fp55.d.ts → types-Cn153-mH.d.ts} +1 -1
- package/dist/{types-Bq_UA8Lg.d.mts → types-D0uLYV2P.d.mts} +1 -1
- package/dist/{usePageUrlGenerator-J_ECv-oQ.d.ts → usePageUrlGenerator-B-3FRKBD.d.ts} +1 -1
- package/dist/{usePageUrlGenerator-uKBjAKqC.d.mts → usePageUrlGenerator-CCJ_cdQy.d.mts} +1 -1
- package/dist/{useSocket-BcH7l7B8.d.mts → useSocket-B9gTbuCj.d.mts} +1 -1
- package/dist/{useSocket-DCZd_eJk.d.ts → useSocket-BHVIyigI.d.ts} +1 -1
- package/dist/{notification.interface-MnRt1Tjm.d.mts → user.interface-CSaGWFbg.d.mts} +1 -31
- package/dist/{notification.interface-CjzV_KII.d.ts → user.interface-CrmwJLYW.d.ts} +1 -31
- package/package.json +11 -1
- package/src/client/index.ts +2 -0
- package/src/components/containers/PageContainer.tsx +1 -1
- package/src/components/containers/RoundPageContainer.tsx +60 -16
- package/src/components/containers/RoundPageContainerTitle.tsx +75 -35
- package/src/components/editors/BlockNoteEditor.tsx +13 -1
- package/src/components/forms/EntityMultiSelector.tsx +4 -4
- package/src/components/forms/EntitySelector.tsx +4 -4
- package/src/components/forms/FormBlockNote.tsx +21 -1
- package/src/components/forms/FormCheckbox.tsx +6 -2
- package/src/components/forms/__tests__/useEditorDialog.test.ts +47 -0
- package/src/components/forms/useEditorDialog.ts +22 -0
- package/src/components/navigations/Breadcrumb.tsx +45 -16
- package/src/components/navigations/Header.tsx +16 -3
- package/src/components/navigations/MobileNavigationBar.tsx +12 -0
- package/src/components/navigations/__tests__/MobileNavigationBar.spec.tsx +17 -0
- package/src/components/tables/ContentListTable.tsx +52 -32
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +7 -0
- package/src/features/__tests__/i18n-keys.contract.spec.ts +1 -0
- package/src/features/analytics/__tests__/server-entry.spec.tsx +64 -0
- package/src/features/analytics/analytics.modules.ts +30 -0
- package/src/features/analytics/components/AnalyticsAdminContainer.tsx +97 -0
- package/src/features/analytics/components/AnalyticsAdminFilterBar.tsx +120 -0
- package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +25 -0
- package/src/features/analytics/components/AnalyticsAdminTiles.tsx +191 -0
- package/src/features/analytics/components/AnalyticsBreakdownTable.tsx +105 -0
- package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +246 -0
- package/src/features/analytics/components/AnalyticsJourneyPageContainer.tsx +14 -0
- package/src/features/analytics/components/AnalyticsSessionsTable.tsx +107 -0
- package/src/features/analytics/components/AnalyticsTimelineChart.tsx +175 -0
- package/src/features/analytics/components/AnalyticsTracker.tsx +112 -0
- package/src/features/analytics/components/__tests__/AnalyticsAdminTiles.spec.tsx +39 -0
- package/src/features/analytics/components/__tests__/AnalyticsBreakdownTable.spec.tsx +39 -0
- package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +124 -0
- package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +117 -0
- package/src/features/analytics/components/__tests__/AnalyticsSessionsTable.spec.tsx +121 -0
- package/src/features/analytics/components/__tests__/AnalyticsTimelineChart.spec.tsx +35 -0
- package/src/features/analytics/components/__tests__/AnalyticsTracker.spec.tsx +248 -0
- package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +28 -0
- package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +25 -0
- package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +273 -0
- package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +140 -0
- package/src/features/analytics/data/AnalyticsAdminService.ts +91 -0
- package/src/features/analytics/data/AnalyticsService.ts +23 -0
- package/src/features/analytics/data/__tests__/AnalyticsAdminService.spec.ts +117 -0
- package/src/features/analytics/data/__tests__/AnalyticsService.spec.ts +56 -0
- package/src/features/analytics/data/__tests__/models.spec.ts +300 -0
- package/src/features/analytics/data/analytics-breakdown.interface.ts +9 -0
- package/src/features/analytics/data/analytics-breakdown.ts +65 -0
- package/src/features/analytics/data/analytics-event.interface.ts +7 -0
- package/src/features/analytics/data/analytics-event.ts +63 -0
- package/src/features/analytics/data/analytics-page-view.interface.ts +9 -0
- package/src/features/analytics/data/analytics-page-view.ts +57 -0
- package/src/features/analytics/data/analytics-session.interface.ts +23 -0
- package/src/features/analytics/data/analytics-session.ts +144 -0
- package/src/features/analytics/data/analytics-summary.interface.ts +11 -0
- package/src/features/analytics/data/analytics-summary.ts +79 -0
- package/src/features/analytics/data/analytics-timeline.interface.ts +10 -0
- package/src/features/analytics/data/analytics-timeline.ts +67 -0
- package/src/features/analytics/data/analytics.types.ts +97 -0
- package/src/features/analytics/data/index.ts +15 -0
- package/src/features/analytics/i18n-keys.ts +76 -0
- package/src/features/analytics/index.ts +31 -0
- package/src/features/analytics/lib/__tests__/utm.spec.ts +20 -0
- package/src/features/analytics/lib/__tests__/visitorCookie.spec.ts +63 -0
- package/src/features/analytics/lib/utm.ts +29 -0
- package/src/features/analytics/lib/visitorCookie.ts +32 -0
- package/src/features/analytics/server-entry.ts +8 -0
- package/src/features/assistant/components/parts/AssistantBlockNoteComposer.tsx +1 -1
- package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
- package/src/features/assistant-message/components/MessageItem.tsx +2 -2
- package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +23 -5
- package/src/features/auth/components/containers/AuthContainer.tsx +8 -1
- package/src/features/company/components/details/CompanyContent.tsx +3 -1
- package/src/features/company/components/details/CompanyDetails.tsx +9 -1
- package/src/features/company/components/details/TokenStatusIndicator.tsx +42 -13
- package/src/features/help/components/HelpHeader.tsx +7 -5
- package/src/features/tokenusage/components/TokenUsageAdminFilterBar.tsx +9 -6
- package/src/features/tokenusage/lib/__tests__/formatters.spec.ts +21 -0
- package/src/features/tokenusage/lib/formatters.ts +5 -1
- package/src/features/waitlist/components/lists/WaitlistList.tsx +68 -121
- package/src/features/waitlist/data/WaitlistService.ts +2 -2
- package/src/features/waitlist/data/index.ts +1 -0
- package/src/features/waitlist/data/waitlist.fields.ts +8 -0
- package/src/features/waitlist/hooks/useWaitlistTableStructure.tsx +69 -30
- package/src/shadcnui/ui/button.tsx +1 -1
- package/src/shadcnui/ui/checkbox.tsx +1 -1
- package/src/shadcnui/ui/command.tsx +4 -4
- package/src/shadcnui/ui/dialog.tsx +2 -0
- package/src/shadcnui/ui/input.tsx +1 -1
- package/src/shadcnui/ui/popover.tsx +9 -0
- package/src/shadcnui/ui/select.tsx +1 -1
- package/src/shadcnui/ui/sheet.tsx +8 -1
- package/src/shadcnui/ui/table.tsx +2 -2
- package/src/shadcnui/ui/textarea.tsx +1 -1
- package/dist/BlockNoteEditor-35D4R7HJ.js.map +0 -1
- package/dist/JsonApiRequest-G625QAH5.js +0 -26
- package/dist/chunk-2XCUAHQT.js.map +0 -1
- package/dist/chunk-4O7R2Z2O.mjs.map +0 -1
- package/dist/chunk-IBVKPLHT.mjs.map +0 -1
- package/dist/chunk-U7QVISQS.js.map +0 -1
- /package/dist/{BlockNoteViewer-KDKGUIG5.mjs.map → BlockNoteViewer-KRTAFTUX.mjs.map} +0 -0
- /package/dist/{JsonApiRequest-RRMNV7YE.mjs.map → JsonApiRequest-UHOWNI3O.mjs.map} +0 -0
- /package/dist/{chunk-T76WLDE5.mjs.map → chunk-37DMFVLN.mjs.map} +0 -0
- /package/dist/{chunk-ZJI5ZZW5.mjs.map → chunk-Q6J2ICKO.mjs.map} +0 -0
|
@@ -7,6 +7,14 @@ import { BlockNoteEditorContainer } from "../editors/BlockNoteEditorContainer";
|
|
|
7
7
|
import type { MentionNameResolver, MentionResolveFn } from "../editors/BlockNoteEditorMentionInlineContent";
|
|
8
8
|
import { FormFieldWrapper } from "./FormFieldWrapper";
|
|
9
9
|
|
|
10
|
+
function isEmptyDocument(value: unknown): boolean {
|
|
11
|
+
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getPath(source: unknown, path: string): unknown {
|
|
15
|
+
return path.split(".").reduce<unknown>((acc, key) => (acc == null ? undefined : (acc as Record<string, unknown>)[key]), source);
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
export function FormBlockNote({
|
|
11
19
|
form,
|
|
12
20
|
id,
|
|
@@ -73,6 +81,10 @@ export function FormBlockNote({
|
|
|
73
81
|
className={cn(
|
|
74
82
|
"flex w-full flex-col",
|
|
75
83
|
stretch && "min-h-0 flex-1 [&>[data-slot=field]]:min-h-0 [&>[data-slot=field]]:flex-1",
|
|
84
|
+
// Phone: the sheet is too short to share with the other fields, so the
|
|
85
|
+
// editor stops filling the leftover space (2px in a long form) and grows
|
|
86
|
+
// with its content instead; the sheet body does the scrolling.
|
|
87
|
+
stretch && "max-md:min-h-64 max-md:flex-none",
|
|
76
88
|
className,
|
|
77
89
|
)}
|
|
78
90
|
>
|
|
@@ -100,7 +112,15 @@ export function FormBlockNote({
|
|
|
100
112
|
initialContent={initialContentRef.current}
|
|
101
113
|
onChange={(content, isEmpty) => {
|
|
102
114
|
lastEditorContentRef.current = content;
|
|
103
|
-
|
|
115
|
+
// BlockNote normalises an empty document ([] / null) to one empty
|
|
116
|
+
// paragraph as soon as it mounts. That is not a user edit: re-seed the
|
|
117
|
+
// field's default with it so an untouched form does not ask
|
|
118
|
+
// "Unsaved changes?" on close.
|
|
119
|
+
if (isEmpty && isEmptyDocument(getPath(form.formState.defaultValues, id)) && !form.getFieldState(id).isDirty) {
|
|
120
|
+
form.resetField(id, { defaultValue: content });
|
|
121
|
+
} else {
|
|
122
|
+
field.onChange(content);
|
|
123
|
+
}
|
|
104
124
|
onEmptyChange?.(isEmpty);
|
|
105
125
|
}}
|
|
106
126
|
placeholder={placeholder}
|
|
@@ -15,7 +15,11 @@ type FormCheckboxProps = {
|
|
|
15
15
|
export function FormCheckbox({ form, id, name, labelBefore, description, isRequired }: FormCheckboxProps) {
|
|
16
16
|
const simpleLabel = () => {
|
|
17
17
|
return (
|
|
18
|
-
|
|
18
|
+
// Phone: the label fills the row and is 40px tall, so the whole row toggles the box.
|
|
19
|
+
<FieldLabel
|
|
20
|
+
htmlFor={id}
|
|
21
|
+
className={`font-normal ${labelBefore ? "" : "ms-3"} max-md:min-h-10 max-md:flex-1`}
|
|
22
|
+
>
|
|
19
23
|
{name}
|
|
20
24
|
</FieldLabel>
|
|
21
25
|
);
|
|
@@ -36,7 +40,7 @@ export function FormCheckbox({ form, id, name, labelBefore, description, isRequi
|
|
|
36
40
|
<div className="flex w-full flex-col">
|
|
37
41
|
<FormFieldWrapper form={form} name={id} orientation="horizontal">
|
|
38
42
|
{(field) => (
|
|
39
|
-
<div className="flex gap-x-4">
|
|
43
|
+
<div className="flex gap-x-4 max-md:min-h-10 max-md:items-center">
|
|
40
44
|
{labelBefore && label()}
|
|
41
45
|
{labelBefore && isRequired && <span className="ms-1 text-destructive">*</span>}
|
|
42
46
|
<Checkbox id={id} checked={field.value ?? false} onCheckedChange={field.onChange} />
|
|
@@ -169,6 +169,53 @@ describe("useEditorDialog", () => {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
describe("escape key with an open picker / menu above the sheet", () => {
|
|
173
|
+
const pressEscape = () => {
|
|
174
|
+
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
|
|
175
|
+
act(() => {
|
|
176
|
+
document.dispatchEvent(event);
|
|
177
|
+
});
|
|
178
|
+
return event;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const mountLayer = (html: string) => {
|
|
182
|
+
const host = document.createElement("div");
|
|
183
|
+
host.innerHTML = html;
|
|
184
|
+
document.body.appendChild(host);
|
|
185
|
+
return () => host.remove();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
it.each([
|
|
189
|
+
['<div data-slot="popover-content" data-open></div>'],
|
|
190
|
+
['<div data-slot="select-content" data-open></div>'],
|
|
191
|
+
['<div data-slot="dropdown-menu-content" data-open></div>'],
|
|
192
|
+
['<div data-slot="dialog-content" data-open></div>'],
|
|
193
|
+
['<div class="bn-suggestion-menu"></div>'],
|
|
194
|
+
])("leaves Escape to the open layer %s and keeps the sheet open", (html) => {
|
|
195
|
+
const { result } = renderHook(() => useEditorDialog(() => true));
|
|
196
|
+
act(() => result.current.setOpen(true));
|
|
197
|
+
const unmount = mountLayer(html);
|
|
198
|
+
|
|
199
|
+
const event = pressEscape();
|
|
200
|
+
|
|
201
|
+
expect(result.current.open).toBe(true);
|
|
202
|
+
expect(result.current.discardDialogProps.open).toBe(false);
|
|
203
|
+
expect(event.defaultPrevented).toBe(false);
|
|
204
|
+
unmount();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("handles Escape again once the layer has closed", () => {
|
|
208
|
+
const { result } = renderHook(() => useEditorDialog(() => false));
|
|
209
|
+
act(() => result.current.setOpen(true));
|
|
210
|
+
const unmount = mountLayer('<div data-slot="popover-content" data-closed></div>');
|
|
211
|
+
|
|
212
|
+
pressEscape();
|
|
213
|
+
|
|
214
|
+
expect(result.current.open).toBe(false);
|
|
215
|
+
unmount();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
172
219
|
describe("escape key with nested editors", () => {
|
|
173
220
|
const pressEscape = () =>
|
|
174
221
|
act(() => {
|
|
@@ -11,6 +11,27 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
11
11
|
// editor that opened it.
|
|
12
12
|
const openEditorStack: object[] = [];
|
|
13
13
|
|
|
14
|
+
// Layers that open ABOVE an editor sheet and close on their own Escape: pickers
|
|
15
|
+
// (popovers), selects, menus, nested dialogs, the discard confirmation, and the
|
|
16
|
+
// rich-text editor's slash / mention menu. Our listener runs in the CAPTURE
|
|
17
|
+
// phase on `document`, so it sees Escape before any of them — if it handled the
|
|
18
|
+
// key it would stopPropagation() and close the whole sheet while the picker
|
|
19
|
+
// stayed open underneath. While one of these is open, Escape is theirs.
|
|
20
|
+
const INNER_LAYER_SELECTOR = [
|
|
21
|
+
"[data-slot=popover-content][data-open]",
|
|
22
|
+
"[data-slot=select-content][data-open]",
|
|
23
|
+
"[data-slot=combobox-content][data-open]",
|
|
24
|
+
"[data-slot=dropdown-menu-content][data-open]",
|
|
25
|
+
"[data-slot=context-menu-content][data-open]",
|
|
26
|
+
"[data-slot=dialog-content][data-open]",
|
|
27
|
+
"[data-slot=alert-dialog-content][data-open]",
|
|
28
|
+
".bn-suggestion-menu",
|
|
29
|
+
].join(",");
|
|
30
|
+
|
|
31
|
+
export function hasOpenInnerLayer(doc: Document = document): boolean {
|
|
32
|
+
return doc.querySelector(INNER_LAYER_SELECTOR) !== null;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
type UseEditorDialogOptions = {
|
|
15
36
|
dialogOpen?: boolean;
|
|
16
37
|
onDialogOpenChange?: (open: boolean) => void;
|
|
@@ -116,6 +137,7 @@ export function useEditorDialog(isFormDirty: () => boolean, options?: UseEditorD
|
|
|
116
137
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
117
138
|
if (event.key === "Escape" && open) {
|
|
118
139
|
if (openEditorStack[openEditorStack.length - 1] !== dialogId.current) return;
|
|
140
|
+
if (hasOpenInnerLayer()) return;
|
|
119
141
|
event.preventDefault();
|
|
120
142
|
event.stopPropagation();
|
|
121
143
|
handleOpenChange(false);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useTranslations } from "next-intl";
|
|
4
|
-
import { Fragment, useState } from "react";
|
|
4
|
+
import { Fragment, useState, type MouseEvent } from "react";
|
|
5
5
|
import { ChevronDownIcon } from "lucide-react";
|
|
6
|
-
import { useIsMobile } from "../../utils";
|
|
6
|
+
import { cn, useIsMobile } from "../../utils";
|
|
7
7
|
import { usePageUrlGenerator } from "../../hooks";
|
|
8
8
|
import { BreadcrumbItemData } from "../../interfaces";
|
|
9
9
|
import {
|
|
@@ -147,24 +147,53 @@ function BreadcrumbMobile({
|
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// One entry means the menu could only offer the page already on screen, so
|
|
151
|
+
// the chevron promised a choice that does not exist: show plain text.
|
|
152
|
+
if (allItems.length <= 1) {
|
|
153
|
+
return (
|
|
154
|
+
<span className="text-foreground block min-w-0 truncate px-1.5 text-xs/relaxed font-normal">
|
|
155
|
+
{lastItem?.name}
|
|
156
|
+
</span>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
150
160
|
return (
|
|
151
161
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<
|
|
162
|
+
{/* min-h-9: a 36px tap target, where the bare text row was ~20px. */}
|
|
163
|
+
<DropdownMenuTrigger className="text-foreground text-xs/relaxed font-normal hover:bg-accent flex min-h-9 min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 transition-colors outline-none">
|
|
164
|
+
<span className="truncate">{lastItem?.name}</span>
|
|
165
|
+
<ChevronDownIcon className="text-muted-foreground size-3.5 shrink-0" />
|
|
155
166
|
</DropdownMenuTrigger>
|
|
156
167
|
<DropdownMenuContent align="start">
|
|
157
|
-
{allItems.map((item, index) =>
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
{allItems.map((item, index) =>
|
|
169
|
+
item.href ? (
|
|
170
|
+
// The item renders AS the link, so the whole row navigates — a Link
|
|
171
|
+
// nested inside the row only answered taps on its own text.
|
|
172
|
+
<DropdownMenuItem
|
|
173
|
+
key={index}
|
|
174
|
+
className="min-h-10"
|
|
175
|
+
render={(props) => (
|
|
176
|
+
<Link
|
|
177
|
+
{...props}
|
|
178
|
+
href={item.href!}
|
|
179
|
+
className={cn(props.className, "text-foreground font-normal")}
|
|
180
|
+
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
|
|
181
|
+
props.onClick?.(event);
|
|
182
|
+
item.onClick?.();
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{props.children}
|
|
186
|
+
</Link>
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{item.name}
|
|
190
|
+
</DropdownMenuItem>
|
|
191
|
+
) : (
|
|
192
|
+
<DropdownMenuItem key={index} className="min-h-10">
|
|
193
|
+
{item.name}
|
|
194
|
+
</DropdownMenuItem>
|
|
195
|
+
),
|
|
196
|
+
)}
|
|
168
197
|
</DropdownMenuContent>
|
|
169
198
|
</DropdownMenu>
|
|
170
199
|
);
|
|
@@ -15,9 +15,22 @@ type HeaderProps = {
|
|
|
15
15
|
/** Rendered before everything else, on mobile only — where the sidebar (and its logo) is off-canvas. */
|
|
16
16
|
logo?: React.ReactNode;
|
|
17
17
|
className?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Drop the sidebar toggle on mobile. Set by page shells that render the mobile
|
|
20
|
+
* bottom bar, whose "Menu" slot opens the same drawer — two controls for one
|
|
21
|
+
* drawer only cost header room on a phone. Desktop always keeps the toggle.
|
|
22
|
+
*/
|
|
23
|
+
hideSidebarTriggerOnMobile?: boolean;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
|
-
export function Header({
|
|
26
|
+
export function Header({
|
|
27
|
+
children,
|
|
28
|
+
mobileChildren,
|
|
29
|
+
leftContent,
|
|
30
|
+
logo,
|
|
31
|
+
className,
|
|
32
|
+
hideSidebarTriggerOnMobile,
|
|
33
|
+
}: HeaderProps) {
|
|
21
34
|
const { breadcrumbs } = useSharedContext();
|
|
22
35
|
const rootLabel = useHeaderRootLabel();
|
|
23
36
|
const isRootHidden = useIsBreadcrumbRootHidden();
|
|
@@ -42,9 +55,9 @@ export function Header({ children, mobileChildren, leftContent, logo, className
|
|
|
42
55
|
>
|
|
43
56
|
<div className="bg-sidebar flex h-12 w-full flex-row items-center justify-between ps-2 pe-4">
|
|
44
57
|
{isMobile && logo && <div className="flex shrink-0 flex-row items-center pe-1">{logo}</div>}
|
|
45
|
-
{sidebar && <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />}
|
|
58
|
+
{sidebar && !(isMobile && hideSidebarTriggerOnMobile) && <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />}
|
|
46
59
|
{leftContent}
|
|
47
|
-
<div className="flex w-full flex-row items-center justify-start">
|
|
60
|
+
<div className="flex w-full flex-row items-center justify-start max-md:min-w-0">
|
|
48
61
|
<BreadcrumbNavigation items={breadcrumbs} rootLabel={rootLabel ?? undefined} showRoot={!isRootHidden} />
|
|
49
62
|
</div>
|
|
50
63
|
{isMobile
|
|
@@ -84,6 +84,18 @@ export function MobileNavigationBar({ className }: { className?: string } = {})
|
|
|
84
84
|
</>
|
|
85
85
|
);
|
|
86
86
|
|
|
87
|
+
// A slot with neither href nor onClick is self-contained: its `render`
|
|
88
|
+
// brings its own control (e.g. an avatar that opens a user menu), so it
|
|
89
|
+
// is not wrapped in a Link/button — that would nest one interactive
|
|
90
|
+
// element inside another.
|
|
91
|
+
if (!item.href && !item.onClick) {
|
|
92
|
+
return (
|
|
93
|
+
<div key={item.key} data-testid={`mobile-nav-${item.key}`} className={cn(slotClass, tone)}>
|
|
94
|
+
<SlotContent item={item} />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
87
99
|
return item.href ? (
|
|
88
100
|
<Link
|
|
89
101
|
key={item.key}
|
|
@@ -109,6 +109,23 @@ describe("MobileNavigationBar", () => {
|
|
|
109
109
|
expect(screen.getByTestId("avatar-stub")).toBeInTheDocument();
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
it("renders a slot with neither href nor onClick as-is, without wrapping it in a link or button", () => {
|
|
113
|
+
renderBar([
|
|
114
|
+
{
|
|
115
|
+
key: "profile",
|
|
116
|
+
label: "Account",
|
|
117
|
+
render: (
|
|
118
|
+
<button type="button" data-testid="menu-trigger">
|
|
119
|
+
Account
|
|
120
|
+
</button>
|
|
121
|
+
),
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
const trigger = screen.getByTestId("menu-trigger");
|
|
125
|
+
expect(trigger.parentElement?.closest("button, a")).toBeNull();
|
|
126
|
+
expect(screen.getAllByRole("button")).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
112
129
|
// The load-bearing case: a bare startsWith would make href="/" match everything.
|
|
113
130
|
it("marks only the exactly-matching slot active when one href is '/'", () => {
|
|
114
131
|
pathnameRef.current = "/conversations";
|
|
@@ -10,6 +10,7 @@ import React, { ReactNode, memo, useMemo, useState } from "react";
|
|
|
10
10
|
import { DataListRetriever, TableContent, useTableGenerator } from "../../hooks";
|
|
11
11
|
import { ModuleWithPermissions } from "../../permissions";
|
|
12
12
|
import { Button, Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "../../shadcnui";
|
|
13
|
+
import { useIsMobile } from "../../utils/use-mobile";
|
|
13
14
|
import { MicroLabel } from "../typography";
|
|
14
15
|
import { ContentTableSearch } from "./ContentTableSearch";
|
|
15
16
|
|
|
@@ -110,6 +111,7 @@ function renderCell(cellDef: unknown, context: unknown): ReactNode {
|
|
|
110
111
|
export const ContentListTable = memo(function ContentListTable(props: ContentListTableProps) {
|
|
111
112
|
const { data, fields, checkedIds, toggleId, allowSearch, filters: _filters, fullWidth, onRowClick } = props;
|
|
112
113
|
const t = useTranslations();
|
|
114
|
+
const isMobile = useIsMobile();
|
|
113
115
|
const [expanded, setExpanded] = useState<ExpandedState>(
|
|
114
116
|
props.defaultExpanded === true ? true : typeof props.defaultExpanded === "object" ? props.defaultExpanded : {},
|
|
115
117
|
);
|
|
@@ -212,13 +214,60 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
|
|
|
212
214
|
// 12px title is the mismatch the opt-in exists to avoid.
|
|
213
215
|
const titleIsLarge = (props.titleSize ?? (fullWidth ? `lg` : `sm`)) === `lg`;
|
|
214
216
|
|
|
217
|
+
const titleLabel = (
|
|
218
|
+
<div
|
|
219
|
+
className={cn(
|
|
220
|
+
"text-muted-foreground flex items-center gap-x-2 font-light whitespace-nowrap",
|
|
221
|
+
titleIsLarge ? `text-lg` : `text-sm`,
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
{props.titleActions}
|
|
225
|
+
{props.tableGeneratorType.icon && (
|
|
226
|
+
<props.tableGeneratorType.icon className={cn(`text-primary`, titleIsLarge ? `h-6 w-6` : `h-4 w-4`)} />
|
|
227
|
+
)}
|
|
228
|
+
{props.title}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const hasTitleControls = !!(props.functions || props.filters || allowSearch);
|
|
233
|
+
const titleControls = (
|
|
234
|
+
<>
|
|
235
|
+
{props.filters}
|
|
236
|
+
{props.functions}
|
|
237
|
+
{/* `allowSearch !== false`, not `allowSearch`: the search box has
|
|
238
|
+
always rendered here whenever `functions`/`filters` were set, and
|
|
239
|
+
most callers rely on that without passing the flag. Gating on the
|
|
240
|
+
flag alone (as the sibling ContentListGrid does) would silently
|
|
241
|
+
remove search from every such list. This only lets a caller whose
|
|
242
|
+
retriever ignores `search` — e.g. notifications, whose repository
|
|
243
|
+
has no search param and no fulltext index — opt out of a control
|
|
244
|
+
that would do nothing. */}
|
|
245
|
+
{allowSearch !== false && <ContentTableSearch data={data} />}
|
|
246
|
+
</>
|
|
247
|
+
);
|
|
248
|
+
|
|
215
249
|
return (
|
|
216
250
|
<div data-help="list.table" className="flex w-full flex-col">
|
|
217
251
|
{/* <div className="overflow-clip rounded-md border"> */}
|
|
218
252
|
<div className={cn(`overflow-clip`, fullWidth ? `` : `rounded-md border`)}>
|
|
253
|
+
{/* On a phone the title bar sits above the table instead of inside its
|
|
254
|
+
header: inside, it is as wide as the table, so on a table wider than
|
|
255
|
+
the screen "Create New", search and filters land off-screen. Above it,
|
|
256
|
+
it wraps within the card while the table scrolls sideways on its own. */}
|
|
257
|
+
{props.title && isMobile && (
|
|
258
|
+
<div
|
|
259
|
+
data-testid="content-list-table-title-bar"
|
|
260
|
+
className="bg-card text-primary flex flex-wrap items-center gap-2 border-b p-4 font-bold"
|
|
261
|
+
>
|
|
262
|
+
<div className="shrink-0">{titleLabel}</div>
|
|
263
|
+
{hasTitleControls && (
|
|
264
|
+
<div className="ms-auto flex flex-wrap items-center justify-end gap-2">{titleControls}</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
219
268
|
<Table>
|
|
220
269
|
<TableHeader className="bg-muted font-semibold">
|
|
221
|
-
{props.title && (
|
|
270
|
+
{props.title && !isMobile && (
|
|
222
271
|
<TableRow>
|
|
223
272
|
<TableHead
|
|
224
273
|
className="bg-card rounded-t-lg text-primary p-4 text-start font-bold"
|
|
@@ -226,37 +275,8 @@ export const ContentListTable = memo(function ContentListTable(props: ContentLis
|
|
|
226
275
|
>
|
|
227
276
|
<div className="flex w-full items-center justify-between gap-x-2">
|
|
228
277
|
{/* <div className="w-full">{fullWidth ? `` : props.title}</div> */}
|
|
229
|
-
<div className="w-full">
|
|
230
|
-
|
|
231
|
-
className={cn(
|
|
232
|
-
"text-muted-foreground flex items-center gap-x-2 font-light whitespace-nowrap",
|
|
233
|
-
titleIsLarge ? `text-lg` : `text-sm`,
|
|
234
|
-
)}
|
|
235
|
-
>
|
|
236
|
-
{props.titleActions}
|
|
237
|
-
{props.tableGeneratorType.icon && (
|
|
238
|
-
<props.tableGeneratorType.icon
|
|
239
|
-
className={cn(`text-primary`, titleIsLarge ? `h-6 w-6` : `h-4 w-4`)}
|
|
240
|
-
/>
|
|
241
|
-
)}
|
|
242
|
-
{props.title}
|
|
243
|
-
</div>
|
|
244
|
-
</div>
|
|
245
|
-
{(props.functions || props.filters || allowSearch) && (
|
|
246
|
-
<>
|
|
247
|
-
{props.filters}
|
|
248
|
-
{props.functions}
|
|
249
|
-
{/* `allowSearch !== false`, not `allowSearch`: the search box has
|
|
250
|
-
always rendered here whenever `functions`/`filters` were set, and
|
|
251
|
-
most callers rely on that without passing the flag. Gating on the
|
|
252
|
-
flag alone (as the sibling ContentListGrid does) would silently
|
|
253
|
-
remove search from every such list. This only lets a caller whose
|
|
254
|
-
retriever ignores `search` — e.g. notifications, whose repository
|
|
255
|
-
has no search param and no fulltext index — opt out of a control
|
|
256
|
-
that would do nothing. */}
|
|
257
|
-
{allowSearch !== false && <ContentTableSearch data={data} />}
|
|
258
|
-
</>
|
|
259
|
-
)}
|
|
278
|
+
<div className="w-full">{titleLabel}</div>
|
|
279
|
+
{hasTitleControls && titleControls}
|
|
260
280
|
</div>
|
|
261
281
|
</TableHead>
|
|
262
282
|
</TableRow>
|
package/src/core/index.ts
CHANGED
|
@@ -113,6 +113,10 @@ export * from "../features/tokenusage/tokenusage-admin.module";
|
|
|
113
113
|
// rationale as the line above: a bootstrapper needs it, and it must not drag
|
|
114
114
|
// the feature's recharts client bundle in.
|
|
115
115
|
export * from "../features/tokenusage/tokenusage.modules";
|
|
116
|
+
// The analytics module bundle. Same placement rationale as the tokenusage lines
|
|
117
|
+
// above: a bootstrapper needs it, and it must not drag the feature's client
|
|
118
|
+
// bundle in.
|
|
119
|
+
export * from "../features/analytics/analytics.modules";
|
|
116
120
|
// configureTokenUsage() is called from the app's bootstrap file, which is
|
|
117
121
|
// evaluated on the server as well as the client. Reaching it through the
|
|
118
122
|
// client-marked ./tokenusage barrel makes it a client function, and calling one
|
|
@@ -73,6 +73,13 @@ export interface FoundationModuleDefinitions {
|
|
|
73
73
|
TokenUsageReportSummary: ModuleWithPermissions;
|
|
74
74
|
TokenUsageReportTimeline: ModuleWithPermissions;
|
|
75
75
|
TokenUsageReportBreakdown: ModuleWithPermissions;
|
|
76
|
+
// First-party web analytics modules (tracker + administrative dashboard)
|
|
77
|
+
AnalyticsEvent: ModuleWithPermissions;
|
|
78
|
+
AnalyticsSummary: ModuleWithPermissions;
|
|
79
|
+
AnalyticsTimeline: ModuleWithPermissions;
|
|
80
|
+
AnalyticsBreakdown: ModuleWithPermissions;
|
|
81
|
+
AnalyticsSession: ModuleWithPermissions;
|
|
82
|
+
AnalyticsPageView: ModuleWithPermissions;
|
|
76
83
|
}
|
|
77
84
|
|
|
78
85
|
// App-specific modules - apps will augment this interface ONLY
|
|
@@ -34,6 +34,7 @@ const DECLARED_FOR_APP_RENDERED_SURFACES = new Set<string>([
|
|
|
34
34
|
"administration.token_usage.description",
|
|
35
35
|
"administration.users.description",
|
|
36
36
|
"administration.ai_connections.description",
|
|
37
|
+
"analytics.admin.description",
|
|
37
38
|
"handbook.chat.delete_confirm",
|
|
38
39
|
"handbook.chat.rename_placeholder",
|
|
39
40
|
"handbook.contents.title",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AnalyticsAdminPageContainer, AnalyticsJourneyPageContainer } from "../index";
|
|
3
|
+
import { AnalyticsAdminPage, AnalyticsJourneyPage } from "../server-entry";
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
hasRole: vi.fn(),
|
|
7
|
+
redirect: vi.fn((url: string) => {
|
|
8
|
+
// next/navigation's redirect throws to stop rendering; mirror that.
|
|
9
|
+
throw new Error(`NEXT_REDIRECT:${url}`);
|
|
10
|
+
}),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// ServerSession reads next/headers cookies, which only exist inside a request.
|
|
14
|
+
vi.mock("../../../server", () => ({
|
|
15
|
+
ServerSession: { hasRole: mocks.hasRole },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("next/navigation", () => ({
|
|
19
|
+
redirect: mocks.redirect,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
describe("analytics server entry", () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("AnalyticsAdminPage renders the admin container for the role holder", async () => {
|
|
28
|
+
mocks.hasRole.mockResolvedValue(true);
|
|
29
|
+
|
|
30
|
+
const element = await AnalyticsAdminPage({
|
|
31
|
+
adminRoleId: "role-admin",
|
|
32
|
+
pageUrl: "/admin/stats",
|
|
33
|
+
sessionPageUrl: "/admin/stats/sessions",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(mocks.hasRole).toHaveBeenCalledWith("role-admin");
|
|
37
|
+
expect(element.type).toBe(AnalyticsAdminPageContainer);
|
|
38
|
+
expect(element.props).toEqual({ pageUrl: "/admin/stats", sessionPageUrl: "/admin/stats/sessions" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("AnalyticsAdminPage redirects to `/` by default without the role", async () => {
|
|
42
|
+
mocks.hasRole.mockResolvedValue(false);
|
|
43
|
+
|
|
44
|
+
await expect(AnalyticsAdminPage({ adminRoleId: "role-admin" })).rejects.toThrow("NEXT_REDIRECT:/");
|
|
45
|
+
expect(mocks.redirect).toHaveBeenCalledWith("/");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("AnalyticsJourneyPage renders the journey container for the role holder", async () => {
|
|
49
|
+
mocks.hasRole.mockResolvedValue(true);
|
|
50
|
+
|
|
51
|
+
const element = await AnalyticsJourneyPage({ adminRoleId: "role-admin", sessionId: "s1", pageUrl: "/admin/stats" });
|
|
52
|
+
|
|
53
|
+
expect(element.type).toBe(AnalyticsJourneyPageContainer);
|
|
54
|
+
expect(element.props).toEqual({ sessionId: "s1", pageUrl: "/admin/stats" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("AnalyticsJourneyPage redirects to deniedRedirect without the role", async () => {
|
|
58
|
+
mocks.hasRole.mockResolvedValue(false);
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
AnalyticsJourneyPage({ adminRoleId: "role-admin", sessionId: "s1", deniedRedirect: "/401" }),
|
|
62
|
+
).rejects.toThrow("NEXT_REDIRECT:/401");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../permissions";
|
|
2
|
+
import { AnalyticsBreakdown } from "./data/analytics-breakdown";
|
|
3
|
+
import { AnalyticsEvent } from "./data/analytics-event";
|
|
4
|
+
import { AnalyticsPageView } from "./data/analytics-page-view";
|
|
5
|
+
import { AnalyticsSession } from "./data/analytics-session";
|
|
6
|
+
import { AnalyticsSummary } from "./data/analytics-summary";
|
|
7
|
+
import { AnalyticsTimeline } from "./data/analytics-timeline";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every resource behind the analytics tracker and dashboard, as one object a
|
|
11
|
+
* consuming app spreads into its `allModules`. Same rationale as
|
|
12
|
+
* `tokenUsageModules`: one spread cannot be partially done, and a forgotten
|
|
13
|
+
* name is `undefined` at runtime even though `Modules.X` typechecks.
|
|
14
|
+
*
|
|
15
|
+
* `AnalyticsPageView` is only a child segment: the service builds
|
|
16
|
+
* `analytics/administration/sessions/{id}/pageviews` with EndpointCreator's
|
|
17
|
+
* `childEndpoint`. None has a pageUrl — these are not navigable resources.
|
|
18
|
+
*
|
|
19
|
+
* `satisfies` keeps the constraint without widening the return type to an
|
|
20
|
+
* index signature (see tokenusage.modules.ts).
|
|
21
|
+
*/
|
|
22
|
+
export const analyticsModules = (factory: ModuleFactory) =>
|
|
23
|
+
({
|
|
24
|
+
AnalyticsEvent: factory({ name: "analytics/events", model: AnalyticsEvent }),
|
|
25
|
+
AnalyticsSummary: factory({ name: "analytics/administration/summary", model: AnalyticsSummary }),
|
|
26
|
+
AnalyticsTimeline: factory({ name: "analytics/administration/timeline", model: AnalyticsTimeline }),
|
|
27
|
+
AnalyticsBreakdown: factory({ name: "analytics/administration/breakdown", model: AnalyticsBreakdown }),
|
|
28
|
+
AnalyticsSession: factory({ name: "analytics/administration/sessions", model: AnalyticsSession }),
|
|
29
|
+
AnalyticsPageView: factory({ name: "pageviews", model: AnalyticsPageView }),
|
|
30
|
+
}) satisfies Record<string, ModuleWithPermissions>;
|