@quazardous/qdadm 2.19.0 → 2.20.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/package.json +2 -2
- package/src/components/lists/ListPage.vue +7 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useListPage.filters.ts +137 -67
- package/src/composables/useListPage.ts +106 -18
- package/src/composables/useListPage.types.ts +74 -5
- package/src/composables/useListPage.utils.ts +58 -0
- package/src/entity/EntityManager.interface.ts +11 -0
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +11 -0
- package/src/entity/storage/ApiStorage.ts +16 -2
- package/src/entity/storage/LocalStorage.ts +1 -1
- package/src/entity/storage/MemoryStorage.ts +7 -2
- package/src/entity/storage/MockApiStorage.ts +1 -1
- package/src/entity/storage/SdkStorage.ts +12 -3
- package/src/index.ts +19 -0
- package/src/kernel/Kernel.registries.ts +2 -2
- package/src/kernel/Kernel.types.ts +16 -0
- package/src/kernel/Kernel.vue.ts +3 -0
- package/src/plugin.ts +1 -1
- package/src/query/clientFilter.ts +14 -3
- package/src/routeState/CompositePersister.ts +144 -0
- package/src/routeState/CookiePersister.ts +109 -0
- package/src/routeState/MemoryPersister.ts +60 -0
- package/src/routeState/NullPersister.ts +33 -0
- package/src/routeState/RouteStatePersister.ts +86 -0
- package/src/routeState/UrlPersister.ts +207 -0
- package/src/routeState/WebStoragePersister.ts +149 -0
- package/src/routeState/defaultPersister.ts +66 -0
- package/src/routeState/factory.ts +86 -0
- package/src/routeState/index.ts +32 -0
- package/src/routeState/keyLocator.ts +84 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state in a cookie (#2146).
|
|
3
|
+
*
|
|
4
|
+
* Here because qdadm already does this — page size lives in a year-long
|
|
5
|
+
* `qdadm_pageSize` cookie — and a seam that could not express what the
|
|
6
|
+
* framework already does would be a seam with a hole in it.
|
|
7
|
+
*
|
|
8
|
+
* **Every cookie is sent on every request to your origin.** That is the whole
|
|
9
|
+
* difference from web storage, and the reason this persister is shaped
|
|
10
|
+
* differently: ONE cookie per scope holding a JSON blob, not one per key. A
|
|
11
|
+
* list with eight filters would otherwise put eight cookies on every image,
|
|
12
|
+
* script and API call the page makes.
|
|
13
|
+
*
|
|
14
|
+
* Which also means: keep it small, and do not reach for this when
|
|
15
|
+
* `local_storage` would do. The case for a cookie is state the SERVER needs
|
|
16
|
+
* to see — a rows-per-page the backend renders against, say. If the server
|
|
17
|
+
* never reads it, it is paying wire cost for nothing.
|
|
18
|
+
*/
|
|
19
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
20
|
+
import { underscoreScopeLocator, type RouteStateScopeLocator } from './keyLocator'
|
|
21
|
+
|
|
22
|
+
/** The document surface this needs, so a test needs no browser. */
|
|
23
|
+
export interface CookieJar {
|
|
24
|
+
cookie: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CookiePersisterOptions {
|
|
28
|
+
/** Defaults to `document`. */
|
|
29
|
+
jar?: CookieJar
|
|
30
|
+
/** How a scope becomes a cookie name. Defaults to `qdadm_offers`. */
|
|
31
|
+
scopeLocator?: RouteStateScopeLocator
|
|
32
|
+
/** Lifetime in days. Defaults to 365 — what `qdadm_pageSize` already used. */
|
|
33
|
+
maxAgeDays?: number
|
|
34
|
+
/** `SameSite` attribute. Defaults to `Lax`. */
|
|
35
|
+
sameSite?: 'Strict' | 'Lax' | 'None'
|
|
36
|
+
/** Path the cookie applies to. Defaults to `/`. */
|
|
37
|
+
path?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class CookiePersister implements RouteStatePersister {
|
|
41
|
+
readonly name = 'cookie'
|
|
42
|
+
|
|
43
|
+
private readonly _jar: CookieJar
|
|
44
|
+
private readonly _scopes: RouteStateScopeLocator
|
|
45
|
+
private readonly _maxAgeDays: number
|
|
46
|
+
private readonly _sameSite: string
|
|
47
|
+
private readonly _path: string
|
|
48
|
+
|
|
49
|
+
constructor(options: CookiePersisterOptions = {}) {
|
|
50
|
+
this._jar = options.jar ?? (document as unknown as CookieJar)
|
|
51
|
+
this._scopes = options.scopeLocator ?? underscoreScopeLocator
|
|
52
|
+
this._maxAgeDays = options.maxAgeDays ?? 365
|
|
53
|
+
this._sameSite = options.sameSite ?? 'Lax'
|
|
54
|
+
this._path = options.path ?? '/'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
read(scope: string): Record<string, unknown> | null {
|
|
58
|
+
const raw = this._readCookie(this._scopes.encode(scope))
|
|
59
|
+
if (raw === null) return null
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(decodeURIComponent(raw))
|
|
62
|
+
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
|
|
63
|
+
} catch {
|
|
64
|
+
// Somebody else's cookie under our name, or a truncated one. Ignoring it
|
|
65
|
+
// is right here: unlike a single key, a corrupt blob tells us nothing
|
|
66
|
+
// partial worth keeping.
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
72
|
+
// The blob is rewritten whole, so the empty-means-absent rule is applied
|
|
73
|
+
// by dropping keys rather than by removing entries.
|
|
74
|
+
const kept: Record<string, unknown> = {}
|
|
75
|
+
for (const [key, value] of Object.entries(state)) {
|
|
76
|
+
if (value === null || value === undefined || value === '') continue
|
|
77
|
+
kept[key] = value
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Nothing left to remember: take the cookie off the wire rather than
|
|
81
|
+
// shipping `{}` on every request for the next year.
|
|
82
|
+
if (!Object.keys(kept).length) {
|
|
83
|
+
this.clear(scope)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._writeCookie(this._scopes.encode(scope), encodeURIComponent(JSON.stringify(kept)))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clear(scope: string): void {
|
|
91
|
+
const name = this._scopes.encode(scope)
|
|
92
|
+
this._jar.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=${this._path}; SameSite=${this._sameSite}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private _readCookie(name: string): string | null {
|
|
96
|
+
for (const part of this._jar.cookie.split(';')) {
|
|
97
|
+
const separator = part.indexOf('=')
|
|
98
|
+
if (separator === -1) continue
|
|
99
|
+
if (part.slice(0, separator).trim() !== name) continue
|
|
100
|
+
return part.slice(separator + 1)
|
|
101
|
+
}
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private _writeCookie(name: string, value: string): void {
|
|
106
|
+
const expires = new Date(Date.now() + this._maxAgeDays * 864e5).toUTCString()
|
|
107
|
+
this._jar.cookie = `${name}=${value}; expires=${expires}; path=${this._path}; SameSite=${this._sameSite}`
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state in memory (#2146).
|
|
3
|
+
*
|
|
4
|
+
* Survives navigating away and back within the running app; does not survive
|
|
5
|
+
* a reload, and is invisible to a link. That makes it the right answer for
|
|
6
|
+
* state that would be noise anywhere else — a wizard's step, a panel a user
|
|
7
|
+
* opened once — and the right answer in tests, where a shared `localStorage`
|
|
8
|
+
* leaks one case into the next.
|
|
9
|
+
*
|
|
10
|
+
* THE STORE OUTLIVES THE PERSISTER ON PURPOSE. A list builds its persister on
|
|
11
|
+
* mount; if the state lived on the instance it would die on unmount, which is
|
|
12
|
+
* exactly the navigation this is supposed to survive. So the default store is
|
|
13
|
+
* module-level and shared. Pass your own `store` to get an isolated one —
|
|
14
|
+
* which is what a test usually wants.
|
|
15
|
+
*/
|
|
16
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
17
|
+
|
|
18
|
+
export type RouteStateMemoryStore = Map<string, Record<string, unknown>>
|
|
19
|
+
|
|
20
|
+
/** The shared default. See the note above on why this is module-level. */
|
|
21
|
+
const defaultStore: RouteStateMemoryStore = new Map()
|
|
22
|
+
|
|
23
|
+
export interface MemoryPersisterOptions {
|
|
24
|
+
store?: RouteStateMemoryStore
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class MemoryPersister implements RouteStatePersister {
|
|
28
|
+
readonly name = 'memory'
|
|
29
|
+
|
|
30
|
+
private readonly _store: RouteStateMemoryStore
|
|
31
|
+
|
|
32
|
+
constructor(options: MemoryPersisterOptions = {}) {
|
|
33
|
+
this._store = options.store ?? defaultStore
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
read(scope: string): Record<string, unknown> | null {
|
|
37
|
+
const stored = this._store.get(scope)
|
|
38
|
+
// Copied out, so a caller mutating what it read cannot rewrite history.
|
|
39
|
+
return stored ? { ...stored } : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
43
|
+
const kept: Record<string, unknown> = {}
|
|
44
|
+
for (const [key, value] of Object.entries(state)) {
|
|
45
|
+
if (value === null || value === undefined || value === '') continue
|
|
46
|
+
kept[key] = value
|
|
47
|
+
}
|
|
48
|
+
if (!Object.keys(kept).length) this._store.delete(scope)
|
|
49
|
+
else this._store.set(scope, kept)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
clear(scope: string): void {
|
|
53
|
+
this._store.delete(scope)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Empty the shared store — for tests that use the default. */
|
|
58
|
+
export function clearRouteStateMemory(): void {
|
|
59
|
+
defaultStore.clear()
|
|
60
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state remembered nowhere (#2146).
|
|
3
|
+
*
|
|
4
|
+
* The way to say "this screen forgets" and have it read as a decision. An
|
|
5
|
+
* embedded list inside a detail view, a picker in a dialog, a preview — none
|
|
6
|
+
* of them should put anything in the address bar, and none of them should
|
|
7
|
+
* leave anything behind.
|
|
8
|
+
*
|
|
9
|
+
* A no-op object looks like the thing
|
|
10
|
+
* [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) forbids, and the
|
|
11
|
+
* difference is the whole point: this one is NAMED. Nothing falls back to it,
|
|
12
|
+
* nothing resolves to it by accident, and an unknown slug throws rather than
|
|
13
|
+
* landing here. It does nothing because somebody wrote `routeState: 'none'`,
|
|
14
|
+
* and that sentence is in the app's source where the next reader will find
|
|
15
|
+
* it.
|
|
16
|
+
*/
|
|
17
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
18
|
+
|
|
19
|
+
export class NullPersister implements RouteStatePersister {
|
|
20
|
+
readonly name = 'none'
|
|
21
|
+
|
|
22
|
+
read(): null {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
write(): void {
|
|
27
|
+
/* deliberately nothing — see the note above */
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear(): void {
|
|
31
|
+
/* deliberately nothing */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a route's state is remembered (#2146).
|
|
3
|
+
*
|
|
4
|
+
* Not "list state": the list is one CONSUMER of routing state, not the
|
|
5
|
+
* subject. A detail page's active tab, a dashboard's date range and a child
|
|
6
|
+
* page's own state are the same problem, and each would invent its own answer
|
|
7
|
+
* if this seam did not exist — which is exactly how qdadm ended up with five
|
|
8
|
+
* unrelated persistence sites, one of them a year-long cookie.
|
|
9
|
+
*
|
|
10
|
+
* The state is opaque on purpose. Every consumer has its own shape, and a
|
|
11
|
+
* fixed one here would only fit whichever consumer was written first.
|
|
12
|
+
*
|
|
13
|
+
* NOT for chrome preferences. A collapsed sidebar belongs to the person, not
|
|
14
|
+
* to the route: sending someone a link must not fold their menu. The line is
|
|
15
|
+
* *what I am looking at* versus *how I like my interface*.
|
|
16
|
+
*/
|
|
17
|
+
export interface RouteStatePersister {
|
|
18
|
+
/** Everything remembered under this scope, or null when nothing is. */
|
|
19
|
+
read(scope: string): Record<string, unknown> | null
|
|
20
|
+
/**
|
|
21
|
+
* Remember this state under this scope.
|
|
22
|
+
*
|
|
23
|
+
* A key whose value is `null`, `undefined` or `''` is REMOVED rather than
|
|
24
|
+
* stored empty — so a pristine screen leaves no trace, and a shared link
|
|
25
|
+
* carries only what was actually chosen.
|
|
26
|
+
*/
|
|
27
|
+
write(scope: string, state: Record<string, unknown>): void
|
|
28
|
+
clear(scope: string): void
|
|
29
|
+
/** For diagnostics: which persister answered. */
|
|
30
|
+
readonly name: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How a consumer names itself.
|
|
35
|
+
*
|
|
36
|
+
* The scope is what makes two lists on one route stop fighting over `page` —
|
|
37
|
+
* the collision `page-compositions.md` currently tells people to avoid by
|
|
38
|
+
* switching persistence off entirely.
|
|
39
|
+
*/
|
|
40
|
+
export type RouteStateScope = string
|
|
41
|
+
|
|
42
|
+
/** A handler as the bootstrap declares it: a slug, or an instance. */
|
|
43
|
+
export type RouteStatePersisterSpec = string | RouteStatePersister
|
|
44
|
+
|
|
45
|
+
/** Anything that can build a persister from a slug. */
|
|
46
|
+
export type RouteStatePersisterFactory = (slug: string) => RouteStatePersister | null
|
|
47
|
+
|
|
48
|
+
/** An instance, duck-typed — the same test the storage factory applies. */
|
|
49
|
+
export function isRouteStatePersister(value: unknown): value is RouteStatePersister {
|
|
50
|
+
if (!value || typeof value !== 'object') return false
|
|
51
|
+
const candidate = value as RouteStatePersister
|
|
52
|
+
return (
|
|
53
|
+
typeof candidate.read === 'function' &&
|
|
54
|
+
typeof candidate.write === 'function' &&
|
|
55
|
+
typeof candidate.clear === 'function'
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Turn a spec into a persister.
|
|
61
|
+
*
|
|
62
|
+
* An unknown slug THROWS. It does not fall back to the URL: a persistence
|
|
63
|
+
* choice that silently does something else is the exact failure
|
|
64
|
+
* [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) exists to forbid, and
|
|
65
|
+
* the one this seam is cleaning up after.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveRouteStatePersister(
|
|
68
|
+
spec: RouteStatePersisterSpec,
|
|
69
|
+
factory: RouteStatePersisterFactory
|
|
70
|
+
): RouteStatePersister {
|
|
71
|
+
if (isRouteStatePersister(spec)) return spec
|
|
72
|
+
|
|
73
|
+
if (typeof spec === 'string') {
|
|
74
|
+
const built = factory(spec)
|
|
75
|
+
if (built) return built
|
|
76
|
+
throw new Error(
|
|
77
|
+
`[qdadm] Unknown route-state persister "${spec}". ` +
|
|
78
|
+
`Pass a known slug, or an object with read/write/clear.`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(
|
|
83
|
+
`[qdadm] Invalid route-state persister: ${typeof spec}. ` +
|
|
84
|
+
`Expected a slug or an object with read/write/clear.`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state in the query string — the default (#2146).
|
|
3
|
+
*
|
|
4
|
+
* The default because it is the only medium where "what I am looking at" is
|
|
5
|
+
* also "what I can send you". A list URL that does not carry its page is a
|
|
6
|
+
* link that shows the recipient something else.
|
|
7
|
+
*
|
|
8
|
+
* CLEAN AROUND THE EXISTING ROUTER: this goes through the `router` the app
|
|
9
|
+
* already has, with `replace`, and touches no route declaration. Paging is
|
|
10
|
+
* not a history step of its own — but the URL must carry the state at the
|
|
11
|
+
* moment the user leaves, so that coming back restores it.
|
|
12
|
+
*
|
|
13
|
+
* Keys are namespaced by scope (`offers.page=2`), which is what stops two
|
|
14
|
+
* lists on one route from fighting over `page` — the collision
|
|
15
|
+
* `page-compositions.md` currently tells people to avoid by switching
|
|
16
|
+
* persistence off.
|
|
17
|
+
*/
|
|
18
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
19
|
+
import { dottedKeyLocator, type RouteStateKeyLocator } from './keyLocator'
|
|
20
|
+
|
|
21
|
+
/** The bits of vue-router this needs, so tests need no real router. */
|
|
22
|
+
/** One query value, shaped as vue-router accepts them. */
|
|
23
|
+
export type UrlPersisterQueryValue = string | number | null | undefined
|
|
24
|
+
|
|
25
|
+
export interface UrlPersisterRouter {
|
|
26
|
+
// Values are narrowed to what vue-router's own query type accepts, so the
|
|
27
|
+
// real router satisfies this without a cast — and so does a two-line fake
|
|
28
|
+
// in a test.
|
|
29
|
+
replace(to: { query: Record<string, UrlPersisterQueryValue> }): unknown
|
|
30
|
+
}
|
|
31
|
+
export interface UrlPersisterRoute {
|
|
32
|
+
query: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface UrlPersisterOptions {
|
|
36
|
+
router: UrlPersisterRouter
|
|
37
|
+
/** Live route — read at call time, never captured. */
|
|
38
|
+
route: UrlPersisterRoute
|
|
39
|
+
/**
|
|
40
|
+
* How a (scope, key) pair becomes a query parameter. Defaults to
|
|
41
|
+
* `offers.page`; override to impose your own scheme.
|
|
42
|
+
*/
|
|
43
|
+
keyLocator?: RouteStateKeyLocator
|
|
44
|
+
/**
|
|
45
|
+
* Also read UNPREFIXED keys when the scope has none of its own.
|
|
46
|
+
*
|
|
47
|
+
* Transitional, and on by default: qdadm wrote `?page=2` flat before this
|
|
48
|
+
* seam existed, so links people bookmarked or pasted into tickets carry
|
|
49
|
+
* that shape. Without this they would silently resolve to page 1 — the
|
|
50
|
+
* reader would see a wrong screen and no error.
|
|
51
|
+
*
|
|
52
|
+
* Writes are always prefixed, so a link refreshes itself the first time
|
|
53
|
+
* anyone touches the list. Set false to drop the compatibility, and expect
|
|
54
|
+
* to remove it here in a later version.
|
|
55
|
+
*/
|
|
56
|
+
readFlatFallback?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class UrlPersister implements RouteStatePersister {
|
|
60
|
+
readonly name = 'url'
|
|
61
|
+
|
|
62
|
+
private readonly _router: UrlPersisterRouter
|
|
63
|
+
private readonly _route: UrlPersisterRoute
|
|
64
|
+
private readonly _keys: RouteStateKeyLocator
|
|
65
|
+
private readonly _readFlatFallback: boolean
|
|
66
|
+
|
|
67
|
+
constructor(options: UrlPersisterOptions) {
|
|
68
|
+
this._router = options.router
|
|
69
|
+
this._route = options.route
|
|
70
|
+
this._keys = options.keyLocator ?? dottedKeyLocator
|
|
71
|
+
this._readFlatFallback = options.readFlatFallback ?? true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
read(scope: string): Record<string, unknown> | null {
|
|
75
|
+
const state: Record<string, unknown> = {}
|
|
76
|
+
let found = false
|
|
77
|
+
|
|
78
|
+
for (const [storedKey, raw] of Object.entries(this._route.query)) {
|
|
79
|
+
const key = this._keys.decode(scope, storedKey)
|
|
80
|
+
if (key === null) continue
|
|
81
|
+
found = true
|
|
82
|
+
state[key] = decodeValue(raw)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (found || !this._readFlatFallback) return found ? state : null
|
|
86
|
+
|
|
87
|
+
// Nothing under this scope: possibly a link written before the seam
|
|
88
|
+
// existed. Read it flat rather than showing the wrong screen without a
|
|
89
|
+
// word — but ONLY keys nobody has scoped. `jobs.page` belongs to the jobs
|
|
90
|
+
// list, and handing it to this one would be worse than ignoring it.
|
|
91
|
+
const flat: Record<string, unknown> = {}
|
|
92
|
+
for (const [storedKey, raw] of Object.entries(this._route.query)) {
|
|
93
|
+
if (this._keys.looksScoped(storedKey)) continue
|
|
94
|
+
flat[storedKey] = decodeValue(raw)
|
|
95
|
+
}
|
|
96
|
+
return Object.keys(flat).length ? flat : null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
100
|
+
const query: Record<string, UrlPersisterQueryValue> = { ...toQuery(this._route.query) }
|
|
101
|
+
|
|
102
|
+
for (const [key, value] of Object.entries(state)) {
|
|
103
|
+
const param = this._keys.encode(scope, key)
|
|
104
|
+
// A flat key left by an older link would shadow the prefixed one on the
|
|
105
|
+
// next read, so writing a scope also retires its legacy twin.
|
|
106
|
+
if (this._readFlatFallback && param !== key) delete query[key]
|
|
107
|
+
// Absent, not stored empty: a pristine screen leaves a clean link.
|
|
108
|
+
if (value === null || value === undefined || value === '') delete query[param]
|
|
109
|
+
else query[param] = encodeValue(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this._router.replace({ query })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clear(scope: string): void {
|
|
116
|
+
const query: Record<string, UrlPersisterQueryValue> = { ...toQuery(this._route.query) }
|
|
117
|
+
let scoped = false
|
|
118
|
+
for (const storedKey of Object.keys(query)) {
|
|
119
|
+
if (this._keys.decode(scope, storedKey) === null) continue
|
|
120
|
+
scoped = true
|
|
121
|
+
delete query[storedKey]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Clear what read() would have READ, or the URL starts lying: arrive on a
|
|
125
|
+
// pre-scope `?page=4` link, clear the filters, and the list shows page 1
|
|
126
|
+
// under an address still claiming page 4 — and a refresh would restore
|
|
127
|
+
// the state that was just cleared.
|
|
128
|
+
if (!scoped && this._readFlatFallback) {
|
|
129
|
+
for (const storedKey of Object.keys(query)) {
|
|
130
|
+
if (!this._keys.looksScoped(storedKey)) delete query[storedKey]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this._router.replace({ query })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A value on its way into a URL.
|
|
140
|
+
*
|
|
141
|
+
* Primitives stay readable — `page=2`, not `page=%222%22` — because a query
|
|
142
|
+
* string people paste into tickets is worth more than perfect round-tripping.
|
|
143
|
+
* Objects and arrays are JSON, which `decodeValue` recognises by its opening
|
|
144
|
+
* brace.
|
|
145
|
+
*/
|
|
146
|
+
function encodeValue(value: unknown): string {
|
|
147
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
148
|
+
return String(value)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* And on its way back out.
|
|
153
|
+
*
|
|
154
|
+
* The coercion is deliberately the one `restoreFilters` already applied to
|
|
155
|
+
* query params, so behaviour does not shift underneath anyone: `'true'`
|
|
156
|
+
* becomes a boolean, a numeric string becomes a number.
|
|
157
|
+
*
|
|
158
|
+
* It follows that a filter whose value is the STRING `'42'` comes back as the
|
|
159
|
+
* NUMBER 42. That was already true before this seam existed; it is written
|
|
160
|
+
* down here rather than left to be rediscovered.
|
|
161
|
+
*
|
|
162
|
+
* BUT ONLY WHEN NOTHING IS LOST (#2147). `'007'` is not the number 7 and
|
|
163
|
+
* `'9876667194123456789'` is not the float it would become — those are text
|
|
164
|
+
* that merely looks numeric, and coercing them hands the app a value the user
|
|
165
|
+
* never typed. So the coercion happens only when it round-trips exactly, and
|
|
166
|
+
* anything else stays the string it was. Reference numbers, invoice ids and
|
|
167
|
+
* phone numbers all live in that gap, and a search box is full of them.
|
|
168
|
+
*/
|
|
169
|
+
function decodeValue(raw: unknown): unknown {
|
|
170
|
+
if (Array.isArray(raw)) return raw.map(decodeValue)
|
|
171
|
+
if (typeof raw !== 'string') return raw
|
|
172
|
+
|
|
173
|
+
if (raw === 'true') return true
|
|
174
|
+
if (raw === 'false') return false
|
|
175
|
+
if (raw === 'null') return null
|
|
176
|
+
if (raw !== '' && !Number.isNaN(Number(raw))) {
|
|
177
|
+
const asNumber = Number(raw)
|
|
178
|
+
// The round-trip test IS the rule: if the number cannot be written back
|
|
179
|
+
// as the exact same text, the text was never a number.
|
|
180
|
+
if (String(asNumber) === raw) return asNumber
|
|
181
|
+
return raw
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const first = raw[0]
|
|
185
|
+
if (first === '{' || first === '[') {
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(raw)
|
|
188
|
+
} catch {
|
|
189
|
+
// Not JSON after all — a filter value that merely starts with a brace.
|
|
190
|
+
return raw
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return raw
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The live route's query, narrowed to what we may hand back to the router. */
|
|
198
|
+
function toQuery(query: Record<string, unknown>): Record<string, UrlPersisterQueryValue> {
|
|
199
|
+
const out: Record<string, UrlPersisterQueryValue> = {}
|
|
200
|
+
for (const [key, value] of Object.entries(query)) {
|
|
201
|
+
if (value === null || value === undefined) continue
|
|
202
|
+
// An array param is one vue-router supports and we do not produce; keep
|
|
203
|
+
// its first value rather than dropping the key entirely.
|
|
204
|
+
out[key] = Array.isArray(value) ? (value[0] as UrlPersisterQueryValue) : (String(value) as string)
|
|
205
|
+
}
|
|
206
|
+
return out
|
|
207
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route state in `localStorage` or `sessionStorage` (#2146).
|
|
3
|
+
*
|
|
4
|
+
* The medium you pick when the state is worth keeping but not worth putting
|
|
5
|
+
* in a link: a back-office list somebody returns to twenty times a day, whose
|
|
6
|
+
* URL nobody ever sends anyone.
|
|
7
|
+
*
|
|
8
|
+
* ONE ENTRY PER KEY, namespaced — `qdadm:offers:page`. Not one JSON blob per
|
|
9
|
+
* scope, because web storage is shared with everything else the app keeps
|
|
10
|
+
* there, and per-key namespacing is what lets this persister pick out its own
|
|
11
|
+
* entries without owning the medium. It also means devtools shows you
|
|
12
|
+
* readable state instead of a blob to decode by hand.
|
|
13
|
+
*
|
|
14
|
+
* VALUES ARE JSON, so they come back exactly as they went in. That differs
|
|
15
|
+
* from `UrlPersister`, which coerces (`'42'` reads back as the number 42)
|
|
16
|
+
* because a query string is meant to be read by people. A list moved from one
|
|
17
|
+
* to the other can therefore see a filter value change TYPE — worth knowing
|
|
18
|
+
* before you switch a screen that compares with `===`.
|
|
19
|
+
*/
|
|
20
|
+
import type { RouteStatePersister } from './RouteStatePersister'
|
|
21
|
+
import { namespacedKeyLocator, type RouteStateKeyLocator } from './keyLocator'
|
|
22
|
+
|
|
23
|
+
/** The bit of the Storage API this needs, so a test needs no browser. */
|
|
24
|
+
export interface RouteStateStorage {
|
|
25
|
+
getItem(key: string): string | null
|
|
26
|
+
setItem(key: string, value: string): void
|
|
27
|
+
removeItem(key: string): void
|
|
28
|
+
readonly length: number
|
|
29
|
+
key(index: number): string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WebStoragePersisterOptions {
|
|
33
|
+
/** `window.localStorage` or `window.sessionStorage`, or a stand-in. */
|
|
34
|
+
storage: RouteStateStorage
|
|
35
|
+
/** For diagnostics — which medium answered. */
|
|
36
|
+
name?: string
|
|
37
|
+
/** How a (scope, key) pair becomes a storage key. Defaults to `qdadm:scope:key`. */
|
|
38
|
+
keyLocator?: RouteStateKeyLocator
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class WebStoragePersister implements RouteStatePersister {
|
|
42
|
+
readonly name: string
|
|
43
|
+
|
|
44
|
+
private readonly _storage: RouteStateStorage
|
|
45
|
+
private readonly _keys: RouteStateKeyLocator
|
|
46
|
+
private _warned = false
|
|
47
|
+
|
|
48
|
+
constructor(options: WebStoragePersisterOptions) {
|
|
49
|
+
this._storage = options.storage
|
|
50
|
+
this._keys = options.keyLocator ?? namespacedKeyLocator
|
|
51
|
+
this.name = options.name ?? 'web-storage'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Web storage throws rather than returning null in more situations than
|
|
56
|
+
* people expect: Safari's private mode, a browser set to block site data,
|
|
57
|
+
* a quota that filled up.
|
|
58
|
+
*
|
|
59
|
+
* We warn ONCE and carry on with the screen working. That is not the silent
|
|
60
|
+
* no-op [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) forbids — the
|
|
61
|
+
* failure is announced, and it is the environment's, not a mistake in the
|
|
62
|
+
* app's configuration. Taking the list down because a page number could not
|
|
63
|
+
* be remembered would be the worse trade.
|
|
64
|
+
*/
|
|
65
|
+
private _guard<T>(operation: () => T, fallback: T): T {
|
|
66
|
+
try {
|
|
67
|
+
return operation()
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!this._warned) {
|
|
70
|
+
this._warned = true
|
|
71
|
+
console.warn(
|
|
72
|
+
`[qdadm] Route-state persister "${this.name}" cannot reach its storage; ` +
|
|
73
|
+
`this screen will not remember its state. ` +
|
|
74
|
+
`Usually private browsing, blocked site data, or a full quota.`,
|
|
75
|
+
error
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return fallback
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
read(scope: string): Record<string, unknown> | null {
|
|
83
|
+
return this._guard(() => {
|
|
84
|
+
const state: Record<string, unknown> = {}
|
|
85
|
+
let found = false
|
|
86
|
+
|
|
87
|
+
for (const storedKey of this._ownKeys(scope)) {
|
|
88
|
+
const key = this._keys.decode(scope, storedKey)
|
|
89
|
+
if (key === null) continue
|
|
90
|
+
const raw = this._storage.getItem(storedKey)
|
|
91
|
+
if (raw === null) continue
|
|
92
|
+
found = true
|
|
93
|
+
state[key] = decode(raw)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return found ? state : null
|
|
97
|
+
}, null)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
write(scope: string, state: Record<string, unknown>): void {
|
|
101
|
+
this._guard(() => {
|
|
102
|
+
for (const [key, value] of Object.entries(state)) {
|
|
103
|
+
const storedKey = this._keys.encode(scope, key)
|
|
104
|
+
// Absent, not stored empty — the same rule every persister follows,
|
|
105
|
+
// so a pristine screen leaves no trace whichever medium it uses.
|
|
106
|
+
if (value === null || value === undefined || value === '') {
|
|
107
|
+
this._storage.removeItem(storedKey)
|
|
108
|
+
} else {
|
|
109
|
+
this._storage.setItem(storedKey, JSON.stringify(value))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}, undefined)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clear(scope: string): void {
|
|
116
|
+
this._guard(() => {
|
|
117
|
+
for (const storedKey of this._ownKeys(scope)) {
|
|
118
|
+
if (this._keys.decode(scope, storedKey) !== null) this._storage.removeItem(storedKey)
|
|
119
|
+
}
|
|
120
|
+
}, undefined)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Snapshot the key names before touching anything.
|
|
125
|
+
*
|
|
126
|
+
* `Storage.key(i)` walks a live collection: removing as you iterate
|
|
127
|
+
* renumbers what is left and skips entries. Reading the names first is the
|
|
128
|
+
* difference between clearing a scope and clearing every other one of it.
|
|
129
|
+
*/
|
|
130
|
+
private _ownKeys(scope: string): string[] {
|
|
131
|
+
const keys: string[] = []
|
|
132
|
+
for (let i = 0; i < this._storage.length; i++) {
|
|
133
|
+
const key = this._storage.key(i)
|
|
134
|
+
if (key !== null && this._keys.decode(scope, key) !== null) keys.push(key)
|
|
135
|
+
}
|
|
136
|
+
return keys
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function decode(raw: string): unknown {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(raw)
|
|
143
|
+
} catch {
|
|
144
|
+
// Written by something other than us, or truncated. Hand back the string
|
|
145
|
+
// rather than dropping the key: a readable wrong value beats a silent
|
|
146
|
+
// absent one.
|
|
147
|
+
return raw
|
|
148
|
+
}
|
|
149
|
+
}
|