@quazardous/qdadm 2.18.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.
Files changed (38) hide show
  1. package/package.json +5 -7
  2. package/src/components/editors/VanillaJsonEditor.vue +14 -4
  3. package/src/components/lists/ListPage.vue +7 -0
  4. package/src/composables/index.ts +3 -0
  5. package/src/composables/useLayoutResolver.ts +10 -0
  6. package/src/composables/useListPage.filters.ts +137 -67
  7. package/src/composables/useListPage.ts +148 -26
  8. package/src/composables/useListPage.types.ts +74 -5
  9. package/src/composables/useListPage.utils.ts +58 -0
  10. package/src/entity/EntityManager.interface.ts +11 -0
  11. package/src/entity/EntityManager.ts +5 -0
  12. package/src/entity/EntityManager.types.ts +11 -0
  13. package/src/entity/storage/ApiStorage.ts +16 -2
  14. package/src/entity/storage/LocalStorage.ts +1 -1
  15. package/src/entity/storage/MemoryStorage.ts +7 -2
  16. package/src/entity/storage/MockApiStorage.ts +1 -1
  17. package/src/entity/storage/SdkStorage.ts +12 -3
  18. package/src/gen/createManagers.ts +15 -1
  19. package/src/gen/index.ts +7 -1
  20. package/src/index.ts +19 -0
  21. package/src/kernel/Kernel.registries.ts +202 -46
  22. package/src/kernel/Kernel.ts +34 -9
  23. package/src/kernel/Kernel.types.ts +19 -0
  24. package/src/kernel/Kernel.vue.ts +7 -0
  25. package/src/kernel/KernelContext.routing.ts +18 -7
  26. package/src/plugin.ts +1 -1
  27. package/src/query/clientFilter.ts +14 -3
  28. package/src/routeState/CompositePersister.ts +144 -0
  29. package/src/routeState/CookiePersister.ts +109 -0
  30. package/src/routeState/MemoryPersister.ts +60 -0
  31. package/src/routeState/NullPersister.ts +33 -0
  32. package/src/routeState/RouteStatePersister.ts +86 -0
  33. package/src/routeState/UrlPersister.ts +207 -0
  34. package/src/routeState/WebStoragePersister.ts +149 -0
  35. package/src/routeState/defaultPersister.ts +66 -0
  36. package/src/routeState/factory.ts +86 -0
  37. package/src/routeState/index.ts +32 -0
  38. package/src/routeState/keyLocator.ts +84 -0
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What a list uses when nobody chose (#2146).
3
+ *
4
+ * Not simply `UrlPersister`, because not all list state wants the same
5
+ * medium. Filters, search and the page belong in a link — that is the whole
6
+ * argument for the URL being the default. Rows-per-page does not: it is a
7
+ * comfort setting somebody picked once, and a link carrying it would impose
8
+ * the sender's row count on the reader.
9
+ *
10
+ * So the default is a composition, and it says out loud what qdadm was
11
+ * already doing by accident:
12
+ *
13
+ * | State | Medium | Scope |
14
+ * |---|---|---|
15
+ * | filters, search, page | query string | the list |
16
+ * | `pageSize` | cookie, a year | the app |
17
+ * | `sort` | `sessionStorage` | the list |
18
+ *
19
+ * **Sort stays in the session** — decided on #2146 rather than moved to the
20
+ * URL. Moving it would make a list link carry its ordering, which is a real
21
+ * gain; it would also mean the sort no longer survives opening a clean
22
+ * `/offers`, which it has always done. Between a gain nobody asked for and a
23
+ * loss every existing user would feel, the seam is worth having without the
24
+ * behaviour change: this expresses today's arrangement rather than replacing
25
+ * it.
26
+ */
27
+ import { CompositePersister } from './CompositePersister'
28
+ import { CookiePersister, type CookieJar } from './CookiePersister'
29
+ import { WebStoragePersister, type RouteStateStorage } from './WebStoragePersister'
30
+ import { UrlPersister, type UrlPersisterRoute, type UrlPersisterRouter } from './UrlPersister'
31
+ import type { RouteStatePersister } from './RouteStatePersister'
32
+
33
+ export interface DefaultRouteStateOptions {
34
+ router: UrlPersisterRouter
35
+ route: UrlPersisterRoute
36
+ /** Stand-in for `document`, for a test or an environment without one. */
37
+ cookieJar?: CookieJar
38
+ /** Stand-in for `sessionStorage`, same reason. */
39
+ sessionStorage?: RouteStateStorage
40
+ }
41
+
42
+ /** The scope `pageSize` is remembered under: one setting for the whole app. */
43
+ export const APP_ROUTE_STATE_SCOPE = 'app'
44
+
45
+ export function createDefaultRouteStatePersister(
46
+ options: DefaultRouteStateOptions
47
+ ): RouteStatePersister {
48
+ const session = new WebStoragePersister({
49
+ storage: options.sessionStorage ?? globalThis.sessionStorage,
50
+ name: 'session_storage',
51
+ })
52
+
53
+ return new CompositePersister({
54
+ fallback: new UrlPersister(options),
55
+ keys: {
56
+ pageSize: {
57
+ persister: new CookiePersister(options.cookieJar ? { jar: options.cookieJar } : {}),
58
+ scope: APP_ROUTE_STATE_SCOPE,
59
+ },
60
+ // Per-list, unlike pageSize: an ordering belongs to the screen you were
61
+ // looking at, not to the whole app.
62
+ sort: { persister: session },
63
+ sortOrder: { persister: session },
64
+ },
65
+ })
66
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Slugs to persisters (#2146).
3
+ *
4
+ * Mirrors what `entity/storage/factory.ts` does for storages — an instance, or
5
+ * a name the framework knows — so a consumer meets one convention rather than
6
+ * two.
7
+ *
8
+ * | Slug | Medium | Reach for it when |
9
+ * |---|---|---|
10
+ * | `default` | query string + cookie | what a list uses when nobody chose |
11
+ * | `url` | query string | EVERYTHING in the link, row count included |
12
+ * | `local_storage` | `localStorage` | worth keeping, not worth linking |
13
+ * | `session_storage` | `sessionStorage` | worth keeping until the tab closes |
14
+ * | `cookie` | one cookie per scope | the SERVER needs to read it |
15
+ * | `memory` | a shared Map | survives navigation, not a reload |
16
+ * | `none` | nowhere | this screen forgets, and says so |
17
+ */
18
+ import type { RouteStatePersister, RouteStatePersisterFactory } from './RouteStatePersister'
19
+ import { UrlPersister, type UrlPersisterRoute, type UrlPersisterRouter } from './UrlPersister'
20
+ import { WebStoragePersister, type RouteStateStorage } from './WebStoragePersister'
21
+ import { CookiePersister, type CookieJar } from './CookiePersister'
22
+ import { MemoryPersister } from './MemoryPersister'
23
+ import { NullPersister } from './NullPersister'
24
+ import { createDefaultRouteStatePersister } from './defaultPersister'
25
+
26
+ export interface RouteStateFactoryContext {
27
+ router: UrlPersisterRouter
28
+ route: UrlPersisterRoute
29
+ /**
30
+ * Overrides for the browser globals the storage-backed slugs reach for.
31
+ *
32
+ * Only needed off a browser — SSR, or a test that would rather not share
33
+ * one `localStorage` between cases. Left alone, each slug uses the real
34
+ * thing, resolved AT BUILD TIME rather than captured here, so a factory
35
+ * created before the app mounts still works.
36
+ */
37
+ localStorage?: RouteStateStorage
38
+ sessionStorage?: RouteStateStorage
39
+ cookieJar?: CookieJar
40
+ }
41
+
42
+ /**
43
+ * The built-in factory.
44
+ *
45
+ * Returns null for a slug it does not know — the CALLER throws, with the name
46
+ * in the message. Returning a default here would make an unknown slug behave
47
+ * like `url` and say nothing, which is the failure ADR 0011 forbids and the
48
+ * one this seam is cleaning up after.
49
+ */
50
+ export function createRouteStatePersisterFactory(
51
+ context: RouteStateFactoryContext
52
+ ): RouteStatePersisterFactory {
53
+ return (slug: string): RouteStatePersister | null => {
54
+ switch (slug) {
55
+ // Nameable, so "the default, stated out loud" has a way to be said.
56
+ // It is NOT `url`: the default routes `pageSize` to a cookie, because a
57
+ // link should not impose the sender's row count on its reader.
58
+ case 'default':
59
+ return createDefaultRouteStatePersister({
60
+ router: context.router,
61
+ route: context.route,
62
+ cookieJar: context.cookieJar,
63
+ })
64
+ case 'url':
65
+ return new UrlPersister(context)
66
+ case 'local_storage':
67
+ return new WebStoragePersister({
68
+ storage: context.localStorage ?? globalThis.localStorage,
69
+ name: 'local_storage',
70
+ })
71
+ case 'session_storage':
72
+ return new WebStoragePersister({
73
+ storage: context.sessionStorage ?? globalThis.sessionStorage,
74
+ name: 'session_storage',
75
+ })
76
+ case 'cookie':
77
+ return new CookiePersister(context.cookieJar ? { jar: context.cookieJar } : {})
78
+ case 'memory':
79
+ return new MemoryPersister()
80
+ case 'none':
81
+ return new NullPersister()
82
+ default:
83
+ return null
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Route-state persistence (#2146).
3
+ *
4
+ * The seam that decides WHERE a route remembers what it is showing. The list
5
+ * is one consumer; a detail page's active tab and a dashboard's date range
6
+ * are the same problem.
7
+ */
8
+ export type {
9
+ RouteStatePersister,
10
+ RouteStateScope,
11
+ RouteStatePersisterSpec,
12
+ RouteStatePersisterFactory,
13
+ } from './RouteStatePersister'
14
+ export { isRouteStatePersister, resolveRouteStatePersister } from './RouteStatePersister'
15
+ export { UrlPersister } from './UrlPersister'
16
+ export type { UrlPersisterOptions, UrlPersisterRouter, UrlPersisterRoute } from './UrlPersister'
17
+ export { createRouteStatePersisterFactory } from './factory'
18
+ export { dottedKeyLocator, namespacedKeyLocator } from './keyLocator'
19
+ export type { RouteStateKeyLocator } from './keyLocator'
20
+ export { WebStoragePersister } from './WebStoragePersister'
21
+ export type { WebStoragePersisterOptions, RouteStateStorage } from './WebStoragePersister'
22
+ export { CookiePersister } from './CookiePersister'
23
+ export type { CookiePersisterOptions, CookieJar } from './CookiePersister'
24
+ export { MemoryPersister, clearRouteStateMemory } from './MemoryPersister'
25
+ export type { MemoryPersisterOptions, RouteStateMemoryStore } from './MemoryPersister'
26
+ export { NullPersister } from './NullPersister'
27
+ export { underscoreScopeLocator } from './keyLocator'
28
+ export type { RouteStateScopeLocator } from './keyLocator'
29
+ export type { RouteStateFactoryContext } from './factory'
30
+ export { CompositePersister } from './CompositePersister'
31
+ export type { CompositePersisterOptions, RoutedKey } from './CompositePersister'
32
+ export { createDefaultRouteStatePersister } from './defaultPersister'
@@ -0,0 +1,84 @@
1
+ /**
2
+ * How a (scope, key) pair becomes a stored key — and back (#2146).
3
+ *
4
+ * Hard-coding `offers.page` inside the URL persister would make the naming
5
+ * scheme a property of qdadm rather than of the app. An app that wants
6
+ * `o_page`, or a single packed parameter, or a `[]`-suffixed convention its
7
+ * backend already understands, must be able to say so — the same way it can
8
+ * already override how a storage is resolved.
9
+ *
10
+ * ONE PER PERSISTER TYPE, because the right scheme differs by medium: dots
11
+ * read well in a query string, colons are the convention in `localStorage`,
12
+ * and a cookie has its own constraints entirely.
13
+ *
14
+ * `decode` returns null for a key that is not ours. That is what lets a
15
+ * persister pick its own entries out of a medium it shares with everything
16
+ * else — the whole reason the scope exists.
17
+ */
18
+ export interface RouteStateKeyLocator {
19
+ encode(scope: string, key: string): string
20
+ /** The bare key, or null when this stored key belongs to someone else. */
21
+ decode(scope: string, storedKey: string): string | null
22
+ /**
23
+ * Whether this key carries a scope at all — ANY scope, not just one.
24
+ *
25
+ * A legacy-compatibility reader needs this: `offers.page` and `jobs.page`
26
+ * both belong to somebody, and treating them as unscoped leftovers would
27
+ * hand one list the other's state. Only a key that is scoped by nobody can
28
+ * be read as pre-scope state.
29
+ */
30
+ looksScoped(storedKey: string): boolean
31
+ readonly name: string
32
+ }
33
+
34
+ /** `offers` + `page` → `offers.page`. The default in a query string. */
35
+ export const dottedKeyLocator: RouteStateKeyLocator = {
36
+ name: 'dotted',
37
+ encode: (scope, key) => (scope ? `${scope}.${key}` : key),
38
+ decode: (scope, storedKey) => {
39
+ if (!scope) return storedKey
40
+ const prefix = `${scope}.`
41
+ return storedKey.startsWith(prefix) ? storedKey.slice(prefix.length) : null
42
+ },
43
+ looksScoped: (storedKey) => storedKey.includes('.'),
44
+ }
45
+
46
+ /** `offers` + `page` → `qdadm:offers:page`. The default in web storage. */
47
+ export const namespacedKeyLocator: RouteStateKeyLocator = {
48
+ name: 'namespaced',
49
+ encode: (scope, key) => `qdadm:${scope}:${key}`,
50
+ decode: (scope, storedKey) => {
51
+ const prefix = `qdadm:${scope}:`
52
+ return storedKey.startsWith(prefix) ? storedKey.slice(prefix.length) : null
53
+ },
54
+ looksScoped: (storedKey) => storedKey.startsWith('qdadm:'),
55
+ }
56
+
57
+ /**
58
+ * How a SCOPE becomes a stored name, for media that keep one entry per scope.
59
+ *
60
+ * The cookie jar is the case: a cookie rides on every request, so a scope
61
+ * gets one cookie holding a blob rather than one per key. There is no key
62
+ * half to encode, which is why this is its own small interface rather than
63
+ * `RouteStateKeyLocator` called with a key nobody uses.
64
+ */
65
+ export interface RouteStateScopeLocator {
66
+ encode(scope: string): string
67
+ /** The bare scope, or null when this stored name belongs to someone else. */
68
+ decode(storedName: string): string | null
69
+ readonly name: string
70
+ }
71
+
72
+ /**
73
+ * `offers` → `qdadm_offers`. The default in a cookie jar.
74
+ *
75
+ * Underscores rather than the colons web storage uses: a cookie NAME is an
76
+ * RFC 6265 token, and `:` is a separator there. `qdadm:offers` is not a
77
+ * cookie name a server is obliged to accept.
78
+ */
79
+ export const underscoreScopeLocator: RouteStateScopeLocator = {
80
+ name: 'underscore',
81
+ encode: (scope) => `qdadm_${scope}`,
82
+ decode: (storedName) =>
83
+ storedName.startsWith('qdadm_') ? storedName.slice('qdadm_'.length) : null,
84
+ }