@mintplayer/ng-spark 22.28.0 → 22.29.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/fesm2022/mintplayer-ng-spark-client-operations.mjs +6 -2
- package/fesm2022/mintplayer-ng-spark-client-operations.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-grid.mjs +37 -9
- package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-po-detail.mjs +1 -1
- package/fesm2022/mintplayer-ng-spark-po-detail.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-spark-query-list.mjs +28 -9
- package/fesm2022/mintplayer-ng-spark-query-list.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mintplayer-ng-spark-client-operations.d.ts +11 -4
- package/types/mintplayer-ng-spark-grid.d.ts +17 -3
|
@@ -228,12 +228,13 @@ var SparkQueryRefreshService = class SparkQueryRefreshService {
|
|
|
228
228
|
tokens = signal({}, ...ngDevMode ? [{ debugName: "tokens" }] : /* istanbul ignore next */ []);
|
|
229
229
|
tokenFor(queryId) {
|
|
230
230
|
if (!queryId) return 0;
|
|
231
|
-
return this.tokens()[queryId] ?? 0;
|
|
231
|
+
return this.tokens()[normalize(queryId)] ?? 0;
|
|
232
232
|
}
|
|
233
233
|
request(queryId) {
|
|
234
|
+
const key = normalize(queryId);
|
|
234
235
|
this.tokens.update((current) => ({
|
|
235
236
|
...current,
|
|
236
|
-
[
|
|
237
|
+
[key]: (current[key] ?? 0) + 1
|
|
237
238
|
}));
|
|
238
239
|
}
|
|
239
240
|
static ɵfac = i0.ɵɵngDeclareFactory({
|
|
@@ -262,6 +263,9 @@ i0.ɵɵngDeclareClassMetadata({
|
|
|
262
263
|
args: [{ providedIn: "root" }]
|
|
263
264
|
}]
|
|
264
265
|
});
|
|
266
|
+
function normalize(queryId) {
|
|
267
|
+
return queryId.toLowerCase();
|
|
268
|
+
}
|
|
265
269
|
function provideSparkClientOperations() {
|
|
266
270
|
return makeEnvironmentProviders([
|
|
267
271
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mintplayer-ng-spark-client-operations.mjs","names":[],"sources":["../../client-operations/src/operations.ts","../../client-operations/src/handlers.token.ts","../../client-operations/src/dispatcher.service.ts","../../client-operations/src/notification.service.ts","../../client-operations/src/toast-container.component.ts","../../client-operations/src/attribute-refresh.service.ts","../../client-operations/src/query-refresh.service.ts","../../client-operations/src/provide.ts"],"sourcesContent":["// Wire types matching MintPlayer.Spark.Abstractions.ClientOperations on the server.\n// Discriminator is the `type` field. Unknown operation types are silently dropped\n// by the dispatcher (forward-compat: new types can land server-side without updating\n// older clients).\n\nimport type { PersistentObject, TranslatedString } from '@mintplayer/ng-spark/models';\n\nexport enum NotificationKind {\n Info = 0,\n Success = 1,\n Warning = 2,\n Error = 3,\n}\n\nexport interface NavigateOperation {\n type: 'navigate';\n objectTypeId?: string;\n id?: string;\n routeName?: string;\n}\n\nexport interface NotifyOperation {\n type: 'notify';\n /** The text in the request's language (the browser's `Accept-Language`). */\n message: string;\n /**\n * The text in every language, when the server has it. Shown in the language the user picked in\n * the app (`SparkLanguageService`), which the server never learns; `message` is the fallback.\n */\n translatedMessage?: TranslatedString | null;\n kind: NotificationKind;\n durationMs?: number;\n}\n\nexport interface RefreshAttributeOperation {\n type: 'refreshAttribute';\n objectTypeId: string;\n id: string;\n attributeName: string;\n value?: unknown;\n\n /**\n * Nested rows of an `AsDetail` attribute. A loaded AsDetail attribute carries its rows in\n * `object` / `objects` and leaves `value` null, so a patch limited to `value` could never\n * refresh a detail grid -- it always sent null, and a null over a null repaints nothing.\n *\n * An empty `objects` array means the grid is now empty, and is distinct from omitting it.\n */\n object?: unknown;\n objects?: unknown[];\n}\n\nexport interface RefreshQueryOperation {\n type: 'refreshQuery';\n queryId: string;\n}\n\n// `disableAction` is gone (#460, D13): the server no longer emits it. Disabled actions arrive on\n// `PersistentObject.disabledActions` / `QueryResult.disabledActions`, and a submitted disabled action\n// is refused with 403.\n\nexport interface RetryOperation {\n type: 'retry';\n step: number;\n title: string;\n options: string[];\n defaultOption?: string | null;\n persistentObject?: PersistentObject | null;\n message?: string | null;\n}\n\n/**\n * Discriminated union of known operation types, plus an open shape for unknown\n * future operations. Handlers should narrow via the `type` discriminator before\n * accessing fields specific to their operation type.\n */\nexport type ClientOperation =\n | NavigateOperation\n | NotifyOperation\n | RefreshAttributeOperation\n | RefreshQueryOperation\n | RetryOperation\n | { type: string; [key: string]: unknown };\n\n/**\n * Wire envelope returned by every action endpoint. `result` carries the primary\n * payload (the PersistentObject for a Create, the QueryResult for an Execute,\n * etc.); `operations` carries the side-effects the frontend dispatches.\n */\nexport interface ClientOperationEnvelope<T = unknown> {\n result: T | null;\n operations: ClientOperation[];\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ClientOperation } from './operations';\n\n/**\n * A handler for a specific operation type. Receives the operation and\n * executes the side-effect (e.g. show a toast, navigate, refresh a query).\n * Handlers should `as`-narrow the operation to the type they registered for.\n */\nexport type ClientOperationHandler = (operation: ClientOperation) => void;\n\n/**\n * One entry in the multi-provider registration. Apps can register custom\n * handlers alongside the built-in ones to extend the operation set with\n * app-specific operation types.\n */\nexport interface ClientOperationHandlerRegistration {\n type: string;\n handler: ClientOperationHandler;\n}\n\n/**\n * Multi-provider token. `provideSparkClientOperations()` registers the\n * built-in handlers; apps can add their own with additional `multi: true`\n * providers using this token.\n */\nexport const SPARK_CLIENT_OPERATION_HANDLERS = new InjectionToken<readonly ClientOperationHandlerRegistration[]>(\n 'SPARK_CLIENT_OPERATION_HANDLERS',\n);\n","import { Injectable, inject } from '@angular/core';\nimport type { ClientOperation } from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS, type ClientOperationHandler } from './handlers.token';\n\n/**\n * Routes received operations to registered handlers. Unknown operation types\n * (no matching registration) are silently dropped — this is the forward-compat\n * contract that lets new operation types ship server-side without coordinated\n * client updates.\n *\n * Last-registered-wins on duplicate `type` values, matching standard\n * Angular multi-provider override semantics.\n *\n * R2-H19 — security contract for handler authors:\n * The dispatcher treats handler resolution as allow-list-by-type (unknown\n * types drop). It does NOT validate the *content* of each operation. Handlers\n * that act on URL-shaped fields (navigate, redirect, openWindow) MUST run\n * the value through `sanitizeReturnUrl` from `@mintplayer/ng-spark-auth/models`\n * (or an equivalent same-origin check) before acting on it. Otherwise a\n * single attribute-echo XSS or a single mid-channel byte flip on a non-TLS\n * path lets the server drive client navigation to an attacker host. The\n * built-in `notify` handler renders via Angular interpolation (escaped) so\n * it's safe to pass through, but anything more powerful must validate.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientOperationDispatcher {\n private readonly handlerMap: ReadonlyMap<string, ClientOperationHandler>;\n\n constructor() {\n const registrations = inject(SPARK_CLIENT_OPERATION_HANDLERS, { optional: true }) ?? [];\n const map = new Map<string, ClientOperationHandler>();\n for (const { type, handler } of registrations) {\n map.set(type, handler);\n }\n this.handlerMap = map;\n }\n\n dispatch(operations: readonly ClientOperation[] | null | undefined): void {\n if (!operations || operations.length === 0) return;\n for (const operation of operations) {\n const handler = this.handlerMap.get(operation.type);\n if (handler) {\n handler(operation);\n }\n // Unknown types: silently dropped (forward-compat).\n }\n }\n}\n","import { Injectable, signal } from '@angular/core';\nimport { NotificationKind } from './operations';\n\nexport interface SparkToast {\n id: string;\n message: string;\n kind: NotificationKind;\n durationMs: number;\n}\n\nconst DEFAULT_DURATION_MS = 4000;\n\n/**\n * Holds the active toasts as a signal. The `<spark-toast-container>` component\n * renders them; the built-in `notify` operation handler pushes new toasts here.\n *\n * Auto-dismissal: each toast schedules its own removal after `durationMs`. Pass\n * `0` to make a toast sticky (manual dismissal only).\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkNotificationService {\n private readonly _toasts = signal<readonly SparkToast[]>([]);\n readonly toasts = this._toasts.asReadonly();\n\n show(message: string, kind: NotificationKind = NotificationKind.Info, durationMs?: number): void {\n const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;\n const effectiveDuration = durationMs ?? DEFAULT_DURATION_MS;\n const toast: SparkToast = { id, message, kind, durationMs: effectiveDuration };\n this._toasts.update(toasts => [...toasts, toast]);\n\n if (effectiveDuration > 0) {\n setTimeout(() => this.dismiss(id), effectiveDuration);\n }\n }\n\n dismiss(id: string): void {\n this._toasts.update(toasts => toasts.filter(t => t.id !== id));\n }\n\n clear(): void {\n this._toasts.set([]);\n }\n}\n","import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { SparkNotificationService } from './notification.service';\nimport { NotificationKind } from './operations';\n\n@Component({\n selector: 'spark-toast-container',\n standalone: true,\n template: `\n <div class=\"spark-toast-container\">\n @for (toast of notifications.toasts(); track toast.id) {\n <div\n class=\"spark-toast\"\n [class.spark-toast--info]=\"toast.kind === Kind.Info\"\n [class.spark-toast--success]=\"toast.kind === Kind.Success\"\n [class.spark-toast--warning]=\"toast.kind === Kind.Warning\"\n [class.spark-toast--error]=\"toast.kind === Kind.Error\"\n (click)=\"notifications.dismiss(toast.id)\"\n >\n {{ toast.message }}\n </div>\n }\n </div>\n `,\n styles: [`\n .spark-toast-container {\n position: fixed;\n top: 1rem;\n right: 1rem;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n pointer-events: none;\n }\n .spark-toast {\n padding: 0.75rem 1rem;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n cursor: pointer;\n min-width: 220px;\n max-width: 400px;\n color: var(--bs-white);\n font-size: 0.95rem;\n pointer-events: auto;\n animation: spark-toast-in 0.18s ease-out;\n }\n .spark-toast--info { background: var(--bs-primary); }\n .spark-toast--success { background: var(--bs-success); }\n .spark-toast--warning { background: var(--bs-warning); color: var(--bs-dark); }\n .spark-toast--error { background: var(--bs-danger); }\n @keyframes spark-toast-in {\n from { opacity: 0; transform: translateX(8px); }\n to { opacity: 1; transform: translateX(0); }\n }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkToastContainerComponent {\n protected readonly notifications = inject(SparkNotificationService);\n protected readonly Kind = NotificationKind;\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshAttribute` to whichever detail page is showing that object.\n *\n * The same broadcast shape as {@link SparkQueryRefreshService}, and for the same reason: detail\n * pages come and go behind `@if` and lazy routes, and nothing in ng-spark holds a component\n * reference. A page reads {@link tokenFor} in an effect and applies {@link patchesFor} when it\n * changes.\n *\n * Until this existed the operation was declared on the wire and handled by nobody: the server\n * computed the new values, serialised them, sent them, and the dispatcher dropped them silently\n * because unknown types are ignored. `IClientAccessor.RefreshAttribute` was inert in every Spark\n * application. It is what made the Coverage home page's Resync button look dead — it recomputed\n * the account and repository counts correctly on every click and none of them ever arrived.\n *\n * The operation carries the new value, so this is a patch and not a re-fetch: no round trip, and\n * no dependence on an index that may still be stale from the write that triggered it.\n */\n/**\n * The parts of an attribute a server patch may carry.\n *\n * Three fields rather than one value because a loaded attribute does not keep every shape in\n * `value`: an `AsDetail` attribute puts its nested rows in `object` / `objects` and leaves\n * `value` null. A patch limited to `value` was therefore incapable of refreshing a detail grid,\n * and did so silently -- see {@link SparkAttributeRefreshService}.\n *\n * Only the keys the server sent are present, and each is applied on its own. An `objects: []`\n * means the grid is now empty, which is why emptiness has to be distinguishable from absence.\n */\nexport interface SparkAttributePatch {\n value?: unknown;\n object?: unknown;\n objects?: unknown[];\n}\n\n@Injectable({ providedIn: 'root' })\nexport class SparkAttributeRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n private readonly patches = signal<Record<string, Record<string, SparkAttributePatch>>>({});\n\n /**\n * Objects are keyed by type AND id: the same id can exist under two types, and a detail page\n * only ever wants the attributes meant for the object it is showing.\n */\n private static keyFor(objectTypeId: string, id: string): string {\n return `${objectTypeId}${id}`;\n }\n\n /** Bumped every time the server patches an attribute of this object. */\n tokenFor(objectTypeId: string | undefined | null, id: string | undefined | null): number {\n if (!objectTypeId || !id) return 0;\n return this.tokens()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? 0;\n }\n\n /**\n * The newest value the server sent for each patched attribute of this object.\n *\n * Cumulative rather than drained on read: a detail page that mounts after the operation arrived\n * still sees the value, and two pages showing the same object both get it. Values are overwritten\n * by later patches, so this holds one entry per attribute, not a history.\n */\n patchesFor(objectTypeId: string | undefined | null, id: string | undefined | null): Record<string, SparkAttributePatch> {\n if (!objectTypeId || !id) return {};\n return this.patches()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? {};\n }\n\n /**\n * Record a new value for one attribute and wake whoever is showing the object.\n *\n * Callers patching several attributes for one user action should do so in one synchronous run:\n * the signals coalesce within a tick into a single effect run, and patches split across an await\n * become one run each.\n */\n request(objectTypeId: string, id: string, attributeName: string, patch: SparkAttributePatch): void {\n const key = SparkAttributeRefreshService.keyFor(objectTypeId, id);\n this.patches.update(current => ({\n ...current,\n [key]: { ...(current[key] ?? {}), [attributeName]: patch },\n }));\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshQuery` to whichever grids are showing that query.\n *\n * A broadcast signal rather than a registry of component handles: grids come and go behind\n * `@if` and lazy routes, and nothing else in ng-spark holds a component reference. A grid\n * reads {@link tokenFor} in an effect and re-fetches when it changes, which is the same\n * declarative shape as the `reloadToken` input a host would use.\n *\n * Until this existed the server could emit the operation and the dispatcher dropped it: only\n * `notify` was registered, and unknown types are ignored silently — so `refreshOnCompleted`\n * on the server had no effect on any grid the action did not happen to be hosted in.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkQueryRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n\n /** Bumped every time the server asks for this query to refresh. */\n tokenFor(queryId: string | undefined): number {\n if (!queryId) return 0;\n return this.tokens()[queryId] ?? 0;\n }\n\n /**\n * Ask every grid showing `queryId` to re-fetch.\n *\n * Matched on the EXACT string a grid passes to {@link tokenFor} -- whichever of id or alias\n * its `queryId` input holds. A caller that knows only the other form will not reach it.\n *\n * Callers bumping several keys for one user action should do so in one synchronous run: the\n * signal coalesces bumps within a tick into a single effect run, and bumps split across an\n * await become one re-fetch each.\n */\n request(queryId: string): void {\n this.tokens.update(current => ({ ...current, [queryId]: (current[queryId] ?? 0) + 1 }));\n }\n}\n","import { type EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { Router } from '@angular/router';\nimport { resolveTranslation } from '@mintplayer/ng-spark/models';\nimport type {\n ClientOperation,\n NavigateOperation,\n NotifyOperation,\n RefreshAttributeOperation,\n RefreshQueryOperation,\n} from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS } from './handlers.token';\nimport { SparkAttributeRefreshService } from './attribute-refresh.service';\nimport { SparkNotificationService } from './notification.service';\nimport { SparkQueryRefreshService } from './query-refresh.service';\n\n/**\n * Registers the built-in client-operation handlers: `notify`, `refreshQuery`,\n * `refreshAttribute` and `navigate`. Apps add this once in their bootstrap providers.\n *\n * Unregistered operation types are dropped SILENTLY by the dispatcher, which is why\n * `refreshQuery` did nothing at all for as long as it went unhandled — the server emitted\n * it, nothing listened, and no error said so.\n *\n * That happened twice more before anyone noticed. `refreshAttribute` and `navigate` were both\n * declared wire types with no handler anywhere in the repository: every\n * `IClientAccessor.RefreshAttribute` and `Navigate` call in every Spark application was\n * computed, serialised, sent and discarded. Both are registered below. The last gap,\n * `disableAction`, was closed by deleting it (#460, D13): disabled actions travel on the object's\n * or result's `disabledActions` and are enforced by the server at submit.\n *\n * The lesson worth keeping: a wire type in `operations.ts` is not a feature. Adding one without\n * a handler here produces a server API that appears to work and does nothing.\n *\n * To register custom operation types alongside the built-ins, add additional\n * `multi: true` providers using <see cref=\"SPARK_CLIENT_OPERATION_HANDLERS\" />.\n */\nexport function provideSparkClientOperations(): EnvironmentProviders {\n return makeEnvironmentProviders([\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const notifications = inject(SparkNotificationService);\n return {\n type: 'notify',\n handler: (operation: ClientOperation) => {\n const notify = operation as NotifyOperation;\n // The app-chosen language, not the browser's that the server resolved `message` in.\n const message = (notify.translatedMessage && resolveTranslation(notify.translatedMessage)) || notify.message;\n notifications.show(message, notify.kind, notify.durationMs);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const refresh = inject(SparkQueryRefreshService);\n return {\n type: 'refreshQuery',\n handler: (operation: ClientOperation) => {\n refresh.request((operation as RefreshQueryOperation).queryId);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const attributes = inject(SparkAttributeRefreshService);\n return {\n type: 'refreshAttribute',\n handler: (operation: ClientOperation) => {\n const patch = operation as RefreshAttributeOperation;\n // Forwarded field by field, not as one value: an AsDetail attribute's rows\n // travel in object/objects and its value is null, so collapsing them here\n // is what made a detail-grid refresh impossible to express.\n attributes.request(patch.objectTypeId, patch.id, patch.attributeName, {\n value: patch.value,\n object: patch.object,\n objects: patch.objects,\n });\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const router = inject(Router);\n return {\n type: 'navigate',\n handler: (operation: ClientOperation) => {\n const navigate = operation as NavigateOperation;\n\n if (navigate.routeName) {\n void router.navigateByUrl(\n navigate.routeName.startsWith('/') ? navigate.routeName : `/${navigate.routeName}`);\n return;\n }\n\n // `/po/{type}/{id}` is the convention the grid's row links already assume,\n // and the detail route resolves a type by id as well as by alias, so the\n // operation's objectTypeId can be used verbatim.\n if (navigate.objectTypeId && navigate.id) {\n void router.navigate(['/po', navigate.objectTypeId, navigate.id]);\n return;\n }\n\n console.warn('[spark] navigate operation carried neither routeName nor objectTypeId+id; ignored.');\n },\n };\n },\n multi: true,\n },\n ]);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"mintplayer-ng-spark-client-operations.mjs","names":[],"sources":["../../client-operations/src/operations.ts","../../client-operations/src/handlers.token.ts","../../client-operations/src/dispatcher.service.ts","../../client-operations/src/notification.service.ts","../../client-operations/src/toast-container.component.ts","../../client-operations/src/attribute-refresh.service.ts","../../client-operations/src/query-refresh.service.ts","../../client-operations/src/provide.ts"],"sourcesContent":["// Wire types matching MintPlayer.Spark.Abstractions.ClientOperations on the server.\n// Discriminator is the `type` field. Unknown operation types are silently dropped\n// by the dispatcher (forward-compat: new types can land server-side without updating\n// older clients).\n\nimport type { PersistentObject, TranslatedString } from '@mintplayer/ng-spark/models';\n\nexport enum NotificationKind {\n Info = 0,\n Success = 1,\n Warning = 2,\n Error = 3,\n}\n\nexport interface NavigateOperation {\n type: 'navigate';\n objectTypeId?: string;\n id?: string;\n routeName?: string;\n}\n\nexport interface NotifyOperation {\n type: 'notify';\n /** The text in the request's language (the browser's `Accept-Language`). */\n message: string;\n /**\n * The text in every language, when the server has it. Shown in the language the user picked in\n * the app (`SparkLanguageService`), which the server never learns; `message` is the fallback.\n */\n translatedMessage?: TranslatedString | null;\n kind: NotificationKind;\n durationMs?: number;\n}\n\nexport interface RefreshAttributeOperation {\n type: 'refreshAttribute';\n objectTypeId: string;\n id: string;\n attributeName: string;\n value?: unknown;\n\n /**\n * Nested rows of an `AsDetail` attribute. A loaded AsDetail attribute carries its rows in\n * `object` / `objects` and leaves `value` null, so a patch limited to `value` could never\n * refresh a detail grid -- it always sent null, and a null over a null repaints nothing.\n *\n * An empty `objects` array means the grid is now empty, and is distinct from omitting it.\n */\n object?: unknown;\n objects?: unknown[];\n}\n\nexport interface RefreshQueryOperation {\n type: 'refreshQuery';\n queryId: string;\n}\n\n// `disableAction` is gone (#460, D13): the server no longer emits it. Disabled actions arrive on\n// `PersistentObject.disabledActions` / `QueryResult.disabledActions`, and a submitted disabled action\n// is refused with 403.\n\nexport interface RetryOperation {\n type: 'retry';\n step: number;\n title: string;\n options: string[];\n defaultOption?: string | null;\n persistentObject?: PersistentObject | null;\n message?: string | null;\n}\n\n/**\n * Discriminated union of known operation types, plus an open shape for unknown\n * future operations. Handlers should narrow via the `type` discriminator before\n * accessing fields specific to their operation type.\n */\nexport type ClientOperation =\n | NavigateOperation\n | NotifyOperation\n | RefreshAttributeOperation\n | RefreshQueryOperation\n | RetryOperation\n | { type: string; [key: string]: unknown };\n\n/**\n * Wire envelope returned by every action endpoint. `result` carries the primary\n * payload (the PersistentObject for a Create, the QueryResult for an Execute,\n * etc.); `operations` carries the side-effects the frontend dispatches.\n */\nexport interface ClientOperationEnvelope<T = unknown> {\n result: T | null;\n operations: ClientOperation[];\n}\n","import { InjectionToken } from '@angular/core';\nimport type { ClientOperation } from './operations';\n\n/**\n * A handler for a specific operation type. Receives the operation and\n * executes the side-effect (e.g. show a toast, navigate, refresh a query).\n * Handlers should `as`-narrow the operation to the type they registered for.\n */\nexport type ClientOperationHandler = (operation: ClientOperation) => void;\n\n/**\n * One entry in the multi-provider registration. Apps can register custom\n * handlers alongside the built-in ones to extend the operation set with\n * app-specific operation types.\n */\nexport interface ClientOperationHandlerRegistration {\n type: string;\n handler: ClientOperationHandler;\n}\n\n/**\n * Multi-provider token. `provideSparkClientOperations()` registers the\n * built-in handlers; apps can add their own with additional `multi: true`\n * providers using this token.\n */\nexport const SPARK_CLIENT_OPERATION_HANDLERS = new InjectionToken<readonly ClientOperationHandlerRegistration[]>(\n 'SPARK_CLIENT_OPERATION_HANDLERS',\n);\n","import { Injectable, inject } from '@angular/core';\nimport type { ClientOperation } from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS, type ClientOperationHandler } from './handlers.token';\n\n/**\n * Routes received operations to registered handlers. Unknown operation types\n * (no matching registration) are silently dropped — this is the forward-compat\n * contract that lets new operation types ship server-side without coordinated\n * client updates.\n *\n * Last-registered-wins on duplicate `type` values, matching standard\n * Angular multi-provider override semantics.\n *\n * R2-H19 — security contract for handler authors:\n * The dispatcher treats handler resolution as allow-list-by-type (unknown\n * types drop). It does NOT validate the *content* of each operation. Handlers\n * that act on URL-shaped fields (navigate, redirect, openWindow) MUST run\n * the value through `sanitizeReturnUrl` from `@mintplayer/ng-spark-auth/models`\n * (or an equivalent same-origin check) before acting on it. Otherwise a\n * single attribute-echo XSS or a single mid-channel byte flip on a non-TLS\n * path lets the server drive client navigation to an attacker host. The\n * built-in `notify` handler renders via Angular interpolation (escaped) so\n * it's safe to pass through, but anything more powerful must validate.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkClientOperationDispatcher {\n private readonly handlerMap: ReadonlyMap<string, ClientOperationHandler>;\n\n constructor() {\n const registrations = inject(SPARK_CLIENT_OPERATION_HANDLERS, { optional: true }) ?? [];\n const map = new Map<string, ClientOperationHandler>();\n for (const { type, handler } of registrations) {\n map.set(type, handler);\n }\n this.handlerMap = map;\n }\n\n dispatch(operations: readonly ClientOperation[] | null | undefined): void {\n if (!operations || operations.length === 0) return;\n for (const operation of operations) {\n const handler = this.handlerMap.get(operation.type);\n if (handler) {\n handler(operation);\n }\n // Unknown types: silently dropped (forward-compat).\n }\n }\n}\n","import { Injectable, signal } from '@angular/core';\nimport { NotificationKind } from './operations';\n\nexport interface SparkToast {\n id: string;\n message: string;\n kind: NotificationKind;\n durationMs: number;\n}\n\nconst DEFAULT_DURATION_MS = 4000;\n\n/**\n * Holds the active toasts as a signal. The `<spark-toast-container>` component\n * renders them; the built-in `notify` operation handler pushes new toasts here.\n *\n * Auto-dismissal: each toast schedules its own removal after `durationMs`. Pass\n * `0` to make a toast sticky (manual dismissal only).\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkNotificationService {\n private readonly _toasts = signal<readonly SparkToast[]>([]);\n readonly toasts = this._toasts.asReadonly();\n\n show(message: string, kind: NotificationKind = NotificationKind.Info, durationMs?: number): void {\n const id = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;\n const effectiveDuration = durationMs ?? DEFAULT_DURATION_MS;\n const toast: SparkToast = { id, message, kind, durationMs: effectiveDuration };\n this._toasts.update(toasts => [...toasts, toast]);\n\n if (effectiveDuration > 0) {\n setTimeout(() => this.dismiss(id), effectiveDuration);\n }\n }\n\n dismiss(id: string): void {\n this._toasts.update(toasts => toasts.filter(t => t.id !== id));\n }\n\n clear(): void {\n this._toasts.set([]);\n }\n}\n","import { ChangeDetectionStrategy, Component, inject } from '@angular/core';\nimport { SparkNotificationService } from './notification.service';\nimport { NotificationKind } from './operations';\n\n@Component({\n selector: 'spark-toast-container',\n standalone: true,\n template: `\n <div class=\"spark-toast-container\">\n @for (toast of notifications.toasts(); track toast.id) {\n <div\n class=\"spark-toast\"\n [class.spark-toast--info]=\"toast.kind === Kind.Info\"\n [class.spark-toast--success]=\"toast.kind === Kind.Success\"\n [class.spark-toast--warning]=\"toast.kind === Kind.Warning\"\n [class.spark-toast--error]=\"toast.kind === Kind.Error\"\n (click)=\"notifications.dismiss(toast.id)\"\n >\n {{ toast.message }}\n </div>\n }\n </div>\n `,\n styles: [`\n .spark-toast-container {\n position: fixed;\n top: 1rem;\n right: 1rem;\n z-index: 9999;\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n pointer-events: none;\n }\n .spark-toast {\n padding: 0.75rem 1rem;\n border-radius: 4px;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);\n cursor: pointer;\n min-width: 220px;\n max-width: 400px;\n color: var(--bs-white);\n font-size: 0.95rem;\n pointer-events: auto;\n animation: spark-toast-in 0.18s ease-out;\n }\n .spark-toast--info { background: var(--bs-primary); }\n .spark-toast--success { background: var(--bs-success); }\n .spark-toast--warning { background: var(--bs-warning); color: var(--bs-dark); }\n .spark-toast--error { background: var(--bs-danger); }\n @keyframes spark-toast-in {\n from { opacity: 0; transform: translateX(8px); }\n to { opacity: 1; transform: translateX(0); }\n }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkToastContainerComponent {\n protected readonly notifications = inject(SparkNotificationService);\n protected readonly Kind = NotificationKind;\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshAttribute` to whichever detail page is showing that object.\n *\n * The same broadcast shape as {@link SparkQueryRefreshService}, and for the same reason: detail\n * pages come and go behind `@if` and lazy routes, and nothing in ng-spark holds a component\n * reference. A page reads {@link tokenFor} in an effect and applies {@link patchesFor} when it\n * changes.\n *\n * Until this existed the operation was declared on the wire and handled by nobody: the server\n * computed the new values, serialised them, sent them, and the dispatcher dropped them silently\n * because unknown types are ignored. `IClientAccessor.RefreshAttribute` was inert in every Spark\n * application. It is what made the Coverage home page's Resync button look dead — it recomputed\n * the account and repository counts correctly on every click and none of them ever arrived.\n *\n * The operation carries the new value, so this is a patch and not a re-fetch: no round trip, and\n * no dependence on an index that may still be stale from the write that triggered it.\n */\n/**\n * The parts of an attribute a server patch may carry.\n *\n * Three fields rather than one value because a loaded attribute does not keep every shape in\n * `value`: an `AsDetail` attribute puts its nested rows in `object` / `objects` and leaves\n * `value` null. A patch limited to `value` was therefore incapable of refreshing a detail grid,\n * and did so silently -- see {@link SparkAttributeRefreshService}.\n *\n * Only the keys the server sent are present, and each is applied on its own. An `objects: []`\n * means the grid is now empty, which is why emptiness has to be distinguishable from absence.\n */\nexport interface SparkAttributePatch {\n value?: unknown;\n object?: unknown;\n objects?: unknown[];\n}\n\n@Injectable({ providedIn: 'root' })\nexport class SparkAttributeRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n private readonly patches = signal<Record<string, Record<string, SparkAttributePatch>>>({});\n\n /**\n * Objects are keyed by type AND id: the same id can exist under two types, and a detail page\n * only ever wants the attributes meant for the object it is showing.\n */\n private static keyFor(objectTypeId: string, id: string): string {\n return `${objectTypeId}${id}`;\n }\n\n /** Bumped every time the server patches an attribute of this object. */\n tokenFor(objectTypeId: string | undefined | null, id: string | undefined | null): number {\n if (!objectTypeId || !id) return 0;\n return this.tokens()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? 0;\n }\n\n /**\n * The newest value the server sent for each patched attribute of this object.\n *\n * Cumulative rather than drained on read: a detail page that mounts after the operation arrived\n * still sees the value, and two pages showing the same object both get it. Values are overwritten\n * by later patches, so this holds one entry per attribute, not a history.\n */\n patchesFor(objectTypeId: string | undefined | null, id: string | undefined | null): Record<string, SparkAttributePatch> {\n if (!objectTypeId || !id) return {};\n return this.patches()[SparkAttributeRefreshService.keyFor(objectTypeId, id)] ?? {};\n }\n\n /**\n * Record a new value for one attribute and wake whoever is showing the object.\n *\n * Callers patching several attributes for one user action should do so in one synchronous run:\n * the signals coalesce within a tick into a single effect run, and patches split across an await\n * become one run each.\n */\n request(objectTypeId: string, id: string, attributeName: string, patch: SparkAttributePatch): void {\n const key = SparkAttributeRefreshService.keyFor(objectTypeId, id);\n this.patches.update(current => ({\n ...current,\n [key]: { ...(current[key] ?? {}), [attributeName]: patch },\n }));\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n","import { Injectable, signal } from '@angular/core';\n\n/**\n * Carries a server-issued `refreshQuery` to whichever grids are showing that query.\n *\n * A broadcast signal rather than a registry of component handles: grids come and go behind\n * `@if` and lazy routes, and nothing else in ng-spark holds a component reference. A grid\n * reads {@link tokenFor} in an effect and re-fetches when it changes, which is the same\n * declarative shape as the `reloadToken` input a host would use.\n *\n * Until this existed the server could emit the operation and the dispatcher dropped it: only\n * `notify` was registered, and unknown types are ignored silently — so `refreshOnCompleted`\n * on the server had no effect on any grid the action did not happen to be hosted in.\n */\n@Injectable({ providedIn: 'root' })\nexport class SparkQueryRefreshService {\n private readonly tokens = signal<Record<string, number>>({});\n\n /**\n * Bumped every time a refresh is asked for this key.\n *\n * ⚠️ Reading it makes the caller depend on EVERY key, not just this one: the counters share one\n * signal. A grid must therefore compare the value with the one it last saw, never react to the\n * read alone (#319) — otherwise it re-fetches for every query refreshed anywhere in the app.\n */\n tokenFor(queryId: string | undefined): number {\n if (!queryId) return 0;\n return this.tokens()[normalize(queryId)] ?? 0;\n }\n\n /**\n * Ask every grid showing `queryId` to re-fetch.\n *\n * `queryId` is the query's id or its alias. A grid answers to both, whichever its own `queryId`\n * input holds, because it learns the other from the query it resolved. Keys are matched\n * case-insensitively, as the server resolves them (`QueryLoader.ResolveQuery`).\n *\n * Callers bumping several keys for one user action should do so in one synchronous run: the\n * signal coalesces bumps within a tick into a single effect run, and bumps split across an\n * await become one re-fetch each (#319).\n */\n request(queryId: string): void {\n const key = normalize(queryId);\n this.tokens.update(current => ({ ...current, [key]: (current[key] ?? 0) + 1 }));\n }\n}\n\nfunction normalize(queryId: string): string {\n return queryId.toLowerCase();\n}\n","import { type EnvironmentProviders, inject, makeEnvironmentProviders } from '@angular/core';\nimport { Router } from '@angular/router';\nimport { resolveTranslation } from '@mintplayer/ng-spark/models';\nimport type {\n ClientOperation,\n NavigateOperation,\n NotifyOperation,\n RefreshAttributeOperation,\n RefreshQueryOperation,\n} from './operations';\nimport { SPARK_CLIENT_OPERATION_HANDLERS } from './handlers.token';\nimport { SparkAttributeRefreshService } from './attribute-refresh.service';\nimport { SparkNotificationService } from './notification.service';\nimport { SparkQueryRefreshService } from './query-refresh.service';\n\n/**\n * Registers the built-in client-operation handlers: `notify`, `refreshQuery`,\n * `refreshAttribute` and `navigate`. Apps add this once in their bootstrap providers.\n *\n * Unregistered operation types are dropped SILENTLY by the dispatcher, which is why\n * `refreshQuery` did nothing at all for as long as it went unhandled — the server emitted\n * it, nothing listened, and no error said so.\n *\n * That happened twice more before anyone noticed. `refreshAttribute` and `navigate` were both\n * declared wire types with no handler anywhere in the repository: every\n * `IClientAccessor.RefreshAttribute` and `Navigate` call in every Spark application was\n * computed, serialised, sent and discarded. Both are registered below. The last gap,\n * `disableAction`, was closed by deleting it (#460, D13): disabled actions travel on the object's\n * or result's `disabledActions` and are enforced by the server at submit.\n *\n * The lesson worth keeping: a wire type in `operations.ts` is not a feature. Adding one without\n * a handler here produces a server API that appears to work and does nothing.\n *\n * To register custom operation types alongside the built-ins, add additional\n * `multi: true` providers using <see cref=\"SPARK_CLIENT_OPERATION_HANDLERS\" />.\n */\nexport function provideSparkClientOperations(): EnvironmentProviders {\n return makeEnvironmentProviders([\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const notifications = inject(SparkNotificationService);\n return {\n type: 'notify',\n handler: (operation: ClientOperation) => {\n const notify = operation as NotifyOperation;\n // The app-chosen language, not the browser's that the server resolved `message` in.\n const message = (notify.translatedMessage && resolveTranslation(notify.translatedMessage)) || notify.message;\n notifications.show(message, notify.kind, notify.durationMs);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const refresh = inject(SparkQueryRefreshService);\n return {\n type: 'refreshQuery',\n handler: (operation: ClientOperation) => {\n refresh.request((operation as RefreshQueryOperation).queryId);\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const attributes = inject(SparkAttributeRefreshService);\n return {\n type: 'refreshAttribute',\n handler: (operation: ClientOperation) => {\n const patch = operation as RefreshAttributeOperation;\n // Forwarded field by field, not as one value: an AsDetail attribute's rows\n // travel in object/objects and its value is null, so collapsing them here\n // is what made a detail-grid refresh impossible to express.\n attributes.request(patch.objectTypeId, patch.id, patch.attributeName, {\n value: patch.value,\n object: patch.object,\n objects: patch.objects,\n });\n },\n };\n },\n multi: true,\n },\n {\n provide: SPARK_CLIENT_OPERATION_HANDLERS,\n useFactory: () => {\n const router = inject(Router);\n return {\n type: 'navigate',\n handler: (operation: ClientOperation) => {\n const navigate = operation as NavigateOperation;\n\n if (navigate.routeName) {\n void router.navigateByUrl(\n navigate.routeName.startsWith('/') ? navigate.routeName : `/${navigate.routeName}`);\n return;\n }\n\n // `/po/{type}/{id}` is the convention the grid's row links already assume,\n // and the detail route resolves a type by id as well as by alias, so the\n // operation's objectTypeId can be used verbatim.\n if (navigate.objectTypeId && navigate.id) {\n void router.navigate(['/po', navigate.objectTypeId, navigate.id]);\n return;\n }\n\n console.warn('[spark] navigate operation carried neither routeName nor objectTypeId+id; ignored.');\n },\n };\n },\n multi: true,\n },\n ]);\n}\n"],"mappings":"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"}
|
|
@@ -614,6 +614,7 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
|
|
|
614
614
|
return this.customActions().filter((action) => !lowered.has(action.name.toLowerCase()));
|
|
615
615
|
}, ...ngDevMode ? [{ debugName: "visibleCustomActions" }] : /* istanbul ignore next */ []);
|
|
616
616
|
fetchFn = signal(null, ...ngDevMode ? [{ debugName: "fetchFn" }] : /* istanbul ignore next */ []);
|
|
617
|
+
datatable = viewChild(BsDatatableComponent, ...ngDevMode ? [{ debugName: "datatable" }] : /* istanbul ignore next */ []);
|
|
617
618
|
errorMessage = signal(null, ...ngDevMode ? [{ debugName: "errorMessage" }] : /* istanbul ignore next */ []);
|
|
618
619
|
offeredDefaultActions = computed(() => {
|
|
619
620
|
if (this.recycleBin()) return [];
|
|
@@ -854,14 +855,27 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
|
|
|
854
855
|
else this.loading.set(false);
|
|
855
856
|
});
|
|
856
857
|
let first = true;
|
|
858
|
+
let lastReloadToken;
|
|
859
|
+
const seen = /* @__PURE__ */ new Map();
|
|
857
860
|
effect(() => {
|
|
858
|
-
this.reloadToken();
|
|
859
|
-
this.
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
861
|
+
const reloadToken = this.reloadToken();
|
|
862
|
+
const query = this.query();
|
|
863
|
+
let refreshed = false;
|
|
864
|
+
for (const key of [
|
|
865
|
+
this.queryId(),
|
|
866
|
+
query?.id,
|
|
867
|
+
query?.alias
|
|
868
|
+
]) {
|
|
869
|
+
if (!key) continue;
|
|
870
|
+
const token = this.queryRefresh.tokenFor(key);
|
|
871
|
+
const last = seen.get(key.toLowerCase());
|
|
872
|
+
if (last !== void 0 && token > last) refreshed = true;
|
|
873
|
+
seen.set(key.toLowerCase(), token);
|
|
863
874
|
}
|
|
864
|
-
|
|
875
|
+
const hostRequested = !first && !Object.is(reloadToken, lastReloadToken);
|
|
876
|
+
lastReloadToken = reloadToken;
|
|
877
|
+
first = false;
|
|
878
|
+
if (refreshed || hostRequested) untracked(() => this.reload());
|
|
865
879
|
});
|
|
866
880
|
let firstSearch = true;
|
|
867
881
|
effect(() => {
|
|
@@ -908,10 +922,13 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
|
|
|
908
922
|
});
|
|
909
923
|
});
|
|
910
924
|
}
|
|
911
|
-
reload() {
|
|
925
|
+
reload(options) {
|
|
912
926
|
if (this.hasExternalData()) return;
|
|
913
927
|
const q = this.query();
|
|
914
|
-
if (q)
|
|
928
|
+
if (!q) return;
|
|
929
|
+
const datatable = this.datatable();
|
|
930
|
+
if (datatable && this.fetchFn()) datatable.reload(options);
|
|
931
|
+
else this.fetchFn.set(this.makeFetch(q, this.parentId(), this.parentType()));
|
|
915
932
|
}
|
|
916
933
|
onSearchChanged() {
|
|
917
934
|
if (this.hasExternalData()) return;
|
|
@@ -994,7 +1011,7 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
|
|
|
994
1011
|
},
|
|
995
1012
|
sortColumns: s.sortColumns
|
|
996
1013
|
}));
|
|
997
|
-
this.reload();
|
|
1014
|
+
this.reload({ resetPage: true });
|
|
998
1015
|
}
|
|
999
1016
|
async onCustomAction(action, rowIds) {
|
|
1000
1017
|
const message = confirmationText(action, rowIds?.length ?? this.selection().length);
|
|
@@ -1244,6 +1261,13 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
|
|
|
1244
1261
|
customActionExecuted: "customActionExecuted",
|
|
1245
1262
|
createClicked: "createClicked"
|
|
1246
1263
|
},
|
|
1264
|
+
viewQueries: [{
|
|
1265
|
+
propertyName: "datatable",
|
|
1266
|
+
first: true,
|
|
1267
|
+
predicate: BsDatatableComponent,
|
|
1268
|
+
descendants: true,
|
|
1269
|
+
isSignal: true
|
|
1270
|
+
}],
|
|
1247
1271
|
ngImport: i0,
|
|
1248
1272
|
template: "<!--\n Three explicit states, in this order, and the order is load-bearing.\n\n This template used to open with `@if (query(); as q)` wrapping EVERYTHING, and `query()` is only\n set after loadData's awaits resolve. Three separate bugs fell out of that one line: the spinner\n was unreachable on a first load (it rendered inside the gate, while `loading` starts true and\n `query` starts null), a first-load failure rendered ZERO DOM — no card, no message, nothing to\n see or report — and a failed RELOAD left the previous query's rows on screen.\n\n So: `loading` is checked first and outside any dependency on `query`, and the final `@else`\n always renders something. Do not fold these back into one gate, and do not move the spinner\n inside the `query()` branch.\n-->\n@if (loading()) {\n <div class=\"text-center p-3\">\n <bs-spinner />\n </div>\n} @else if (query()) {\n <!-- A page-fetch failure while the grid is already resolved. Without this the catch returns an\n empty page and a failed query is indistinguishable from one that legitimately has no rows. -->\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n }\n\n <!--\n One datatable, two row sources. `[data]` and `[fetch]` are mutually exclusive in\n ng-bootstrap — binding both makes `[fetch]` win silently — so exactly one is ever non-null\n here: `fetchFn` is left null whenever `data` is bound. See the class comment for why rows can\n arrive from outside at all (streaming must not put a websocket in every detail page's bundle).\n -->\n <!--\n The selection bar (#460, M15): the \"N selected\" chip, only while rows are selected, and part\n of the grid rather than of a host, so the query card and the query-list page show the same\n thing. There is deliberately no select-all (owner decision, 2026-09-29): with paged, lazy or\n virtual-scrolled rows it could only tick the rows loaded so far. The selection survives paging\n (#467, D19), so the chip counts every ticked row — exactly what an action receives — and says how\n many are on other pages; its ⊗ drops them all, since the header checkbox only sees this page.\n The bar keeps its height while empty, so ticking the first row does not push the table down\n under the pointer.\n -->\n @if (selectionMode() !== 'none') {\n <div class=\"spark-selection-bar d-flex align-items-center gap-2 mb-2\" style=\"min-height: 1.5rem\">\n @if (selection().length) {\n <bs-badge [type]=\"colors.primary\" class=\"spark-selection-chip\">\n {{ selection().length }} {{ 'common.selected' | t }}\n @if (offPageCount()) {\n <span class=\"spark-selection-off-page\">· {{ offPageCount() }} {{ 'common.onOtherPages' | t }}</span>\n }\n <button type=\"button\" class=\"spark-selection-clear\"\n [attr.aria-label]=\"'common.clearSelection' | t\" (click)=\"clearSelection()\">\n <spark-icon name=\"x-circle\" />\n </button>\n </bs-badge>\n }\n </div>\n }\n\n <!-- 'checkbox' (#467, D9): only the checkbox cell selects; a row click opens the row. -->\n <bs-datatable\n [selectionMode]=\"selectionMode() === 'multiple' ? 'checkbox' : 'none'\"\n [(selection)]=\"selection\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [data]=\"data()\"\n [fetch]=\"fetchFn()\"\n [distincts]=\"distinctsFn\"\n (filterChange)=\"onFilterChange($event)\"\n (rowClick)=\"onRowClick($event)\"\n [(settings)]=\"settings\">\n @for (col of allColumns(); track col.name) {\n <!--\n `canSort`/`canFilter` arrive already resolved by the server (query override → attribute →\n true), so this only spells the polarity. `!== false` is needed for BOTH, for opposite\n reasons: absent means capable on our wire, while the datatable's own `filterable` defaults\n to false, unlike `sortable`.\n\n ⚠️ `filterActive` and `filterSummary` are deliberately NOT bound. Any *bsDatatableColumn\n input change recomputes the column set, which retires the live filter view — while the web\n component's mount-once guard leaves the panel's nodes in place and never re-renders them.\n Binding them to state our own panel mutates would freeze the panel the instant a value is\n ticked, until it is closed and reopened. The cost is that a filtered column carries no\n visual marker yet, which is worth far less than a panel that dies on first use. See #431 F7.\n -->\n <div *bsDatatableColumn=\"col.name; sortable: col.canSort !== false; filterable: col.canFilter !== false\">\n {{ (col.label | resolveTranslation) || col.name }}\n <spark-attribute-description [description]=\"col.description\" />\n\n @if (col.canFilter !== false) {\n <ng-container *bsDatatableFilterPanel=\"let values; let ctx = ctx\">\n <spark-column-filter-panel\n [column]=\"col\"\n [values]=\"values()\"\n [ctx]=\"ctx\"\n [filterLabel]=\"filterLabelFor(col)\" />\n </ng-container>\n }\n </div>\n }\n <!--\n The per-row ⋮ menu's column: no header, never sorted or filtered.\n\n ⚠️ Only once the data columns exist. Those ship WITH the first page, so the rows reach the\n datatable one render before the column templates do. The datatable sizes its columns ONCE,\n from the first render that has both columns and rows, then switches to `table-layout: fixed`\n and never measures again. If this column were already registered it would be the only one\n measured; the data columns would share what it left over equally, and at phone width their\n unclipped headers ran into each other. With no column at all the datatable waits.\n -->\n @if (rowActions().length && allColumns().length) {\n <div *bsDatatableColumn=\"'__sparkRowActions'; sortable: false; filterable: false\">\n <span class=\"visually-hidden\">{{ 'common.actions' | t }}</span>\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (col of allColumns(); track col.name; let first = $first) {\n <td>\n @if (row) {\n <!-- The first column links to the row's detail page. Two things about this are\n routinely misread:\n\n 1. `cellContent` is projected INSIDE the anchor, so a custom `renderer` does NOT\n suppress this link — a renderer that emits its own <a> produces nested anchors\n (invalid HTML) rather than replacing this one.\n 2. `canRead()` is the whole gate, and it is the rights model: `Query` without\n `Read` lists the rows and withholds the link. Withhold `Read` for a query\n whose rows no detail page can load — a Custom.* query that fabricates rows in\n memory, say.\n 3. `rowRoute` replaces the DESTINATION only, never the gate: returning a route\n from it cannot make a row clickable that `canRead()` withheld, and returning\n null from it suppresses the link for that one row. -->\n @if (first && canRead() && rowRouteFor(row); as route) {\n <a [routerLink]=\"route\" [queryParams]=\"rowQueryParams()\" [state]=\"rowLinkState()\"\n (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, col: col }\"></ng-container>\n }\n } @else {\n \n }\n </td>\n }\n @if (rowActions().length) {\n <!-- The row menu: every action whose rule accepts one row, run on THIS row only. The\n checkbox selection is left alone (runRowAction never reads or writes it). -->\n <td class=\"spark-row-actions text-end\" (click)=\"$event.stopPropagation()\" (dblclick)=\"$event.stopPropagation()\">\n @if (row) {\n <button type=\"button\" cdkOverlayOrigin #rowMenuOrigin=\"cdkOverlayOrigin\" #rowMenuToggle\n class=\"btn btn-sm btn-link spark-row-menu-toggle\"\n aria-haspopup=\"menu\" [attr.aria-expanded]=\"openRowMenu() === row.id\"\n [attr.aria-label]=\"'common.actions' | t\" (click)=\"toggleRowMenu(row)\">\n <spark-icon name=\"three-dots-vertical\" />\n </button>\n <ng-template cdkConnectedOverlay\n [cdkConnectedOverlayOrigin]=\"rowMenuOrigin\"\n [cdkConnectedOverlayOpen]=\"openRowMenu() === row.id\"\n [cdkConnectedOverlayPositions]=\"rowMenuPositions\"\n (overlayOutsideClick)=\"onRowMenuOutsideClick($event, rowMenuToggle, row.id)\"\n (overlayKeydown)=\"$event.key === 'Escape' && closeRowMenu(row.id)\"\n (detach)=\"closeRowMenu(row.id)\">\n <bs-dropdown-menu class=\"spark-row-menu\">\n @for (action of rowActions(); track action.name) {\n <li bsDropdownItem class=\"spark-row-action\" [attr.data-action]=\"action.name\"\n (click)=\"chooseRowAction(action, row)\">\n {{ action.definition.label | resolveTranslation }}\n </li>\n }\n </bs-dropdown-menu>\n </ng-template>\n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n} @else {\n <!-- The query could not be resolved. Rendering something keeps the host's layout intact and,\n crucially, makes the failure visible: this branch used to render nothing at all. -->\n <bs-alert [type]=\"colors.danger\" class=\"mb-0\">\n {{ errorMessage() || ('spark.query.unavailable' | t) }}\n </bs-alert>\n}\n\n<ng-template #cellContent let-item let-col=\"col\">\n <!--\n Presentation lives in <spark-grid-cell>, shared with the AsDetail table on the PO detail page.\n Value resolution stays here: a query row is a PersistentObject with an attribute list, and the\n AsDetail table's rows are plain dictionaries, so only the rendering is common.\n\n `link` is deliberately not passed. The first-column link is the grid's own rule and wraps this\n template from outside; setting both would nest anchors.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(col | queryCellValue:item:lookupReferenceOptions())\"\n [rendererValue]=\"rendererValueFor(item, col)\"\n [item]=\"item\"\n [chips]=\"(col | queryReferenceChips:item)\" />\n</ng-template>\n",
|
|
1249
1273
|
styles: [":host ::ng-deep tbody tr[data-selected=true] a:not(.btn),:host ::ng-deep tbody tr[data-selected=true] .spark-row-menu-toggle{color:var(--mp-datatable-row-selected-color)}:host ::ng-deep tbody tr[data-selected=true] .spark-row-menu-toggle{--bs-btn-color: var(--mp-datatable-row-selected-color);--bs-btn-hover-color: var(--mp-datatable-row-selected-color);--bs-btn-active-color: var(--mp-datatable-row-selected-color)}\n"],
|
|
@@ -1675,6 +1699,10 @@ i0.ɵɵngDeclareClassMetadata({
|
|
|
1675
1699
|
createClicked: [{
|
|
1676
1700
|
type: i0.Output,
|
|
1677
1701
|
args: ["createClicked"]
|
|
1702
|
+
}],
|
|
1703
|
+
datatable: [{
|
|
1704
|
+
type: i0.ViewChild,
|
|
1705
|
+
args: [i0.forwardRef(() => BsDatatableComponent), { isSignal: true }]
|
|
1678
1706
|
}]
|
|
1679
1707
|
}
|
|
1680
1708
|
});
|