@byline/host-tanstack-start 6.0.1 → 6.0.3
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/admin-shell/chrome/th-sortable.js +2 -1
- package/dist/admin-shell/chrome/th-sortable.test.d.ts +1 -0
- package/dist/admin-shell/collections/list.js +4 -2
- package/dist/routes/create-admin-analytics-route.js +18 -5
- package/dist/routes/create-admin-users-list-route.js +2 -1
- package/dist/routes/create-collection-history-route.js +2 -1
- package/dist/routes/create-singleton-history-route.js +2 -1
- package/dist/routes/list-return-state.d.ts +3 -4
- package/dist/routes/list-return-state.js +2 -2
- package/dist/routes/search-boolean.d.ts +2 -0
- package/dist/routes/search-boolean.js +3 -0
- package/dist/server-fns/analytics/index.d.ts +1 -0
- package/dist/server-fns/analytics/index.js +1 -0
- package/dist/server-fns/analytics/preferences.d.ts +36 -0
- package/dist/server-fns/analytics/preferences.js +60 -0
- package/package.json +10 -10
- package/src/admin-shell/chrome/th-sortable.test.tsx +142 -0
- package/src/admin-shell/chrome/th-sortable.tsx +4 -1
- package/src/admin-shell/collections/list.tsx +5 -3
- package/src/routes/create-admin-analytics-route.tsx +31 -5
- package/src/routes/create-admin-users-list-route.tsx +2 -1
- package/src/routes/create-collection-history-route.tsx +2 -1
- package/src/routes/create-singleton-history-route.tsx +2 -1
- package/src/routes/list-return-state.ts +5 -5
- package/src/routes/search-boolean.test.node.ts +39 -0
- package/src/routes/search-boolean.ts +8 -0
- package/src/server-fns/analytics/index.ts +4 -0
- package/src/server-fns/analytics/preferences.ts +99 -0
|
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { useRouterState } from "@tanstack/react-router";
|
|
4
4
|
import { Table } from "@byline/ui/react";
|
|
5
5
|
import clsx from "clsx";
|
|
6
|
+
import { searchBooleanSchema } from "../../routes/search-boolean.js";
|
|
6
7
|
import { useNavigate } from "./loose-router.js";
|
|
7
8
|
import { SortAscendingIcon, SortDescendingIcon, SortNeutralIcon } from "./sort-icons.js";
|
|
8
9
|
import th_sortable_module from "./th-sortable.module.js";
|
|
@@ -30,7 +31,7 @@ function TableHeadingCellSortable({ path: _path = '/', fieldName, label, sortabl
|
|
|
30
31
|
if (null != fieldName) {
|
|
31
32
|
const order = void 0 !== activeOrder ? activeOrder : location.search.order;
|
|
32
33
|
const d = void 0 !== activeOrder ? activeDesc : location.search.desc;
|
|
33
|
-
order === fieldName ? setDesc(d ??
|
|
34
|
+
order === fieldName ? setDesc(searchBooleanSchema.safeParse(d).data ?? true) : setDesc(null);
|
|
34
35
|
}
|
|
35
36
|
}, [
|
|
36
37
|
fieldName,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -11,6 +11,7 @@ import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalList
|
|
|
11
11
|
import clsx from "clsx";
|
|
12
12
|
import { getAdminRoutePath } from "../../routes/admin-path.js";
|
|
13
13
|
import { encodeListReturnState } from "../../routes/list-return-state.js";
|
|
14
|
+
import { searchBooleanSchema } from "../../routes/search-boolean.js";
|
|
14
15
|
import { setListViewPreference } from "../../server-fns/collections/index.js";
|
|
15
16
|
import { Link, useNavigate } from "../chrome/loose-router.js";
|
|
16
17
|
import { RouterPager } from "../chrome/router-pager.js";
|
|
@@ -94,10 +95,11 @@ const ListView = ({ data, columns, workflowStatuses, useAsTitle, orderable = fal
|
|
|
94
95
|
mutation.blocked
|
|
95
96
|
]);
|
|
96
97
|
const searchParams = location.search;
|
|
97
|
-
const
|
|
98
|
+
const searchDesc = searchBooleanSchema.safeParse(searchParams.desc).data;
|
|
99
|
+
const isCanonicalView = !searchParams.order && !searchDesc && !searchParams.query && !searchParams.status;
|
|
98
100
|
const dragEnabled = orderable && isCanonicalView && !!onReorder && !mutation.blocked;
|
|
99
101
|
const activeOrder = searchParams.order ?? data?.meta.order;
|
|
100
|
-
const activeDesc = null != searchParams.order ?
|
|
102
|
+
const activeDesc = null != searchParams.order ? searchDesc : data?.meta.desc;
|
|
101
103
|
const sensors = useSensors(useSensor(PointerSensor, {
|
|
102
104
|
activationConstraint: {
|
|
103
105
|
distance: 5
|
|
@@ -6,7 +6,7 @@ import { useTranslation } from "@byline/i18n/react";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { BreadcrumbsClient } from "../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js";
|
|
8
8
|
import { useNavigate } from "../admin-shell/chrome/loose-router.js";
|
|
9
|
-
import { getAnalyticsCountries, getAnalyticsReferrers, getAnalyticsReportCoverage, getAnalyticsRuntime, getAnalyticsSummary, getAnalyticsTop } from "../server-fns/analytics/index.js";
|
|
9
|
+
import { getAnalyticsCountries, getAnalyticsDashboardPreference, getAnalyticsReferrers, getAnalyticsReportCoverage, getAnalyticsRuntime, getAnalyticsSummary, getAnalyticsTop, setAnalyticsDashboardPreference } from "../server-fns/analytics/index.js";
|
|
10
10
|
import { getAdminRoutePath } from "./admin-path.js";
|
|
11
11
|
import { buildAnalyticsDashboardRange } from "./analytics-range.js";
|
|
12
12
|
const periodSchema = z.preprocess((value)=>{
|
|
@@ -21,13 +21,19 @@ function createAdminAnalyticsRoute(path) {
|
|
|
21
21
|
const Route = createFileRoute(path)({
|
|
22
22
|
validateSearch: searchSchema,
|
|
23
23
|
loaderDeps: ({ search })=>({
|
|
24
|
-
period: search.period
|
|
24
|
+
period: search.period
|
|
25
25
|
}),
|
|
26
26
|
loader: async ({ deps })=>{
|
|
27
27
|
const runtime = await getAnalyticsRuntime();
|
|
28
28
|
if (!runtime.enabled) throw notFound();
|
|
29
|
-
const coverage = await
|
|
30
|
-
|
|
29
|
+
const [coverage, preference] = await Promise.all([
|
|
30
|
+
getAnalyticsReportCoverage(),
|
|
31
|
+
null == deps.period ? getAnalyticsDashboardPreference() : Promise.resolve({
|
|
32
|
+
period: null
|
|
33
|
+
})
|
|
34
|
+
]);
|
|
35
|
+
const period = deps.period ?? preference.period ?? 30;
|
|
36
|
+
const range = buildAnalyticsDashboardRange(period, new Date(), coverage.summaryFrom);
|
|
31
37
|
const [summary, pages, downloads, referrers, countries] = await Promise.all([
|
|
32
38
|
getAnalyticsSummary({
|
|
33
39
|
data: range
|
|
@@ -66,7 +72,7 @@ function createAdminAnalyticsRoute(path) {
|
|
|
66
72
|
range,
|
|
67
73
|
coverage
|
|
68
74
|
},
|
|
69
|
-
period
|
|
75
|
+
period
|
|
70
76
|
};
|
|
71
77
|
},
|
|
72
78
|
component: function() {
|
|
@@ -109,6 +115,13 @@ function createAdminAnalyticsRoute(path) {
|
|
|
109
115
|
period: nextPeriod
|
|
110
116
|
}
|
|
111
117
|
});
|
|
118
|
+
setAnalyticsDashboardPreference({
|
|
119
|
+
data: {
|
|
120
|
+
period: nextPeriod
|
|
121
|
+
}
|
|
122
|
+
}).catch((err)=>{
|
|
123
|
+
console.warn('analytics dashboard preference save failed:', err);
|
|
124
|
+
});
|
|
112
125
|
}
|
|
113
126
|
})
|
|
114
127
|
]
|
|
@@ -6,6 +6,7 @@ import { AdminUsersListView } from "../admin-shell/admin-users/list.js";
|
|
|
6
6
|
import { BreadcrumbsClient } from "../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js";
|
|
7
7
|
import { listAdminUsers } from "../server-fns/admin-users/index.js";
|
|
8
8
|
import { getAdminRoutePath } from "./admin-path.js";
|
|
9
|
+
import { searchBooleanSchema } from "./search-boolean.js";
|
|
9
10
|
const orderSchema = z["enum"]([
|
|
10
11
|
'given_name',
|
|
11
12
|
'family_name',
|
|
@@ -19,7 +20,7 @@ const searchSchema = z.object({
|
|
|
19
20
|
page_size: z.coerce.number().int().min(1).max(100).optional(),
|
|
20
21
|
query: z.string().optional(),
|
|
21
22
|
order: orderSchema.optional(),
|
|
22
|
-
desc:
|
|
23
|
+
desc: searchBooleanSchema
|
|
23
24
|
});
|
|
24
25
|
function createAdminUsersListRoute(path) {
|
|
25
26
|
const Route = createFileRoute(path)({
|
|
@@ -9,11 +9,12 @@ import { getCollectionDocument, getCollectionDocumentAuditLog, getCollectionDocu
|
|
|
9
9
|
import { getAdminRoutePath } from "./admin-path.js";
|
|
10
10
|
import { getContentLocaleRouteConfig } from "./get-content-locale-route-config.js";
|
|
11
11
|
import { getMultiCollectionDefinition } from "./get-multi-collection-definition.js";
|
|
12
|
+
import { searchBooleanSchema } from "./search-boolean.js";
|
|
12
13
|
const searchSchema = z.object({
|
|
13
14
|
page: z.coerce.number().min(1).optional(),
|
|
14
15
|
page_size: z.coerce.number().max(100).optional(),
|
|
15
16
|
order: z.string().optional(),
|
|
16
|
-
desc:
|
|
17
|
+
desc: searchBooleanSchema,
|
|
17
18
|
locale: z.string().optional(),
|
|
18
19
|
tab: z["enum"]([
|
|
19
20
|
'versions',
|
|
@@ -9,11 +9,12 @@ import { getSingleton, getSingletonHistory } from "../server-fns/singletons/inde
|
|
|
9
9
|
import { getAdminRoutePath } from "./admin-path.js";
|
|
10
10
|
import { getContentLocaleRouteConfig } from "./get-content-locale-route-config.js";
|
|
11
11
|
import { getSingletonDefinition } from "./get-singleton-definition.js";
|
|
12
|
+
import { searchBooleanSchema } from "./search-boolean.js";
|
|
12
13
|
const searchSchema = z.object({
|
|
13
14
|
page: z.coerce.number().min(1).optional(),
|
|
14
15
|
page_size: z.coerce.number().max(100).optional(),
|
|
15
16
|
order: z.string().optional(),
|
|
16
|
-
desc:
|
|
17
|
+
desc: searchBooleanSchema,
|
|
17
18
|
locale: z.string().optional()
|
|
18
19
|
});
|
|
19
20
|
function createSingletonHistoryRoute(path) {
|
|
@@ -24,7 +24,7 @@ export declare const collectionListSearchSchema: z.ZodObject<{
|
|
|
24
24
|
page: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
25
25
|
page_size: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
|
|
26
26
|
order: z.ZodOptional<z.ZodString>;
|
|
27
|
-
desc: z.ZodOptional<z.
|
|
27
|
+
desc: z.ZodPreprocess<z.ZodOptional<z.ZodBoolean>, unknown>;
|
|
28
28
|
query: z.ZodOptional<z.ZodString>;
|
|
29
29
|
locale: z.ZodOptional<z.ZodString>;
|
|
30
30
|
status: z.ZodOptional<z.ZodString>;
|
|
@@ -38,8 +38,7 @@ export declare function encodeListReturnState(search: Record<string, unknown>):
|
|
|
38
38
|
/**
|
|
39
39
|
* Parse a `from` param back into list search params. Runs the decoded
|
|
40
40
|
* pairs through the list search schema; anything malformed returns
|
|
41
|
-
* `undefined` (→ bare list).
|
|
42
|
-
*
|
|
43
|
-
* `true`.
|
|
41
|
+
* `undefined` (→ bare list). The schema accepts both boolean and wire-string
|
|
42
|
+
* directions, including `'false'` for ascending.
|
|
44
43
|
*/
|
|
45
44
|
export declare function decodeListReturnState(from: string | undefined): Record<string, unknown> | undefined;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { searchBooleanSchema } from "./search-boolean.js";
|
|
2
3
|
const collectionListSearchSchema = z.object({
|
|
3
4
|
page: z.coerce.number().min(1).optional(),
|
|
4
5
|
page_size: z.coerce.number().max(100).optional(),
|
|
5
6
|
order: z.string().optional(),
|
|
6
|
-
desc:
|
|
7
|
+
desc: searchBooleanSchema,
|
|
7
8
|
query: z.string().optional(),
|
|
8
9
|
locale: z.string().optional(),
|
|
9
10
|
status: z.string().optional(),
|
|
@@ -33,7 +34,6 @@ function decodeListReturnState(from) {
|
|
|
33
34
|
if (null == from || 0 === from.length) return;
|
|
34
35
|
const raw = Object.fromEntries(new URLSearchParams(from));
|
|
35
36
|
delete raw.action;
|
|
36
|
-
if ('string' == typeof raw.desc) raw.desc = 'true' === raw.desc;
|
|
37
37
|
const parsed = collectionListSearchSchema.safeParse(raw);
|
|
38
38
|
if (!parsed.success) return;
|
|
39
39
|
const result = {};
|
|
@@ -6,4 +6,5 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
export { type DeleteAnalyticsEventsInput, deleteAnalyticsEvents, rebuildAnalyticsDay, } from './maintenance.js';
|
|
9
|
+
export { getAnalyticsDashboardPreference, setAnalyticsDashboardPreference, } from './preferences.js';
|
|
9
10
|
export { type AnalyticsRuntimeState, type AnalyticsTopInput, getAnalyticsCountries, getAnalyticsReferrers, getAnalyticsReportCoverage, getAnalyticsRuntime, getAnalyticsSummary, getAnalyticsTop, } from './queries.js';
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { deleteAnalyticsEvents, rebuildAnalyticsDay } from "./maintenance.js";
|
|
2
|
+
export { getAnalyticsDashboardPreference, setAnalyticsDashboardPreference } from "./preferences.js";
|
|
2
3
|
export { getAnalyticsCountries, getAnalyticsReferrers, getAnalyticsReportCoverage, getAnalyticsRuntime, getAnalyticsSummary, getAnalyticsTop } from "./queries.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import type { AnalyticsDashboardPeriod } from '@byline/admin/analytics';
|
|
9
|
+
export interface SetAnalyticsDashboardPreferenceInput {
|
|
10
|
+
period: AnalyticsDashboardPeriod;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The stored period, or `null` when the user has never chosen one (or
|
|
14
|
+
* this host has no admin store).
|
|
15
|
+
*
|
|
16
|
+
* Never throws: a preference read must not be able to take the
|
|
17
|
+
* dashboard down. A headless context, an unauthenticated preview, or a
|
|
18
|
+
* database hiccup logs and yields `null`, and the caller falls back to
|
|
19
|
+
* the built-in default — the same posture the collection list's
|
|
20
|
+
* preference read takes.
|
|
21
|
+
*/
|
|
22
|
+
export declare const getAnalyticsDashboardPreference: import("@tanstack/react-start").OptionalFetcher<readonly [import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, undefined, {
|
|
23
|
+
bylineExpectedSessionId: string | null;
|
|
24
|
+
}, undefined, undefined>], undefined, Promise<{
|
|
25
|
+
period: AnalyticsDashboardPeriod | null;
|
|
26
|
+
}>>;
|
|
27
|
+
/**
|
|
28
|
+
* Persist the chosen period. Fire-and-forget from the dashboard: the
|
|
29
|
+
* navigation has already happened, so a failed save must never toast,
|
|
30
|
+
* block, or roll anything back.
|
|
31
|
+
*/
|
|
32
|
+
export declare const setAnalyticsDashboardPreference: import("@tanstack/react-start").RequiredFetcher<readonly [import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, undefined, {
|
|
33
|
+
bylineExpectedSessionId: string | null;
|
|
34
|
+
}, undefined, undefined>], (input: SetAnalyticsDashboardPreferenceInput) => SetAnalyticsDashboardPreferenceInput, Promise<{
|
|
35
|
+
ok: true;
|
|
36
|
+
}>>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { getPreferenceCommand, setPreferenceCommand } from "@byline/admin/admin-preferences";
|
|
3
|
+
import { isAnalyticsDashboardPeriod } from "@byline/analytics/config";
|
|
4
|
+
import { getAdminRequestContext } from "@byline/client/server";
|
|
5
|
+
import { getLogger } from "@byline/core";
|
|
6
|
+
import { bylineCore } from "../../integrations/byline-core.js";
|
|
7
|
+
import { adminSessionMiddleware } from "../../integrations/session-middleware.js";
|
|
8
|
+
const ANALYTICS_DASHBOARD_SCOPE = 'analytics.dashboard';
|
|
9
|
+
const getAnalyticsDashboardPreference = createServerFn({
|
|
10
|
+
method: 'GET'
|
|
11
|
+
}).middleware([
|
|
12
|
+
adminSessionMiddleware
|
|
13
|
+
]).handler(async ()=>{
|
|
14
|
+
const adminStore = bylineCore().adminStore;
|
|
15
|
+
if (null == adminStore) return {
|
|
16
|
+
period: null
|
|
17
|
+
};
|
|
18
|
+
try {
|
|
19
|
+
const context = await getAdminRequestContext();
|
|
20
|
+
const result = await getPreferenceCommand(context, {
|
|
21
|
+
scope: ANALYTICS_DASHBOARD_SCOPE
|
|
22
|
+
}, {
|
|
23
|
+
store: adminStore
|
|
24
|
+
});
|
|
25
|
+
const period = result.value?.period;
|
|
26
|
+
return {
|
|
27
|
+
period: isAnalyticsDashboardPeriod(period) ? period : null
|
|
28
|
+
};
|
|
29
|
+
} catch (err) {
|
|
30
|
+
getLogger().warn({
|
|
31
|
+
err
|
|
32
|
+
}, 'analytics dashboard preference read failed — using default');
|
|
33
|
+
return {
|
|
34
|
+
period: null
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
const setAnalyticsDashboardPreference = createServerFn({
|
|
39
|
+
method: 'POST'
|
|
40
|
+
}).middleware([
|
|
41
|
+
adminSessionMiddleware
|
|
42
|
+
]).validator((input)=>input).handler(async ({ data })=>{
|
|
43
|
+
const adminStore = bylineCore().adminStore;
|
|
44
|
+
if (null == adminStore) return {
|
|
45
|
+
ok: true
|
|
46
|
+
};
|
|
47
|
+
const context = await getAdminRequestContext();
|
|
48
|
+
await setPreferenceCommand(context, {
|
|
49
|
+
scope: ANALYTICS_DASHBOARD_SCOPE,
|
|
50
|
+
value: {
|
|
51
|
+
period: data.period
|
|
52
|
+
}
|
|
53
|
+
}, {
|
|
54
|
+
store: adminStore
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
ok: true
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
export { getAnalyticsDashboardPreference, setAnalyticsDashboardPreference };
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "6.0.
|
|
6
|
+
"version": "6.0.3",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -125,15 +125,15 @@
|
|
|
125
125
|
"react-json-view-lite": "^2.5.0",
|
|
126
126
|
"react-swipeable": "^7.0.2",
|
|
127
127
|
"zod": "^4.6.2",
|
|
128
|
-
"@byline/
|
|
129
|
-
"@byline/
|
|
130
|
-
"@byline/auth": "6.0.
|
|
131
|
-
"@byline/
|
|
132
|
-
"@byline/
|
|
133
|
-
"@byline/
|
|
134
|
-
"@byline/
|
|
135
|
-
"@byline/
|
|
136
|
-
"@byline/
|
|
128
|
+
"@byline/admin": "6.0.3",
|
|
129
|
+
"@byline/ai": "6.0.3",
|
|
130
|
+
"@byline/auth": "6.0.3",
|
|
131
|
+
"@byline/client": "6.0.3",
|
|
132
|
+
"@byline/analytics-agent": "6.0.3",
|
|
133
|
+
"@byline/i18n": "6.0.3",
|
|
134
|
+
"@byline/analytics": "6.0.3",
|
|
135
|
+
"@byline/core": "6.0.3",
|
|
136
|
+
"@byline/ui": "6.0.3"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
|
139
139
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { act, type ComponentProps } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
createMemoryHistory,
|
|
4
|
+
createRootRoute,
|
|
5
|
+
createRoute,
|
|
6
|
+
createRouter,
|
|
7
|
+
RouterProvider,
|
|
8
|
+
useRouterState,
|
|
9
|
+
} from '@tanstack/react-router'
|
|
10
|
+
|
|
11
|
+
import { createRoot } from 'react-dom/client'
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
|
|
14
|
+
import { collectionListSearchSchema } from '../../routes/list-return-state.js'
|
|
15
|
+
import { resolveListViewState } from '../../server-fns/collections/list-view-state.js'
|
|
16
|
+
import { TableHeadingCellSortable } from './th-sortable.js'
|
|
17
|
+
|
|
18
|
+
vi.mock('@byline/ui/react', () => ({
|
|
19
|
+
Table: {
|
|
20
|
+
HeadingCell: (props: ComponentProps<'th'>) => <th {...props} />,
|
|
21
|
+
},
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
// FORRU preserves wire values as strings and repeated keys as arrays for its
|
|
25
|
+
// public library URLs. Admin routes share that router and must parse booleans.
|
|
26
|
+
function parseWireSearch(search: string): Record<string, unknown> {
|
|
27
|
+
const params = new URLSearchParams(search)
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
[...new Set(params.keys())].map((key) => {
|
|
30
|
+
const values = params.getAll(key)
|
|
31
|
+
return [key, values.length === 1 ? values[0] : values]
|
|
32
|
+
})
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stringifyWireSearch(search: Record<string, unknown>): string {
|
|
37
|
+
const params = new URLSearchParams()
|
|
38
|
+
for (const [key, value] of Object.entries(search)) {
|
|
39
|
+
if (value == null) continue
|
|
40
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
41
|
+
params.append(key, String(entry))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return params.size ? `?${params}` : ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
49
|
+
vi.spyOn(window, 'scrollTo').mockImplementation(() => {})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks()
|
|
54
|
+
vi.unstubAllGlobals()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe.each(['default', 'wire'] as const)('%s search parser', (parser) => {
|
|
58
|
+
it.each([true, false])(
|
|
59
|
+
'toggles sort requests and indicators with effective metadata: %s',
|
|
60
|
+
async (effective) => {
|
|
61
|
+
const rootRoute = createRootRoute()
|
|
62
|
+
const route = createRoute({
|
|
63
|
+
getParentRoute: () => rootRoute,
|
|
64
|
+
path: '/admin/collections/news',
|
|
65
|
+
validateSearch: collectionListSearchSchema,
|
|
66
|
+
loaderDeps: ({ search }) => search,
|
|
67
|
+
loader: ({ deps }) =>
|
|
68
|
+
resolveListViewState({
|
|
69
|
+
params: deps,
|
|
70
|
+
preference: null,
|
|
71
|
+
orderable: false,
|
|
72
|
+
sortableFields: ['title'],
|
|
73
|
+
}),
|
|
74
|
+
component: () => {
|
|
75
|
+
const data = route.useLoaderData()
|
|
76
|
+
// Match ListView: location.search is the host parser's raw output,
|
|
77
|
+
// unlike route.useSearch(), which has already passed validation.
|
|
78
|
+
const location = useRouterState({ select: (s) => s.location })
|
|
79
|
+
const search = location.search as { order?: string; desc?: boolean }
|
|
80
|
+
return (
|
|
81
|
+
<table>
|
|
82
|
+
<thead>
|
|
83
|
+
<tr>
|
|
84
|
+
<TableHeadingCellSortable
|
|
85
|
+
fieldName="title"
|
|
86
|
+
label="Title"
|
|
87
|
+
sortable
|
|
88
|
+
{...(effective
|
|
89
|
+
? {
|
|
90
|
+
activeOrder: search.order ?? data.metaOrder,
|
|
91
|
+
activeDesc: search.order != null ? search.desc : data.metaDesc,
|
|
92
|
+
}
|
|
93
|
+
: {})}
|
|
94
|
+
/>
|
|
95
|
+
</tr>
|
|
96
|
+
</thead>
|
|
97
|
+
<tbody>
|
|
98
|
+
<tr>
|
|
99
|
+
<td>{JSON.stringify(data.sort)}</td>
|
|
100
|
+
</tr>
|
|
101
|
+
</tbody>
|
|
102
|
+
</table>
|
|
103
|
+
)
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
const router = createRouter({
|
|
107
|
+
routeTree: rootRoute.addChildren([route]),
|
|
108
|
+
history: createMemoryHistory({
|
|
109
|
+
initialEntries: ['/admin/collections/news?order=title&desc=true'],
|
|
110
|
+
}),
|
|
111
|
+
...(parser === 'wire'
|
|
112
|
+
? { parseSearch: parseWireSearch, stringifySearch: stringifyWireSearch }
|
|
113
|
+
: {}),
|
|
114
|
+
})
|
|
115
|
+
const container = document.createElement('div')
|
|
116
|
+
document.body.append(container)
|
|
117
|
+
const root = createRoot(container)
|
|
118
|
+
try {
|
|
119
|
+
await act(async () => {
|
|
120
|
+
await router.load()
|
|
121
|
+
root.render(<RouterProvider router={router} />)
|
|
122
|
+
})
|
|
123
|
+
expect(container.querySelector('button')?.textContent).toBe('TitleZA')
|
|
124
|
+
for (const direction of ['asc', 'desc', 'asc']) {
|
|
125
|
+
await act(async () => {
|
|
126
|
+
container.querySelector('button')?.click()
|
|
127
|
+
await vi.waitFor(() => expect(router.state.status).toBe('idle'))
|
|
128
|
+
})
|
|
129
|
+
expect(container.querySelector('td')?.textContent).toBe(
|
|
130
|
+
JSON.stringify({ title: direction })
|
|
131
|
+
)
|
|
132
|
+
expect(container.querySelector('button')?.textContent).toBe(
|
|
133
|
+
direction === 'asc' ? 'TitleAZ' : 'TitleZA'
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
} finally {
|
|
137
|
+
await act(async () => root.unmount())
|
|
138
|
+
container.remove()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
})
|
|
@@ -13,6 +13,7 @@ import { useRouterState } from '@tanstack/react-router'
|
|
|
13
13
|
import { Table } from '@byline/ui/react'
|
|
14
14
|
import cx from 'clsx'
|
|
15
15
|
|
|
16
|
+
import { searchBooleanSchema } from '../../routes/search-boolean.js'
|
|
16
17
|
import { useNavigate } from './loose-router.js'
|
|
17
18
|
import { SortAscendingIcon, SortDescendingIcon, SortNeutralIcon } from './sort-icons.js'
|
|
18
19
|
import styles from './th-sortable.module.css'
|
|
@@ -91,7 +92,9 @@ export function TableHeadingCellSortable({
|
|
|
91
92
|
? activeDesc
|
|
92
93
|
: ((location.search as Record<string, unknown>).desc as boolean | undefined)
|
|
93
94
|
if (order === fieldName) {
|
|
94
|
-
|
|
95
|
+
// location.search can contain wire strings with a custom host parser.
|
|
96
|
+
// The route validator normalizes loader deps, not this raw location.
|
|
97
|
+
setDesc(searchBooleanSchema.safeParse(d).data ?? true)
|
|
95
98
|
} else {
|
|
96
99
|
setDesc(null)
|
|
97
100
|
}
|
|
@@ -47,6 +47,7 @@ import cx from 'clsx'
|
|
|
47
47
|
|
|
48
48
|
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
49
49
|
import { encodeListReturnState } from '../../routes/list-return-state.js'
|
|
50
|
+
import { searchBooleanSchema } from '../../routes/search-boolean.js'
|
|
50
51
|
import { setListViewPreference } from '../../server-fns/collections/index.js'
|
|
51
52
|
import { Link, useNavigate } from '../chrome/loose-router.js'
|
|
52
53
|
import { RouterPager } from '../chrome/router-pager.js'
|
|
@@ -206,12 +207,13 @@ export const ListView = ({
|
|
|
206
207
|
const searchParams = location.search as {
|
|
207
208
|
page_size?: number
|
|
208
209
|
order?: string
|
|
209
|
-
desc?: boolean
|
|
210
|
+
desc?: boolean | string
|
|
210
211
|
query?: string
|
|
211
212
|
status?: string
|
|
212
213
|
}
|
|
214
|
+
const searchDesc = searchBooleanSchema.safeParse(searchParams.desc).data
|
|
213
215
|
const isCanonicalView =
|
|
214
|
-
!searchParams.order && !
|
|
216
|
+
!searchParams.order && !searchDesc && !searchParams.query && !searchParams.status
|
|
215
217
|
const dragEnabled = orderable && isCanonicalView && !!onReorder && !mutation.blocked
|
|
216
218
|
|
|
217
219
|
// The *effective* sort for the header indicators: explicit URL params win;
|
|
@@ -220,7 +222,7 @@ export const ListView = ({
|
|
|
220
222
|
// which column ordered the rows. (Orderable collections never set
|
|
221
223
|
// `meta.order` — their default is the drag order, indicated separately.)
|
|
222
224
|
const activeOrder = searchParams.order ?? data?.meta.order
|
|
223
|
-
const activeDesc = searchParams.order != null ?
|
|
225
|
+
const activeDesc = searchParams.order != null ? searchDesc : data?.meta.desc
|
|
224
226
|
|
|
225
227
|
const sensors = useSensors(
|
|
226
228
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
@@ -21,11 +21,13 @@ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs
|
|
|
21
21
|
import { useNavigate } from '../admin-shell/chrome/loose-router.js'
|
|
22
22
|
import {
|
|
23
23
|
getAnalyticsCountries,
|
|
24
|
+
getAnalyticsDashboardPreference,
|
|
24
25
|
getAnalyticsReferrers,
|
|
25
26
|
getAnalyticsReportCoverage,
|
|
26
27
|
getAnalyticsRuntime,
|
|
27
28
|
getAnalyticsSummary,
|
|
28
29
|
getAnalyticsTop,
|
|
30
|
+
setAnalyticsDashboardPreference,
|
|
29
31
|
} from '../server-fns/analytics/index.js'
|
|
30
32
|
import { getAdminRoutePath } from './admin-path.js'
|
|
31
33
|
import { buildAnalyticsDashboardRange } from './analytics-range.js'
|
|
@@ -47,15 +49,29 @@ interface AnalyticsSearch {
|
|
|
47
49
|
export function createAdminAnalyticsRoute(path: string) {
|
|
48
50
|
const Route: any = createFileRoute(path as never)({
|
|
49
51
|
validateSearch: searchSchema,
|
|
52
|
+
// `period` stays possibly-undefined here rather than defaulting to 30:
|
|
53
|
+
// "no period in the URL" is a distinct state from "30 in the URL", and
|
|
54
|
+
// only the former defers to the user's stored preference. It is still a
|
|
55
|
+
// loader dep, so an explicit period change refetches.
|
|
50
56
|
loaderDeps: ({ search }: { search: AnalyticsSearch }) => ({
|
|
51
|
-
period: search.period
|
|
57
|
+
period: search.period,
|
|
52
58
|
}),
|
|
53
|
-
loader: async ({ deps }: { deps: { period
|
|
59
|
+
loader: async ({ deps }: { deps: { period?: AnalyticsDashboardPeriod } }) => {
|
|
54
60
|
const runtime = await getAnalyticsRuntime()
|
|
55
61
|
if (!runtime.enabled) throw notFound()
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
// Both are roundtrips the dashboard has to make before it can build a
|
|
64
|
+
// range, so the preference read rides along in parallel and costs no
|
|
65
|
+
// added latency. It is skipped entirely when the URL already decides.
|
|
66
|
+
const [coverage, preference] = await Promise.all([
|
|
67
|
+
getAnalyticsReportCoverage(),
|
|
68
|
+
deps.period == null ? getAnalyticsDashboardPreference() : Promise.resolve({ period: null }),
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
// Precedence, matching the collection list: explicit URL param →
|
|
72
|
+
// stored per-user preference → the built-in default.
|
|
73
|
+
const period: AnalyticsDashboardPeriod = deps.period ?? preference.period ?? 30
|
|
74
|
+
const range = buildAnalyticsDashboardRange(period, new Date(), coverage.summaryFrom)
|
|
59
75
|
const [summary, pages, downloads, referrers, countries] = await Promise.all([
|
|
60
76
|
getAnalyticsSummary({ data: range }),
|
|
61
77
|
// Fetch exactly the preview depth each card shows; the full list is
|
|
@@ -76,7 +92,9 @@ export function createAdminAnalyticsRoute(path: string) {
|
|
|
76
92
|
range,
|
|
77
93
|
coverage,
|
|
78
94
|
} satisfies AnalyticsDashboardData,
|
|
79
|
-
|
|
95
|
+
// The *effective* period, so a params-less landing still highlights
|
|
96
|
+
// the period that actually ordered the data.
|
|
97
|
+
period,
|
|
80
98
|
}
|
|
81
99
|
},
|
|
82
100
|
component: function AdminAnalyticsComponent() {
|
|
@@ -116,6 +134,14 @@ export function createAdminAnalyticsRoute(path: string) {
|
|
|
116
134
|
to: getAdminRoutePath('analytics'),
|
|
117
135
|
search: { period: nextPeriod },
|
|
118
136
|
})
|
|
137
|
+
// Quietly make the choice sticky for next session. Fire-and-
|
|
138
|
+
// forget: the navigation already happened, so a failed save
|
|
139
|
+
// must never toast, block, or roll anything back.
|
|
140
|
+
setAnalyticsDashboardPreference({ data: { period: nextPeriod } }).catch(
|
|
141
|
+
(err: unknown) => {
|
|
142
|
+
console.warn('analytics dashboard preference save failed:', err)
|
|
143
|
+
}
|
|
144
|
+
)
|
|
119
145
|
}}
|
|
120
146
|
/>
|
|
121
147
|
</>
|
|
@@ -15,6 +15,7 @@ import { AdminUsersListView } from '../admin-shell/admin-users/list.js'
|
|
|
15
15
|
import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
|
|
16
16
|
import { type AdminUserListResponse, listAdminUsers } from '../server-fns/admin-users/index.js'
|
|
17
17
|
import { getAdminRoutePath } from './admin-path.js'
|
|
18
|
+
import { searchBooleanSchema } from './search-boolean.js'
|
|
18
19
|
|
|
19
20
|
const orderSchema = z.enum([
|
|
20
21
|
'given_name',
|
|
@@ -30,7 +31,7 @@ const searchSchema = z.object({
|
|
|
30
31
|
page_size: z.coerce.number().int().min(1).max(100).optional(),
|
|
31
32
|
query: z.string().optional(),
|
|
32
33
|
order: orderSchema.optional(),
|
|
33
|
-
desc:
|
|
34
|
+
desc: searchBooleanSchema,
|
|
34
35
|
})
|
|
35
36
|
|
|
36
37
|
export function createAdminUsersListRoute(path: string) {
|
|
@@ -23,12 +23,13 @@ import {
|
|
|
23
23
|
import { getAdminRoutePath } from './admin-path.js'
|
|
24
24
|
import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
|
|
25
25
|
import { getMultiCollectionDefinition } from './get-multi-collection-definition.js'
|
|
26
|
+
import { searchBooleanSchema } from './search-boolean.js'
|
|
26
27
|
|
|
27
28
|
const searchSchema = z.object({
|
|
28
29
|
page: z.coerce.number().min(1).optional(),
|
|
29
30
|
page_size: z.coerce.number().max(100).optional(),
|
|
30
31
|
order: z.string().optional(),
|
|
31
|
-
desc:
|
|
32
|
+
desc: searchBooleanSchema,
|
|
32
33
|
locale: z.string().optional(),
|
|
33
34
|
// Which sub-view of the history page is active (docs/07-auth-and-security/02-auditability.md — Workstream
|
|
34
35
|
// 3). 'versions' is the content version stream; 'document' is the
|
|
@@ -18,13 +18,14 @@ import { getSingleton, getSingletonHistory } from '../server-fns/singletons/inde
|
|
|
18
18
|
import { getAdminRoutePath } from './admin-path.js'
|
|
19
19
|
import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
|
|
20
20
|
import { getSingletonDefinition } from './get-singleton-definition.js'
|
|
21
|
+
import { searchBooleanSchema } from './search-boolean.js'
|
|
21
22
|
import type { VersionHistoryData } from '../admin-shell/collections/version-history.js'
|
|
22
23
|
|
|
23
24
|
const searchSchema = z.object({
|
|
24
25
|
page: z.coerce.number().min(1).optional(),
|
|
25
26
|
page_size: z.coerce.number().max(100).optional(),
|
|
26
27
|
order: z.string().optional(),
|
|
27
|
-
desc:
|
|
28
|
+
desc: searchBooleanSchema,
|
|
28
29
|
locale: z.string().optional(),
|
|
29
30
|
})
|
|
30
31
|
|
|
@@ -23,11 +23,13 @@
|
|
|
23
23
|
|
|
24
24
|
import { z } from 'zod'
|
|
25
25
|
|
|
26
|
+
import { searchBooleanSchema } from './search-boolean.js'
|
|
27
|
+
|
|
26
28
|
export const collectionListSearchSchema = z.object({
|
|
27
29
|
page: z.coerce.number().min(1).optional(),
|
|
28
30
|
page_size: z.coerce.number().max(100).optional(),
|
|
29
31
|
order: z.string().optional(),
|
|
30
|
-
desc:
|
|
32
|
+
desc: searchBooleanSchema,
|
|
31
33
|
query: z.string().optional(),
|
|
32
34
|
locale: z.string().optional(),
|
|
33
35
|
status: z.string().optional(),
|
|
@@ -56,9 +58,8 @@ export function encodeListReturnState(search: Record<string, unknown>): string |
|
|
|
56
58
|
/**
|
|
57
59
|
* Parse a `from` param back into list search params. Runs the decoded
|
|
58
60
|
* pairs through the list search schema; anything malformed returns
|
|
59
|
-
* `undefined` (→ bare list).
|
|
60
|
-
*
|
|
61
|
-
* `true`.
|
|
61
|
+
* `undefined` (→ bare list). The schema accepts both boolean and wire-string
|
|
62
|
+
* directions, including `'false'` for ascending.
|
|
62
63
|
*/
|
|
63
64
|
export function decodeListReturnState(
|
|
64
65
|
from: string | undefined
|
|
@@ -66,7 +67,6 @@ export function decodeListReturnState(
|
|
|
66
67
|
if (from == null || from.length === 0) return undefined
|
|
67
68
|
const raw: Record<string, unknown> = Object.fromEntries(new URLSearchParams(from))
|
|
68
69
|
delete raw.action
|
|
69
|
-
if (typeof raw.desc === 'string') raw.desc = raw.desc === 'true'
|
|
70
70
|
const parsed = collectionListSearchSchema.safeParse(raw)
|
|
71
71
|
if (!parsed.success) return undefined
|
|
72
72
|
const result: Record<string, unknown> = {}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { resolveListViewState } from '../server-fns/collections/list-view-state.js'
|
|
4
|
+
import { collectionListSearchSchema, decodeListReturnState } from './list-return-state.js'
|
|
5
|
+
import { searchBooleanSchema } from './search-boolean.js'
|
|
6
|
+
|
|
7
|
+
describe('search booleans', () => {
|
|
8
|
+
it.each([
|
|
9
|
+
[true, true],
|
|
10
|
+
[false, false],
|
|
11
|
+
['true', true],
|
|
12
|
+
['false', false],
|
|
13
|
+
[undefined, undefined],
|
|
14
|
+
])('parses %s as %s', (input, expected) => {
|
|
15
|
+
expect(searchBooleanSchema.parse(input)).toBe(expected)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it.each(['invalid', '', ['false'], null, 0, 1])('rejects ambiguous input %s', (input) => {
|
|
19
|
+
expect(searchBooleanSchema.safeParse(input).success).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it.each(['true', 'false'])('carries desc=%s from wire params into the list query', (desc) => {
|
|
23
|
+
const params = collectionListSearchSchema.parse(
|
|
24
|
+
Object.fromEntries(new URLSearchParams(`order=title&desc=${desc}`))
|
|
25
|
+
)
|
|
26
|
+
const result = resolveListViewState({
|
|
27
|
+
params,
|
|
28
|
+
preference: null,
|
|
29
|
+
orderable: false,
|
|
30
|
+
sortableFields: ['title'],
|
|
31
|
+
})
|
|
32
|
+
expect(result.sort).toEqual({ title: desc === 'true' ? 'desc' : 'asc' })
|
|
33
|
+
expect(result.metaDesc).toBe(desc === 'true')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('rejects a malformed return direction instead of silently changing it to ascending', () => {
|
|
37
|
+
expect(decodeListReturnState('order=title&desc=invalid')).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
// Host routers may preserve URL values as strings. Boolean('false') is true,
|
|
4
|
+
// so boolean coercion cannot be used for an explicit URL direction.
|
|
5
|
+
export const searchBooleanSchema = z.preprocess(
|
|
6
|
+
(value) => (value === 'true' ? true : value === 'false' ? false : value),
|
|
7
|
+
z.boolean().optional()
|
|
8
|
+
)
|
|
@@ -11,6 +11,10 @@ export {
|
|
|
11
11
|
deleteAnalyticsEvents,
|
|
12
12
|
rebuildAnalyticsDay,
|
|
13
13
|
} from './maintenance.js'
|
|
14
|
+
export {
|
|
15
|
+
getAnalyticsDashboardPreference,
|
|
16
|
+
setAnalyticsDashboardPreference,
|
|
17
|
+
} from './preferences.js'
|
|
14
18
|
export {
|
|
15
19
|
type AnalyticsRuntimeState,
|
|
16
20
|
type AnalyticsTopInput,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read and persist the signed-in admin user's sticky analytics
|
|
11
|
+
* dashboard period.
|
|
12
|
+
*
|
|
13
|
+
* The dashboard keeps its period in the URL (`?period=90`), so a shared
|
|
14
|
+
* link still opens exactly as sent; this preference only supplies the
|
|
15
|
+
* period when the URL carries none. Same precedence the collection list
|
|
16
|
+
* uses for page size and sort — explicit params win, the stored
|
|
17
|
+
* preference fills in, the built-in default is last.
|
|
18
|
+
*
|
|
19
|
+
* The scope is a singleton (`analytics.dashboard`), unlike the
|
|
20
|
+
* collection list's per-path scopes, so there is nothing to validate
|
|
21
|
+
* the way `set-list-view-preference` validates a collection path.
|
|
22
|
+
*
|
|
23
|
+
* Self-service: the target user is always the authenticated actor.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
27
|
+
|
|
28
|
+
import { getPreferenceCommand, setPreferenceCommand } from '@byline/admin/admin-preferences'
|
|
29
|
+
import type { AnalyticsDashboardPeriod } from '@byline/admin/analytics'
|
|
30
|
+
import { isAnalyticsDashboardPeriod } from '@byline/analytics/config'
|
|
31
|
+
import { getAdminRequestContext } from '@byline/client/server'
|
|
32
|
+
import { getLogger } from '@byline/core'
|
|
33
|
+
|
|
34
|
+
import { bylineCore } from '../../integrations/byline-core.js'
|
|
35
|
+
import { adminSessionMiddleware } from '../../integrations/session-middleware.js'
|
|
36
|
+
|
|
37
|
+
const ANALYTICS_DASHBOARD_SCOPE = 'analytics.dashboard'
|
|
38
|
+
|
|
39
|
+
export interface SetAnalyticsDashboardPreferenceInput {
|
|
40
|
+
period: AnalyticsDashboardPeriod
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The stored period, or `null` when the user has never chosen one (or
|
|
45
|
+
* this host has no admin store).
|
|
46
|
+
*
|
|
47
|
+
* Never throws: a preference read must not be able to take the
|
|
48
|
+
* dashboard down. A headless context, an unauthenticated preview, or a
|
|
49
|
+
* database hiccup logs and yields `null`, and the caller falls back to
|
|
50
|
+
* the built-in default — the same posture the collection list's
|
|
51
|
+
* preference read takes.
|
|
52
|
+
*/
|
|
53
|
+
export const getAnalyticsDashboardPreference = createServerFn({ method: 'GET' })
|
|
54
|
+
.middleware([adminSessionMiddleware])
|
|
55
|
+
.handler(async (): Promise<{ period: AnalyticsDashboardPeriod | null }> => {
|
|
56
|
+
const adminStore = bylineCore().adminStore
|
|
57
|
+
if (adminStore == null) return { period: null }
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const context = await getAdminRequestContext()
|
|
61
|
+
const result = await getPreferenceCommand(
|
|
62
|
+
context,
|
|
63
|
+
{ scope: ANALYTICS_DASHBOARD_SCOPE },
|
|
64
|
+
{ store: adminStore }
|
|
65
|
+
)
|
|
66
|
+
const period = (result.value as { period?: unknown } | null)?.period
|
|
67
|
+
// Re-validated on the way out, not just on the way in: a row
|
|
68
|
+
// written before a period was retired from the dashboard must
|
|
69
|
+
// degrade to the default rather than reach `buildAnalyticsDashboardRange`.
|
|
70
|
+
return { period: isAnalyticsDashboardPeriod(period) ? period : null }
|
|
71
|
+
} catch (err) {
|
|
72
|
+
getLogger().warn({ err }, 'analytics dashboard preference read failed — using default')
|
|
73
|
+
return { period: null }
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Persist the chosen period. Fire-and-forget from the dashboard: the
|
|
79
|
+
* navigation has already happened, so a failed save must never toast,
|
|
80
|
+
* block, or roll anything back.
|
|
81
|
+
*/
|
|
82
|
+
export const setAnalyticsDashboardPreference = createServerFn({ method: 'POST' })
|
|
83
|
+
.middleware([adminSessionMiddleware])
|
|
84
|
+
.validator((input: SetAnalyticsDashboardPreferenceInput) => input)
|
|
85
|
+
.handler(async ({ data }) => {
|
|
86
|
+
const adminStore = bylineCore().adminStore
|
|
87
|
+
if (adminStore == null) {
|
|
88
|
+
// Headless hosts without an admin store have no preference storage —
|
|
89
|
+
// the save is a silent no-op, mirroring set-locale's posture.
|
|
90
|
+
return { ok: true as const }
|
|
91
|
+
}
|
|
92
|
+
const context = await getAdminRequestContext()
|
|
93
|
+
await setPreferenceCommand(
|
|
94
|
+
context,
|
|
95
|
+
{ scope: ANALYTICS_DASHBOARD_SCOPE, value: { period: data.period } },
|
|
96
|
+
{ store: adminStore }
|
|
97
|
+
)
|
|
98
|
+
return { ok: true as const }
|
|
99
|
+
})
|