@mintplayer/ng-spark 22.33.0 → 22.35.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mintplayer-ng-spark-dashboards.mjs","names":[],"sources":["../../dashboards/src/dashboard-format.ts","../../dashboards/src/spark-stat.component.ts","../../dashboards/src/spark-meter.component.ts","../../dashboards/src/spark-dashboard-table.component.ts","../../dashboards/src/spark-dashboard.component.ts","../../dashboards/src/provide-spark-dashboards.ts"],"sourcesContent":["import { formatNumber } from '@angular/common';\n\n/** How a dashboard value is written: a plain number, bytes (binary units), a percentage, a date, or as is. */\nexport type SparkDashboardFormat = 'number' | 'bytes' | 'percent' | 'date' | 'datetime' | 'text';\n\n/** A finite number from a wire value (a number or a numeric string), else null. */\nexport function toSparkNumber(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null;\n if (typeof value === 'string' && value.trim() !== '') {\n const parsed = Number(value);\n return Number.isFinite(parsed) ? parsed : null;\n }\n return null;\n}\n\n/** 1536 → \"1.5 KB\": binary units, one decimal from KB up; \"—\" for no value. */\nexport function formatSparkBytes(bytes: number | null | undefined, locale = 'en'): string {\n if (bytes == null || !Number.isFinite(bytes)) return '—';\n const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];\n let value = bytes;\n let unit = 0;\n while (Math.abs(value) >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n return unit === 0 ? `${formatNumber(value, locale, '1.0-0')} B` : `${formatNumber(value, locale, '1.1-1')} ${units[unit]}`;\n}\n\n/**\n * A value as a dashboard writes it: \"—\" for none; numbers by `format` (`decimals` places for `number`/`percent`,\n * default 0); dates in the viewer's locale; anything else as text.\n */\nexport function formatSparkDashboardValue(value: unknown, format: SparkDashboardFormat | undefined, locale = 'en', decimals?: number): string {\n if (value === null || value === undefined || value === '') return '—';\n switch (format) {\n case 'bytes':\n return formatSparkBytes(toSparkNumber(value), locale);\n case 'percent': {\n const n = toSparkNumber(value);\n const d = decimals ?? 1;\n return n === null ? '—' : `${formatNumber(n, locale, `1.${d}-${d}`)}%`;\n }\n case 'date':\n case 'datetime': {\n const date = new Date(String(value));\n if (Number.isNaN(date.getTime())) return String(value);\n return format === 'date' ? date.toLocaleDateString(locale) : date.toLocaleString(locale);\n }\n case 'text':\n return String(value);\n case 'number':\n default: {\n const n = typeof value === 'boolean' ? null : toSparkNumber(value);\n if (n === null) return String(value);\n const d = decimals ?? 0;\n return formatNumber(n, locale, `1.${d}-${d}`);\n }\n }\n}\n\n/** The colour a threshold gives a value: `danger` at or past `danger`, `warning` at or past `warning`, else none. */\nexport function sparkThresholdColor(\n value: number | null,\n thresholds: { warning?: number; danger?: number; direction?: 'above' | 'below' },\n): 'warning' | 'danger' | null {\n if (value === null) return null;\n const past = (limit: number | undefined) =>\n typeof limit === 'number' && (thresholds.direction === 'below' ? value <= limit : value >= limit);\n if (past(thresholds.danger)) return 'danger';\n if (past(thresholds.warning)) return 'warning';\n return null;\n}\n","import { ChangeDetectionStrategy, Component, computed, inject, input, LOCALE_ID } from '@angular/core';\nimport type { SparkAttributeColumnRenderer, SparkAttributeDetailRenderer } from '@mintplayer/ng-spark/renderers';\nimport { formatSparkDashboardValue, SparkDashboardFormat, sparkThresholdColor, toSparkNumber } from './dashboard-format';\n\n/** `rendererOptions` of the `spark-stat` renderer. Every member is optional. */\nexport interface SparkStatOptions {\n /** How the value is written. Default `'number'`. */\n format?: SparkDashboardFormat;\n /** Decimals for `number` (default 0) and `percent` (default 1). */\n decimals?: number;\n /** Text after the value, e.g. `' sites'`. Default none. */\n suffix?: string;\n /** The value turns amber at or past this. */\n warning?: number;\n /** The value turns red at or past this. */\n danger?: number;\n /** `'above'` (default): high is bad, as for disk use. `'below'`: low is bad, as for free space. */\n direction?: 'above' | 'below';\n}\n\n/**\n * The `spark-stat` renderer: one figure, large on a detail page (a dashboard tile) and plain in a grid cell,\n * coloured amber or red once it passes the model's `warning` / `danger` threshold. No value draws a muted dash.\n */\n@Component({\n selector: 'spark-stat',\n template: `\n <span class=\"spark-stat\" [class.text-warning-emphasis]=\"color() === 'warning'\" [class.text-danger]=\"color() === 'danger'\"\n [class.text-body-secondary]=\"text() === '—'\" [attr.data-threshold]=\"color()\">{{ text() }}</span>\n `,\n styles: [`\n :host { display: inline-block; }\n .spark-stat { font-size: var(--spark-stat-font-size, 1.75rem); font-weight: 600; line-height: 1.2; font-variant-numeric: tabular-nums; }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkStatComponent implements SparkAttributeColumnRenderer, SparkAttributeDetailRenderer {\n private readonly locale = inject(LOCALE_ID);\n\n readonly value = input<any>();\n readonly options = input<Record<string, any> | undefined>();\n\n private readonly settings = computed(() => (this.options() ?? {}) as SparkStatOptions);\n\n protected readonly text = computed(() => {\n const s = this.settings();\n const text = formatSparkDashboardValue(this.value(), s.format, this.locale, typeof s.decimals === 'number' ? s.decimals : undefined);\n return text === '—' || typeof s.suffix !== 'string' ? text : text + s.suffix;\n });\n\n protected readonly color = computed(() => sparkThresholdColor(toSparkNumber(this.value()), this.settings()));\n}\n","import { ChangeDetectionStrategy, Component, computed, inject, input, LOCALE_ID } from '@angular/core';\nimport { formatNumber } from '@angular/common';\nimport { Color } from '@mintplayer/ng-bootstrap';\nimport { BsProgressBarComponent, BsProgressComponent } from '@mintplayer/ng-bootstrap/progress-bar';\nimport type { SparkAttributeColumnRenderer, SparkAttributeDetailRenderer } from '@mintplayer/ng-spark/renderers';\nimport { formatSparkDashboardValue, SparkDashboardFormat, sparkThresholdColor, toSparkNumber } from './dashboard-format';\n\n/** `rendererOptions` of the `spark-meter` renderer. Every member is optional. */\nexport interface SparkMeterOptions {\n /** The ceiling when the value is a plain number (a value `{ value, max }` brings its own). Default 100. */\n max?: number;\n /** How the value and the ceiling are written. Default `'number'`. */\n format?: SparkDashboardFormat;\n /** Amber from this share of the ceiling, in percent. Default 75. */\n warning?: number;\n /** Red from this share of the ceiling, in percent. Default 90. */\n danger?: number;\n /** The meter's accessible name. Default `'Usage'`. */\n label?: string;\n}\n\ninterface MeterView {\n value: number;\n max: number;\n /** The share of the ceiling, in percent (0–100), one decimal. */\n share: number;\n color: Color;\n text: string;\n}\n\n/**\n * The `spark-meter` renderer: how much of something is used, as a bar coloured by its share (green, amber from 75 %,\n * red from 90 % by default) with \"used of total (share)\" next to it. The value is a number against the `max` option, or\n * `{ value, max }` from the server (the dashboards package's `DashboardValues.Meter`), e.g. disk bytes used of the total.\n * No value, or no ceiling, draws a muted dash.\n */\n@Component({\n selector: 'spark-meter',\n imports: [BsProgressComponent, BsProgressBarComponent],\n template: `\n @if (view(); as v) {\n <div class=\"d-flex flex-column gap-1 spark-meter\">\n <bs-progress [style.height]=\"'.75rem'\">\n <bs-progress-bar [value]=\"v.share\" [minimum]=\"0\" [maximum]=\"100\" [color]=\"v.color\"\n [ariaLabel]=\"label()\" [valueText]=\"v.text\" />\n </bs-progress>\n <span class=\"small text-nowrap\" data-meter-text>{{ v.text }}</span>\n </div>\n } @else {\n <span class=\"text-muted small\">—</span>\n }\n `,\n styles: [`:host { display: block; min-width: 8rem; }`],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkMeterComponent implements SparkAttributeColumnRenderer, SparkAttributeDetailRenderer {\n private readonly locale = inject(LOCALE_ID);\n\n readonly value = input<any>();\n readonly options = input<Record<string, any> | undefined>();\n\n private readonly settings = computed(() => (this.options() ?? {}) as SparkMeterOptions);\n protected readonly label = computed(() => (typeof this.settings().label === 'string' ? this.settings().label! : 'Usage'));\n\n protected readonly view = computed<MeterView | null>(() => {\n const raw = this.value();\n const s = this.settings();\n const isPair = raw !== null && typeof raw === 'object' && 'value' in raw;\n const value = toSparkNumber(isPair ? raw.value : raw);\n const max = toSparkNumber(isPair ? raw.max : undefined) ?? toSparkNumber(s.max) ?? (isPair ? null : 100);\n if (value === null || max === null || max <= 0) return null;\n\n const share = Math.round(Math.min(100, Math.max(0, (value / max) * 100)) * 10) / 10;\n const threshold = sparkThresholdColor(share, {\n warning: typeof s.warning === 'number' ? s.warning : 75,\n danger: typeof s.danger === 'number' ? s.danger : 90,\n });\n const format = s.format ?? 'number';\n return {\n value,\n max,\n share,\n color: threshold === 'danger' ? Color.danger : threshold === 'warning' ? Color.warning : Color.success,\n text: `${formatSparkDashboardValue(value, format, this.locale)} / ${formatSparkDashboardValue(max, format, this.locale)} (${formatNumber(share, this.locale, '1.0-1')}%)`,\n };\n });\n}\n","import { ChangeDetectionStrategy, Component, computed, inject, input, LOCALE_ID } from '@angular/core';\nimport { BsTableComponent } from '@mintplayer/ng-bootstrap/table';\nimport { SparkIconComponent } from '@mintplayer/ng-spark/icon';\nimport type { SparkAttributeDetailRenderer } from '@mintplayer/ng-spark/renderers';\nimport { SparkLanguageService } from '@mintplayer/ng-spark/services';\nimport { formatSparkDashboardValue, SparkDashboardFormat } from './dashboard-format';\n\n/** One column of a `spark-dashboard-table`. */\nexport interface SparkDashboardTableColumn {\n /** The row field. For a value that is a dictionary, its rows are `{ key, value }`. */\n key: string;\n /** A translation key for the header (`translations.json`), shown as is when it has no translation. Default `key`. */\n label?: string;\n /** How the cells are written. Numbers and bytes are right-aligned. Default: as is. */\n format?: SparkDashboardFormat | 'boolean';\n /** Translate each cell as `{translate}{value}` (e.g. `'seoUi.runStatus.'`), falling back to the value. */\n translate?: string;\n}\n\n/** `rendererOptions` of the `spark-dashboard-table` renderer. */\nexport interface SparkDashboardTableOptions {\n /** The columns, in order. Default: the first row's fields. */\n columns?: SparkDashboardTableColumn[];\n /** A translation key shown when there are no rows. Default `'dashboards.empty'`. */\n empty?: string;\n}\n\ninterface TableCell {\n text: string;\n check: boolean;\n}\n\n/**\n * The `spark-dashboard-table` renderer (detail pages): a dashboard value that is a list of rows, such as the\n * RavenDB statistics' collections (D12: tables are JSON attributes). A dictionary (`{ \"Completed\": 3 }`) reads as\n * rows `{ key, value }`.\n */\n@Component({\n selector: 'spark-dashboard-table',\n imports: [BsTableComponent, SparkIconComponent],\n template: `\n @if (rows().length > 0) {\n <bs-table [isResponsive]=\"true\" class=\"d-block mb-0\">\n <thead>\n <tr>\n @for (column of columns(); track column.key) {\n <th [class.text-end]=\"isNumeric(column)\">{{ header(column) }}</th>\n }\n </tr>\n </thead>\n <tbody>\n @for (row of cells(); track $index) {\n <tr>\n @for (cell of row; track $index; let i = $index) {\n <td [class.text-end]=\"isNumeric(columns()[i])\" [class.text-break]=\"!isNumeric(columns()[i])\">\n @if (cell.check) { <spark-icon name=\"check-lg\" class=\"text-success\" aria-hidden=\"true\" /><span class=\"visually-hidden\">{{ cell.text }}</span> } @else { {{ cell.text }} }\n </td>\n }\n </tr>\n }\n </tbody>\n </bs-table>\n } @else {\n <span class=\"text-body-secondary small\" data-empty>{{ lang.t(settings().empty ?? 'dashboards.empty', '—') }}</span>\n }\n `,\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkDashboardTableComponent implements SparkAttributeDetailRenderer {\n private readonly locale = inject(LOCALE_ID);\n protected readonly lang = inject(SparkLanguageService);\n\n readonly value = input<any>();\n readonly options = input<Record<string, any> | undefined>();\n\n protected readonly settings = computed(() => (this.options() ?? {}) as SparkDashboardTableOptions);\n\n protected readonly rows = computed<Record<string, unknown>[]>(() => toSparkTableRows(this.value()));\n\n protected readonly columns = computed<SparkDashboardTableColumn[]>(() => {\n const declared = this.settings().columns;\n if (Array.isArray(declared) && declared.length > 0) return declared.filter(c => typeof c?.key === 'string');\n const first = this.rows()[0];\n return first ? Object.keys(first).map(key => ({ key })) : [];\n });\n\n protected readonly cells = computed<TableCell[][]>(() => {\n const columns = this.columns();\n return this.rows().map(row => columns.map(column => this.cell(row[column.key], column)));\n });\n\n protected header(column: SparkDashboardTableColumn): string {\n return column.label ? this.lang.t(column.label, column.label) : column.key;\n }\n\n protected isNumeric(column: SparkDashboardTableColumn | undefined): boolean {\n return column?.format === 'number' || column?.format === 'bytes' || column?.format === 'percent';\n }\n\n private cell(value: unknown, column: SparkDashboardTableColumn): TableCell {\n if (column.format === 'boolean')\n return { text: value === true ? this.lang.t('dashboards.yes', 'yes') : '', check: value === true };\n if (column.translate && value !== null && value !== undefined && value !== '')\n return { text: this.lang.t(column.translate + String(value), String(value)), check: false };\n return { text: formatSparkDashboardValue(value, column.format, this.locale), check: false };\n }\n}\n\n/** The rows of a table value: a list of objects as is, a dictionary as `{ key, value }` rows, anything else none. */\nexport function toSparkTableRows(value: unknown): Record<string, unknown>[] {\n if (Array.isArray(value)) return value.filter((r): r is Record<string, unknown> => r !== null && typeof r === 'object' && !Array.isArray(r));\n if (value !== null && typeof value === 'object') return Object.entries(value).map(([key, v]) => ({ key, value: v }));\n if (typeof value === 'string' && value.trim().startsWith('[')) {\n try {\n return toSparkTableRows(JSON.parse(value));\n } catch {\n return [];\n }\n }\n return [];\n}\n","import { ChangeDetectionStrategy, Component, computed, inject, input, LOCALE_ID, Type } from '@angular/core';\nimport { NgComponentOutlet } from '@angular/common';\nimport { BsCardBodyComponent, BsCardComponent } from '@mintplayer/ng-bootstrap/card';\nimport { EntityAttributeDefinition, hasShowedOnFlag, PersistentObject, ShowedOn, TranslatedString } from '@mintplayer/ng-spark/models';\nimport type { SparkDetailContext } from '@mintplayer/ng-spark/panels';\nimport { ResolveTranslationPipe, TranslateKeyPipe } from '@mintplayer/ng-spark/pipes';\nimport { rendererValue, SPARK_ATTRIBUTE_RENDERERS, withDeclaredInputs } from '@mintplayer/ng-spark/renderers';\nimport { formatSparkDashboardValue } from './dashboard-format';\n\n/** The `metadata` key a dashboard carries (the dashboards package's `SparkDashboardActions.MetadataKey`). */\nexport const SPARK_DASHBOARD_METADATA_KEY = 'dashboard';\n\n/** Whether `item` is a dashboard: the server marks one with `metadata.dashboard`. */\nexport function isSparkDashboard(item: PersistentObject | null | undefined): boolean {\n const marker = item?.metadata?.[SPARK_DASHBOARD_METADATA_KEY];\n return marker !== null && typeof marker === 'object';\n}\n\ninterface DashboardTile {\n attribute: EntityAttributeDefinition;\n /** Columns spanned: 1, 2, or the whole row. */\n span: 1 | 2 | 'row';\n component: Type<any> | null;\n inputs: Record<string, any>;\n /** The value as text, for an attribute without a detail renderer. */\n text: string;\n}\n\ninterface DashboardSection {\n id: string;\n name: string | null;\n label?: TranslatedString;\n tiles: DashboardTile[];\n}\n\n/**\n * The dashboard tile layout (D12): replaces a dashboard's attribute cards on the routed detail page\n * (`SPARK_DETAIL_LAYOUTS`, registered by `provideSparkDashboards()`). Each group of the model is a section, each\n * attribute a tile: its label above its detail renderer (`spark-stat`, `spark-meter`, `spark-trend`,\n * `spark-dashboard-table`, …) or its plain value. `columnSpan` 2 takes two columns, 3 and more (and a table without one) the\n * whole row. An attribute the caller may not read is not on the object and has no tile. The page follows the object\n * over `/spark/live` like any live type, so the tiles change in place.\n */\n@Component({\n selector: 'spark-dashboard',\n imports: [NgComponentOutlet, BsCardComponent, BsCardBodyComponent, ResolveTranslationPipe, TranslateKeyPipe],\n template: `\n <div class=\"spark-dashboard mx-3 my-2\">\n @for (section of sections(); track section.id) {\n <section class=\"mb-3\" [attr.data-section]=\"section.name\">\n @if (section.name) {\n <h3 class=\"h6 text-body-secondary mb-2\">{{ section.label | resolveTranslation:section.name }}</h3>\n }\n <div class=\"spark-dashboard-tiles\">\n @for (tile of section.tiles; track tile.attribute.id) {\n <bs-card class=\"d-block h-100\" [class.spark-dashboard-span-2]=\"tile.span === 2\"\n [class.spark-dashboard-span-row]=\"tile.span === 'row'\" [attr.data-tile]=\"tile.attribute.name\">\n <bs-card-body>\n <div class=\"small text-body-secondary mb-1\">{{ tile.attribute.label | resolveTranslation:tile.attribute.name }}</div>\n @if (tile.component) {\n <ng-container *ngComponentOutlet=\"tile.component; inputs: tile.inputs\" />\n } @else {\n <div class=\"fs-4 fw-semibold text-break\">{{ tile.text }}</div>\n }\n </bs-card-body>\n </bs-card>\n }\n </div>\n </section>\n }\n @if (generatedAt(); as at) {\n <p class=\"small text-body-secondary mb-0\" data-generated>{{ 'dashboards.updated' | t }} {{ at }}</p>\n }\n </div>\n `,\n styles: [`\n .spark-dashboard-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr)); gap: .75rem; }\n .spark-dashboard-tiles > * { min-width: 0; }\n .spark-dashboard-span-row { grid-column: 1 / -1; }\n /* Two columns only where two fit: on a phone the grid has one, and span 2 would widen the page. */\n @media (min-width: 576px) { .spark-dashboard-span-2 { grid-column: span 2; } }\n `],\n changeDetection: ChangeDetectionStrategy.OnPush,\n})\nexport class SparkDashboardComponent {\n private readonly locale = inject(LOCALE_ID);\n private readonly renderers = inject(SPARK_ATTRIBUTE_RENDERERS);\n\n readonly context = input.required<SparkDetailContext>();\n\n protected readonly sections = computed<DashboardSection[]>(() => {\n const { item, entityType } = this.context();\n const loaded = new Map(item.attributes.map(a => [a.name, a] as const));\n const formData: Record<string, any> = {};\n for (const a of item.attributes) formData[a.name] = rendererValue(a);\n\n const tiles = (entityType.attributes ?? [])\n .filter(a => loaded.has(a.name) && hasShowedOnFlag(loaded.get(a.name)?.showedOn ?? a.showedOn, ShowedOn.PersistentObject))\n .sort((a, b) => a.order - b.order)\n .map(attribute => this.tile(attribute, item, formData));\n\n const groups = [...(entityType.groups ?? [])].sort((a, b) => a.order - b.order);\n const groupIds = new Set(groups.map(g => g.id));\n const sections: DashboardSection[] = [];\n const ungrouped = tiles.filter(t => !t.attribute.group || !groupIds.has(t.attribute.group));\n if (ungrouped.length > 0) sections.push({ id: '__ungrouped__', name: null, tiles: ungrouped });\n for (const group of groups) {\n const grouped = tiles.filter(t => t.attribute.group === group.id);\n if (grouped.length > 0) sections.push({ id: group.id, name: group.name, label: group.label, tiles: grouped });\n }\n return sections;\n });\n\n /** When the server read the values, in the viewer's locale. */\n protected readonly generatedAt = computed(() => {\n const marker = this.context().item.metadata?.[SPARK_DASHBOARD_METADATA_KEY];\n const at = marker && typeof marker === 'object' ? (marker as Record<string, unknown>)['generatedAtUtc'] : null;\n if (typeof at !== 'string') return null;\n const date = new Date(at);\n return Number.isNaN(date.getTime()) ? null : date.toLocaleTimeString(this.locale);\n });\n\n private tile(attribute: EntityAttributeDefinition, item: PersistentObject, formData: Record<string, any>): DashboardTile {\n const value = rendererValue(item.attributes.find(a => a.name === attribute.name));\n const registration = attribute.renderer ? this.renderers.find(r => r.name === attribute.renderer) : undefined;\n const component = registration?.detailComponent ?? null;\n // A table without a columnSpan takes the row; a columnSpan of 3 or more is the row, 2 is two columns.\n const columnSpan = attribute.columnSpan ?? (attribute.renderer === 'spark-dashboard-table' ? 3 : 1);\n const span = columnSpan >= 3 ? 'row' : columnSpan === 2 ? 2 : 1;\n return {\n attribute,\n span,\n component,\n inputs: component\n ? withDeclaredInputs(component, { value, attribute, options: attribute.rendererOptions, formData, item })\n : {},\n text: formatSparkDashboardValue(value, undefined, this.locale),\n };\n }\n}\n","import { Provider } from '@angular/core';\nimport { provideSparkDetailLayouts } from '@mintplayer/ng-spark/panels';\nimport type { SparkAttributeRendererRegistration } from '@mintplayer/ng-spark/renderers';\nimport { isSparkDashboard, SparkDashboardComponent } from './spark-dashboard.component';\nimport { SparkDashboardTableComponent } from './spark-dashboard-table.component';\nimport { SparkMeterComponent } from './spark-meter.component';\nimport { SparkStatComponent } from './spark-stat.component';\n\n/**\n * The dashboard renderers under their model names. Renderers are one list, so add these to the app's own in its\n * single `provideSparkAttributeRenderers` (or `provideSparkScoreRenderers`, which `spark-trend` comes from) call:\n *\n * @example\n * provideSparkScoreRenderers([...appRenderers, ...sparkDashboardRenderers]),\n * provideSparkDashboards(),\n */\nexport const sparkDashboardRenderers: readonly SparkAttributeRendererRegistration[] = [\n { name: 'spark-stat', columnComponent: SparkStatComponent, detailComponent: SparkStatComponent },\n { name: 'spark-meter', columnComponent: SparkMeterComponent, detailComponent: SparkMeterComponent },\n { name: 'spark-dashboard-table', detailComponent: SparkDashboardTableComponent },\n];\n\n/**\n * Draws every dashboard (an object the server marks with `metadata.dashboard`, the dashboards package's live virtual\n * types) with the tile layout instead of the attribute cards, on the routed detail page. The renderers are registered\n * separately: see {@link sparkDashboardRenderers}.\n */\nexport function provideSparkDashboards(): Provider[] {\n return provideSparkDetailLayouts({\n id: 'spark-dashboard',\n component: SparkDashboardComponent,\n matches: item => isSparkDashboard(item),\n });\n}\n"],"mappings":"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"}
@@ -7,6 +7,14 @@ function provideSparkQueryRowActions(...actions) {
7
7
  multi: true
8
8
  }));
9
9
  }
10
+ const SPARK_DETAIL_LAYOUTS = new InjectionToken("SparkDetailLayouts");
11
+ function provideSparkDetailLayouts(...layouts) {
12
+ return layouts.map((layout) => ({
13
+ provide: SPARK_DETAIL_LAYOUTS,
14
+ useValue: layout,
15
+ multi: true
16
+ }));
17
+ }
10
18
  const SPARK_DETAIL_PANELS = new InjectionToken("SparkDetailPanels");
11
19
  const SPARK_DETAIL_ACTIONS = new InjectionToken("SparkDetailActions");
12
20
  const SPARK_QUERY_LIST_ACTIONS = new InjectionToken("SparkQueryListActions");
@@ -55,6 +63,6 @@ function parseSparkDeletedParam(value) {
55
63
  default: return null;
56
64
  }
57
65
  }
58
- export { SPARK_DETAIL_ACTIONS, SPARK_DETAIL_PANELS, SPARK_QUERY_LIST_ACTIONS, SPARK_QUERY_ROW_ACTIONS, orderSparkExtensions, parseSparkDeletedParam, provideSparkDetailActions, provideSparkDetailPanels, provideSparkQueryListActions, provideSparkQueryRowActions };
66
+ export { SPARK_DETAIL_ACTIONS, SPARK_DETAIL_LAYOUTS, SPARK_DETAIL_PANELS, SPARK_QUERY_LIST_ACTIONS, SPARK_QUERY_ROW_ACTIONS, orderSparkExtensions, parseSparkDeletedParam, provideSparkDetailActions, provideSparkDetailLayouts, provideSparkDetailPanels, provideSparkQueryListActions, provideSparkQueryRowActions };
59
67
 
60
68
  //# sourceMappingURL=mintplayer-ng-spark-panels.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"mintplayer-ng-spark-panels.mjs","names":[],"sources":["../../panels/src/spark-panels.ts"],"sourcesContent":["import { InjectionToken, Provider, Type } from '@angular/core';\nimport {\n EntityPermissions,\n EntityType,\n PersistentObject,\n SparkDeletedFilter,\n QueryResultItem,\n SparkQuery,\n} from '@mintplayer/ng-spark/models';\n\n/**\n * What a detail-page extension is told about the object on screen (#460).\n *\n * Handed to every {@link SPARK_DETAIL_PANELS} and {@link SPARK_DETAIL_ACTIONS} component through its\n * `context` input. A new object is passed whenever the page's state changes (a reload, a patch), so a\n * component reads it as a plain value and never needs to subscribe to anything.\n */\nexport interface SparkDetailContext {\n /** The entity type id or alias from the route (`/po/:type/:id`). */\n type: string;\n /** The object id from the route. */\n id: string;\n item: PersistentObject;\n entityType: EntityType;\n /**\n * Type-level rights (`/spark/permissions/{type}`). `null` while they load — treat as \"not allowed\".\n * The optional add-on flags (`canRestore`, `canViewHistory`, …) are absent on an older server.\n */\n permissions: EntityPermissions | null;\n /**\n * The soft-deletion mode the object was loaded with — the route's `?deleted=` query parameter\n * (`include` / `only`), or `null` for a normal load. `only` means the object was opened from the\n * recycle bin and is a deleted row.\n */\n deleted: SparkDeletedFilter | null;\n /** Re-fetches the object (and its permissions) with the same mode. */\n reload(): Promise<void>;\n}\n\n/**\n * What a query-page extension is told about the list on screen (#460).\n *\n * Handed to every {@link SPARK_QUERY_LIST_ACTIONS} component through its `context` input.\n */\nexport interface SparkQueryListContext {\n query: SparkQuery;\n /** Null for a query whose rows are not an entity type (a composed query). */\n entityType: EntityType | null;\n /** Type-level rights of `entityType`; null while they load or when there is no entity type. */\n permissions: EntityPermissions | null;\n /** The soft-deletion mode the list currently runs with (`exclude` unless the route says otherwise). */\n deleted: SparkDeletedFilter;\n /**\n * Switches the list's soft-deletion mode. Written to the route as `?deleted=`, so the recycle bin\n * survives a reload and the back button; `exclude` removes the parameter.\n */\n setDeleted(mode: SparkDeletedFilter): void;\n /** Re-runs the query, keeping page and sort. */\n reload(): void;\n}\n\n/**\n * A component shown under the attributes of every routed detail page (`sparkRoutes()`'s\n * `po/:type/:id`). It must declare `context = input.required<SparkDetailContext>()`.\n *\n * `sparkRoutes()` passes no templates to the pages it routes to, so a multi-provider is the only way\n * an add-on (History, Moderation) can put something on them without the app replacing the page.\n */\nexport interface SparkDetailPanel {\n /** Unique name; a second registration with the same id replaces the first. */\n id: string;\n component: Type<unknown>;\n /** Ascending; panels without an order come last, in registration order. */\n order?: number;\n}\n\n/**\n * A component rendered in the action bar of every routed detail page, after the built-in and custom\n * actions. It must declare `context = input.required<SparkDetailContext>()` and render nothing when\n * it does not apply (it is responsible for its own permission checks).\n */\nexport interface SparkDetailAction {\n id: string;\n component: Type<unknown>;\n /** Priority in the action bar's overflow ordering; defaults to 60. Lower stays visible longer. */\n priority?: number;\n}\n\n/**\n * A component rendered in the action bar of every routed query page (`query/:queryId`, `po/:type`).\n * It must declare `context = input.required<SparkQueryListContext>()`.\n */\nexport interface SparkQueryListAction {\n id: string;\n component: Type<unknown>;\n /** Priority in the action bar's overflow ordering; defaults to 60. */\n priority?: number;\n}\n\n/** What a grid row action (`SPARK_QUERY_ROW_ACTIONS`) is asked whether it is offered. */\nexport interface SparkQueryRowActionScope {\n query: SparkQuery;\n entityType: EntityType;\n permissions: EntityPermissions | null;\n /** The grid's soft-deletion mode (null when the grid sends none). */\n deleted: SparkDeletedFilter | null;\n}\n\n/** What a grid row action runs with: the scope, the one row, and the grid's refresh. */\nexport interface SparkQueryRowActionContext extends SparkQueryRowActionScope {\n row: QueryResultItem;\n /** Re-runs the grid's query (page, sort and filters kept). */\n reload(): void;\n}\n\n/**\n * An add-on entry of a Spark grid's per-row `⋮` menu (the query page and the sub-query card): an\n * action an add-on package serves on its own route, so it is not in the type's custom-action\n * catalogue (e.g. Contributions' \"Revert to this version\").\n *\n * `run` executes in the grid's injection context, so it may `inject()` synchronously before its first\n * `await`. A rejection is shown in the grid's alert (the server's message when it sent one).\n */\nexport interface SparkQueryRowAction {\n id: string;\n /** Translation key of the menu label. */\n labelKey: string;\n /** Order among the row menu's entries (custom actions are 10+); defaults to 50. */\n priority?: number;\n isOffered(scope: SparkQueryRowActionScope): boolean;\n run(context: SparkQueryRowActionContext): Promise<void>;\n}\n\n/** Multi-provider: add-on entries of every Spark grid's row menu. */\nexport const SPARK_QUERY_ROW_ACTIONS = new InjectionToken<SparkQueryRowAction[]>('SparkQueryRowActions');\n\n/** Registers grid row actions (multi — every call adds). */\nexport function provideSparkQueryRowActions(...actions: SparkQueryRowAction[]): Provider[] {\n return actions.map(action => ({ provide: SPARK_QUERY_ROW_ACTIONS, useValue: action, multi: true }));\n}\n\n/** Multi-provider: panels under the attributes of the routed detail page. */\nexport const SPARK_DETAIL_PANELS = new InjectionToken<SparkDetailPanel[]>('SparkDetailPanels');\n\n/** Multi-provider: extra buttons in the routed detail page's action bar. */\nexport const SPARK_DETAIL_ACTIONS = new InjectionToken<SparkDetailAction[]>('SparkDetailActions');\n\n/** Multi-provider: extra buttons in the routed query page's action bar. */\nexport const SPARK_QUERY_LIST_ACTIONS = new InjectionToken<SparkQueryListAction[]>('SparkQueryListActions');\n\n/** Registers detail panels (multi — every call adds). */\nexport function provideSparkDetailPanels(...panels: SparkDetailPanel[]): Provider[] {\n return panels.map(panel => ({ provide: SPARK_DETAIL_PANELS, useValue: panel, multi: true }));\n}\n\n/** Registers detail-page actions (multi — every call adds). */\nexport function provideSparkDetailActions(...actions: SparkDetailAction[]): Provider[] {\n return actions.map(action => ({ provide: SPARK_DETAIL_ACTIONS, useValue: action, multi: true }));\n}\n\n/** Registers query-page actions (multi — every call adds). */\nexport function provideSparkQueryListActions(...actions: SparkQueryListAction[]): Provider[] {\n return actions.map(action => ({ provide: SPARK_QUERY_LIST_ACTIONS, useValue: action, multi: true }));\n}\n\n/**\n * Normalises a multi-provided list: the last registration of an id wins (so an app can replace a\n * shipped panel by registering its own under the same id), then sorted by `key` ascending with\n * unordered entries last, keeping registration order among equals.\n */\nexport function orderSparkExtensions<T extends { id: string }>(\n entries: readonly T[] | null | undefined,\n key: (entry: T) => number | undefined,\n): T[] {\n if (!entries?.length) return [];\n\n const byId = new Map<string, { entry: T; index: number }>();\n entries.forEach((entry, index) => {\n const previous = byId.get(entry.id);\n // Replacement keeps the ORIGINAL slot, so overriding a shipped panel does not move it.\n byId.set(entry.id, { entry, index: previous?.index ?? index });\n });\n\n return [...byId.values()]\n .sort((a, b) => {\n const ka = key(a.entry) ?? Number.POSITIVE_INFINITY;\n const kb = key(b.entry) ?? Number.POSITIVE_INFINITY;\n return ka === kb ? a.index - b.index : ka - kb;\n })\n .map(x => x.entry);\n}\n\n/** Reads `?deleted=` into a filter, ignoring anything that is not one of the three modes. */\nexport function parseSparkDeletedParam(value: string | null | undefined): SparkDeletedFilter | null {\n switch (value?.toLowerCase()) {\n case 'include': return 'include';\n case 'only': return 'only';\n case 'exclude': return 'exclude';\n default: return null;\n }\n}\n"],"mappings":";AAsIA,MAAa,0BAA0B,IAAI,eAAsC,sBAAsB;AAGvG,SAAgB,4BAA4B,GAAG,SAA8B;CAC3E,OAAO,QAAQ,KAAI,YAAW;EAAE,SAAS;EAAyB,UAAU;EAAQ,OAAO;CAAI,EAAG;AACpG;AAGA,MAAa,sBAAsB,IAAI,eAAmC,mBAAmB;AAG7F,MAAa,uBAAuB,IAAI,eAAoC,oBAAoB;AAGhG,MAAa,2BAA2B,IAAI,eAAuC,uBAAuB;AAG1G,SAAgB,yBAAyB,GAAG,QAA0B;CACpE,OAAO,OAAO,KAAI,WAAU;EAAE,SAAS;EAAqB,UAAU;EAAO,OAAO;CAAI,EAAG;AAC7F;AAGA,SAAgB,0BAA0B,GAAG,SAA4B;CACvE,OAAO,QAAQ,KAAI,YAAW;EAAE,SAAS;EAAsB,UAAU;EAAQ,OAAO;CAAI,EAAG;AACjG;AAGA,SAAgB,6BAA6B,GAAG,SAA+B;CAC7E,OAAO,QAAQ,KAAI,YAAW;EAAE,SAAS;EAA0B,UAAU;EAAQ,OAAO;CAAI,EAAG;AACrG;AAOA,SAAgB,qBACd,SACA,KAAqC;CAErC,IAAI,CAAC,SAAS,QAAQ,OAAO,CAAA;CAE7B,MAAM,uBAAO,IAAI,IAAG;CACpB,QAAQ,SAAS,OAAO,UAAS;EAC/B,MAAM,WAAW,KAAK,IAAI,MAAM,EAAE;EAElC,KAAK,IAAI,MAAM,IAAI;GAAE;GAAO,OAAO,UAAU,SAAS;EAAK,CAAE;CAC/D,CAAC;CAED,OAAO,CAAC,GAAG,KAAK,OAAM,CAAE,CAAC,CACtB,MAAM,GAAG,MAAK;EACb,MAAM,KAAK,IAAI,EAAE,KAAK,KAAK,OAAO;EAClC,MAAM,KAAK,IAAI,EAAE,KAAK,KAAK,OAAO;EAClC,OAAO,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,KAAK;CAC9C,CAAC,CAAC,CACD,KAAI,MAAK,EAAE,KAAK;AACrB;AAGA,SAAgB,uBAAuB,OAAgC;CACrE,QAAQ,OAAO,YAAW,GAA1B;EACE,KAAK,WAAW,OAAO;EACvB,KAAK,QAAQ,OAAO;EACpB,KAAK,WAAW,OAAO;EACvB,SAAS,OAAO;CAClB;AACF"}
1
+ {"version":3,"file":"mintplayer-ng-spark-panels.mjs","names":[],"sources":["../../panels/src/spark-panels.ts"],"sourcesContent":["import { InjectionToken, Provider, Type } from '@angular/core';\nimport {\n EntityPermissions,\n EntityType,\n PersistentObject,\n SparkDeletedFilter,\n QueryResultItem,\n SparkQuery,\n} from '@mintplayer/ng-spark/models';\n\n/**\n * What a detail-page extension is told about the object on screen (#460).\n *\n * Handed to every {@link SPARK_DETAIL_PANELS} and {@link SPARK_DETAIL_ACTIONS} component through its\n * `context` input. A new object is passed whenever the page's state changes (a reload, a patch), so a\n * component reads it as a plain value and never needs to subscribe to anything.\n */\nexport interface SparkDetailContext {\n /** The entity type id or alias from the route (`/po/:type/:id`). */\n type: string;\n /** The object id from the route. */\n id: string;\n item: PersistentObject;\n entityType: EntityType;\n /**\n * Type-level rights (`/spark/permissions/{type}`). `null` while they load — treat as \"not allowed\".\n * The optional add-on flags (`canRestore`, `canViewHistory`, …) are absent on an older server.\n */\n permissions: EntityPermissions | null;\n /**\n * The soft-deletion mode the object was loaded with — the route's `?deleted=` query parameter\n * (`include` / `only`), or `null` for a normal load. `only` means the object was opened from the\n * recycle bin and is a deleted row.\n */\n deleted: SparkDeletedFilter | null;\n /** Re-fetches the object (and its permissions) with the same mode. */\n reload(): Promise<void>;\n}\n\n/**\n * What a query-page extension is told about the list on screen (#460).\n *\n * Handed to every {@link SPARK_QUERY_LIST_ACTIONS} component through its `context` input.\n */\nexport interface SparkQueryListContext {\n query: SparkQuery;\n /** Null for a query whose rows are not an entity type (a composed query). */\n entityType: EntityType | null;\n /** Type-level rights of `entityType`; null while they load or when there is no entity type. */\n permissions: EntityPermissions | null;\n /** The soft-deletion mode the list currently runs with (`exclude` unless the route says otherwise). */\n deleted: SparkDeletedFilter;\n /**\n * Switches the list's soft-deletion mode. Written to the route as `?deleted=`, so the recycle bin\n * survives a reload and the back button; `exclude` removes the parameter.\n */\n setDeleted(mode: SparkDeletedFilter): void;\n /** Re-runs the query, keeping page and sort. */\n reload(): void;\n}\n\n/**\n * A component shown under the attributes of every routed detail page (`sparkRoutes()`'s\n * `po/:type/:id`). It must declare `context = input.required<SparkDetailContext>()`.\n *\n * `sparkRoutes()` passes no templates to the pages it routes to, so a multi-provider is the only way\n * an add-on (History, Moderation) can put something on them without the app replacing the page.\n */\nexport interface SparkDetailPanel {\n /** Unique name; a second registration with the same id replaces the first. */\n id: string;\n component: Type<unknown>;\n /** Ascending; panels without an order come last, in registration order. */\n order?: number;\n}\n\n/**\n * A component rendered in the action bar of every routed detail page, after the built-in and custom\n * actions. It must declare `context = input.required<SparkDetailContext>()` and render nothing when\n * it does not apply (it is responsible for its own permission checks).\n */\nexport interface SparkDetailAction {\n id: string;\n component: Type<unknown>;\n /** Priority in the action bar's overflow ordering; defaults to 60. Lower stays visible longer. */\n priority?: number;\n}\n\n/**\n * A component rendered in the action bar of every routed query page (`query/:queryId`, `po/:type`).\n * It must declare `context = input.required<SparkQueryListContext>()`.\n */\nexport interface SparkQueryListAction {\n id: string;\n component: Type<unknown>;\n /** Priority in the action bar's overflow ordering; defaults to 60. */\n priority?: number;\n}\n\n/** What a grid row action (`SPARK_QUERY_ROW_ACTIONS`) is asked whether it is offered. */\nexport interface SparkQueryRowActionScope {\n query: SparkQuery;\n entityType: EntityType;\n permissions: EntityPermissions | null;\n /** The grid's soft-deletion mode (null when the grid sends none). */\n deleted: SparkDeletedFilter | null;\n}\n\n/** What a grid row action runs with: the scope, the one row, and the grid's refresh. */\nexport interface SparkQueryRowActionContext extends SparkQueryRowActionScope {\n row: QueryResultItem;\n /** Re-runs the grid's query (page, sort and filters kept). */\n reload(): void;\n}\n\n/**\n * An add-on entry of a Spark grid's per-row `⋮` menu (the query page and the sub-query card): an\n * action an add-on package serves on its own route, so it is not in the type's custom-action\n * catalogue (e.g. Contributions' \"Revert to this version\").\n *\n * `run` executes in the grid's injection context, so it may `inject()` synchronously before its first\n * `await`. A rejection is shown in the grid's alert (the server's message when it sent one).\n */\nexport interface SparkQueryRowAction {\n id: string;\n /** Translation key of the menu label. */\n labelKey: string;\n /** Order among the row menu's entries (custom actions are 10+); defaults to 50. */\n priority?: number;\n isOffered(scope: SparkQueryRowActionScope): boolean;\n run(context: SparkQueryRowActionContext): Promise<void>;\n}\n\n/** Multi-provider: add-on entries of every Spark grid's row menu. */\nexport const SPARK_QUERY_ROW_ACTIONS = new InjectionToken<SparkQueryRowAction[]>('SparkQueryRowActions');\n\n/** Registers grid row actions (multi — every call adds). */\nexport function provideSparkQueryRowActions(...actions: SparkQueryRowAction[]): Provider[] {\n return actions.map(action => ({ provide: SPARK_QUERY_ROW_ACTIONS, useValue: action, multi: true }));\n}\n\n/**\n * A layout that draws a detail page's attributes in place of the tabs and cards (#504), for objects it\n * `matches`: Dashboards' tile layout for objects carrying `metadata.dashboard`. It must declare\n * `context = input.required<SparkDetailContext>()`. The action bar, sub-queries, the extra content\n * template and the detail panels stay; the first matching layout wins.\n */\nexport interface SparkDetailLayout {\n id: string;\n component: Type<unknown>;\n /** Whether this layout draws `item`. Called on every change of the object, so keep it cheap. */\n matches(item: PersistentObject, entityType: EntityType): boolean;\n}\n\n/** Multi-provider: layouts that replace the attribute cards of the routed detail page for the objects they match. */\nexport const SPARK_DETAIL_LAYOUTS = new InjectionToken<SparkDetailLayout[]>('SparkDetailLayouts');\n\n/** Registers detail layouts (multi — every call adds). */\nexport function provideSparkDetailLayouts(...layouts: SparkDetailLayout[]): Provider[] {\n return layouts.map(layout => ({ provide: SPARK_DETAIL_LAYOUTS, useValue: layout, multi: true }));\n}\n\n/** Multi-provider: panels under the attributes of the routed detail page. */\nexport const SPARK_DETAIL_PANELS = new InjectionToken<SparkDetailPanel[]>('SparkDetailPanels');\n\n/** Multi-provider: extra buttons in the routed detail page's action bar. */\nexport const SPARK_DETAIL_ACTIONS = new InjectionToken<SparkDetailAction[]>('SparkDetailActions');\n\n/** Multi-provider: extra buttons in the routed query page's action bar. */\nexport const SPARK_QUERY_LIST_ACTIONS = new InjectionToken<SparkQueryListAction[]>('SparkQueryListActions');\n\n/** Registers detail panels (multi — every call adds). */\nexport function provideSparkDetailPanels(...panels: SparkDetailPanel[]): Provider[] {\n return panels.map(panel => ({ provide: SPARK_DETAIL_PANELS, useValue: panel, multi: true }));\n}\n\n/** Registers detail-page actions (multi — every call adds). */\nexport function provideSparkDetailActions(...actions: SparkDetailAction[]): Provider[] {\n return actions.map(action => ({ provide: SPARK_DETAIL_ACTIONS, useValue: action, multi: true }));\n}\n\n/** Registers query-page actions (multi — every call adds). */\nexport function provideSparkQueryListActions(...actions: SparkQueryListAction[]): Provider[] {\n return actions.map(action => ({ provide: SPARK_QUERY_LIST_ACTIONS, useValue: action, multi: true }));\n}\n\n/**\n * Normalises a multi-provided list: the last registration of an id wins (so an app can replace a\n * shipped panel by registering its own under the same id), then sorted by `key` ascending with\n * unordered entries last, keeping registration order among equals.\n */\nexport function orderSparkExtensions<T extends { id: string }>(\n entries: readonly T[] | null | undefined,\n key: (entry: T) => number | undefined,\n): T[] {\n if (!entries?.length) return [];\n\n const byId = new Map<string, { entry: T; index: number }>();\n entries.forEach((entry, index) => {\n const previous = byId.get(entry.id);\n // Replacement keeps the ORIGINAL slot, so overriding a shipped panel does not move it.\n byId.set(entry.id, { entry, index: previous?.index ?? index });\n });\n\n return [...byId.values()]\n .sort((a, b) => {\n const ka = key(a.entry) ?? Number.POSITIVE_INFINITY;\n const kb = key(b.entry) ?? Number.POSITIVE_INFINITY;\n return ka === kb ? a.index - b.index : ka - kb;\n })\n .map(x => x.entry);\n}\n\n/** Reads `?deleted=` into a filter, ignoring anything that is not one of the three modes. */\nexport function parseSparkDeletedParam(value: string | null | undefined): SparkDeletedFilter | null {\n switch (value?.toLowerCase()) {\n case 'include': return 'include';\n case 'only': return 'only';\n case 'exclude': return 'exclude';\n default: return null;\n }\n}\n"],"mappings":";AAsIA,MAAa,0BAA0B,IAAI,eAAsC,sBAAsB;AAGvG,SAAgB,4BAA4B,GAAG,SAA8B;CAC3E,OAAO,QAAQ,KAAI,YAAW;EAAE,SAAS;EAAyB,UAAU;EAAQ,OAAO;CAAI,EAAG;AACpG;AAgBA,MAAa,uBAAuB,IAAI,eAAoC,oBAAoB;AAGhG,SAAgB,0BAA0B,GAAG,SAA4B;CACvE,OAAO,QAAQ,KAAI,YAAW;EAAE,SAAS;EAAsB,UAAU;EAAQ,OAAO;CAAI,EAAG;AACjG;AAGA,MAAa,sBAAsB,IAAI,eAAmC,mBAAmB;AAG7F,MAAa,uBAAuB,IAAI,eAAoC,oBAAoB;AAGhG,MAAa,2BAA2B,IAAI,eAAuC,uBAAuB;AAG1G,SAAgB,yBAAyB,GAAG,QAA0B;CACpE,OAAO,OAAO,KAAI,WAAU;EAAE,SAAS;EAAqB,UAAU;EAAO,OAAO;CAAI,EAAG;AAC7F;AAGA,SAAgB,0BAA0B,GAAG,SAA4B;CACvE,OAAO,QAAQ,KAAI,YAAW;EAAE,SAAS;EAAsB,UAAU;EAAQ,OAAO;CAAI,EAAG;AACjG;AAGA,SAAgB,6BAA6B,GAAG,SAA+B;CAC7E,OAAO,QAAQ,KAAI,YAAW;EAAE,SAAS;EAA0B,UAAU;EAAQ,OAAO;CAAI,EAAG;AACrG;AAOA,SAAgB,qBACd,SACA,KAAqC;CAErC,IAAI,CAAC,SAAS,QAAQ,OAAO,CAAA;CAE7B,MAAM,uBAAO,IAAI,IAAG;CACpB,QAAQ,SAAS,OAAO,UAAS;EAC/B,MAAM,WAAW,KAAK,IAAI,MAAM,EAAE;EAElC,KAAK,IAAI,MAAM,IAAI;GAAE;GAAO,OAAO,UAAU,SAAS;EAAK,CAAE;CAC/D,CAAC;CAED,OAAO,CAAC,GAAG,KAAK,OAAM,CAAE,CAAC,CACtB,MAAM,GAAG,MAAK;EACb,MAAM,KAAK,IAAI,EAAE,KAAK,KAAK,OAAO;EAClC,MAAM,KAAK,IAAI,EAAE,KAAK,KAAK,OAAO;EAClC,OAAO,OAAO,KAAK,EAAE,QAAQ,EAAE,QAAQ,KAAK;CAC9C,CAAC,CAAC,CACD,KAAI,MAAK,EAAE,KAAK;AACrB;AAGA,SAAgB,uBAAuB,OAAgC;CACrE,QAAQ,OAAO,YAAW,GAA1B;EACE,KAAK,WAAW,OAAO;EACvB,KAAK,QAAQ,OAAO;EACpB,KAAK,WAAW,OAAO;EACvB,SAAS,OAAO;CAClB;AACF"}
@@ -23,7 +23,7 @@ import { SparkAttributeDescriptionComponent } from "@mintplayer/ng-spark/attribu
23
23
  import { SparkGridCellComponent, SparkQueryCardComponent } from "@mintplayer/ng-spark/grid";
24
24
  import { SPARK_ATTRIBUTE_RENDERERS, rendererValue, rowRendererInputs, withDeclaredInputs } from "@mintplayer/ng-spark/renderers";
25
25
  import { ShowedOn, builtInAction, confirmationText, filterDetailActions, hasShowedOnFlag, isShowedOnDetail, subQueriesOf } from "@mintplayer/ng-spark/models";
26
- import { SPARK_DETAIL_ACTIONS, SPARK_DETAIL_PANELS, orderSparkExtensions, parseSparkDeletedParam } from "@mintplayer/ng-spark/panels";
26
+ import { SPARK_DETAIL_ACTIONS, SPARK_DETAIL_LAYOUTS, SPARK_DETAIL_PANELS, orderSparkExtensions, parseSparkDeletedParam } from "@mintplayer/ng-spark/panels";
27
27
  import { combineLatest } from "rxjs";
28
28
  var SparkPoDetailComponent = class SparkPoDetailComponent {
29
29
  route = inject(ActivatedRoute);
@@ -38,6 +38,7 @@ var SparkPoDetailComponent = class SparkPoDetailComponent {
38
38
  rendererRegistry = inject(SPARK_ATTRIBUTE_RENDERERS);
39
39
  detailPanels = orderSparkExtensions(inject(SPARK_DETAIL_PANELS, { optional: true }), (p) => p.order);
40
40
  detailActions = orderSparkExtensions(inject(SPARK_DETAIL_ACTIONS, { optional: true }), (a) => a.priority ?? 60);
41
+ detailLayouts = orderSparkExtensions(inject(SPARK_DETAIL_LAYOUTS, { optional: true }), () => void 0);
41
42
  showCustomActions = input(true, ...ngDevMode ? [{ debugName: "showCustomActions" }] : /* istanbul ignore next */ []);
42
43
  extraActionsTemplate = input(null, ...ngDevMode ? [{ debugName: "extraActionsTemplate" }] : /* istanbul ignore next */ []);
43
44
  extraContentTemplate = input(null, ...ngDevMode ? [{ debugName: "extraContentTemplate" }] : /* istanbul ignore next */ []);
@@ -81,6 +82,12 @@ var SparkPoDetailComponent = class SparkPoDetailComponent {
81
82
  reload: () => this.load()
82
83
  };
83
84
  }, ...ngDevMode ? [{ debugName: "detailContext" }] : /* istanbul ignore next */ []);
85
+ detailLayout = computed(() => {
86
+ const item = this.item();
87
+ const entityType = this.entityType();
88
+ if (!item || !entityType) return null;
89
+ return this.detailLayouts.find((l) => l.matches(item, entityType)) ?? null;
90
+ }, ...ngDevMode ? [{ debugName: "detailLayout" }] : /* istanbul ignore next */ []);
84
91
  visibleCustomActions = computed(() => {
85
92
  const withheld = this.item()?.disabledActions;
86
93
  if (!withheld?.length) return this.customActions();
@@ -470,7 +477,7 @@ var SparkPoDetailComponent = class SparkPoDetailComponent {
470
477
  customActionExecuted: "customActionExecuted"
471
478
  },
472
479
  ngImport: i0,
473
- template: "<bs-container>\n<div class=\"container\">\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n } @else if (item(); as currentItem) {\n @if (entityType(); as et) {\n @if (actionError(); as actionErr) {\n <bs-alert [type]=\"colors.danger\" class=\"d-block mt-2 spark-action-error\" role=\"alert\">\n <div class=\"d-flex align-items-start gap-2\">\n <span class=\"flex-grow-1\">{{ actionErr }}</span>\n <button type=\"button\" class=\"btn btn-sm btn-link p-0 text-reset text-decoration-none\" [attr.aria-label]=\"lang.t('common.close', 'Close')\" (click)=\"actionError.set(null)\">&times;</button>\n </div>\n </bs-alert>\n }\n <div class=\"spark-actionbar py-2\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-outline-secondary\" (click)=\"onBack()\">\n <spark-icon name=\"arrow-left\" /> {{ 'common.back' | t }}\n </button>\n <!-- Edit and Delete are the composed catalogue's (#467, D8): label, icon and variant included. -->\n @if (editAction(); as edit) {\n <button *bsPriorityNavItem=\"2\" [class]=\"builtInActionClass(edit, 'primary')\" (click)=\"onEdit()\">\n @if (edit.icon) { <spark-icon [name]=\"edit.icon\" /> } {{ edit.label | resolveTranslation }}\n </button>\n }\n @if (deleteAction(); as del) {\n <button *bsPriorityNavItem=\"3\" [class]=\"builtInActionClass(del, 'danger')\" (click)=\"onDelete()\">\n @if (del.icon) { <spark-icon [name]=\"del.icon\" /> } {{ del.label | resolveTranslation }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of visibleCustomActions(); track action.name) {\n @let unavailable = customActionUnavailableReason(action);\n <button *bsPriorityNavItem=\"10 + action.offset\" [class]=\"customActionClass(action)\" [attr.data-action]=\"action.name\"\n [disabled]=\"runningAction() !== null || unavailable !== null\" [attr.title]=\"unavailable\" (click)=\"onCustomAction(action)\">\n {{ action.label | resolveTranslation }}\n </button>\n }\n }\n @if (extraActionsTemplate(); as extraActionsTpl) {\n <ng-container *bsPriorityNavItem=\"50\">\n <ng-container *ngTemplateOutlet=\"extraActionsTpl\"></ng-container>\n </ng-container>\n }\n <!-- Add-on actions (SPARK_DETAIL_ACTIONS): each decides for itself whether it applies. -->\n @if (detailContext(); as ctx) {\n @for (action of detailActions; track action.id) {\n <ng-container *bsPriorityNavItem=\"action.priority ?? 60\">\n <ng-container *ngComponentOutlet=\"action.component; inputs: { context: ctx }\" />\n </ng-container>\n }\n }\n </bs-priority-nav>\n </div>\n <h2>\n {{ currentItem.breadcrumb || currentItem.name }}\n @if (isDeletedView()) {\n <bs-badge [type]=\"colors.secondary\" class=\"align-middle ms-2 spark-deleted-badge\">{{ 'softDelete.deleted' | t }}</bs-badge>\n }\n </h2>\n\n <bs-grid>\n <bs-tab-control>\n @for (tab of resolvedTabs(); track tab.id) {\n <bs-tab-page>\n <ng-template bsTabPageHeader>{{ tab.label | resolveTranslation:tab.name }}</ng-template>\n <ng-container *ngTemplateOutlet=\"detailTabContent; context: { $implicit: tab }\"></ng-container>\n </bs-tab-page>\n }\n </bs-tab-control>\n\n <ng-template #detailTabContent let-tab>\n @if (tab.id === '__default__') {\n @let ungroupedAttrs = ungroupedAttributes();\n @if (ungroupedAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of ungroupedAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n @for (group of groupsForTab(tab); track group.id) {\n @if (attrsForGroup(group); as groupAttrs) {\n @if (groupAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n @if (group.label) {\n <bs-card-header>{{ group.label | resolveTranslation:group.name }}</bs-card-header>\n }\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of groupAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n }\n </ng-template>\n\n <ng-template #detailAttrField let-attr let-currentItem=\"item\">\n <!-- A full-width renderer (the core 'paragraph') is body text: no label, the whole row. -->\n @let fullWidth = isFullWidthRenderer(attr);\n @if (!fullWidth) {\n <dt [sm]=\"3\">{{ (attr.label | resolveTranslation) || attr.name }}<spark-attribute-description [description]=\"attr.description\" /></dt>\n }\n <dd [sm]=\"fullWidth ? 12 : 9\">\n @if (getDetailRendererComponent(attr); as rendererType) {\n <ng-container *ngComponentOutlet=\"rendererType; inputs: getDetailRendererInputs(rendererType, attr, currentItem)\"></ng-container>\n } @else if (attr.dataType === 'AsDetail' && attr.isArray) {\n <bs-table [isResponsive]=\"true\">\n <thead>\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name) {\n <th>{{ (col.label | resolveTranslation) || col.name }}<spark-attribute-description [description]=\"col.description\" /></th>\n }\n </tr>\n </thead>\n <tbody class=\"align-middle\">\n @let rowRenderer = (attr | asDetailRowRenderer:asDetailTypes():rendererRegistry);\n @for (row of (attr.name | arrayValue:currentItem); track $index) {\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name; let firstCol = $first) {\n <td>\n <!--\n Shared with the query grid. This cell used to hand-roll the renderer\n dispatch and print everything else as text, so a `boolean` column read\n \"true\" here and rendered a checkbox in a grid, and a `color` showed its\n hex. Only the VALUE is resolved locally: these rows are plain\n dictionaries, not PersistentObjects.\n\n `chips` is left unset for a reference array on purpose — an AsDetail\n row's `__sparkBreadcrumbs` is keyed by column, not by id, so there are\n no labels for the members. Text beats a row of raw ids.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(row | asDetailCellValue:attr:col:asDetailReferenceOptions())\"\n [rendererValue]=\"row[col.name]\"\n [item]=\"row\"\n [link]=\"col.dataType === 'Reference' && col.referenceType\n ? (col.referenceType | referenceLinkRoute:row[col.name]:allEntityTypes())\n : null\" />\n <!-- A row renderer (rowComponent: contributions' attribution line) draws\n its attributes once, under the row's first cell, instead of as columns. -->\n @if (firstCol && rowRenderer) {\n <div class=\"spark-row-renderer\">\n <ng-container *ngComponentOutlet=\"rowRenderer.component; inputs: getRowRendererInputs(rowRenderer, row, currentItem)\"></ng-container>\n </div>\n }\n </td>\n }\n </tr>\n } @empty {\n <tr>\n <td [attr.colspan]=\"(attr | asDetailColumns:asDetailTypes():rendererRegistry).length\" class=\"text-center text-muted\">\n {{ 'common.noItemsFound' | t }}\n </td>\n </tr>\n }\n </tbody>\n </bs-table>\n } @else if (attr.dataType === 'boolean') {\n <input type=\"checkbox\"\n [checked]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) === true\"\n [indeterminate]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) == null\"\n disabled\n onclick=\"return false;\"\n class=\"opacity-100\">\n } @else if (attr.dataType === 'color') {\n @let colorVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (colorVal) {\n <span class=\"spark-color-swatch d-inline-block align-middle border rounded me-2\" [style.background-color]=\"colorVal\"></span>\n {{ colorVal }}\n } @else {\n -\n }\n } @else if (attr.dataType === 'image') {\n @let imageSrc = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (imageSrc) {\n <img [src]=\"imageSrc\" [alt]=\"(attr.label | resolveTranslation) || attr.name\" loading=\"lazy\"\n class=\"img-fluid rounded\" style=\"max-height: 12rem;\">\n } @else {\n -\n }\n } @else if (attr.dataType === 'url') {\n @let hrefVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (hrefVal) {\n <a [href]=\"hrefVal\" target=\"_blank\" rel=\"noopener noreferrer\">{{ hrefVal }}</a>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.isArray) {\n @let chips = (attr.name | referenceChips:item());\n @if (chips.length) {\n <div class=\"d-flex flex-wrap gap-1\">\n @for (chip of chips; track chip.id) {\n @let chipRoute = (attr.referenceType ? (attr.referenceType | referenceLinkRoute:chip.id:allEntityTypes()) : null);\n @if (chipRoute) {\n <a class=\"spark-chip\" [routerLink]=\"chipRoute\">{{ chip.label }}</a>\n } @else {\n <span class=\"spark-chip\">{{ chip.label }}</span>\n }\n }\n </div>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.referenceType) {\n @let refRoute = (attr.referenceType | referenceLinkRoute:(attr.name | rawAttributeValue:item()):allEntityTypes());\n @if (refRoute && (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes())) {\n <a [routerLink]=\"refRoute\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) }}</a>\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else if (attr.dataType === 'MultiLineString') {\n <div class=\"spark-multiline-value\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}</div>\n } @else if (attr.dataType === 'date' || attr.dataType === 'datetime') {\n <!--\n Formatted the same way, and in the same timezone, as the grid renders it.\n\n Before this, a detail page printed the raw wire value — `2026-12-31T23:59:00-08:00`\n in a definition list — while the grid showed `01/01/2027, 08:59` for the same\n document. Not a rounding difference: a DateTimeOffset whose offset differs from the\n viewer's can disagree on the DATE, so the two pages named different days for one car.\n\n The grid's meaning wins because it is the one a reader can act on: a timestamp is an\n instant, shown in the viewer's own zone. The originating offset is still on the wire\n for code that needs it — server-side logic, exports, \"which country's morning was\n this?\" — it simply is not what a viewer is shown by default.\n -->\n @let when = (attr.name | rawAttributeValue:item()) | parsedDate;\n @if (when) {\n {{ when | date:(attr.dataType === 'date' ? 'mediumDate' : 'short') }}\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n </dd>\n </ng-template>\n </bs-grid>\n @for (subQuery of subQueries(); track subQuery.query) {\n <!-- The entry's selectionMode overrides the query's own for this parent (#460, D17). -->\n <spark-query-card [queryId]=\"subQuery.query\" [parentId]=\"currentItem.id!\" [parentType]=\"et.name\"\n [selectionMode]=\"subQuery.selectionMode ?? null\"\n [parentDeleted]=\"isDeletedView()\"\n [iconTemplate]=\"queryIconTemplate()\" [captionTemplate]=\"queryCaptionTemplate()\"\n [actionsTemplate]=\"queryActionsTemplate()\" />\n }\n @if (extraContentTemplate(); as extraContentTpl) {\n <ng-container *ngTemplateOutlet=\"extraContentTpl; context: { $implicit: currentItem, entityType: et }\"></ng-container>\n }\n <!-- Add-on panels (SPARK_DETAIL_PANELS), e.g. <spark-po-history>. -->\n @if (detailContext(); as ctx) {\n @for (panel of detailPanels; track panel.id) {\n <div class=\"spark-detail-panel m-3\">\n <ng-container *ngComponentOutlet=\"panel.component; inputs: { context: ctx }\" />\n </div>\n }\n }\n }\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n</bs-container>\n",
480
+ template: "<bs-container>\n<div class=\"container\">\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n } @else if (item(); as currentItem) {\n @if (entityType(); as et) {\n @if (actionError(); as actionErr) {\n <bs-alert [type]=\"colors.danger\" class=\"d-block mt-2 spark-action-error\" role=\"alert\">\n <div class=\"d-flex align-items-start gap-2\">\n <span class=\"flex-grow-1\">{{ actionErr }}</span>\n <button type=\"button\" class=\"btn btn-sm btn-link p-0 text-reset text-decoration-none\" [attr.aria-label]=\"lang.t('common.close', 'Close')\" (click)=\"actionError.set(null)\">&times;</button>\n </div>\n </bs-alert>\n }\n <div class=\"spark-actionbar py-2\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-outline-secondary\" (click)=\"onBack()\">\n <spark-icon name=\"arrow-left\" /> {{ 'common.back' | t }}\n </button>\n <!-- Edit and Delete are the composed catalogue's (#467, D8): label, icon and variant included. -->\n @if (editAction(); as edit) {\n <button *bsPriorityNavItem=\"2\" [class]=\"builtInActionClass(edit, 'primary')\" (click)=\"onEdit()\">\n @if (edit.icon) { <spark-icon [name]=\"edit.icon\" /> } {{ edit.label | resolveTranslation }}\n </button>\n }\n @if (deleteAction(); as del) {\n <button *bsPriorityNavItem=\"3\" [class]=\"builtInActionClass(del, 'danger')\" (click)=\"onDelete()\">\n @if (del.icon) { <spark-icon [name]=\"del.icon\" /> } {{ del.label | resolveTranslation }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of visibleCustomActions(); track action.name) {\n @let unavailable = customActionUnavailableReason(action);\n <button *bsPriorityNavItem=\"10 + action.offset\" [class]=\"customActionClass(action)\" [attr.data-action]=\"action.name\"\n [disabled]=\"runningAction() !== null || unavailable !== null\" [attr.title]=\"unavailable\" (click)=\"onCustomAction(action)\">\n {{ action.label | resolveTranslation }}\n </button>\n }\n }\n @if (extraActionsTemplate(); as extraActionsTpl) {\n <ng-container *bsPriorityNavItem=\"50\">\n <ng-container *ngTemplateOutlet=\"extraActionsTpl\"></ng-container>\n </ng-container>\n }\n <!-- Add-on actions (SPARK_DETAIL_ACTIONS): each decides for itself whether it applies. -->\n @if (detailContext(); as ctx) {\n @for (action of detailActions; track action.id) {\n <ng-container *bsPriorityNavItem=\"action.priority ?? 60\">\n <ng-container *ngComponentOutlet=\"action.component; inputs: { context: ctx }\" />\n </ng-container>\n }\n }\n </bs-priority-nav>\n </div>\n <h2>\n {{ currentItem.breadcrumb || currentItem.name }}\n @if (isDeletedView()) {\n <bs-badge [type]=\"colors.secondary\" class=\"align-middle ms-2 spark-deleted-badge\">{{ 'softDelete.deleted' | t }}</bs-badge>\n }\n </h2>\n\n @if (detailLayout(); as layout) {\n <!-- A registered layout draws the attributes instead (SPARK_DETAIL_LAYOUTS): Dashboards' tiles. -->\n <ng-container *ngComponentOutlet=\"layout.component; inputs: { context: detailContext() }\" />\n } @else {\n <bs-grid>\n <bs-tab-control>\n @for (tab of resolvedTabs(); track tab.id) {\n <bs-tab-page>\n <ng-template bsTabPageHeader>{{ tab.label | resolveTranslation:tab.name }}</ng-template>\n <ng-container *ngTemplateOutlet=\"detailTabContent; context: { $implicit: tab }\"></ng-container>\n </bs-tab-page>\n }\n </bs-tab-control>\n\n <ng-template #detailTabContent let-tab>\n @if (tab.id === '__default__') {\n @let ungroupedAttrs = ungroupedAttributes();\n @if (ungroupedAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of ungroupedAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n @for (group of groupsForTab(tab); track group.id) {\n @if (attrsForGroup(group); as groupAttrs) {\n @if (groupAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n @if (group.label) {\n <bs-card-header>{{ group.label | resolveTranslation:group.name }}</bs-card-header>\n }\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of groupAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n }\n </ng-template>\n\n <ng-template #detailAttrField let-attr let-currentItem=\"item\">\n <!-- A full-width renderer (the core 'paragraph') is body text: no label, the whole row. -->\n @let fullWidth = isFullWidthRenderer(attr);\n @if (!fullWidth) {\n <dt [sm]=\"3\">{{ (attr.label | resolveTranslation) || attr.name }}<spark-attribute-description [description]=\"attr.description\" /></dt>\n }\n <dd [sm]=\"fullWidth ? 12 : 9\">\n @if (getDetailRendererComponent(attr); as rendererType) {\n <ng-container *ngComponentOutlet=\"rendererType; inputs: getDetailRendererInputs(rendererType, attr, currentItem)\"></ng-container>\n } @else if (attr.dataType === 'AsDetail' && attr.isArray) {\n <bs-table [isResponsive]=\"true\">\n <thead>\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name) {\n <th>{{ (col.label | resolveTranslation) || col.name }}<spark-attribute-description [description]=\"col.description\" /></th>\n }\n </tr>\n </thead>\n <tbody class=\"align-middle\">\n @let rowRenderer = (attr | asDetailRowRenderer:asDetailTypes():rendererRegistry);\n @for (row of (attr.name | arrayValue:currentItem); track $index) {\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name; let firstCol = $first) {\n <td>\n <!--\n Shared with the query grid. This cell used to hand-roll the renderer\n dispatch and print everything else as text, so a `boolean` column read\n \"true\" here and rendered a checkbox in a grid, and a `color` showed its\n hex. Only the VALUE is resolved locally: these rows are plain\n dictionaries, not PersistentObjects.\n\n `chips` is left unset for a reference array on purpose — an AsDetail\n row's `__sparkBreadcrumbs` is keyed by column, not by id, so there are\n no labels for the members. Text beats a row of raw ids.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(row | asDetailCellValue:attr:col:asDetailReferenceOptions())\"\n [rendererValue]=\"row[col.name]\"\n [item]=\"row\"\n [link]=\"col.dataType === 'Reference' && col.referenceType\n ? (col.referenceType | referenceLinkRoute:row[col.name]:allEntityTypes())\n : null\" />\n <!-- A row renderer (rowComponent: contributions' attribution line) draws\n its attributes once, under the row's first cell, instead of as columns. -->\n @if (firstCol && rowRenderer) {\n <div class=\"spark-row-renderer\">\n <ng-container *ngComponentOutlet=\"rowRenderer.component; inputs: getRowRendererInputs(rowRenderer, row, currentItem)\"></ng-container>\n </div>\n }\n </td>\n }\n </tr>\n } @empty {\n <tr>\n <td [attr.colspan]=\"(attr | asDetailColumns:asDetailTypes():rendererRegistry).length\" class=\"text-center text-muted\">\n {{ 'common.noItemsFound' | t }}\n </td>\n </tr>\n }\n </tbody>\n </bs-table>\n } @else if (attr.dataType === 'boolean') {\n <input type=\"checkbox\"\n [checked]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) === true\"\n [indeterminate]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) == null\"\n disabled\n onclick=\"return false;\"\n class=\"opacity-100\">\n } @else if (attr.dataType === 'color') {\n @let colorVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (colorVal) {\n <span class=\"spark-color-swatch d-inline-block align-middle border rounded me-2\" [style.background-color]=\"colorVal\"></span>\n {{ colorVal }}\n } @else {\n -\n }\n } @else if (attr.dataType === 'image') {\n @let imageSrc = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (imageSrc) {\n <img [src]=\"imageSrc\" [alt]=\"(attr.label | resolveTranslation) || attr.name\" loading=\"lazy\"\n class=\"img-fluid rounded\" style=\"max-height: 12rem;\">\n } @else {\n -\n }\n } @else if (attr.dataType === 'url') {\n @let hrefVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (hrefVal) {\n <a [href]=\"hrefVal\" target=\"_blank\" rel=\"noopener noreferrer\">{{ hrefVal }}</a>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.isArray) {\n @let chips = (attr.name | referenceChips:item());\n @if (chips.length) {\n <div class=\"d-flex flex-wrap gap-1\">\n @for (chip of chips; track chip.id) {\n @let chipRoute = (attr.referenceType ? (attr.referenceType | referenceLinkRoute:chip.id:allEntityTypes()) : null);\n @if (chipRoute) {\n <a class=\"spark-chip\" [routerLink]=\"chipRoute\">{{ chip.label }}</a>\n } @else {\n <span class=\"spark-chip\">{{ chip.label }}</span>\n }\n }\n </div>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.referenceType) {\n @let refRoute = (attr.referenceType | referenceLinkRoute:(attr.name | rawAttributeValue:item()):allEntityTypes());\n @if (refRoute && (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes())) {\n <a [routerLink]=\"refRoute\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) }}</a>\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else if (attr.dataType === 'MultiLineString') {\n <div class=\"spark-multiline-value\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}</div>\n } @else if (attr.dataType === 'date' || attr.dataType === 'datetime') {\n <!--\n Formatted the same way, and in the same timezone, as the grid renders it.\n\n Before this, a detail page printed the raw wire value — `2026-12-31T23:59:00-08:00`\n in a definition list — while the grid showed `01/01/2027, 08:59` for the same\n document. Not a rounding difference: a DateTimeOffset whose offset differs from the\n viewer's can disagree on the DATE, so the two pages named different days for one car.\n\n The grid's meaning wins because it is the one a reader can act on: a timestamp is an\n instant, shown in the viewer's own zone. The originating offset is still on the wire\n for code that needs it — server-side logic, exports, \"which country's morning was\n this?\" — it simply is not what a viewer is shown by default.\n -->\n @let when = (attr.name | rawAttributeValue:item()) | parsedDate;\n @if (when) {\n {{ when | date:(attr.dataType === 'date' ? 'mediumDate' : 'short') }}\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n </dd>\n </ng-template>\n </bs-grid>\n }\n @for (subQuery of subQueries(); track subQuery.query) {\n <!-- The entry's selectionMode overrides the query's own for this parent (#460, D17). -->\n <spark-query-card [queryId]=\"subQuery.query\" [parentId]=\"currentItem.id!\" [parentType]=\"et.name\"\n [selectionMode]=\"subQuery.selectionMode ?? null\"\n [parentDeleted]=\"isDeletedView()\"\n [iconTemplate]=\"queryIconTemplate()\" [captionTemplate]=\"queryCaptionTemplate()\"\n [actionsTemplate]=\"queryActionsTemplate()\" />\n }\n @if (extraContentTemplate(); as extraContentTpl) {\n <ng-container *ngTemplateOutlet=\"extraContentTpl; context: { $implicit: currentItem, entityType: et }\"></ng-container>\n }\n <!-- Add-on panels (SPARK_DETAIL_PANELS), e.g. <spark-po-history>. -->\n @if (detailContext(); as ctx) {\n @for (panel of detailPanels; track panel.id) {\n <div class=\"spark-detail-panel m-3\">\n <ng-container *ngComponentOutlet=\"panel.component; inputs: { context: ctx }\" />\n </div>\n }\n }\n }\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n</bs-container>\n",
474
481
  styles: [".spark-actionbar{--_bleed-block: var(--spark-content-inset-block, 0px);--_bleed-inline: var(--spark-content-inset-inline, 0px);--_gutter: var(--spark-actionbar-gutter, 1rem);position:sticky;top:calc(-1 * var(--_bleed-block));z-index:400;background-color:var(--spark-shell-main-bg, var(--bs-body-bg));border-bottom:1px solid var(--bs-border-color);margin-bottom:1rem;margin-inline:calc(-1 * var(--_bleed-inline));padding-inline:calc(var(--_bleed-inline) + var(--_gutter))}.spark-actionbar .btn{border-radius:0}.spark-actionbar-wide{--spark-actionbar-gutter: 1.5rem}.spark-chip{display:inline-block;padding:.35em .9em;font-size:.8125em;font-weight:600;line-height:1.4;white-space:nowrap;text-align:center;vertical-align:baseline;color:var(--bs-body-color);background-color:var(--bs-secondary-bg);border:1px solid var(--bs-border-color);border-radius:50rem;text-decoration:none}a.spark-chip:hover,a.spark-chip:focus-visible{background-color:var(--bs-tertiary-bg)}.spark-color-swatch{width:1.5em;height:1.5em}.container{--spark-content-inset-inline: 0px}.spark-multiline-value{white-space:pre-wrap}\n"],
475
482
  dependencies: [
476
483
  {
@@ -813,7 +820,7 @@ i0.ɵɵngDeclareClassMetadata({
813
820
  SparkAttributeDescriptionComponent
814
821
  ],
815
822
  changeDetection: ChangeDetectionStrategy.OnPush,
816
- template: "<bs-container>\n<div class=\"container\">\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n } @else if (item(); as currentItem) {\n @if (entityType(); as et) {\n @if (actionError(); as actionErr) {\n <bs-alert [type]=\"colors.danger\" class=\"d-block mt-2 spark-action-error\" role=\"alert\">\n <div class=\"d-flex align-items-start gap-2\">\n <span class=\"flex-grow-1\">{{ actionErr }}</span>\n <button type=\"button\" class=\"btn btn-sm btn-link p-0 text-reset text-decoration-none\" [attr.aria-label]=\"lang.t('common.close', 'Close')\" (click)=\"actionError.set(null)\">&times;</button>\n </div>\n </bs-alert>\n }\n <div class=\"spark-actionbar py-2\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-outline-secondary\" (click)=\"onBack()\">\n <spark-icon name=\"arrow-left\" /> {{ 'common.back' | t }}\n </button>\n <!-- Edit and Delete are the composed catalogue's (#467, D8): label, icon and variant included. -->\n @if (editAction(); as edit) {\n <button *bsPriorityNavItem=\"2\" [class]=\"builtInActionClass(edit, 'primary')\" (click)=\"onEdit()\">\n @if (edit.icon) { <spark-icon [name]=\"edit.icon\" /> } {{ edit.label | resolveTranslation }}\n </button>\n }\n @if (deleteAction(); as del) {\n <button *bsPriorityNavItem=\"3\" [class]=\"builtInActionClass(del, 'danger')\" (click)=\"onDelete()\">\n @if (del.icon) { <spark-icon [name]=\"del.icon\" /> } {{ del.label | resolveTranslation }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of visibleCustomActions(); track action.name) {\n @let unavailable = customActionUnavailableReason(action);\n <button *bsPriorityNavItem=\"10 + action.offset\" [class]=\"customActionClass(action)\" [attr.data-action]=\"action.name\"\n [disabled]=\"runningAction() !== null || unavailable !== null\" [attr.title]=\"unavailable\" (click)=\"onCustomAction(action)\">\n {{ action.label | resolveTranslation }}\n </button>\n }\n }\n @if (extraActionsTemplate(); as extraActionsTpl) {\n <ng-container *bsPriorityNavItem=\"50\">\n <ng-container *ngTemplateOutlet=\"extraActionsTpl\"></ng-container>\n </ng-container>\n }\n <!-- Add-on actions (SPARK_DETAIL_ACTIONS): each decides for itself whether it applies. -->\n @if (detailContext(); as ctx) {\n @for (action of detailActions; track action.id) {\n <ng-container *bsPriorityNavItem=\"action.priority ?? 60\">\n <ng-container *ngComponentOutlet=\"action.component; inputs: { context: ctx }\" />\n </ng-container>\n }\n }\n </bs-priority-nav>\n </div>\n <h2>\n {{ currentItem.breadcrumb || currentItem.name }}\n @if (isDeletedView()) {\n <bs-badge [type]=\"colors.secondary\" class=\"align-middle ms-2 spark-deleted-badge\">{{ 'softDelete.deleted' | t }}</bs-badge>\n }\n </h2>\n\n <bs-grid>\n <bs-tab-control>\n @for (tab of resolvedTabs(); track tab.id) {\n <bs-tab-page>\n <ng-template bsTabPageHeader>{{ tab.label | resolveTranslation:tab.name }}</ng-template>\n <ng-container *ngTemplateOutlet=\"detailTabContent; context: { $implicit: tab }\"></ng-container>\n </bs-tab-page>\n }\n </bs-tab-control>\n\n <ng-template #detailTabContent let-tab>\n @if (tab.id === '__default__') {\n @let ungroupedAttrs = ungroupedAttributes();\n @if (ungroupedAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of ungroupedAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n @for (group of groupsForTab(tab); track group.id) {\n @if (attrsForGroup(group); as groupAttrs) {\n @if (groupAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n @if (group.label) {\n <bs-card-header>{{ group.label | resolveTranslation:group.name }}</bs-card-header>\n }\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of groupAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n }\n </ng-template>\n\n <ng-template #detailAttrField let-attr let-currentItem=\"item\">\n <!-- A full-width renderer (the core 'paragraph') is body text: no label, the whole row. -->\n @let fullWidth = isFullWidthRenderer(attr);\n @if (!fullWidth) {\n <dt [sm]=\"3\">{{ (attr.label | resolveTranslation) || attr.name }}<spark-attribute-description [description]=\"attr.description\" /></dt>\n }\n <dd [sm]=\"fullWidth ? 12 : 9\">\n @if (getDetailRendererComponent(attr); as rendererType) {\n <ng-container *ngComponentOutlet=\"rendererType; inputs: getDetailRendererInputs(rendererType, attr, currentItem)\"></ng-container>\n } @else if (attr.dataType === 'AsDetail' && attr.isArray) {\n <bs-table [isResponsive]=\"true\">\n <thead>\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name) {\n <th>{{ (col.label | resolveTranslation) || col.name }}<spark-attribute-description [description]=\"col.description\" /></th>\n }\n </tr>\n </thead>\n <tbody class=\"align-middle\">\n @let rowRenderer = (attr | asDetailRowRenderer:asDetailTypes():rendererRegistry);\n @for (row of (attr.name | arrayValue:currentItem); track $index) {\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name; let firstCol = $first) {\n <td>\n <!--\n Shared with the query grid. This cell used to hand-roll the renderer\n dispatch and print everything else as text, so a `boolean` column read\n \"true\" here and rendered a checkbox in a grid, and a `color` showed its\n hex. Only the VALUE is resolved locally: these rows are plain\n dictionaries, not PersistentObjects.\n\n `chips` is left unset for a reference array on purpose — an AsDetail\n row's `__sparkBreadcrumbs` is keyed by column, not by id, so there are\n no labels for the members. Text beats a row of raw ids.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(row | asDetailCellValue:attr:col:asDetailReferenceOptions())\"\n [rendererValue]=\"row[col.name]\"\n [item]=\"row\"\n [link]=\"col.dataType === 'Reference' && col.referenceType\n ? (col.referenceType | referenceLinkRoute:row[col.name]:allEntityTypes())\n : null\" />\n <!-- A row renderer (rowComponent: contributions' attribution line) draws\n its attributes once, under the row's first cell, instead of as columns. -->\n @if (firstCol && rowRenderer) {\n <div class=\"spark-row-renderer\">\n <ng-container *ngComponentOutlet=\"rowRenderer.component; inputs: getRowRendererInputs(rowRenderer, row, currentItem)\"></ng-container>\n </div>\n }\n </td>\n }\n </tr>\n } @empty {\n <tr>\n <td [attr.colspan]=\"(attr | asDetailColumns:asDetailTypes():rendererRegistry).length\" class=\"text-center text-muted\">\n {{ 'common.noItemsFound' | t }}\n </td>\n </tr>\n }\n </tbody>\n </bs-table>\n } @else if (attr.dataType === 'boolean') {\n <input type=\"checkbox\"\n [checked]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) === true\"\n [indeterminate]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) == null\"\n disabled\n onclick=\"return false;\"\n class=\"opacity-100\">\n } @else if (attr.dataType === 'color') {\n @let colorVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (colorVal) {\n <span class=\"spark-color-swatch d-inline-block align-middle border rounded me-2\" [style.background-color]=\"colorVal\"></span>\n {{ colorVal }}\n } @else {\n -\n }\n } @else if (attr.dataType === 'image') {\n @let imageSrc = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (imageSrc) {\n <img [src]=\"imageSrc\" [alt]=\"(attr.label | resolveTranslation) || attr.name\" loading=\"lazy\"\n class=\"img-fluid rounded\" style=\"max-height: 12rem;\">\n } @else {\n -\n }\n } @else if (attr.dataType === 'url') {\n @let hrefVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (hrefVal) {\n <a [href]=\"hrefVal\" target=\"_blank\" rel=\"noopener noreferrer\">{{ hrefVal }}</a>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.isArray) {\n @let chips = (attr.name | referenceChips:item());\n @if (chips.length) {\n <div class=\"d-flex flex-wrap gap-1\">\n @for (chip of chips; track chip.id) {\n @let chipRoute = (attr.referenceType ? (attr.referenceType | referenceLinkRoute:chip.id:allEntityTypes()) : null);\n @if (chipRoute) {\n <a class=\"spark-chip\" [routerLink]=\"chipRoute\">{{ chip.label }}</a>\n } @else {\n <span class=\"spark-chip\">{{ chip.label }}</span>\n }\n }\n </div>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.referenceType) {\n @let refRoute = (attr.referenceType | referenceLinkRoute:(attr.name | rawAttributeValue:item()):allEntityTypes());\n @if (refRoute && (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes())) {\n <a [routerLink]=\"refRoute\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) }}</a>\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else if (attr.dataType === 'MultiLineString') {\n <div class=\"spark-multiline-value\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}</div>\n } @else if (attr.dataType === 'date' || attr.dataType === 'datetime') {\n <!--\n Formatted the same way, and in the same timezone, as the grid renders it.\n\n Before this, a detail page printed the raw wire value — `2026-12-31T23:59:00-08:00`\n in a definition list — while the grid showed `01/01/2027, 08:59` for the same\n document. Not a rounding difference: a DateTimeOffset whose offset differs from the\n viewer's can disagree on the DATE, so the two pages named different days for one car.\n\n The grid's meaning wins because it is the one a reader can act on: a timestamp is an\n instant, shown in the viewer's own zone. The originating offset is still on the wire\n for code that needs it — server-side logic, exports, \"which country's morning was\n this?\" — it simply is not what a viewer is shown by default.\n -->\n @let when = (attr.name | rawAttributeValue:item()) | parsedDate;\n @if (when) {\n {{ when | date:(attr.dataType === 'date' ? 'mediumDate' : 'short') }}\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n </dd>\n </ng-template>\n </bs-grid>\n @for (subQuery of subQueries(); track subQuery.query) {\n <!-- The entry's selectionMode overrides the query's own for this parent (#460, D17). -->\n <spark-query-card [queryId]=\"subQuery.query\" [parentId]=\"currentItem.id!\" [parentType]=\"et.name\"\n [selectionMode]=\"subQuery.selectionMode ?? null\"\n [parentDeleted]=\"isDeletedView()\"\n [iconTemplate]=\"queryIconTemplate()\" [captionTemplate]=\"queryCaptionTemplate()\"\n [actionsTemplate]=\"queryActionsTemplate()\" />\n }\n @if (extraContentTemplate(); as extraContentTpl) {\n <ng-container *ngTemplateOutlet=\"extraContentTpl; context: { $implicit: currentItem, entityType: et }\"></ng-container>\n }\n <!-- Add-on panels (SPARK_DETAIL_PANELS), e.g. <spark-po-history>. -->\n @if (detailContext(); as ctx) {\n @for (panel of detailPanels; track panel.id) {\n <div class=\"spark-detail-panel m-3\">\n <ng-container *ngComponentOutlet=\"panel.component; inputs: { context: ctx }\" />\n </div>\n }\n }\n }\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n</bs-container>\n",
823
+ template: "<bs-container>\n<div class=\"container\">\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n } @else if (item(); as currentItem) {\n @if (entityType(); as et) {\n @if (actionError(); as actionErr) {\n <bs-alert [type]=\"colors.danger\" class=\"d-block mt-2 spark-action-error\" role=\"alert\">\n <div class=\"d-flex align-items-start gap-2\">\n <span class=\"flex-grow-1\">{{ actionErr }}</span>\n <button type=\"button\" class=\"btn btn-sm btn-link p-0 text-reset text-decoration-none\" [attr.aria-label]=\"lang.t('common.close', 'Close')\" (click)=\"actionError.set(null)\">&times;</button>\n </div>\n </bs-alert>\n }\n <div class=\"spark-actionbar py-2\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-outline-secondary\" (click)=\"onBack()\">\n <spark-icon name=\"arrow-left\" /> {{ 'common.back' | t }}\n </button>\n <!-- Edit and Delete are the composed catalogue's (#467, D8): label, icon and variant included. -->\n @if (editAction(); as edit) {\n <button *bsPriorityNavItem=\"2\" [class]=\"builtInActionClass(edit, 'primary')\" (click)=\"onEdit()\">\n @if (edit.icon) { <spark-icon [name]=\"edit.icon\" /> } {{ edit.label | resolveTranslation }}\n </button>\n }\n @if (deleteAction(); as del) {\n <button *bsPriorityNavItem=\"3\" [class]=\"builtInActionClass(del, 'danger')\" (click)=\"onDelete()\">\n @if (del.icon) { <spark-icon [name]=\"del.icon\" /> } {{ del.label | resolveTranslation }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of visibleCustomActions(); track action.name) {\n @let unavailable = customActionUnavailableReason(action);\n <button *bsPriorityNavItem=\"10 + action.offset\" [class]=\"customActionClass(action)\" [attr.data-action]=\"action.name\"\n [disabled]=\"runningAction() !== null || unavailable !== null\" [attr.title]=\"unavailable\" (click)=\"onCustomAction(action)\">\n {{ action.label | resolveTranslation }}\n </button>\n }\n }\n @if (extraActionsTemplate(); as extraActionsTpl) {\n <ng-container *bsPriorityNavItem=\"50\">\n <ng-container *ngTemplateOutlet=\"extraActionsTpl\"></ng-container>\n </ng-container>\n }\n <!-- Add-on actions (SPARK_DETAIL_ACTIONS): each decides for itself whether it applies. -->\n @if (detailContext(); as ctx) {\n @for (action of detailActions; track action.id) {\n <ng-container *bsPriorityNavItem=\"action.priority ?? 60\">\n <ng-container *ngComponentOutlet=\"action.component; inputs: { context: ctx }\" />\n </ng-container>\n }\n }\n </bs-priority-nav>\n </div>\n <h2>\n {{ currentItem.breadcrumb || currentItem.name }}\n @if (isDeletedView()) {\n <bs-badge [type]=\"colors.secondary\" class=\"align-middle ms-2 spark-deleted-badge\">{{ 'softDelete.deleted' | t }}</bs-badge>\n }\n </h2>\n\n @if (detailLayout(); as layout) {\n <!-- A registered layout draws the attributes instead (SPARK_DETAIL_LAYOUTS): Dashboards' tiles. -->\n <ng-container *ngComponentOutlet=\"layout.component; inputs: { context: detailContext() }\" />\n } @else {\n <bs-grid>\n <bs-tab-control>\n @for (tab of resolvedTabs(); track tab.id) {\n <bs-tab-page>\n <ng-template bsTabPageHeader>{{ tab.label | resolveTranslation:tab.name }}</ng-template>\n <ng-container *ngTemplateOutlet=\"detailTabContent; context: { $implicit: tab }\"></ng-container>\n </bs-tab-page>\n }\n </bs-tab-control>\n\n <ng-template #detailTabContent let-tab>\n @if (tab.id === '__default__') {\n @let ungroupedAttrs = ungroupedAttributes();\n @if (ungroupedAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of ungroupedAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n @for (group of groupsForTab(tab); track group.id) {\n @if (attrsForGroup(group); as groupAttrs) {\n @if (groupAttrs.length > 0) {\n <bs-card class=\"d-block m-3\">\n @if (group.label) {\n <bs-card-header>{{ group.label | resolveTranslation:group.name }}</bs-card-header>\n }\n <div class=\"p-3\">\n <dl bsRow>\n @for (attr of groupAttrs; track attr.id) {\n <ng-container *ngTemplateOutlet=\"detailAttrField; context: { $implicit: attr, item: currentItem }\"></ng-container>\n }\n </dl>\n </div>\n </bs-card>\n }\n }\n }\n </ng-template>\n\n <ng-template #detailAttrField let-attr let-currentItem=\"item\">\n <!-- A full-width renderer (the core 'paragraph') is body text: no label, the whole row. -->\n @let fullWidth = isFullWidthRenderer(attr);\n @if (!fullWidth) {\n <dt [sm]=\"3\">{{ (attr.label | resolveTranslation) || attr.name }}<spark-attribute-description [description]=\"attr.description\" /></dt>\n }\n <dd [sm]=\"fullWidth ? 12 : 9\">\n @if (getDetailRendererComponent(attr); as rendererType) {\n <ng-container *ngComponentOutlet=\"rendererType; inputs: getDetailRendererInputs(rendererType, attr, currentItem)\"></ng-container>\n } @else if (attr.dataType === 'AsDetail' && attr.isArray) {\n <bs-table [isResponsive]=\"true\">\n <thead>\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name) {\n <th>{{ (col.label | resolveTranslation) || col.name }}<spark-attribute-description [description]=\"col.description\" /></th>\n }\n </tr>\n </thead>\n <tbody class=\"align-middle\">\n @let rowRenderer = (attr | asDetailRowRenderer:asDetailTypes():rendererRegistry);\n @for (row of (attr.name | arrayValue:currentItem); track $index) {\n <tr>\n @for (col of (attr | asDetailColumns:asDetailTypes():rendererRegistry); track col.name; let firstCol = $first) {\n <td>\n <!--\n Shared with the query grid. This cell used to hand-roll the renderer\n dispatch and print everything else as text, so a `boolean` column read\n \"true\" here and rendered a checkbox in a grid, and a `color` showed its\n hex. Only the VALUE is resolved locally: these rows are plain\n dictionaries, not PersistentObjects.\n\n `chips` is left unset for a reference array on purpose — an AsDetail\n row's `__sparkBreadcrumbs` is keyed by column, not by id, so there are\n no labels for the members. Text beats a row of raw ids.\n -->\n <spark-grid-cell\n [column]=\"col\"\n [display]=\"(row | asDetailCellValue:attr:col:asDetailReferenceOptions())\"\n [rendererValue]=\"row[col.name]\"\n [item]=\"row\"\n [link]=\"col.dataType === 'Reference' && col.referenceType\n ? (col.referenceType | referenceLinkRoute:row[col.name]:allEntityTypes())\n : null\" />\n <!-- A row renderer (rowComponent: contributions' attribution line) draws\n its attributes once, under the row's first cell, instead of as columns. -->\n @if (firstCol && rowRenderer) {\n <div class=\"spark-row-renderer\">\n <ng-container *ngComponentOutlet=\"rowRenderer.component; inputs: getRowRendererInputs(rowRenderer, row, currentItem)\"></ng-container>\n </div>\n }\n </td>\n }\n </tr>\n } @empty {\n <tr>\n <td [attr.colspan]=\"(attr | asDetailColumns:asDetailTypes():rendererRegistry).length\" class=\"text-center text-muted\">\n {{ 'common.noItemsFound' | t }}\n </td>\n </tr>\n }\n </tbody>\n </bs-table>\n } @else if (attr.dataType === 'boolean') {\n <input type=\"checkbox\"\n [checked]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) === true\"\n [indeterminate]=\"(attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) == null\"\n disabled\n onclick=\"return false;\"\n class=\"opacity-100\">\n } @else if (attr.dataType === 'color') {\n @let colorVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (colorVal) {\n <span class=\"spark-color-swatch d-inline-block align-middle border rounded me-2\" [style.background-color]=\"colorVal\"></span>\n {{ colorVal }}\n } @else {\n -\n }\n } @else if (attr.dataType === 'image') {\n @let imageSrc = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (imageSrc) {\n <img [src]=\"imageSrc\" [alt]=\"(attr.label | resolveTranslation) || attr.name\" loading=\"lazy\"\n class=\"img-fluid rounded\" style=\"max-height: 12rem;\">\n } @else {\n -\n }\n } @else if (attr.dataType === 'url') {\n @let hrefVal = (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes());\n @if (hrefVal) {\n <a [href]=\"hrefVal\" target=\"_blank\" rel=\"noopener noreferrer\">{{ hrefVal }}</a>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.isArray) {\n @let chips = (attr.name | referenceChips:item());\n @if (chips.length) {\n <div class=\"d-flex flex-wrap gap-1\">\n @for (chip of chips; track chip.id) {\n @let chipRoute = (attr.referenceType ? (attr.referenceType | referenceLinkRoute:chip.id:allEntityTypes()) : null);\n @if (chipRoute) {\n <a class=\"spark-chip\" [routerLink]=\"chipRoute\">{{ chip.label }}</a>\n } @else {\n <span class=\"spark-chip\">{{ chip.label }}</span>\n }\n }\n </div>\n } @else {\n -\n }\n } @else if (attr.dataType === 'Reference' && attr.referenceType) {\n @let refRoute = (attr.referenceType | referenceLinkRoute:(attr.name | rawAttributeValue:item()):allEntityTypes());\n @if (refRoute && (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes())) {\n <a [routerLink]=\"refRoute\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) }}</a>\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else if (attr.dataType === 'MultiLineString') {\n <div class=\"spark-multiline-value\">{{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}</div>\n } @else if (attr.dataType === 'date' || attr.dataType === 'datetime') {\n <!--\n Formatted the same way, and in the same timezone, as the grid renders it.\n\n Before this, a detail page printed the raw wire value — `2026-12-31T23:59:00-08:00`\n in a definition list — while the grid showed `01/01/2027, 08:59` for the same\n document. Not a rounding difference: a DateTimeOffset whose offset differs from the\n viewer's can disagree on the DATE, so the two pages named different days for one car.\n\n The grid's meaning wins because it is the one a reader can act on: a timestamp is an\n instant, shown in the viewer's own zone. The originating offset is still on the wire\n for code that needs it — server-side logic, exports, \"which country's morning was\n this?\" — it simply is not what a viewer is shown by default.\n -->\n @let when = (attr.name | rawAttributeValue:item()) | parsedDate;\n @if (when) {\n {{ when | date:(attr.dataType === 'date' ? 'mediumDate' : 'short') }}\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n } @else {\n {{ (attr.name | attributeValue:item():entityType():lookupReferenceOptions():allEntityTypes()) | orDash }}\n }\n </dd>\n </ng-template>\n </bs-grid>\n }\n @for (subQuery of subQueries(); track subQuery.query) {\n <!-- The entry's selectionMode overrides the query's own for this parent (#460, D17). -->\n <spark-query-card [queryId]=\"subQuery.query\" [parentId]=\"currentItem.id!\" [parentType]=\"et.name\"\n [selectionMode]=\"subQuery.selectionMode ?? null\"\n [parentDeleted]=\"isDeletedView()\"\n [iconTemplate]=\"queryIconTemplate()\" [captionTemplate]=\"queryCaptionTemplate()\"\n [actionsTemplate]=\"queryActionsTemplate()\" />\n }\n @if (extraContentTemplate(); as extraContentTpl) {\n <ng-container *ngTemplateOutlet=\"extraContentTpl; context: { $implicit: currentItem, entityType: et }\"></ng-container>\n }\n <!-- Add-on panels (SPARK_DETAIL_PANELS), e.g. <spark-po-history>. -->\n @if (detailContext(); as ctx) {\n @for (panel of detailPanels; track panel.id) {\n <div class=\"spark-detail-panel m-3\">\n <ng-container *ngComponentOutlet=\"panel.component; inputs: { context: ctx }\" />\n </div>\n }\n }\n }\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n</bs-container>\n",
817
824
  styles: [".spark-actionbar{--_bleed-block: var(--spark-content-inset-block, 0px);--_bleed-inline: var(--spark-content-inset-inline, 0px);--_gutter: var(--spark-actionbar-gutter, 1rem);position:sticky;top:calc(-1 * var(--_bleed-block));z-index:400;background-color:var(--spark-shell-main-bg, var(--bs-body-bg));border-bottom:1px solid var(--bs-border-color);margin-bottom:1rem;margin-inline:calc(-1 * var(--_bleed-inline));padding-inline:calc(var(--_bleed-inline) + var(--_gutter))}.spark-actionbar .btn{border-radius:0}.spark-actionbar-wide{--spark-actionbar-gutter: 1.5rem}.spark-chip{display:inline-block;padding:.35em .9em;font-size:.8125em;font-weight:600;line-height:1.4;white-space:nowrap;text-align:center;vertical-align:baseline;color:var(--bs-body-color);background-color:var(--bs-secondary-bg);border:1px solid var(--bs-border-color);border-radius:50rem;text-decoration:none}a.spark-chip:hover,a.spark-chip:focus-visible{background-color:var(--bs-tertiary-bg)}.spark-color-swatch{width:1.5em;height:1.5em}.container{--spark-content-inset-inline: 0px}.spark-multiline-value{white-space:pre-wrap}\n"]
818
825
  }]
819
826
  }],