@mintplayer/ng-spark 22.24.0 → 22.26.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 (54) hide show
  1. package/README.md +15 -0
  2. package/fesm2022/mintplayer-ng-spark-client-operations.mjs +5 -3
  3. package/fesm2022/mintplayer-ng-spark-client-operations.mjs.map +1 -1
  4. package/fesm2022/mintplayer-ng-spark-contributions.mjs +721 -0
  5. package/fesm2022/mintplayer-ng-spark-contributions.mjs.map +1 -0
  6. package/fesm2022/mintplayer-ng-spark-grid.mjs +131 -4
  7. package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -1
  8. package/fesm2022/mintplayer-ng-spark-history.mjs +1 -14
  9. package/fesm2022/mintplayer-ng-spark-history.mjs.map +1 -1
  10. package/fesm2022/mintplayer-ng-spark-models.mjs +41 -2
  11. package/fesm2022/mintplayer-ng-spark-models.mjs.map +1 -1
  12. package/fesm2022/mintplayer-ng-spark-moderation.mjs +4 -4
  13. package/fesm2022/mintplayer-ng-spark-moderation.mjs.map +1 -1
  14. package/fesm2022/mintplayer-ng-spark-panels.mjs +9 -1
  15. package/fesm2022/mintplayer-ng-spark-panels.mjs.map +1 -1
  16. package/fesm2022/mintplayer-ng-spark-pipes.mjs +39 -3
  17. package/fesm2022/mintplayer-ng-spark-pipes.mjs.map +1 -1
  18. package/fesm2022/mintplayer-ng-spark-po-create.mjs +16 -12
  19. package/fesm2022/mintplayer-ng-spark-po-create.mjs.map +1 -1
  20. package/fesm2022/mintplayer-ng-spark-po-detail.mjs +15 -6
  21. package/fesm2022/mintplayer-ng-spark-po-detail.mjs.map +1 -1
  22. package/fesm2022/mintplayer-ng-spark-po-edit.mjs +926 -16
  23. package/fesm2022/mintplayer-ng-spark-po-edit.mjs.map +1 -1
  24. package/fesm2022/mintplayer-ng-spark-po-form.mjs +142 -30
  25. package/fesm2022/mintplayer-ng-spark-po-form.mjs.map +1 -1
  26. package/fesm2022/mintplayer-ng-spark-query-list.mjs +60 -5
  27. package/fesm2022/mintplayer-ng-spark-query-list.mjs.map +1 -1
  28. package/fesm2022/mintplayer-ng-spark-renderers.mjs +28 -1
  29. package/fesm2022/mintplayer-ng-spark-renderers.mjs.map +1 -1
  30. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs +2 -0
  31. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs.map +1 -1
  32. package/fesm2022/mintplayer-ng-spark-services.mjs +3 -2
  33. package/fesm2022/mintplayer-ng-spark-services.mjs.map +1 -1
  34. package/fesm2022/mintplayer-ng-spark-shell.mjs +31 -13
  35. package/fesm2022/mintplayer-ng-spark-shell.mjs.map +1 -1
  36. package/fesm2022/mintplayer-ng-spark.mjs +4 -1
  37. package/fesm2022/mintplayer-ng-spark.mjs.map +1 -1
  38. package/package.json +7 -2
  39. package/types/mintplayer-ng-spark-client-operations.d.ts +7 -1
  40. package/types/mintplayer-ng-spark-contributions.d.ts +231 -0
  41. package/types/mintplayer-ng-spark-grid.d.ts +35 -3
  42. package/types/mintplayer-ng-spark-history.d.ts +3 -10
  43. package/types/mintplayer-ng-spark-models.d.ts +60 -3
  44. package/types/mintplayer-ng-spark-panels.d.ts +36 -1
  45. package/types/mintplayer-ng-spark-pipes.d.ts +12 -1
  46. package/types/mintplayer-ng-spark-po-create.d.ts +10 -0
  47. package/types/mintplayer-ng-spark-po-detail.d.ts +4 -1
  48. package/types/mintplayer-ng-spark-po-edit.d.ts +305 -1
  49. package/types/mintplayer-ng-spark-po-form.d.ts +84 -15
  50. package/types/mintplayer-ng-spark-query-list.d.ts +26 -1
  51. package/types/mintplayer-ng-spark-renderers.d.ts +44 -2
  52. package/types/mintplayer-ng-spark-services.d.ts +10 -1
  53. package/types/mintplayer-ng-spark-shell.d.ts +24 -9
  54. package/types/mintplayer-ng-spark.d.ts +12 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mintplayer-ng-spark-contributions.mjs","names":[],"sources":["../../contributions/src/line-diff.ts","../../contributions/src/spark-line-diff.component.ts","../../contributions/src/spark-contribution-attribution.component.ts","../../contributions/src/spark-revert-contribution.ts","../../contributions/src/provide-spark-contributions.ts"],"sourcesContent":["/** One line of a line diff: unchanged, only in the new text, or only in the old one. */\nexport interface SparkDiffLine {\n kind: 'same' | 'added' | 'removed';\n text: string;\n}\n\n/** Above this many cells (old lines × new lines) the LCS table is not built; see {@link lineDiff}. */\nconst MAX_CELLS = 4_000_000;\n\n/** Splits a text into lines (`\\r\\n`, `\\r` and `\\n` alike); an empty or absent text has none. */\nexport function splitLines(text: string | null | undefined): string[] {\n if (text === null || text === undefined || text === '') return [];\n return String(text).split(/\\r\\n|\\r|\\n/);\n}\n\n/**\n * A line diff of `newText` against `oldText`: the longest common subsequence of their lines kept as\n * `same`, everything else `removed` (old) or `added` (new), in reading order — at a change, the old\n * lines come before the new ones. Pure, no dependency.\n *\n * Beyond {@link MAX_CELLS} cells (thousands of lines on both sides) the common prefix and suffix are\n * still matched, and the middle is reported as removed-then-added rather than building a huge table.\n */\nexport function lineDiff(oldText: string | null | undefined, newText: string | null | undefined): SparkDiffLine[] {\n const a = splitLines(oldText);\n const b = splitLines(newText);\n\n // Common prefix and suffix first: most edits touch a few lines, and this keeps the table small.\n let start = 0;\n while (start < a.length && start < b.length && a[start] === b[start]) start++;\n let endA = a.length;\n let endB = b.length;\n while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }\n\n const head: SparkDiffLine[] = a.slice(0, start).map(text => ({ kind: 'same', text }));\n const tail: SparkDiffLine[] = a.slice(endA).map(text => ({ kind: 'same', text }));\n const midA = a.slice(start, endA);\n const midB = b.slice(start, endB);\n\n return [...head, ...diffMiddle(midA, midB), ...tail];\n}\n\nfunction diffMiddle(a: string[], b: string[]): SparkDiffLine[] {\n const n = a.length;\n const m = b.length;\n if (n === 0) return b.map(text => ({ kind: 'added', text }));\n if (m === 0) return a.map(text => ({ kind: 'removed', text }));\n if (n * m > MAX_CELLS) {\n return [...a.map(text => ({ kind: 'removed' as const, text })), ...b.map(text => ({ kind: 'added' as const, text }))];\n }\n\n // lcs[i][j] = LCS length of a[i..] and b[j..], flattened row-major with a (m+1) stride.\n const stride = m + 1;\n const lcs = new Uint32Array((n + 1) * stride);\n for (let i = n - 1; i >= 0; i--) {\n for (let j = m - 1; j >= 0; j--) {\n lcs[i * stride + j] = a[i] === b[j]\n ? lcs[(i + 1) * stride + j + 1] + 1\n : Math.max(lcs[(i + 1) * stride + j], lcs[i * stride + j + 1]);\n }\n }\n\n const result: SparkDiffLine[] = [];\n let i = 0;\n let j = 0;\n while (i < n && j < m) {\n if (a[i] === b[j]) {\n result.push({ kind: 'same', text: a[i] });\n i++; j++;\n } else if (lcs[(i + 1) * stride + j] >= lcs[i * stride + j + 1]) {\n result.push({ kind: 'removed', text: a[i++] });\n } else {\n result.push({ kind: 'added', text: b[j++] });\n }\n }\n while (i < n) result.push({ kind: 'removed', text: a[i++] });\n while (j < m) result.push({ kind: 'added', text: b[j++] });\n return result;\n}\n\n/** Whether a diff changes anything. */\nexport function diffHasChanges(lines: readonly SparkDiffLine[]): boolean {\n return lines.some(l => l.kind !== 'same');\n}\n","import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from '@angular/core';\nimport { EntityAttributeDefinition, PersistentObject, PersistentObjectAttribute } from '@mintplayer/ng-spark/models';\nimport { SparkService } from '@mintplayer/ng-spark/services';\nimport { TranslateKeyPipe } from '@mintplayer/ng-spark/pipes';\nimport { SparkDiffLine, diffHasChanges, lineDiff } from './line-diff';\n\n/**\n * Where the `lineDiff` renderer finds the text to compare with — its `rendererOptions`:\n *\n * - `compareType`: the model name (or alias, or id) of the object to load;\n * - its id: `compareIdAttribute` (an attribute of this object holding it), or\n * `compareIdPattern` + `compareIdReplacement` (a regular expression and replacement applied to this\n * object's id);\n * - `compareAttribute`: the attribute of that object holding the text — or, for an AsDetail list, the\n * list, with `compareRowKeyPattern` + `compareRowKeyReplacement` (deriving the row key from this\n * object's id) and `compareRowAttribute` (the text on the row); for a single AsDetail object,\n * `compareRowAttribute` alone.\n *\n * Contributions seeds these on a generated contribution type's text values, comparing against the\n * target's row of the same slot (the current version).\n */\nexport interface SparkLineDiffSource {\n type: string;\n id: string;\n attribute: string;\n rowKey?: string;\n rowAttribute?: string;\n}\n\n/** The comparison source for `item`, or null when the options do not describe one. */\nexport function lineDiffSourceOf(options: Record<string, any> | null | undefined, item: PersistentObject | null | undefined): SparkLineDiffSource | null {\n if (!options || !item) return null;\n const type = asText(options['compareType']);\n const attribute = asText(options['compareAttribute']);\n if (!type || !attribute) return null;\n\n const idAttribute = asText(options['compareIdAttribute']);\n const id = idAttribute\n ? asText(item.attributes?.find(a => a.name === idAttribute)?.value)\n : derive(item.id, options['compareIdPattern'], options['compareIdReplacement']);\n if (!id) return null;\n\n const rowKeyPattern = options['compareRowKeyPattern'];\n const rowKey = rowKeyPattern !== undefined ? derive(item.id, rowKeyPattern, options['compareRowKeyReplacement']) : undefined;\n if (rowKeyPattern !== undefined && rowKey === null) return null;\n const rowAttribute = asText(options['compareRowAttribute']) ?? undefined;\n return { type, id, attribute, ...(rowKey !== undefined && rowKey !== null ? { rowKey } : {}), ...(rowAttribute ? { rowAttribute } : {}) };\n}\n\n/** The text at `source` in the loaded object, or null when it is not there (or not readable). */\nexport function lineDiffTextOf(po: PersistentObject | null | undefined, source: SparkLineDiffSource): string | null {\n const attribute = po?.attributes?.find(a => a.name === source.attribute);\n if (!attribute) return null;\n if (source.rowKey !== undefined) {\n const row = attribute.objects?.find(o => o.id === source.rowKey);\n // No row for the slot: there is no current version (all hidden) — compare with nothing.\n if (!row) return '';\n return valueText(row.attributes?.find(a => a.name === source.rowAttribute));\n }\n if (source.rowAttribute && attribute.object !== undefined) {\n return attribute.object ? valueText(attribute.object.attributes?.find(a => a.name === source.rowAttribute)) : '';\n }\n return valueText(attribute);\n}\n\nfunction valueText(attribute: PersistentObjectAttribute | undefined): string | null {\n if (!attribute) return null;\n const value = attribute.value;\n return value === null || value === undefined ? '' : String(value);\n}\n\nfunction asText(value: unknown): string | null {\n return typeof value === 'string' && value !== '' ? value : null;\n}\n\nfunction derive(id: string | undefined, pattern: unknown, replacement: unknown): string | null {\n if (!id || typeof pattern !== 'string') return null;\n let regex: RegExp;\n try { regex = new RegExp(pattern); } catch { return null; }\n if (!regex.test(id)) return null;\n return id.replace(regex, typeof replacement === 'string' ? replacement : '');\n}\n\n/**\n * The generic `lineDiff` detail renderer: this attribute's text, diffed line by line against a text\n * elsewhere ({@link SparkLineDiffSource}) — added lines marked `+`, removed ones `−`. When the other\n * object cannot be read (no right, gone), the text is shown as it is, with a note.\n *\n * Register through `sparkContributionRenderers` (or on its own: `{ name: 'lineDiff', detailComponent:\n * SparkLineDiffComponent }`).\n */\n@Component({\n selector: 'spark-line-diff',\n imports: [TranslateKeyPipe],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @switch (state()) {\n @case ('loading') {\n <pre class=\"spark-line-diff-plain mb-0\">{{ text() }}</pre>\n }\n @case ('diff') {\n @if (changed()) {\n <div class=\"spark-line-diff\" role=\"table\" [attr.aria-label]=\"attribute()?.name\">\n @for (line of lines(); track $index) {\n <div role=\"row\" [class]=\"'spark-line-diff-line spark-line-diff-' + line.kind\">\n <span class=\"spark-line-diff-marker\" role=\"cell\"\n [attr.aria-label]=\"line.kind === 'added' ? ('common.diffAdded' | t) : line.kind === 'removed' ? ('common.diffRemoved' | t) : null\">{{ marker(line) }}</span>\n <span class=\"spark-line-diff-text\" role=\"cell\">{{ line.text }}</span>\n </div>\n }\n </div>\n } @else {\n <pre class=\"spark-line-diff-plain mb-1\">{{ text() }}</pre>\n <div class=\"small text-muted spark-line-diff-identical\">{{ 'common.diffIdentical' | t }}</div>\n }\n }\n @default {\n <pre class=\"spark-line-diff-plain mb-1\">{{ text() }}</pre>\n <div class=\"small text-muted spark-line-diff-unavailable\">{{ 'common.diffUnavailable' | t }}</div>\n }\n }\n `,\n styles: [`\n :host { display: block; }\n .spark-line-diff-plain { white-space: pre-wrap; font-family: inherit; }\n .spark-line-diff { font-family: var(--bs-font-monospace, monospace); font-size: .875em; border: 1px solid var(--bs-border-color, #dee2e6); border-radius: var(--bs-border-radius, .375rem); overflow-x: auto; }\n .spark-line-diff-line { display: flex; white-space: pre-wrap; }\n .spark-line-diff-marker { flex: 0 0 1.5rem; text-align: center; user-select: none; color: var(--bs-secondary-color, #6c757d); }\n .spark-line-diff-text { flex: 1 1 auto; min-width: 0; padding-right: .5rem; }\n .spark-line-diff-added { background: var(--bs-success-bg-subtle, #d1e7dd); color: var(--bs-success-text-emphasis, #0a3622); }\n .spark-line-diff-removed { background: var(--bs-danger-bg-subtle, #f8d7da); color: var(--bs-danger-text-emphasis, #58151c); text-decoration: line-through; }\n `],\n})\nexport class SparkLineDiffComponent {\n private readonly spark = inject(SparkService);\n\n value = input<any>();\n attribute = input<EntityAttributeDefinition | undefined>();\n options = input<Record<string, any> | undefined>();\n item = input<PersistentObject | undefined>();\n\n protected readonly text = computed(() => {\n const value = this.value();\n return value === null || value === undefined ? '' : String(value);\n });\n\n /** The compared text: undefined while loading, null when it could not be read. */\n private readonly compared = signal<string | null | undefined>(undefined);\n\n protected readonly state = computed<'loading' | 'diff' | 'unavailable'>(() => {\n const compared = this.compared();\n return compared === undefined ? 'loading' : compared === null ? 'unavailable' : 'diff';\n });\n\n protected readonly lines = computed<SparkDiffLine[]>(() => lineDiff(this.compared() ?? '', this.text()));\n protected readonly changed = computed(() => diffHasChanges(this.lines()));\n\n private generation = 0;\n\n constructor() {\n effect(() => {\n const source = lineDiffSourceOf(this.options(), this.item());\n untracked(() => void this.load(source));\n });\n }\n\n protected marker(line: SparkDiffLine): string {\n return line.kind === 'added' ? '+' : line.kind === 'removed' ? '−' : ' ';\n }\n\n private async load(source: SparkLineDiffSource | null): Promise<void> {\n const generation = ++this.generation;\n if (!source) { this.compared.set(null); return; }\n this.compared.set(undefined);\n try {\n const type = await this.typeIdOf(source.type);\n const po = await this.spark.get(type, source.id);\n if (generation === this.generation) this.compared.set(lineDiffTextOf(po, source));\n } catch {\n if (generation === this.generation) this.compared.set(null);\n }\n }\n\n /** The entity type id for a model name or alias (the load endpoint is keyed by the type's id). */\n private async typeIdOf(type: string): Promise<string> {\n const types = await this.spark.getEntityTypes();\n return types.find(t => t.name === type || t.alias === type || t.id === type)?.id ?? type;\n }\n}\n","import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\nimport { SparkLanguageService } from '@mintplayer/ng-spark/services';\n\n/** The `rendererOptions` Contributions writes on each attribution attribute (library README, \"Client contract\"). */\nexport interface SparkContributionAttributionOptions {\n /** The generated contributions query's alias (`songlyricscontributions`). */\n contributionsQuery: string;\n /** The target's model name: the query's `parentType` (`Song`). */\n targetType: string;\n /** The `[Contribution]` property (`Lyrics`). */\n property: string;\n /** The slot attributes, in id order: one column filter each (`Language`, `Script`). */\n slots: string[];\n /** The attribution attributes the row carries: `ContributorName`, `UpdatedAt`, `ContributionCount`. */\n attribution: string[];\n}\n\n/** The History link of a row: `/query/{contributionsQuery}?parentId=…&parentType=…&{slot}=…`. */\nexport interface SparkContributionHistoryLink {\n commands: string[];\n queryParams: Record<string, string>;\n}\n\n/** The History link for `row` of the object `ownerId`, or null when either is missing. */\nexport function contributionHistoryLink(\n options: Partial<SparkContributionAttributionOptions> | null | undefined,\n row: Record<string, any> | null | undefined,\n ownerId: string | null | undefined,\n): SparkContributionHistoryLink | null {\n if (!options?.contributionsQuery || !options.targetType || !ownerId || !row) return null;\n const queryParams: Record<string, string> = { parentId: ownerId, parentType: options.targetType };\n for (const slot of options.slots ?? []) {\n const value = row[slot];\n if (value === null || value === undefined || value === '') return null; // an unsaved row has no history yet\n queryParams[slot] = String(value);\n }\n return { commands: ['/query', options.contributionsQuery], queryParams };\n}\n\n/** \"3 days ago\" in `language`, from an ISO instant; null when it does not parse. */\nexport function relativeTime(value: unknown, language: string, now: number = Date.now()): string | null {\n if (typeof value !== 'string' && !(value instanceof Date)) return null;\n const time = new Date(value).getTime();\n if (Number.isNaN(time)) return null;\n const seconds = Math.round((time - now) / 1000);\n const units: [Intl.RelativeTimeFormatUnit, number][] = [\n ['year', 365 * 24 * 3600], ['month', 30 * 24 * 3600], ['week', 7 * 24 * 3600],\n ['day', 24 * 3600], ['hour', 3600], ['minute', 60],\n ];\n let format: Intl.RelativeTimeFormat;\n try { format = new Intl.RelativeTimeFormat(language, { numeric: 'auto' }); }\n catch { format = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); }\n for (const [unit, size] of units) {\n if (Math.abs(seconds) >= size) return format.format(Math.round(seconds / size), unit);\n }\n return format.format(seconds, 'second');\n}\n\n/**\n * The `contributionAttribution` AsDetail **row** renderer (contributions PRD Q6): one line under a\n * row — \"by Alice · 3 days ago · History (4)\" — instead of three columns. Only the parts the\n * declaration asked for (`rendererOptions.attribution`) and that the row has; History links to the\n * slot's contributions query (`/query/{contributionsQuery}` with the parent and one filter per slot).\n */\n@Component({\n selector: 'spark-contribution-attribution',\n imports: [RouterLink],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (parts().length) {\n <span class=\"spark-contribution-attribution small text-muted\">\n @for (part of parts(); track part.kind; let last = $last) {\n @switch (part.kind) {\n @case ('by') { <span class=\"spark-contribution-by\">{{ part.text }}</span> }\n @case ('at') { <time class=\"spark-contribution-at\" [attr.datetime]=\"part.iso\" [attr.title]=\"part.title\">{{ part.text }}</time> }\n @case ('history') {\n @if (historyLink(); as link) {\n <a class=\"spark-contribution-history\" [routerLink]=\"link.commands\" [queryParams]=\"link.queryParams\">{{ part.text }}</a>\n } @else {\n <span class=\"spark-contribution-history\">{{ part.text }}</span>\n }\n }\n }\n @if (!last) { <span aria-hidden=\"true\"> · </span> }\n }\n </span>\n }\n `,\n})\nexport class SparkContributionAttributionComponent {\n private readonly lang = inject(SparkLanguageService);\n\n row = input<Record<string, any>>({});\n options = input<Record<string, any> | undefined>();\n ownerId = input<string | undefined>();\n\n private readonly asked = computed(() => new Set<string>(this.options()?.['attribution'] ?? ['ContributorName', 'UpdatedAt', 'ContributionCount']));\n\n protected readonly historyLink = computed(() =>\n contributionHistoryLink(this.options() as Partial<SparkContributionAttributionOptions> | undefined, this.row(), this.ownerId()));\n\n protected readonly parts = computed(() => {\n const row = this.row() ?? {};\n const asked = this.asked();\n const language = this.lang.language();\n const parts: { kind: 'by' | 'at' | 'history'; text: string; iso?: string; title?: string }[] = [];\n\n const name = row['ContributorName'];\n if (asked.has('ContributorName') && typeof name === 'string' && name.trim() !== '')\n parts.push({ kind: 'by', text: this.lang.t('contributions.by').replace('{name}', name) });\n\n const at = row['UpdatedAt'];\n const relative = asked.has('UpdatedAt') ? relativeTime(at, language) : null;\n if (relative) {\n const date = new Date(at);\n parts.push({ kind: 'at', text: relative, iso: date.toISOString(), title: date.toLocaleString(language) });\n }\n\n const count = row['ContributionCount'];\n if (asked.has('ContributionCount') && typeof count === 'number')\n parts.push({ kind: 'history', text: this.lang.t('contributions.history').replace('{count}', String(count)) });\n\n return parts;\n });\n}\n","import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal, untracked } from '@angular/core';\nimport { HttpErrorResponse } from '@angular/common/http';\nimport { SparkQuery } from '@mintplayer/ng-spark/models';\nimport { SparkLanguageService, SparkService } from '@mintplayer/ng-spark/services';\nimport { TranslateKeyPipe } from '@mintplayer/ng-spark/pipes';\nimport { SparkDetailContext, SparkQueryRowAction } from '@mintplayer/ng-spark/panels';\n\n/** The source of every generated `{Target}{Property}Contributions` query (`ContributionDescriptor.ContributionsQueryMethod`). */\nexport const SPARK_CONTRIBUTIONS_QUERY_SOURCE = 'Custom.SparkContributionsOfTarget';\n\n/** `POST /spark/po/revert-contribution` (the Contributions server package). */\nexport const SPARK_REVERT_CONTRIBUTION_PATH = '/po/revert-contribution';\n\n/** Whether `query` is a generated contributions query (its rows are contributions). */\nexport function isContributionsQuery(query: Pick<SparkQuery, 'source'> | null | undefined): boolean {\n return query?.source === SPARK_CONTRIBUTIONS_QUERY_SOURCE;\n}\n\n/**\n * Makes a contribution current again: every newer visible version of its slot is hidden (reason\n * `reverted`), atomically, by the server. The response's notice (\"…newer version(s) were hidden\") is\n * dispatched like every operation. Rejects with the server's error (400 \"restore it first\" for a\n * hidden one, 404 without the right).\n */\nexport async function revertContribution(spark: SparkService, objectTypeId: string, id: string): Promise<void> {\n await spark.postEnvelope(SPARK_REVERT_CONTRIBUTION_PATH, { objectTypeId, id });\n}\n\n/**\n * \"Revert to this version\" in the row menu of a contributions query — for holders of\n * `RevertContribution/{type}`, outside the recycle bin. Asks first; refreshes the list afterwards.\n */\nexport const sparkRevertContributionRowAction: SparkQueryRowAction = {\n id: 'spark-revert-contribution',\n labelKey: 'contributions.revert',\n priority: 20,\n isOffered: scope => scope.permissions?.canRevertContribution === true\n && isContributionsQuery(scope.query)\n && scope.deleted !== 'only',\n run: async context => {\n const spark = inject(SparkService);\n const lang = inject(SparkLanguageService);\n if (!confirm(lang.t('contributions.confirmRevert'))) return;\n await revertContribution(spark, context.entityType.id, context.row.id);\n context.reload();\n },\n};\n\n/**\n * \"Revert to this version\" on a contribution's detail page (a `SPARK_DETAIL_ACTIONS` entry): shown for\n * `RevertContribution/{type}` holders on a generated contribution type (one that has the generated\n * contributions query), outside the recycle bin. Reloads the page afterwards.\n */\n@Component({\n selector: 'spark-revert-contribution',\n imports: [TranslateKeyPipe],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (offered()) {\n <span class=\"d-inline-flex gap-2 align-items-center\">\n <button type=\"button\" class=\"btn btn-outline-primary spark-revert-contribution\" [disabled]=\"busy()\" (click)=\"revert()\">\n {{ 'contributions.revert' | t }}\n </button>\n @if (error(); as message) {\n <span class=\"text-danger small spark-revert-contribution-error\" role=\"alert\">{{ message }}</span>\n }\n </span>\n }\n `,\n})\nexport class SparkRevertContributionComponent {\n private readonly spark = inject(SparkService);\n private readonly lang = inject(SparkLanguageService);\n\n context = input.required<SparkDetailContext>();\n\n /** Whether the type is a generated contribution type: it has the generated contributions query. */\n private readonly isContributionType = signal(false);\n\n protected readonly offered = computed(() => {\n const ctx = this.context();\n return this.isContributionType() && ctx.permissions?.canRevertContribution === true && ctx.deleted !== 'only';\n });\n\n protected readonly busy = signal(false);\n protected readonly error = signal<string | null>(null);\n\n constructor() {\n effect(() => {\n const ctx = this.context();\n // Asked only of a caller who holds the right — everyone else never sees the button anyway.\n if (ctx.permissions?.canRevertContribution !== true || !ctx.entityType?.name) {\n untracked(() => this.isContributionType.set(false));\n return;\n }\n const name = ctx.entityType.name;\n untracked(() => void this.spark.getQueryByName(`${name}s`)\n .then(query => this.isContributionType.set(isContributionsQuery(query) && query?.entityType === name))\n .catch(() => this.isContributionType.set(false)));\n });\n }\n\n async revert(): Promise<void> {\n if (this.busy()) return;\n if (!confirm(this.lang.t('contributions.confirmRevert'))) return;\n const ctx = this.context();\n this.busy.set(true);\n this.error.set(null);\n try {\n await revertContribution(this.spark, ctx.entityType.id, ctx.id);\n await ctx.reload();\n } catch (e) {\n const err = e as HttpErrorResponse;\n const first = err?.error?.result?.errors?.[0];\n const validation = typeof first?.errorMessage === 'string' ? first.errorMessage\n : first?.errorMessage ? this.lang.resolve(first.errorMessage) : '';\n this.error.set(validation || err?.error?.result?.error || err?.error?.error || this.lang.t('common.actionFailed'));\n } finally {\n this.busy.set(false);\n }\n }\n}\n","import { Provider } from '@angular/core';\nimport { SparkAttributeRendererRegistration } from '@mintplayer/ng-spark/renderers';\nimport { provideSparkDetailActions, provideSparkQueryRowActions } from '@mintplayer/ng-spark/panels';\nimport { SparkContributionAttributionComponent } from './spark-contribution-attribution.component';\nimport { SparkLineDiffComponent } from './spark-line-diff.component';\nimport { SparkRevertContributionComponent, sparkRevertContributionRowAction } from './spark-revert-contribution';\n\n/**\n * Adds the Contributions UI to the pages `sparkRoutes()` routes to — no routes of its own (PRD Q7):\n * \"Revert to this version\" in the contributions query's row menu and on a contribution's detail page.\n * Needs the server package `MintPlayer.Spark.Contributions`.\n *\n * ```ts\n * providers: [\n * provideSpark(...),\n * provideSparkContributions(),\n * provideSparkAttributeRenderers([...sparkContributionRenderers, ...yourRenderers]),\n * ]\n * ```\n *\n * The renderers are registered with the app's own, because `provideSparkAttributeRenderers` takes one\n * list: see {@link sparkContributionRenderers}.\n */\nexport function provideSparkContributions(options?: { detailActionPriority?: number }): Provider[] {\n return [\n ...provideSparkQueryRowActions(sparkRevertContributionRowAction),\n ...provideSparkDetailActions({ id: 'spark-revert-contribution', component: SparkRevertContributionComponent, priority: options?.detailActionPriority ?? 6 }),\n ];\n}\n\n/**\n * The two renderers the Contributions model names:\n *\n * - `contributionAttribution` — an AsDetail **row** renderer: \"by Alice · 3 days ago · History (4)\"\n * once per row, in place of the `ContributorName`/`UpdatedAt`/`ContributionCount` columns;\n * - `lineDiff` — a detail renderer, generic: a text diffed line by line against a text of another\n * object (Contributions seeds it on a contribution's text values, against the current version).\n */\nexport const sparkContributionRenderers: SparkAttributeRendererRegistration[] = [\n { name: 'contributionAttribution', rowComponent: SparkContributionAttributionComponent },\n { name: 'lineDiff', detailComponent: SparkLineDiffComponent },\n];\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { BsDatatableColumnDirective, BsDatatableComponent, BsDatatableFilterPanelDirective, BsRowTemplateDirective, DatatableSettings } from "@mintplayer/ng-bootstrap/datatable";
2
2
  import { ShowedOn, defaultQueryActions, filterQueryActions, hasShowedOnFlag, parseSelectionRule, selectionModeFor, valueFor } from "@mintplayer/ng-spark/models";
3
3
  import * as i0 from "@angular/core";
4
- import { ChangeDetectionStrategy, Component, DestroyRef, Directive, Injectable, TemplateRef, computed, contentChildren, effect, inject, input, linkedSignal, model, output, signal, untracked, viewChild } from "@angular/core";
4
+ import { ChangeDetectionStrategy, Component, DestroyRef, Directive, Injectable, Injector, TemplateRef, computed, contentChildren, effect, inject, input, linkedSignal, model, output, runInInjectionContext, signal, untracked, viewChild } from "@angular/core";
5
5
  import { SPARK_ATTRIBUTE_RENDERERS, cellValue, withDeclaredInputs } from "@mintplayer/ng-spark/renderers";
6
6
  import { SPARK_RETURN_URL_STATE_KEY, SparkLanguageService, SparkService } from "@mintplayer/ng-spark/services";
7
7
  import * as i1$1 from "@angular/common";
@@ -21,6 +21,7 @@ import { SparkQueryRefreshService } from "@mintplayer/ng-spark/client-operations
21
21
  import { QueryCellValuePipe, QueryReferenceChipsPipe, ResolveTranslationPipe, TranslateKeyPipe } from "@mintplayer/ng-spark/pipes";
22
22
  import { SparkAttributeDescriptionComponent } from "@mintplayer/ng-spark/attribute-description";
23
23
  import { SparkColumnFilterPanelComponent } from "@mintplayer/ng-spark/column-filter";
24
+ import { SPARK_QUERY_ROW_ACTIONS, orderSparkExtensions } from "@mintplayer/ng-spark/panels";
24
25
  import { BsCardComponent, BsCardHeaderComponent } from "@mintplayer/ng-bootstrap/card";
25
26
  import { BsPriorityNavComponent, BsPriorityNavItemDirective } from "@mintplayer/ng-bootstrap/priority-nav";
26
27
  import * as i1 from "@angular/forms";
@@ -282,6 +283,41 @@ var SparkQueryActionsContext = class {
282
283
  selection = [];
283
284
  query = null;
284
285
  };
286
+ const NUMERIC_TYPES = /* @__PURE__ */ new Set([
287
+ "number",
288
+ "int",
289
+ "integer",
290
+ "long",
291
+ "decimal",
292
+ "double",
293
+ "float"
294
+ ]);
295
+ function applicablePresetFilters(filters, entityType) {
296
+ if (!filters?.length || !entityType) return [];
297
+ const result = [];
298
+ for (const filter of filters) {
299
+ const attribute = (entityType.attributes ?? []).find((a) => a.name === filter.name);
300
+ if (!attribute || attribute.isVisible === false) continue;
301
+ const coerce = (value) => {
302
+ if (typeof value !== "string") return value;
303
+ if (NUMERIC_TYPES.has(attribute.dataType)) {
304
+ const n = Number(value);
305
+ return value.trim() !== "" && Number.isFinite(n) ? n : value;
306
+ }
307
+ if (attribute.dataType === "boolean") return value === "true" ? true : value === "false" ? false : value;
308
+ return value;
309
+ };
310
+ const includes = filter.includes?.map(coerce);
311
+ const excludes = filter.excludes?.map(coerce);
312
+ if (!includes?.length && !excludes?.length) continue;
313
+ result.push({
314
+ name: filter.name,
315
+ ...includes?.length ? { includes } : {},
316
+ ...excludes?.length ? { excludes } : {}
317
+ });
318
+ }
319
+ return result;
320
+ }
285
321
  var SparkGridCellComponent = class SparkGridCellComponent {
286
322
  renderers = inject(SparkGridRenderers);
287
323
  column = input.required(...ngDevMode ? [{ debugName: "column" }] : /* istanbul ignore next */ []);
@@ -486,10 +522,14 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
486
522
  sparkService = inject(SparkService);
487
523
  gridRenderers = inject(SparkGridRenderers);
488
524
  queryRefresh = inject(SparkQueryRefreshService);
525
+ injector = inject(Injector);
526
+ addonRowActions = orderSparkExtensions(inject(SPARK_QUERY_ROW_ACTIONS, { optional: true }), (a) => a.priority ?? 50);
489
527
  lang = inject(SparkLanguageService);
490
528
  queryId = input.required(...ngDevMode ? [{ debugName: "queryId" }] : /* istanbul ignore next */ []);
491
529
  parentId = input("", ...ngDevMode ? [{ debugName: "parentId" }] : /* istanbul ignore next */ []);
492
530
  parentType = input("", ...ngDevMode ? [{ debugName: "parentType" }] : /* istanbul ignore next */ []);
531
+ presetFilters = input([], ...ngDevMode ? [{ debugName: "presetFilters" }] : /* istanbul ignore next */ []);
532
+ appliedPresetFilters = signal([], ...ngDevMode ? [{ debugName: "appliedPresetFilters" }] : /* istanbul ignore next */ []);
493
533
  data = input(null, ...ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []);
494
534
  search = input("", ...ngDevMode ? [{ debugName: "search" }] : /* istanbul ignore next */ []);
495
535
  columns = input(null, ...ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []);
@@ -627,8 +667,37 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
627
667
  definition,
628
668
  priority: 10 + definition.offset
629
669
  });
670
+ const scope = this.rowActionScope();
671
+ if (scope) for (const addon of this.addonRowActions) {
672
+ if (!addon.isOffered(scope)) continue;
673
+ const priority = addon.priority ?? 50;
674
+ actions.push({
675
+ kind: "addon",
676
+ name: addon.id,
677
+ addon,
678
+ priority,
679
+ definition: {
680
+ name: addon.id,
681
+ displayName: { en: this.lang.t(addon.labelKey) },
682
+ showedOn: "query",
683
+ refreshOnCompleted: true,
684
+ offset: priority
685
+ }
686
+ });
687
+ }
630
688
  return actions;
631
689
  }, ...ngDevMode ? [{ debugName: "rowActions" }] : /* istanbul ignore next */ []);
690
+ rowActionScope = computed(() => {
691
+ const query = this.query();
692
+ const entityType = this.entityType();
693
+ if (!query || !entityType) return null;
694
+ return {
695
+ query,
696
+ entityType,
697
+ permissions: this.permissions(),
698
+ deleted: this.effectiveDeleted() ?? null
699
+ };
700
+ }, ...ngDevMode ? [{ debugName: "rowActionScope" }] : /* istanbul ignore next */ []);
632
701
  offersDelete(withheld) {
633
702
  return !this.recycleBin() && !withheld.has("delete");
634
703
  }
@@ -681,8 +750,29 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
681
750
  await this.deleteRows([row.id], action.definition);
682
751
  return;
683
752
  }
753
+ if (action.kind === "addon" && action.addon) {
754
+ await this.runAddonRowAction(action.addon, row);
755
+ return;
756
+ }
684
757
  await this.onCustomAction(action.definition, [row.id]);
685
758
  }
759
+ async runAddonRowAction(addon, row) {
760
+ const scope = this.rowActionScope();
761
+ if (!scope) return;
762
+ try {
763
+ this.errorMessage.set(null);
764
+ await runInInjectionContext(this.injector, () => addon.run({
765
+ ...scope,
766
+ row,
767
+ reload: () => this.reload()
768
+ }));
769
+ } catch (e) {
770
+ const err = e;
771
+ const first = err?.error?.result?.errors?.[0];
772
+ const validation = typeof first?.errorMessage === "string" ? first.errorMessage : first?.errorMessage ? this.lang.resolve(first.errorMessage) : "";
773
+ this.errorMessage.set(validation || first?.message || err?.error?.result?.error || err?.error?.error || (err?.status === 404 ? this.lang.t("spark.query.unavailable") : "") || err?.message || this.lang.t("common.actionFailed") || "Action failed");
774
+ }
775
+ }
686
776
  startNew() {
687
777
  const type = this.entityType();
688
778
  if (!type) return;
@@ -766,6 +856,23 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
766
856
  this.onSearchChanged();
767
857
  });
768
858
  });
859
+ let firstPreset = true;
860
+ effect(() => {
861
+ const presetFilters = this.presetFilters();
862
+ if (firstPreset) {
863
+ firstPreset = false;
864
+ return;
865
+ }
866
+ untracked(() => {
867
+ const next = applicablePresetFilters(presetFilters, this.entityType());
868
+ const previous = new Set(this.appliedPresetFilters().map((f) => f.name));
869
+ const replaced = new Set(next.map((f) => f.name));
870
+ this.appliedPresetFilters.set(next);
871
+ this.filters.set([...this.filters().filter((f) => !previous.has(f.name) && !replaced.has(f.name)), ...next]);
872
+ this.selection.set([]);
873
+ this.onFilterChanged();
874
+ });
875
+ });
769
876
  let firstDeleted = true;
770
877
  effect(() => {
771
878
  this.effectiveDeleted();
@@ -907,12 +1014,16 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
907
1014
  this.resultCount.set(null);
908
1015
  this.selection.set([]);
909
1016
  this.filters.set([]);
1017
+ this.fetchedColumns.set([]);
910
1018
  try {
911
1019
  const [resolvedQuery, entityTypes] = await Promise.all([this.sparkService.getQuery(queryId), this.sparkService.getEntityTypes()]);
912
1020
  this.query.set(resolvedQuery);
913
1021
  this.allEntityTypes.set(entityTypes);
914
1022
  const et = this.resolveEntityType(resolvedQuery, entityTypes);
915
1023
  this.entityType.set(et);
1024
+ const preset = applicablePresetFilters(untracked(() => this.presetFilters()), et);
1025
+ this.appliedPresetFilters.set(preset);
1026
+ this.filters.set([...preset]);
916
1027
  if (et) {
917
1028
  const [permissions, actions] = await Promise.all([this.sparkService.getPermissions(et.id), this.sparkService.getCustomActions(et.id)]);
918
1029
  this.canRead.set(permissions.canRead);
@@ -1024,6 +1135,13 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
1024
1135
  isRequired: false,
1025
1136
  transformFunction: null
1026
1137
  },
1138
+ presetFilters: {
1139
+ classPropertyName: "presetFilters",
1140
+ publicName: "presetFilters",
1141
+ isSignal: true,
1142
+ isRequired: false,
1143
+ transformFunction: null
1144
+ },
1027
1145
  data: {
1028
1146
  classPropertyName: "data",
1029
1147
  publicName: "data",
@@ -1104,7 +1222,7 @@ var SparkQueryGridComponent = class SparkQueryGridComponent {
1104
1222
  createClicked: "createClicked"
1105
1223
  },
1106
1224
  ngImport: i0,
1107
- 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. Deselect-all is the\n datatable's own header checkbox, which it shows only while a row is selected; the chip does\n not repeat it. Single selection has no checkbox column, so no header checkbox either, and a\n row click only ever selects: there the chip's ⊗ is the deselect. The bar keeps its height\n while empty, so ticking the first row does not push the table down 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 (selectionMode() === 'single') {\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 }\n </bs-badge>\n }\n </div>\n }\n\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\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 (rowDblClick)=\"onRowDblClick($event)\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); 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 @if (rowActions().length) {\n <!-- The per-row ⋮ menu's column: no header, never sorted or filtered. -->\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 visibleColumns(); 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 &nbsp;\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.displayName | 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",
1225
+ 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. Deselect-all is the\n datatable's own header checkbox, which it shows only while a row is selected; the chip does\n not repeat it. Single selection has no checkbox column, so no header checkbox either, and a\n row click only ever selects: there the chip's ⊗ is the deselect. The bar keeps its height\n while empty, so ticking the first row does not push the table down 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 (selectionMode() === 'single') {\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 }\n </bs-badge>\n }\n </div>\n }\n\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\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 (rowDblClick)=\"onRowDblClick($event)\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); 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 && visibleColumns().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 visibleColumns(); 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 &nbsp;\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.displayName | 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",
1108
1226
  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"],
1109
1227
  dependencies: [
1110
1228
  {
@@ -1395,7 +1513,7 @@ i0.ɵɵngDeclareClassMetadata({
1395
1513
  SparkIconComponent
1396
1514
  ],
1397
1515
  changeDetection: ChangeDetectionStrategy.OnPush,
1398
- 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. Deselect-all is the\n datatable's own header checkbox, which it shows only while a row is selected; the chip does\n not repeat it. Single selection has no checkbox column, so no header checkbox either, and a\n row click only ever selects: there the chip's ⊗ is the deselect. The bar keeps its height\n while empty, so ticking the first row does not push the table down 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 (selectionMode() === 'single') {\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 }\n </bs-badge>\n }\n </div>\n }\n\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\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 (rowDblClick)=\"onRowDblClick($event)\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); 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 @if (rowActions().length) {\n <!-- The per-row ⋮ menu's column: no header, never sorted or filtered. -->\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 visibleColumns(); 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 &nbsp;\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.displayName | 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",
1516
+ 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. Deselect-all is the\n datatable's own header checkbox, which it shows only while a row is selected; the chip does\n not repeat it. Single selection has no checkbox column, so no header checkbox either, and a\n row click only ever selects: there the chip's ⊗ is the deselect. The bar keeps its height\n while empty, so ticking the first row does not push the table down 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 (selectionMode() === 'single') {\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 }\n </bs-badge>\n }\n </div>\n }\n\n <bs-datatable\n [selectionMode]=\"selectionMode()\"\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 (rowDblClick)=\"onRowDblClick($event)\"\n [(settings)]=\"settings\">\n @for (col of visibleColumns(); 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 && visibleColumns().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 visibleColumns(); 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 &nbsp;\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.displayName | 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",
1399
1517
  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"]
1400
1518
  }]
1401
1519
  }],
@@ -1425,6 +1543,14 @@ i0.ɵɵngDeclareClassMetadata({
1425
1543
  required: false
1426
1544
  }]
1427
1545
  }],
1546
+ presetFilters: [{
1547
+ type: i0.Input,
1548
+ args: [{
1549
+ isSignal: true,
1550
+ alias: "presetFilters",
1551
+ required: false
1552
+ }]
1553
+ }],
1428
1554
  data: [{
1429
1555
  type: i0.Input,
1430
1556
  args: [{
@@ -2054,6 +2180,7 @@ var SparkQueryCardComponent = class SparkQueryCardComponent {
2054
2180
  "queryId",
2055
2181
  "parentId",
2056
2182
  "parentType",
2183
+ "presetFilters",
2057
2184
  "data",
2058
2185
  "search",
2059
2186
  "columns",
@@ -2268,6 +2395,6 @@ i0.ɵɵngDeclareClassMetadata({
2268
2395
  }]
2269
2396
  }
2270
2397
  });
2271
- export { SPARK_GRID_PAGE_SIZES, SPARK_SEARCH_DEBOUNCE_MS, SparkGridCellComponent, SparkGridRenderers, SparkQueryActionsContext, SparkQueryActionsDirective, SparkQueryCaptionContext, SparkQueryCaptionDirective, SparkQueryCardComponent, SparkQueryGridComponent, SparkQueryIconDirective, SparkQuerySlotContext, SparkSearchBoxComponent, initialGridSettings, isVirtualScrollingQuery, slotFor, sparkActionClass, visibleGridAttributes };
2398
+ export { SPARK_GRID_PAGE_SIZES, SPARK_SEARCH_DEBOUNCE_MS, SparkGridCellComponent, SparkGridRenderers, SparkQueryActionsContext, SparkQueryActionsDirective, SparkQueryCaptionContext, SparkQueryCaptionDirective, SparkQueryCardComponent, SparkQueryGridComponent, SparkQueryIconDirective, SparkQuerySlotContext, SparkSearchBoxComponent, applicablePresetFilters, initialGridSettings, isVirtualScrollingQuery, slotFor, sparkActionClass, visibleGridAttributes };
2272
2399
 
2273
2400
  //# sourceMappingURL=mintplayer-ng-spark-grid.mjs.map