@byline/host-tanstack-start 4.6.1 → 4.7.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/admin-shell/collections/edit.js +7 -2
- package/dist/routes/create-collection-edit-route.js +14 -2
- package/dist/routes/list-return-storage.d.ts +13 -0
- package/dist/routes/list-return-storage.js +36 -0
- package/package.json +8 -8
- package/src/admin-shell/collections/edit.tsx +11 -3
- package/src/routes/create-collection-edit-route.tsx +21 -2
- package/src/routes/list-return-storage.test.node.ts +77 -0
- package/src/routes/list-return-storage.ts +80 -0
|
@@ -5,6 +5,7 @@ import { getDefaultStatus, getWorkflowStatuses } from "@byline/core";
|
|
|
5
5
|
import { useTranslation } from "@byline/i18n/react";
|
|
6
6
|
import { Container, Section, useToastManager } from "@byline/ui/react";
|
|
7
7
|
import { getAdminRoutePath } from "../../routes/admin-path.js";
|
|
8
|
+
import { clearListReturnState } from "../../routes/list-return-storage.js";
|
|
8
9
|
import { copyDocumentToLocale, deleteDocument, deleteDocumentLocale, duplicateCollectionDocument, hasDeleteSideEffectFailures, unpublishDocument, updateCollectionDocumentSystemFields, updateCollectionDocumentWithPatches, updateDocumentStatus } from "../../server-fns/collections/index.js";
|
|
9
10
|
import { useNavigate } from "../chrome/loose-router.js";
|
|
10
11
|
import { useTanStackNavigationGuard } from "./tanstack-navigation-guard.js";
|
|
@@ -390,6 +391,7 @@ const EditView = ({ collectionDefinition, adminConfig, initialData, locale, cont
|
|
|
390
391
|
status: 'success',
|
|
391
392
|
message: description
|
|
392
393
|
});
|
|
394
|
+
clearListReturnState(path, String(initialData.id));
|
|
393
395
|
navigate({
|
|
394
396
|
to: getAdminRoutePath('collections', '$collection'),
|
|
395
397
|
params: {
|
|
@@ -533,13 +535,16 @@ const EditView = ({ collectionDefinition, adminConfig, initialData, locale, cont
|
|
|
533
535
|
restoreWarnings: restoreWarnings,
|
|
534
536
|
nextStatus: nextStatus,
|
|
535
537
|
workflowStatuses: workflowStatuses,
|
|
536
|
-
onCancel: ()=>
|
|
538
|
+
onCancel: ()=>{
|
|
539
|
+
clearListReturnState(path, String(initialData.id));
|
|
540
|
+
navigate({
|
|
537
541
|
to: getAdminRoutePath('collections', '$collection'),
|
|
538
542
|
params: {
|
|
539
543
|
collection: path
|
|
540
544
|
},
|
|
541
545
|
search: returnSearch
|
|
542
|
-
})
|
|
546
|
+
});
|
|
547
|
+
},
|
|
543
548
|
collectionPath: path
|
|
544
549
|
})
|
|
545
550
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
3
|
import { createFileRoute, notFound } from "@tanstack/react-router";
|
|
4
4
|
import { getCollectionAdminConfig, getCollectionDefinition } from "@byline/core";
|
|
5
5
|
import { useTranslation } from "@byline/i18n/react";
|
|
@@ -12,6 +12,7 @@ import { getCollectionDocument } from "../server-fns/collections/index.js";
|
|
|
12
12
|
import { getAdminRoutePath } from "./admin-path.js";
|
|
13
13
|
import { getContentLocaleRouteConfig } from "./get-content-locale-route-config.js";
|
|
14
14
|
import { decodeListReturnState } from "./list-return-state.js";
|
|
15
|
+
import { persistListReturnState, readListReturnState } from "./list-return-storage.js";
|
|
15
16
|
const searchSchema = z.object({
|
|
16
17
|
locale: z.string().optional(),
|
|
17
18
|
action: z["enum"]([
|
|
@@ -46,6 +47,17 @@ function createCollectionEditRoute(path) {
|
|
|
46
47
|
const toastManager = useToastManager();
|
|
47
48
|
const navigate = useNavigate();
|
|
48
49
|
const { contentLocales, defaultContentLocale } = getContentLocaleRouteConfig();
|
|
50
|
+
const [returnSearch, setReturnSearch] = useState(()=>decodeListReturnState(search.from));
|
|
51
|
+
useEffect(()=>{
|
|
52
|
+
if (null != search.from && search.from.length > 0) {
|
|
53
|
+
persistListReturnState(collection, id, search.from);
|
|
54
|
+
setReturnSearch(decodeListReturnState(search.from));
|
|
55
|
+
} else setReturnSearch(decodeListReturnState(readListReturnState(collection, id)));
|
|
56
|
+
}, [
|
|
57
|
+
search.from,
|
|
58
|
+
collection,
|
|
59
|
+
id
|
|
60
|
+
]);
|
|
49
61
|
const createdToastFiredRef = useRef(false);
|
|
50
62
|
useEffect(()=>{
|
|
51
63
|
if ('created' !== search.action) {
|
|
@@ -108,7 +120,7 @@ function createCollectionEditRoute(path) {
|
|
|
108
120
|
locale: locale,
|
|
109
121
|
contentLocales: contentLocales,
|
|
110
122
|
defaultContentLocale: defaultContentLocale,
|
|
111
|
-
returnSearch:
|
|
123
|
+
returnSearch: returnSearch
|
|
112
124
|
})
|
|
113
125
|
]
|
|
114
126
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
/** Persist the encoded `from` state for one document. No-op when unavailable. */
|
|
9
|
+
export declare function persistListReturnState(collection: string, id: string, from: string): void;
|
|
10
|
+
/** Read the stored `from` state for one document, or `undefined`. */
|
|
11
|
+
export declare function readListReturnState(collection: string, id: string): string | undefined;
|
|
12
|
+
/** Clear the stored `from` state for one document (on close / delete). */
|
|
13
|
+
export declare function clearListReturnState(collection: string, id: string): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const KEY_PREFIX = 'byline:list-return:';
|
|
2
|
+
function storage() {
|
|
3
|
+
try {
|
|
4
|
+
return globalThis.sessionStorage ?? void 0;
|
|
5
|
+
} catch {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function keyFor(collection, id) {
|
|
10
|
+
return `${KEY_PREFIX}${collection}:${id}`;
|
|
11
|
+
}
|
|
12
|
+
function persistListReturnState(collection, id, from) {
|
|
13
|
+
if (0 === from.length) return;
|
|
14
|
+
const store = storage();
|
|
15
|
+
if (null == store) return;
|
|
16
|
+
try {
|
|
17
|
+
store.setItem(keyFor(collection, id), from);
|
|
18
|
+
} catch {}
|
|
19
|
+
}
|
|
20
|
+
function readListReturnState(collection, id) {
|
|
21
|
+
const store = storage();
|
|
22
|
+
if (null == store) return;
|
|
23
|
+
try {
|
|
24
|
+
return store.getItem(keyFor(collection, id)) ?? void 0;
|
|
25
|
+
} catch {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function clearListReturnState(collection, id) {
|
|
30
|
+
const store = storage();
|
|
31
|
+
if (null == store) return;
|
|
32
|
+
try {
|
|
33
|
+
store.removeItem(keyFor(collection, id));
|
|
34
|
+
} catch {}
|
|
35
|
+
}
|
|
36
|
+
export { clearListReturnState, persistListReturnState, readListReturnState };
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.7.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -118,13 +118,13 @@
|
|
|
118
118
|
"react-json-view-lite": "^2.5.0",
|
|
119
119
|
"react-swipeable": "^7.0.2",
|
|
120
120
|
"zod": "^4.4.3",
|
|
121
|
-
"@byline/admin": "4.
|
|
122
|
-
"@byline/ai": "4.
|
|
123
|
-
"@byline/
|
|
124
|
-
"@byline/
|
|
125
|
-
"@byline/core": "4.
|
|
126
|
-
"@byline/
|
|
127
|
-
"@byline/i18n": "4.
|
|
121
|
+
"@byline/admin": "4.7.0",
|
|
122
|
+
"@byline/ai": "4.7.0",
|
|
123
|
+
"@byline/auth": "4.7.0",
|
|
124
|
+
"@byline/ui": "4.7.0",
|
|
125
|
+
"@byline/core": "4.7.0",
|
|
126
|
+
"@byline/client": "4.7.0",
|
|
127
|
+
"@byline/i18n": "4.7.0"
|
|
128
128
|
},
|
|
129
129
|
"peerDependencies": {
|
|
130
130
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -16,6 +16,7 @@ import { useTranslation } from '@byline/i18n/react'
|
|
|
16
16
|
import { Container, Section, useToastManager } from '@byline/ui/react'
|
|
17
17
|
|
|
18
18
|
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
19
|
+
import { clearListReturnState } from '../../routes/list-return-storage.js'
|
|
19
20
|
import {
|
|
20
21
|
copyDocumentToLocale,
|
|
21
22
|
deleteDocument,
|
|
@@ -373,7 +374,10 @@ export const EditView = ({
|
|
|
373
374
|
},
|
|
374
375
|
})
|
|
375
376
|
setEditState({ status: 'success', message: description })
|
|
376
|
-
// Navigate back to the collection list after deletion.
|
|
377
|
+
// Navigate back to the collection list after deletion. The return
|
|
378
|
+
// target has now been consumed — clear the stored fallback so a later
|
|
379
|
+
// visit doesn't resurrect a stale list position.
|
|
380
|
+
clearListReturnState(path, String(initialData.id))
|
|
377
381
|
navigate({
|
|
378
382
|
to: getAdminRoutePath('collections', '$collection'),
|
|
379
383
|
params: { collection: path },
|
|
@@ -536,13 +540,17 @@ export const EditView = ({
|
|
|
536
540
|
restoreWarnings={restoreWarnings}
|
|
537
541
|
nextStatus={nextStatus}
|
|
538
542
|
workflowStatuses={workflowStatuses}
|
|
539
|
-
onCancel={() =>
|
|
543
|
+
onCancel={() => {
|
|
544
|
+
// Close consumes the return target — clear the stored fallback so
|
|
545
|
+
// re-opening this document later degrades to the bare list unless
|
|
546
|
+
// a fresh `from` is supplied.
|
|
547
|
+
clearListReturnState(path, String(initialData.id))
|
|
540
548
|
navigate({
|
|
541
549
|
to: getAdminRoutePath('collections', '$collection'),
|
|
542
550
|
params: { collection: path },
|
|
543
551
|
search: returnSearch,
|
|
544
552
|
})
|
|
545
|
-
}
|
|
553
|
+
}}
|
|
546
554
|
collectionPath={path}
|
|
547
555
|
/>
|
|
548
556
|
</Container>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useEffect, useRef } from 'react'
|
|
9
|
+
import { useEffect, useRef, useState } from 'react'
|
|
10
10
|
import { createFileRoute, notFound } from '@tanstack/react-router'
|
|
11
11
|
|
|
12
12
|
import type { CollectionDefinition } from '@byline/core'
|
|
@@ -22,6 +22,7 @@ import { getCollectionDocument } from '../server-fns/collections/index.js'
|
|
|
22
22
|
import { getAdminRoutePath } from './admin-path.js'
|
|
23
23
|
import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
|
|
24
24
|
import { decodeListReturnState } from './list-return-state.js'
|
|
25
|
+
import { persistListReturnState, readListReturnState } from './list-return-storage.js'
|
|
25
26
|
|
|
26
27
|
const searchSchema = z.object({
|
|
27
28
|
locale: z.string().optional(),
|
|
@@ -83,6 +84,24 @@ export function createCollectionEditRoute(path: string) {
|
|
|
83
84
|
const navigate = useNavigate()
|
|
84
85
|
const { contentLocales, defaultContentLocale } = getContentLocaleRouteConfig()
|
|
85
86
|
|
|
87
|
+
// Return-to-list state. The URL `from` param is the primary carrier;
|
|
88
|
+
// when present we also mirror it into tab-scoped sessionStorage so the
|
|
89
|
+
// full-page preview round-trip (which discards the admin URL) can
|
|
90
|
+
// recover it. When the URL has no `from` — e.g. the editor was
|
|
91
|
+
// re-opened from the public preview bar's Edit link — we fall back to
|
|
92
|
+
// the stored value. See list-return-storage.ts.
|
|
93
|
+
const [returnSearch, setReturnSearch] = useState<Record<string, unknown> | undefined>(() =>
|
|
94
|
+
decodeListReturnState(search.from)
|
|
95
|
+
)
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (search.from != null && search.from.length > 0) {
|
|
98
|
+
persistListReturnState(collection, id, search.from)
|
|
99
|
+
setReturnSearch(decodeListReturnState(search.from))
|
|
100
|
+
} else {
|
|
101
|
+
setReturnSearch(decodeListReturnState(readListReturnState(collection, id)))
|
|
102
|
+
}
|
|
103
|
+
}, [search.from, collection, id])
|
|
104
|
+
|
|
86
105
|
// Post-create toast for the create → edit redirect (?action=created).
|
|
87
106
|
// Ref-guarded for the same reason as the list route's created toast:
|
|
88
107
|
// `toastManager` changes identity on every add, so the effect depends
|
|
@@ -138,7 +157,7 @@ export function createCollectionEditRoute(path: string) {
|
|
|
138
157
|
locale={locale}
|
|
139
158
|
contentLocales={contentLocales}
|
|
140
159
|
defaultContentLocale={defaultContentLocale}
|
|
141
|
-
returnSearch={
|
|
160
|
+
returnSearch={returnSearch}
|
|
142
161
|
/>
|
|
143
162
|
</>
|
|
144
163
|
)
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
clearListReturnState,
|
|
13
|
+
persistListReturnState,
|
|
14
|
+
readListReturnState,
|
|
15
|
+
} from './list-return-storage.js'
|
|
16
|
+
|
|
17
|
+
/** Minimal Map-backed `Storage` stand-in — node mode has no sessionStorage. */
|
|
18
|
+
function mockSessionStorage(): Storage {
|
|
19
|
+
const map = new Map<string, string>()
|
|
20
|
+
return {
|
|
21
|
+
get length() {
|
|
22
|
+
return map.size
|
|
23
|
+
},
|
|
24
|
+
clear: () => map.clear(),
|
|
25
|
+
getItem: (k: string) => (map.has(k) ? (map.get(k) as string) : null),
|
|
26
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
27
|
+
removeItem: (k: string) => {
|
|
28
|
+
map.delete(k)
|
|
29
|
+
},
|
|
30
|
+
setItem: (k: string, v: string) => {
|
|
31
|
+
map.set(k, v)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stub(): void {
|
|
37
|
+
;(globalThis as { sessionStorage?: Storage }).sessionStorage = mockSessionStorage()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('list-return-storage', () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
delete (globalThis as { sessionStorage?: Storage }).sessionStorage
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('persists and reads a value keyed by collection + id', () => {
|
|
46
|
+
stub()
|
|
47
|
+
persistListReturnState('news', '42', 'page=6&order=title')
|
|
48
|
+
expect(readListReturnState('news', '42')).toBe('page=6&order=title')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('scopes stored state by collection + id', () => {
|
|
52
|
+
stub()
|
|
53
|
+
persistListReturnState('news', '42', 'page=6')
|
|
54
|
+
expect(readListReturnState('news', '99')).toBeUndefined()
|
|
55
|
+
expect(readListReturnState('pages', '42')).toBeUndefined()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('clears a stored value on consume', () => {
|
|
59
|
+
stub()
|
|
60
|
+
persistListReturnState('news', '42', 'page=6')
|
|
61
|
+
clearListReturnState('news', '42')
|
|
62
|
+
expect(readListReturnState('news', '42')).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('ignores an empty from value (nothing worth carrying)', () => {
|
|
66
|
+
stub()
|
|
67
|
+
persistListReturnState('news', '42', '')
|
|
68
|
+
expect(readListReturnState('news', '42')).toBeUndefined()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('degrades silently when sessionStorage is unavailable', () => {
|
|
72
|
+
// No stub installed — mirrors SSR / privacy-mode contexts.
|
|
73
|
+
expect(() => persistListReturnState('news', '42', 'page=6')).not.toThrow()
|
|
74
|
+
expect(readListReturnState('news', '42')).toBeUndefined()
|
|
75
|
+
expect(() => clearListReturnState('news', '42')).not.toThrow()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
* Tab-scoped fallback for the collection editor's return-to-list state.
|
|
11
|
+
*
|
|
12
|
+
* The primary carrier is the `from` URL param (see list-return-state.ts) —
|
|
13
|
+
* pure, shareable, and enough for every in-app navigation. But the preview
|
|
14
|
+
* affordance does a full-page `window.location.assign()` to the public site,
|
|
15
|
+
* which discards the admin URL (and its `from`). When the editor is re-opened
|
|
16
|
+
* from the public preview bar's "Edit" link, that link carries only the
|
|
17
|
+
* collection and document id — no `from` — so the URL alone can no longer say
|
|
18
|
+
* which list page / filter to return to on close.
|
|
19
|
+
*
|
|
20
|
+
* sessionStorage bridges that gap: it survives a full-page navigation within
|
|
21
|
+
* the same tab, is scoped to the tab, and never leaks into shareable URLs. The
|
|
22
|
+
* editor persists `from` whenever it renders with one, keyed by collection +
|
|
23
|
+
* document id, and reads it back only when the URL carries no `from`. Closing
|
|
24
|
+
* or deleting the document consumes (clears) the entry, so a later visit to
|
|
25
|
+
* the same document with no context degrades to the bare list rather than a
|
|
26
|
+
* stale page.
|
|
27
|
+
*
|
|
28
|
+
* Every access is guarded: an absent `sessionStorage` (SSR, privacy modes,
|
|
29
|
+
* quota failures) degrades silently to "no stored state" — never an error.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const KEY_PREFIX = 'byline:list-return:'
|
|
33
|
+
|
|
34
|
+
/** Resolve `sessionStorage` defensively — absent under SSR, may throw in some
|
|
35
|
+
* sandboxed / privacy contexts. */
|
|
36
|
+
function storage(): Storage | undefined {
|
|
37
|
+
try {
|
|
38
|
+
return globalThis.sessionStorage ?? undefined
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function keyFor(collection: string, id: string): string {
|
|
45
|
+
return `${KEY_PREFIX}${collection}:${id}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Persist the encoded `from` state for one document. No-op when unavailable. */
|
|
49
|
+
export function persistListReturnState(collection: string, id: string, from: string): void {
|
|
50
|
+
if (from.length === 0) return
|
|
51
|
+
const store = storage()
|
|
52
|
+
if (store == null) return
|
|
53
|
+
try {
|
|
54
|
+
store.setItem(keyFor(collection, id), from)
|
|
55
|
+
} catch {
|
|
56
|
+
// Quota exceeded / storage disabled — persistence is best-effort only.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the stored `from` state for one document, or `undefined`. */
|
|
61
|
+
export function readListReturnState(collection: string, id: string): string | undefined {
|
|
62
|
+
const store = storage()
|
|
63
|
+
if (store == null) return undefined
|
|
64
|
+
try {
|
|
65
|
+
return store.getItem(keyFor(collection, id)) ?? undefined
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clear the stored `from` state for one document (on close / delete). */
|
|
72
|
+
export function clearListReturnState(collection: string, id: string): void {
|
|
73
|
+
const store = storage()
|
|
74
|
+
if (store == null) return
|
|
75
|
+
try {
|
|
76
|
+
store.removeItem(keyFor(collection, id))
|
|
77
|
+
} catch {
|
|
78
|
+
// Best-effort — nothing to recover if removal fails.
|
|
79
|
+
}
|
|
80
|
+
}
|