@quazardous/qdadm 2.14.0 → 2.16.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/README.md +1 -0
- package/package.json +7 -5
- package/src/chain/index.ts +5 -0
- package/src/composables/useEntityItemShowPage.ts +8 -0
- package/src/composables/useListPage.ts +23 -0
- package/src/composables/useListPage.types.ts +3 -0
- package/src/composables/useLiveEntity.ts +115 -0
- package/src/deferred/index.ts +5 -0
- package/src/entity/EntityManager.cache.ts +20 -0
- package/src/entity/EntityManager.interface.ts +3 -0
- package/src/entity/EntityManager.ts +29 -1
- package/src/entity/EntityManager.types.ts +32 -0
- package/src/gen/vite-plugin.ts +4 -0
- package/src/i18n/I18n.ts +25 -1
- package/src/i18n/__tests__/I18n.missingCap.test.ts +82 -0
- package/src/index.ts +10 -0
- package/src/kernel/Kernel.registries.ts +31 -5
- package/src/kernel/Kernel.ts +3 -0
- package/src/kernel/Kernel.types.ts +31 -0
- package/src/kernel/LiveEntityRouter.ts +189 -0
- package/src/kernel/SSEBridge.ts +3 -0
- package/src/query/index.ts +7 -0
- package/src/types/index.ts +2 -0
- package/src/utils/pluralize.ts +472 -0
- package/src/vite/qdadmPlugin.ts +5 -19
- package/src/vite-env.d.ts +0 -16
- package/src/generated/managers/testManager.js +0 -45
- package/src/types/pluralize.d.ts +0 -19
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,16 +51,15 @@
|
|
|
51
51
|
"LICENSE"
|
|
52
52
|
],
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@quazardous/qdcore": "^1.
|
|
55
|
-
"@
|
|
56
|
-
"@quazardous/qddebug": "^1.0.0",
|
|
54
|
+
"@quazardous/qdcore": "^1.1.0",
|
|
55
|
+
"@quazardous/qddebug": "^1.1.0",
|
|
57
56
|
"@quazardous/quarkernel": "^2.1.0",
|
|
58
|
-
"pluralize": "^8.0.0",
|
|
59
57
|
"yaml": "^2.8.4"
|
|
60
58
|
},
|
|
61
59
|
"peerDependencies": {
|
|
62
60
|
"pinia": "^2.0.0 || ^3.0.0 || ^4.0.0",
|
|
63
61
|
"primevue": "^4.0.0",
|
|
62
|
+
"sass-embedded": "^1.0.0",
|
|
64
63
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
|
65
64
|
"vue": "^3.3.0",
|
|
66
65
|
"vue-router": "^4.0.0 || ^5.0.0"
|
|
@@ -74,6 +73,9 @@
|
|
|
74
73
|
},
|
|
75
74
|
"primevue": {
|
|
76
75
|
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"sass-embedded": {
|
|
78
|
+
"optional": "true"
|
|
77
79
|
}
|
|
78
80
|
},
|
|
79
81
|
"optionalDependencies": {
|
package/src/chain/index.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* - useActiveStack: Composable to build and access the sync stack
|
|
8
8
|
* - useStackHydrator: Composable to access hydrated data
|
|
9
9
|
*
|
|
10
|
+
* @experimental Navigation-stack plumbing that happens to be exported: the
|
|
11
|
+
* level shape and the hydration payloads may change in a minor release. The
|
|
12
|
+
* parent/child routing built on top of it is stable — see
|
|
13
|
+
* docs/API_STABILITY.md.
|
|
14
|
+
*
|
|
10
15
|
* @module chain
|
|
11
16
|
*/
|
|
12
17
|
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
import type { OrchestratorLike } from '../entity/EntityManager.interface'
|
|
58
58
|
import type { ButtonSeverity } from '../types'
|
|
59
59
|
import { useActionRegistry } from './useActionRegistry'
|
|
60
|
+
import { useLiveEntity } from './useLiveEntity'
|
|
60
61
|
|
|
61
62
|
// #1191 — shared structural view (manager tier from useEntityItemPage)
|
|
62
63
|
type Orchestrator = OrchestratorLike<EntityManager>
|
|
@@ -306,6 +307,13 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
|
|
|
306
307
|
})
|
|
307
308
|
|
|
308
309
|
const { manager, orchestrator: baseOrchestrator, data, loading, error, entityId, entityLabel, isLoaded, hydrator } = base
|
|
310
|
+
|
|
311
|
+
// Reload when THIS record changed outside the session (#1888 lot D).
|
|
312
|
+
// Scoped by id: an event about another record must not reload every open
|
|
313
|
+
// detail page. An event carrying no id concerns the whole entity and applies.
|
|
314
|
+
useLiveEntity(entity, manager, async () => {
|
|
315
|
+
await base.reload()
|
|
316
|
+
}, { id: () => entityId.value })
|
|
309
317
|
// Cast orchestrator to include toast methods
|
|
310
318
|
const orchestrator = baseOrchestrator as Orchestrator
|
|
311
319
|
|
|
@@ -34,6 +34,7 @@ import { useHooks } from './useHooks.js'
|
|
|
34
34
|
import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
|
|
35
35
|
import { useActiveStack } from '../chain/useActiveStack.js'
|
|
36
36
|
import { useI18n } from '../i18n/useI18n'
|
|
37
|
+
import { useLiveEntity } from './useLiveEntity'
|
|
37
38
|
import { formatDateOnly } from '../utils/formatters'
|
|
38
39
|
import { humanizeFieldName } from '../utils/humanize'
|
|
39
40
|
|
|
@@ -1047,6 +1048,27 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1047
1048
|
window.removeEventListener('resize', handleResize)
|
|
1048
1049
|
})
|
|
1049
1050
|
|
|
1051
|
+
// Reload when this entity changed outside the session (#1888 lot D).
|
|
1052
|
+
//
|
|
1053
|
+
// Only fires for entities the app declared in sse.entities, so an app with
|
|
1054
|
+
// no live backend is untouched. The reload preserves selection: a list
|
|
1055
|
+
// refreshing under someone mid-bulk-action must not silently empty their
|
|
1056
|
+
// checkboxes — the rows are re-matched by key, and anything that vanished
|
|
1057
|
+
// server-side simply drops out.
|
|
1058
|
+
useLiveEntity(entity, manager, async () => {
|
|
1059
|
+
const keptKeys = new Set(
|
|
1060
|
+
selected.value.map((item) => (item as Record<string, unknown>)[resolvedDataKey])
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
await loadItems({}, { force: true })
|
|
1064
|
+
|
|
1065
|
+
if (keptKeys.size) {
|
|
1066
|
+
selected.value = items.value.filter((item) =>
|
|
1067
|
+
keptKeys.has((item as Record<string, unknown>)[resolvedDataKey])
|
|
1068
|
+
) as T[]
|
|
1069
|
+
}
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1050
1072
|
// ============ UTILITIES ============
|
|
1051
1073
|
// Browser-locale by default (unified policy — see utils/formatters).
|
|
1052
1074
|
function formatDate(
|
|
@@ -1112,6 +1134,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
|
|
|
1112
1134
|
|
|
1113
1135
|
const listProps = computed<ListPageProps>(() => ({
|
|
1114
1136
|
title: manager.labelPlural,
|
|
1137
|
+
subtitle: config.subtitle !== undefined ? config.subtitle : (manager.description ?? null),
|
|
1115
1138
|
headerActions: headerActions.value,
|
|
1116
1139
|
cards: cards.value,
|
|
1117
1140
|
columns: columns.value,
|
|
@@ -247,6 +247,8 @@ export interface UseListPageOptions<T = unknown> {
|
|
|
247
247
|
entityName?: string
|
|
248
248
|
/** Override manager.labelPlural */
|
|
249
249
|
entityNamePlural?: string
|
|
250
|
+
/** Override manager.description (list page subtitle). Pass null to suppress it. */
|
|
251
|
+
subtitle?: string | null
|
|
250
252
|
/** Override manager.routePrefix */
|
|
251
253
|
routePrefix?: string
|
|
252
254
|
}
|
|
@@ -256,6 +258,7 @@ export interface UseListPageOptions<T = unknown> {
|
|
|
256
258
|
*/
|
|
257
259
|
export interface ListPageProps {
|
|
258
260
|
title: string | undefined
|
|
261
|
+
subtitle: string | null
|
|
259
262
|
headerActions: ResolvedHeaderAction[]
|
|
260
263
|
cards: CardConfig[]
|
|
261
264
|
columns: ColumnConfig[]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useLiveEntity — reload a mounted screen when its entity changed elsewhere
|
|
3
|
+
* (#1888 lot D, reported in #1887).
|
|
4
|
+
*
|
|
5
|
+
* Invalidating marks a cache stale; it does not repaint anything. A list that
|
|
6
|
+
* is *already on screen* keeps its rows until the user navigates away and back
|
|
7
|
+
* — which is precisely the case a pushing backend exists for: someone is
|
|
8
|
+
* looking at the screen right now.
|
|
9
|
+
*
|
|
10
|
+
* There is no page-level opt-in, deliberately. The gate is upstream: only an
|
|
11
|
+
* entity the app declared in `sse.entities` ever produces a remote event, so an
|
|
12
|
+
* app with no live backend sees no behaviour change and has no flag to forget.
|
|
13
|
+
* What the entity does about it is its own policy (`live` on the manager).
|
|
14
|
+
*
|
|
15
|
+
* @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { inject, onUnmounted } from 'vue'
|
|
19
|
+
import type { SignalBus } from '../kernel/SignalBus'
|
|
20
|
+
|
|
21
|
+
export interface UseLiveEntityOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Only react to events carrying this record id. Detail screens pass their
|
|
24
|
+
* own id: without it, changing one record reloads every open detail page.
|
|
25
|
+
*/
|
|
26
|
+
id?: () => string | number | null | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LivePolicy {
|
|
30
|
+
refresh?: 'mounted' | false
|
|
31
|
+
coalesceMs?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe the calling component to remote changes of `entityName`.
|
|
37
|
+
*
|
|
38
|
+
* @param entityName - entity to watch
|
|
39
|
+
* @param manager - its manager, read for the `live` policy (may be null)
|
|
40
|
+
* @param reload - what to run; coalesced, never called concurrently by us
|
|
41
|
+
* @returns unsubscribe function (also called automatically on unmount)
|
|
42
|
+
*/
|
|
43
|
+
export function useLiveEntity(
|
|
44
|
+
entityName: string,
|
|
45
|
+
// Taken as unknown and read defensively: the page composables carry
|
|
46
|
+
// structural manager views (EntityManagerRead) that predate this policy.
|
|
47
|
+
manager: unknown,
|
|
48
|
+
reload: () => void | Promise<void>,
|
|
49
|
+
options: UseLiveEntityOptions = {}
|
|
50
|
+
): () => void {
|
|
51
|
+
const signals = inject<SignalBus | null>('qdadmSignals', null)
|
|
52
|
+
if (!signals || !entityName) return () => {}
|
|
53
|
+
|
|
54
|
+
const policy = (manager as { live?: LivePolicy } | null | undefined)?.live ?? {}
|
|
55
|
+
// `refresh: false` — invalidation still happened upstream, the screen just
|
|
56
|
+
// does not chase it. Subscribing would only burn a listener.
|
|
57
|
+
if (policy.refresh === false) return () => {}
|
|
58
|
+
|
|
59
|
+
const coalesceMs = policy.coalesceMs ?? 300
|
|
60
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
61
|
+
let disposed = false
|
|
62
|
+
|
|
63
|
+
const fire = (): void => {
|
|
64
|
+
timer = null
|
|
65
|
+
if (disposed) return
|
|
66
|
+
void reload()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const off = signals.on('entity:data-invalidate', (event: { name: string; data: unknown }) => {
|
|
70
|
+
const payload = (event.data || {}) as {
|
|
71
|
+
entity?: string
|
|
72
|
+
id?: string | number
|
|
73
|
+
source?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Local writes are the manager's own echo: it has already repaired its
|
|
77
|
+
// cache and the page that triggered the write reloads on its own path.
|
|
78
|
+
if (payload.source !== 'remote') return
|
|
79
|
+
if (payload.entity !== entityName) return
|
|
80
|
+
|
|
81
|
+
if (options.id) {
|
|
82
|
+
const watched = options.id()
|
|
83
|
+
// An event carrying no id concerns the whole entity, so it always
|
|
84
|
+
// applies; one carrying a different id is somebody else's record.
|
|
85
|
+
if (
|
|
86
|
+
watched != null &&
|
|
87
|
+
payload.id !== undefined &&
|
|
88
|
+
String(payload.id) !== String(watched)
|
|
89
|
+
) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Coalesce: a backend replaying fifty rows must cost one reload, not fifty.
|
|
95
|
+
if (coalesceMs <= 0) {
|
|
96
|
+
fire()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (timer) return
|
|
100
|
+
timer = setTimeout(fire, coalesceMs)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const stop = (): void => {
|
|
104
|
+
disposed = true
|
|
105
|
+
if (timer) {
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
timer = null
|
|
108
|
+
}
|
|
109
|
+
off()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
onUnmounted(stop)
|
|
113
|
+
|
|
114
|
+
return stop
|
|
115
|
+
}
|
package/src/deferred/index.ts
CHANGED
|
@@ -332,6 +332,26 @@ export function applyCacheMethods(EntityManagerClass: { prototype: any }): void
|
|
|
332
332
|
)
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// Listen for REMOTE changes to this entity — a mutation nobody here made.
|
|
336
|
+
//
|
|
337
|
+
// The invalidation signals were written for local mutations, where the
|
|
338
|
+
// manager that mutates repairs its own cache on the way out. A change that
|
|
339
|
+
// happened elsewhere repairs nothing, so the list cache would stay stale
|
|
340
|
+
// while claiming to be valid (#1887). Gated on the origin marker: without
|
|
341
|
+
// it, this handler would fire on every local write too and force a refetch
|
|
342
|
+
// of a list the manager is already holding correctly.
|
|
343
|
+
cleanups.push(
|
|
344
|
+
signals.on('entity:data-invalidate', (event: { name: string; data: unknown }) => {
|
|
345
|
+
const { entity, source } = (event.data || {}) as {
|
|
346
|
+
entity?: string
|
|
347
|
+
source?: string
|
|
348
|
+
}
|
|
349
|
+
if (source !== 'remote') return
|
|
350
|
+
if (entity !== this.name) return
|
|
351
|
+
this.invalidateCache()
|
|
352
|
+
})
|
|
353
|
+
)
|
|
354
|
+
|
|
335
355
|
// Listen for own entity data changes to invalidate detail cache
|
|
336
356
|
if (this.isAsymmetric && this.isDetailCacheEnabled) {
|
|
337
357
|
cleanups.push(
|
|
@@ -37,6 +37,8 @@ export interface EntityManagerBase<T = unknown> {
|
|
|
37
37
|
readonly idField: string
|
|
38
38
|
label?: string
|
|
39
39
|
labelPlural?: string
|
|
40
|
+
/** Optional one-liner explaining what the entity is (list page subtitle) */
|
|
41
|
+
description?: string | null
|
|
40
42
|
routePrefix?: string
|
|
41
43
|
labelField?: string | ((entity: T) => string)
|
|
42
44
|
readOnly?: boolean
|
|
@@ -137,6 +139,7 @@ export interface EntityManagerLike {
|
|
|
137
139
|
idField: string
|
|
138
140
|
label?: string
|
|
139
141
|
labelPlural?: string
|
|
142
|
+
description?: string | null
|
|
140
143
|
// Function-typed PROPERTY → strictly contravariant param; `never` is the
|
|
141
144
|
// contravariant top, so the canonical `(entity: T) => string` assigns.
|
|
142
145
|
// Cast at the call site when invoking.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { PermissiveAuthAdapter } from './auth/PermissiveAdapter'
|
|
2
2
|
import { AuthActions, type AuthUser } from './auth/EntityAuthAdapter'
|
|
3
3
|
import type { EntityAuthAdapter as IEntityAuthAdapter } from './auth/EntityAuthAdapter'
|
|
4
|
-
import pluralize from 'pluralize'
|
|
4
|
+
import pluralize from '../utils/pluralize'
|
|
5
5
|
import { applyCacheMethods } from './EntityManager.cache'
|
|
6
6
|
import { applyQueryMethods } from './EntityManager.query'
|
|
7
7
|
import { applyRelationsMethods } from './EntityManager.relations'
|
|
@@ -63,6 +63,7 @@ import type {
|
|
|
63
63
|
SeverityMap,
|
|
64
64
|
SeverityDescriptor,
|
|
65
65
|
EntityManagerOptions,
|
|
66
|
+
LiveEntityPolicy,
|
|
66
67
|
} from './EntityManager.types'
|
|
67
68
|
|
|
68
69
|
/**
|
|
@@ -82,6 +83,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
82
83
|
protected _badges: ((entity: T) => EntityBadge[]) | null
|
|
83
84
|
protected _label: string | null
|
|
84
85
|
protected _labelPlural: string | null
|
|
86
|
+
protected _description: string | null
|
|
85
87
|
protected _routePrefix: string | null
|
|
86
88
|
protected _fields: Record<string, FieldConfig>
|
|
87
89
|
|
|
@@ -97,6 +99,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
97
99
|
protected _readOnly: boolean
|
|
98
100
|
protected _warmup: boolean
|
|
99
101
|
protected _authSensitive: boolean
|
|
102
|
+
/** Reaction policy for changes made outside this session (#1888). */
|
|
103
|
+
protected _live: Required<LiveEntityPolicy>
|
|
100
104
|
protected _system: boolean
|
|
101
105
|
|
|
102
106
|
protected _scopeWhitelist: string[] | null
|
|
@@ -153,6 +157,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
153
157
|
idField = 'id',
|
|
154
158
|
label = null,
|
|
155
159
|
labelPlural = null,
|
|
160
|
+
description = null,
|
|
156
161
|
routePrefix = null,
|
|
157
162
|
labelField = 'name',
|
|
158
163
|
badges = null,
|
|
@@ -166,6 +171,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
166
171
|
readOnly = false,
|
|
167
172
|
warmup = true,
|
|
168
173
|
authSensitive,
|
|
174
|
+
live,
|
|
169
175
|
system = false,
|
|
170
176
|
scopeWhitelist = null,
|
|
171
177
|
isOwn = null,
|
|
@@ -185,6 +191,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
185
191
|
|
|
186
192
|
this._label = label
|
|
187
193
|
this._labelPlural = labelPlural
|
|
194
|
+
this._description = description
|
|
188
195
|
this._routePrefix = routePrefix
|
|
189
196
|
this._fields = fields
|
|
190
197
|
|
|
@@ -197,6 +204,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
197
204
|
this._readOnly = readOnly
|
|
198
205
|
this._warmup = warmup
|
|
199
206
|
this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
|
|
207
|
+
// Pre-wired defaults: the common case declares nothing.
|
|
208
|
+
this._live = {
|
|
209
|
+
refresh: live?.refresh ?? 'mounted',
|
|
210
|
+
coalesceMs: live?.coalesceMs ?? 300,
|
|
211
|
+
}
|
|
200
212
|
this._system = system
|
|
201
213
|
|
|
202
214
|
this._scopeWhitelist = scopeWhitelist
|
|
@@ -258,6 +270,10 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
258
270
|
entity: this.name,
|
|
259
271
|
action,
|
|
260
272
|
id,
|
|
273
|
+
// This manager just performed the mutation and has already repaired its
|
|
274
|
+
// own cache — marking the origin lets it ignore its own echo instead of
|
|
275
|
+
// refetching a list it is already holding correctly (#1888 lot B).
|
|
276
|
+
source: 'local',
|
|
261
277
|
})
|
|
262
278
|
}
|
|
263
279
|
|
|
@@ -415,6 +431,13 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
415
431
|
return plural.charAt(0).toUpperCase() + plural.slice(1)
|
|
416
432
|
}
|
|
417
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Optional one-liner explaining what the entity is (list page subtitle)
|
|
436
|
+
*/
|
|
437
|
+
get description(): string | null {
|
|
438
|
+
return this._description
|
|
439
|
+
}
|
|
440
|
+
|
|
418
441
|
/**
|
|
419
442
|
* Get route prefix for this entity
|
|
420
443
|
*/
|
|
@@ -554,6 +577,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
554
577
|
/**
|
|
555
578
|
* Check if user can read entities
|
|
556
579
|
*/
|
|
580
|
+
/** Reaction policy for changes made outside this session (#1888). */
|
|
581
|
+
get live(): Required<LiveEntityPolicy> {
|
|
582
|
+
return this._live
|
|
583
|
+
}
|
|
584
|
+
|
|
557
585
|
canRead(entity: T | null = null): boolean {
|
|
558
586
|
return this.canAccess(AuthActions.READ, entity)
|
|
559
587
|
}
|
|
@@ -200,6 +200,24 @@ export type SeverityMapValue = string | SeverityDescriptor
|
|
|
200
200
|
/** Severity map: field value → severity string or descriptor */
|
|
201
201
|
export type SeverityMap = Record<string, SeverityMapValue>
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Policy for reacting to a change that happened outside this session.
|
|
205
|
+
*
|
|
206
|
+
* @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
|
|
207
|
+
*/
|
|
208
|
+
export interface LiveEntityPolicy {
|
|
209
|
+
/**
|
|
210
|
+
* `'mounted'` (default) — a screen currently showing this entity reloads.
|
|
211
|
+
* `false` — invalidate the cache only; the screen updates next time it asks.
|
|
212
|
+
*/
|
|
213
|
+
refresh?: 'mounted' | false
|
|
214
|
+
/**
|
|
215
|
+
* Window (ms) over which a burst of events collapses into a single reload.
|
|
216
|
+
* Default 300. `0` reloads on every event — rarely what you want.
|
|
217
|
+
*/
|
|
218
|
+
coalesceMs?: number
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
/**
|
|
204
222
|
* EntityManager constructor options
|
|
205
223
|
*/
|
|
@@ -209,6 +227,8 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
209
227
|
idField?: string
|
|
210
228
|
label?: string
|
|
211
229
|
labelPlural?: string
|
|
230
|
+
/** Optional one-liner explaining what the entity is — shown under the list page title */
|
|
231
|
+
description?: string
|
|
212
232
|
routePrefix?: string
|
|
213
233
|
labelField?: string | ((entity: T) => string)
|
|
214
234
|
/** Callback to compute badges for an entity item header */
|
|
@@ -228,6 +248,18 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
228
248
|
readOnly?: boolean
|
|
229
249
|
warmup?: boolean
|
|
230
250
|
authSensitive?: boolean
|
|
251
|
+
/**
|
|
252
|
+
* What this entity does when it learns it changed elsewhere (#1888).
|
|
253
|
+
*
|
|
254
|
+
* The kernel's `sse.entities` declares *that* an entity has an external
|
|
255
|
+
* writer — a deployment property. This is *what the entity does about it*,
|
|
256
|
+
* which is behaviour and belongs here. Pre-wired so the common case needs
|
|
257
|
+
* nothing: mounted screens refresh, bursts coalesce over 300 ms.
|
|
258
|
+
*
|
|
259
|
+
* Override per entity — a heavy `logs` list may want `refresh: false`, i.e.
|
|
260
|
+
* drop the stale cache but leave the screen alone until the user asks.
|
|
261
|
+
*/
|
|
262
|
+
live?: LiveEntityPolicy
|
|
231
263
|
system?: boolean
|
|
232
264
|
scopeWhitelist?: string[] | null
|
|
233
265
|
isOwn?: ((record: T, user: AuthUser) => boolean) | null
|
package/src/gen/vite-plugin.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* Integrates generateManagers with Vite's build pipeline, triggering
|
|
5
5
|
* entity manager file generation during the buildStart hook.
|
|
6
6
|
*
|
|
7
|
+
* @experimental Undocumented build-time entry. Prefer the programmatic
|
|
8
|
+
* `generateManagers` entry (`@quazardous/qdadm/gen`), which is stable — see
|
|
9
|
+
* docs/API_STABILITY.md.
|
|
10
|
+
*
|
|
7
11
|
* @module gen/vite-plugin
|
|
8
12
|
*/
|
|
9
13
|
|
package/src/i18n/I18n.ts
CHANGED
|
@@ -70,6 +70,8 @@ export class I18n {
|
|
|
70
70
|
// Concurrent domain loads — share one in-flight promise per (locale, domain)
|
|
71
71
|
// pair across providers and t() callers.
|
|
72
72
|
private _inflightDomains: Map<string, Promise<void>> = new Map()
|
|
73
|
+
// (locale::key) pairs already announced on the bus — see onMissing.
|
|
74
|
+
private _missingReported: Set<string> = new Set()
|
|
73
75
|
|
|
74
76
|
constructor(options: I18nOptions = {}, deps: I18nDeps = {}) {
|
|
75
77
|
this._defaultLocale = options.defaultLocale ?? 'en'
|
|
@@ -101,7 +103,16 @@ export class I18n {
|
|
|
101
103
|
fallbackLocale: this._fallbackLocale,
|
|
102
104
|
globalAliases: this._currentStrategyAliases(),
|
|
103
105
|
onMissing: (key, locale) => {
|
|
104
|
-
if (this._emitMissing)
|
|
106
|
+
if (!this._emitMissing) return
|
|
107
|
+
// A missing key is a FACT, not a stream: knowing it once is the whole
|
|
108
|
+
// diagnostic value. Emitting on every resolution turned a signal into
|
|
109
|
+
// a firehose — the same seven nav keys fired 1616 times in two seconds
|
|
110
|
+
// downstream, and each emission was recorded by two collectors, which
|
|
111
|
+
// fed the tick that triggered the next resolution (#1896).
|
|
112
|
+
const seenKey = `${locale}::${key}`
|
|
113
|
+
if (this._missingReported.has(seenKey)) return
|
|
114
|
+
this._missingReported.add(seenKey)
|
|
115
|
+
this._signals?.emit('i18n:missing', { key, locale })
|
|
105
116
|
},
|
|
106
117
|
})
|
|
107
118
|
|
|
@@ -184,6 +195,7 @@ export class I18n {
|
|
|
184
195
|
if (this.locale.value === locale && this._loadedLocales.has(locale)) return
|
|
185
196
|
await this._loadLocale(locale)
|
|
186
197
|
this.locale.value = locale
|
|
198
|
+
this._resetMissingReports()
|
|
187
199
|
this._signals?.emit('locale:changed', locale)
|
|
188
200
|
}
|
|
189
201
|
|
|
@@ -247,6 +259,17 @@ export class I18n {
|
|
|
247
259
|
// Internals
|
|
248
260
|
// ---------------------------------------------------------------------------
|
|
249
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Forget which missing keys were already announced.
|
|
264
|
+
*
|
|
265
|
+
* Called whenever new messages arrive or the locale changes: a key that was
|
|
266
|
+
* missing may now resolve, and one that goes missing again afterwards is
|
|
267
|
+
* news worth reporting once more.
|
|
268
|
+
*/
|
|
269
|
+
private _resetMissingReports(): void {
|
|
270
|
+
this._missingReported.clear()
|
|
271
|
+
}
|
|
272
|
+
|
|
250
273
|
private async _loadLocale(locale: string): Promise<void> {
|
|
251
274
|
for (const p of this._providers) {
|
|
252
275
|
const bundle = await p.load(locale)
|
|
@@ -300,6 +323,7 @@ export class I18n {
|
|
|
300
323
|
this._loadedDomains.add(cacheKey)
|
|
301
324
|
this._inflightDomains.delete(cacheKey)
|
|
302
325
|
if (merged) {
|
|
326
|
+
this._resetMissingReports()
|
|
303
327
|
this._signals?.emit('i18n:domain-loaded', { locale, domain })
|
|
304
328
|
}
|
|
305
329
|
})()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A missing key is announced once, not on every resolution (#1896).
|
|
3
|
+
*
|
|
4
|
+
* Downstream, seven nav labels fired 1616 `i18n:missing` each in two seconds:
|
|
5
|
+
* every render resolved them, every resolution emitted, and each emission was
|
|
6
|
+
* recorded by two collectors that bumped the tick driving the next render.
|
|
7
|
+
* Capping the signal removes the fuel — the diagnostic value of a missing key
|
|
8
|
+
* is knowing it exists, which takes exactly one signal.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
11
|
+
import { I18n } from '../I18n'
|
|
12
|
+
|
|
13
|
+
function makeI18n(options = {}) {
|
|
14
|
+
const emitted: Array<{ signal: string; payload: unknown }> = []
|
|
15
|
+
const signals = {
|
|
16
|
+
emit: (signal: string, payload?: unknown) => {
|
|
17
|
+
emitted.push({ signal, payload })
|
|
18
|
+
},
|
|
19
|
+
on: () => () => {},
|
|
20
|
+
}
|
|
21
|
+
const i18n = new I18n({ disableDefaultCoreBundle: true, ...options }, { signals })
|
|
22
|
+
const missing = () => emitted.filter((e) => e.signal === 'i18n:missing')
|
|
23
|
+
return { i18n, emitted, missing }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('I18n — missing keys are announced once', () => {
|
|
27
|
+
it('emits once however many times the key is resolved', () => {
|
|
28
|
+
const { i18n, missing } = makeI18n()
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < 200; i++) i18n.t('nav.sections.Control')
|
|
31
|
+
|
|
32
|
+
const announced = missing()
|
|
33
|
+
expect(announced).toHaveLength(1)
|
|
34
|
+
expect((announced[0]!.payload as { key: string }).key).toBe('nav.sections.Control')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('still announces each distinct key', () => {
|
|
38
|
+
const { i18n, missing } = makeI18n()
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < 50; i++) {
|
|
41
|
+
i18n.t('nav.routes.run')
|
|
42
|
+
i18n.t('nav.routes.job')
|
|
43
|
+
i18n.t('breadcrumb.details')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
expect(missing()).toHaveLength(3)
|
|
47
|
+
expect(missing().map((m) => (m.payload as { key: string }).key).sort()).toEqual([
|
|
48
|
+
'breadcrumb.details',
|
|
49
|
+
'nav.routes.job',
|
|
50
|
+
'nav.routes.run',
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps returning the fallback — capping the signal changes no output', () => {
|
|
55
|
+
const { i18n } = makeI18n()
|
|
56
|
+
|
|
57
|
+
const first = i18n.t('nav.routes.run')
|
|
58
|
+
const later = i18n.t('nav.routes.run')
|
|
59
|
+
|
|
60
|
+
expect(later).toBe(first)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('honours emitMissing: false', () => {
|
|
64
|
+
const { i18n, missing } = makeI18n({ emitMissing: false })
|
|
65
|
+
|
|
66
|
+
i18n.t('nav.routes.run')
|
|
67
|
+
|
|
68
|
+
expect(missing()).toHaveLength(0)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('announces again after the locale changes — the fact may no longer hold', async () => {
|
|
72
|
+
const { i18n, missing } = makeI18n()
|
|
73
|
+
|
|
74
|
+
i18n.t('nav.routes.run')
|
|
75
|
+
expect(missing()).toHaveLength(1)
|
|
76
|
+
|
|
77
|
+
await i18n.changeLocale('fr')
|
|
78
|
+
i18n.t('nav.routes.run')
|
|
79
|
+
|
|
80
|
+
expect(missing()).toHaveLength(2)
|
|
81
|
+
})
|
|
82
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,16 @@ export type {
|
|
|
39
39
|
} from './kernel/EventRouter'
|
|
40
40
|
export { SSEBridge, createSSEBridge, SSE_SIGNALS } from './kernel/SSEBridge'
|
|
41
41
|
export type { SSEBridgeOptions } from './kernel/SSEBridge'
|
|
42
|
+
export { useLiveEntity } from './composables/useLiveEntity'
|
|
43
|
+
export type { UseLiveEntityOptions } from './composables/useLiveEntity'
|
|
44
|
+
export type { LiveEntityPolicy } from './entity/EntityManager.types'
|
|
45
|
+
export { LiveEntityRouter, createLiveEntityRouter } from './kernel/LiveEntityRouter'
|
|
46
|
+
export type {
|
|
47
|
+
LiveEntityRouterOptions,
|
|
48
|
+
LiveEntityAction,
|
|
49
|
+
LiveEntitiesDeclaration,
|
|
50
|
+
LiveOrchestratorLike,
|
|
51
|
+
} from './kernel/LiveEntityRouter'
|
|
42
52
|
export { Module } from './kernel/Module'
|
|
43
53
|
export { KernelContext, createKernelContext } from './kernel/KernelContext'
|
|
44
54
|
export type {
|