@mintplayer/ng-spark 22.2.0 → 22.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/fesm2022/mintplayer-ng-spark-client-operations.mjs +78 -4
  2. package/fesm2022/mintplayer-ng-spark-client-operations.mjs.map +1 -1
  3. package/fesm2022/mintplayer-ng-spark-grid.mjs +860 -0
  4. package/fesm2022/mintplayer-ng-spark-grid.mjs.map +1 -0
  5. package/fesm2022/mintplayer-ng-spark-models.mjs +160 -1
  6. package/fesm2022/mintplayer-ng-spark-models.mjs.map +1 -1
  7. package/fesm2022/mintplayer-ng-spark-pipes.mjs +31 -5
  8. package/fesm2022/mintplayer-ng-spark-pipes.mjs.map +1 -1
  9. package/fesm2022/mintplayer-ng-spark-po-create.mjs +2 -2
  10. package/fesm2022/mintplayer-ng-spark-po-create.mjs.map +1 -1
  11. package/fesm2022/mintplayer-ng-spark-po-detail.mjs +36 -153
  12. package/fesm2022/mintplayer-ng-spark-po-detail.mjs.map +1 -1
  13. package/fesm2022/mintplayer-ng-spark-po-edit.mjs +2 -2
  14. package/fesm2022/mintplayer-ng-spark-po-edit.mjs.map +1 -1
  15. package/fesm2022/mintplayer-ng-spark-po-form.mjs +4 -4
  16. package/fesm2022/mintplayer-ng-spark-po-form.mjs.map +1 -1
  17. package/fesm2022/mintplayer-ng-spark-query-list.mjs +148 -277
  18. package/fesm2022/mintplayer-ng-spark-query-list.mjs.map +1 -1
  19. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs +5 -3
  20. package/fesm2022/mintplayer-ng-spark-retry-action-modal.mjs.map +1 -1
  21. package/fesm2022/mintplayer-ng-spark.mjs +1 -1
  22. package/fesm2022/mintplayer-ng-spark.mjs.map +1 -1
  23. package/package.json +6 -2
  24. package/types/mintplayer-ng-spark-client-operations.d.ts +39 -4
  25. package/types/mintplayer-ng-spark-grid.d.ts +499 -0
  26. package/types/mintplayer-ng-spark-models.d.ts +74 -3
  27. package/types/mintplayer-ng-spark-po-detail.d.ts +19 -31
  28. package/types/mintplayer-ng-spark-query-list.d.ts +56 -36
@@ -1,34 +1,48 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, DestroyRef, input, output, signal, computed, ChangeDetectionStrategy, Component } from '@angular/core';
2
+ import { inject, DestroyRef, input, output, signal, viewChild, computed, effect, ChangeDetectionStrategy, Component } from '@angular/core';
3
3
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
4
4
  import * as i1 from '@angular/common';
5
- import { CommonModule, NgTemplateOutlet, NgComponentOutlet } from '@angular/common';
5
+ import { CommonModule, NgTemplateOutlet } from '@angular/common';
6
6
  import * as i2 from '@angular/forms';
7
7
  import { FormsModule } from '@angular/forms';
8
- import * as i3 from '@angular/router';
9
- import { ActivatedRoute, Router, RouterModule } from '@angular/router';
8
+ import { ActivatedRoute, Router } from '@angular/router';
10
9
  import { Color } from '@mintplayer/ng-bootstrap';
10
+ import { BsBadgeComponent } from '@mintplayer/ng-bootstrap/badge';
11
11
  import { BsAlertComponent } from '@mintplayer/ng-bootstrap/alert';
12
- import { DatatableSettings, BsDatatableComponent, BsDatatableColumnDirective, BsRowTemplateDirective } from '@mintplayer/ng-bootstrap/datatable';
13
12
  import { BsFormComponent, BsFormControlDirective } from '@mintplayer/ng-bootstrap/form';
14
13
  import { BsGridComponent, BsGridRowDirective, BsGridColumnDirective } from '@mintplayer/ng-bootstrap/grid';
15
14
  import { BsInputGroupComponent } from '@mintplayer/ng-bootstrap/input-group';
16
15
  import { BsPriorityNavComponent, BsPriorityNavItemDirective } from '@mintplayer/ng-bootstrap/priority-nav';
17
16
  import { BsSpinnerComponent } from '@mintplayer/ng-bootstrap/spinner';
18
17
  import { SparkService, SparkStreamingService, SparkLanguageService } from '@mintplayer/ng-spark/services';
19
- import { ResolveTranslationPipe, TranslateKeyPipe, AttributeValuePipe, ReferenceChipsPipe } from '@mintplayer/ng-spark/pipes';
18
+ import { ResolveTranslationPipe, TranslateKeyPipe } from '@mintplayer/ng-spark/pipes';
20
19
  import { SparkIconComponent } from '@mintplayer/ng-spark/icon';
21
- import { SPARK_ATTRIBUTE_RENDERERS, withDeclaredInputs, rendererValue } from '@mintplayer/ng-spark/renderers';
22
- import { hasShowedOnFlag, ShowedOn } from '@mintplayer/ng-spark/models';
20
+ import { SparkQueryGridComponent } from '@mintplayer/ng-spark/grid';
23
21
 
22
+ /**
23
+ * The routed query page: chrome around one {@link SparkQueryGridComponent}.
24
+ *
25
+ * It owns what is genuinely page-shaped and route-shaped, and nothing else:
26
+ *
27
+ * - **Route resolution.** It has no `queryId` input; it reads `paramMap`, and it serves two
28
+ * routes — `query/:queryId`, and `po/:type`, which resolves an entity type to a query. That
29
+ * second one is type-to-query resolution, not query rendering, and is why this component still
30
+ * exists rather than the router pointing at the grid.
31
+ * - **Streaming.** The websocket lives here so it stays out of every PO detail page's bundle;
32
+ * the snapshot is filtered and sorted client-side and handed to the grid as `[data]`.
33
+ * - The action bar, the caption, the LIVE badge, the search box and the New button.
34
+ *
35
+ * The grid itself — columns, cells, paging, the row link, selection, custom-action execution —
36
+ * is the shared component. This page previously wrote out `<bs-datatable>` twice, once per
37
+ * transport, with a shared row template between them; both are gone.
38
+ */
24
39
  class SparkQueryListComponent {
25
40
  route = inject(ActivatedRoute);
26
41
  router = inject(Router);
27
42
  sparkService = inject(SparkService);
28
43
  streamingService = inject(SparkStreamingService);
29
- lang = inject(SparkLanguageService);
30
- rendererRegistry = inject(SPARK_ATTRIBUTE_RENDERERS);
31
44
  destroyRef = inject(DestroyRef);
45
+ lang = inject(SparkLanguageService);
32
46
  extraActionsTemplate = input(null, /* @ts-ignore */
33
47
  ...(ngDevMode ? [{ debugName: "extraActionsTemplate" }] : /* istanbul ignore next */ []));
34
48
  showCustomActions = input(true, /* @ts-ignore */
@@ -37,25 +51,40 @@ class SparkQueryListComponent {
37
51
  createClicked = output();
38
52
  customActionExecuted = output();
39
53
  colors = Color;
54
+ /** The query the grid should render, resolved from the route. Null until it is known. */
55
+ queryId = signal(null, /* @ts-ignore */
56
+ ...(ngDevMode ? [{ debugName: "queryId" }] : /* istanbul ignore next */ []));
40
57
  errorMessage = signal(null, /* @ts-ignore */
41
58
  ...(ngDevMode ? [{ debugName: "errorMessage" }] : /* istanbul ignore next */ []));
42
- query = signal(null, /* @ts-ignore */
59
+ searchTerm = signal('', /* @ts-ignore */
60
+ ...(ngDevMode ? [{ debugName: "searchTerm" }] : /* istanbul ignore next */ []));
61
+ grid = viewChild(SparkQueryGridComponent, /* @ts-ignore */
62
+ ...(ngDevMode ? [{ debugName: "grid" }] : /* istanbul ignore next */ []));
63
+ /**
64
+ * Grid state, surfaced for this page's chrome.
65
+ *
66
+ * Optional `viewChild`, read defensively: the action bar and caption render above the grid, so
67
+ * on the first change-detection pass the query has not resolved yet.
68
+ */
69
+ query = computed(() => this.grid()?.query() ?? null, /* @ts-ignore */
43
70
  ...(ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []));
44
- entityType = signal(null, /* @ts-ignore */
71
+ entityType = computed(() => this.grid()?.entityType() ?? null, /* @ts-ignore */
45
72
  ...(ngDevMode ? [{ debugName: "entityType" }] : /* istanbul ignore next */ []));
46
- allEntityTypes = signal([], /* @ts-ignore */
47
- ...(ngDevMode ? [{ debugName: "allEntityTypes" }] : /* istanbul ignore next */ []));
48
- lookupReferenceOptions = signal({}, /* @ts-ignore */
49
- ...(ngDevMode ? [{ debugName: "lookupReferenceOptions" }] : /* istanbul ignore next */ []));
50
- resultCount = signal(null, /* @ts-ignore */
51
- ...(ngDevMode ? [{ debugName: "resultCount" }] : /* istanbul ignore next */ []));
52
- searchTerm = '';
53
- canRead = signal(false, /* @ts-ignore */
54
- ...(ngDevMode ? [{ debugName: "canRead" }] : /* istanbul ignore next */ []));
55
- canCreate = signal(false, /* @ts-ignore */
56
- ...(ngDevMode ? [{ debugName: "canCreate" }] : /* istanbul ignore next */ []));
57
- customActions = signal([], /* @ts-ignore */
73
+ customActions = computed(() => this.grid()?.customActions() ?? [], /* @ts-ignore */
58
74
  ...(ngDevMode ? [{ debugName: "customActions" }] : /* istanbul ignore next */ []));
75
+ canCreate = computed(() => this.grid()?.canCreate() ?? false, /* @ts-ignore */
76
+ ...(ngDevMode ? [{ debugName: "canCreate" }] : /* istanbul ignore next */ []));
77
+ resultCount = computed(() => this.grid()?.resultCount() ?? null, /* @ts-ignore */
78
+ ...(ngDevMode ? [{ debugName: "resultCount" }] : /* istanbul ignore next */ []));
79
+ isVirtualScrolling = computed(() => this.grid()?.isVirtualScrolling() ?? false, /* @ts-ignore */
80
+ ...(ngDevMode ? [{ debugName: "isVirtualScrolling" }] : /* istanbul ignore next */ []));
81
+ gridError = computed(() => this.grid()?.errorMessage() ?? null, /* @ts-ignore */
82
+ ...(ngDevMode ? [{ debugName: "gridError" }] : /* istanbul ignore next */ []));
83
+ /** Whether an action's selection rule is satisfied. Delegated: the grid holds the selection. */
84
+ isActionEnabled(action) {
85
+ return this.grid()?.isActionEnabled(action) ?? false;
86
+ }
87
+ // --- streaming ---------------------------------------------------------------
59
88
  isStreaming = signal(false, /* @ts-ignore */
60
89
  ...(ngDevMode ? [{ debugName: "isStreaming" }] : /* istanbul ignore next */ []));
61
90
  streamingSub = null;
@@ -63,254 +92,107 @@ class SparkQueryListComponent {
63
92
  ...(ngDevMode ? [{ debugName: "allItems" }] : /* istanbul ignore next */ []));
64
93
  streamItems = signal([], /* @ts-ignore */
65
94
  ...(ngDevMode ? [{ debugName: "streamItems" }] : /* istanbul ignore next */ []));
66
- fetchFn = signal(null, /* @ts-ignore */
67
- ...(ngDevMode ? [{ debugName: "fetchFn" }] : /* istanbul ignore next */ []));
68
- settings = signal(new DatatableSettings({
69
- perPage: { values: [10, 25, 50], selected: 10 },
70
- page: { values: [1], selected: 1 },
71
- sortColumns: []
72
- }), /* @ts-ignore */
73
- ...(ngDevMode ? [{ debugName: "settings" }] : /* istanbul ignore next */ []));
95
+ /**
96
+ * Rows handed to the grid, or `null` to let it fetch for itself.
97
+ *
98
+ * Null for a normal query an empty array would read as "here are no rows" and suppress the
99
+ * fetch entirely.
100
+ */
101
+ gridData = computed(() => this.query()?.isStreamingQuery ? this.streamItems() : null, /* @ts-ignore */
102
+ ...(ngDevMode ? [{ debugName: "gridData" }] : /* istanbul ignore next */ []));
74
103
  constructor() {
75
- this.route.paramMap.pipe(takeUntilDestroyed()).subscribe(params => this.onParamsChange(params));
104
+ this.route.paramMap.pipe(takeUntilDestroyed()).subscribe(params => {
105
+ // The handler is async and this is a subscribe, so a rejection lands nowhere: the metadata
106
+ // load would reject, the query would stay null, and the template would render a spinner
107
+ // FOREVER — while this component has had an errorMessage surface all along that only the
108
+ // fetch path ever reached.
109
+ this.onParamsChange(params).catch((e) => this.reportLoadFailure(e));
110
+ });
76
111
  this.destroyRef.onDestroy(() => this.disconnectStreaming());
112
+ // Connect the socket once the grid has resolved a streaming query, and disconnect whenever it
113
+ // resolves anything else. The grid knows not to fetch for a streaming query, so there is no
114
+ // window in which both transports are live.
115
+ effect(() => {
116
+ const q = this.query();
117
+ if (q?.isStreamingQuery) {
118
+ this.connectStreaming(q.id);
119
+ }
120
+ else {
121
+ this.disconnectStreaming();
122
+ }
123
+ });
124
+ // Client-side filter and sort for the streaming snapshot. The server never sees these: there
125
+ // is no request to attach them to.
126
+ effect(() => {
127
+ this.searchTerm();
128
+ this.grid()?.settings();
129
+ this.allItems();
130
+ if (this.isStreaming())
131
+ this.applyFilter();
132
+ });
77
133
  }
78
134
  async onParamsChange(params) {
79
- // Reset prior-route state so we render the spinner (not stale rows from
80
- // the previous query) while the new query/entityType resolve.
81
- this.entityType.set(null);
82
- this.fetchFn.set(null);
83
- this.resultCount.set(null);
135
+ this.errorMessage.set(null);
136
+ this.queryId.set(null);
84
137
  this.allItems.set([]);
85
138
  this.streamItems.set([]);
86
139
  this.disconnectStreaming();
87
140
  const queryId = params.get('queryId');
88
141
  const typeParam = params.get('type');
89
- let resolvedQuery = null;
90
- let resolvedEntityType = null;
91
- let resolvedEntityTypes = [];
92
142
  if (queryId) {
93
- resolvedQuery = await this.sparkService.getQuery(queryId);
94
- if (resolvedQuery) {
95
- const result = await this.resolveEntityTypeForQuery(resolvedQuery);
96
- resolvedEntityType = result.entityType;
97
- resolvedEntityTypes = result.entityTypes;
98
- }
99
- }
100
- else if (typeParam) {
101
- resolvedEntityTypes = await this.sparkService.getEntityTypes();
102
- resolvedEntityType = resolvedEntityTypes.find(t => t.id === typeParam || t.alias === typeParam) || null;
103
- if (resolvedEntityType) {
104
- const queries = await this.sparkService.getQueries();
105
- const singularName = resolvedEntityType.name;
106
- resolvedQuery = queries.find(q => {
107
- // Match by explicit entityType
108
- if (q.entityType === singularName)
109
- return true;
110
- // Match by source name
111
- const sourceName = this.extractSourceName(q.source);
112
- const sourceSingular = this.singularize(sourceName);
113
- return sourceName === singularName ||
114
- sourceSingular === singularName ||
115
- sourceName === singularName + 's';
116
- }) || null;
117
- }
118
- }
119
- if (resolvedQuery)
120
- this.query.set(resolvedQuery);
121
- if (resolvedEntityType) {
122
- this.entityType.set(resolvedEntityType);
123
- this.allEntityTypes.set(resolvedEntityTypes);
124
- const initialSortColumns = (resolvedQuery?.sortColumns || []).map(sc => ({
125
- property: sc.property,
126
- direction: sc.direction === 'desc' ? 'descending' : 'ascending'
127
- }));
128
- this.settings.set(new DatatableSettings({
129
- perPage: { values: [10, 25, 50], selected: 10 },
130
- page: { values: [1], selected: 1 },
131
- sortColumns: initialSortColumns
132
- }));
133
- if (resolvedQuery?.isStreamingQuery) {
134
- // Streaming: WebSocket feeds allItems; the datatable binds [data]="streamItems()".
135
- this.connectStreaming(resolvedQuery.id);
136
- }
137
- else if (resolvedQuery) {
138
- // Non-streaming: the datatable drives paging/sorting via [(settings)] and calls
139
- // fetchFn per page. Virtual scrolling is just the [virtualScroll] template flag —
140
- // the datatable front-loads all pages from fetchFn when virtual.
141
- this.fetchFn.set(this.makeFetch(resolvedQuery));
142
- }
143
- this.loadLookupReferenceOptions();
144
- const [permissions, actions] = await Promise.all([
145
- this.sparkService.getPermissions(resolvedEntityType.id),
146
- this.sparkService.getCustomActions(resolvedEntityType.id),
147
- ]);
148
- this.canRead.set(permissions.canRead);
149
- this.canCreate.set(permissions.canCreate);
150
- this.customActions.set(actions.filter(a => a.showedOn === 'list' || a.showedOn === 'both'));
151
- }
152
- }
153
- async onCustomAction(action) {
154
- if (action.confirmationMessageKey) {
155
- const message = this.lang.t(action.confirmationMessageKey) || 'Are you sure?';
156
- if (!confirm(message))
157
- return;
158
- }
159
- try {
160
- await this.sparkService.executeCustomAction(this.entityType().id, action.name);
161
- this.customActionExecuted.emit({ action });
162
- if (action.refreshOnCompleted) {
163
- this.refresh();
164
- }
165
- }
166
- catch (e) {
167
- const err = e;
168
- this.errorMessage.set(err.error?.error || err.message || 'Action failed');
169
- }
170
- }
171
- async resolveEntityTypeForQuery(query) {
172
- const entityTypes = await this.sparkService.getEntityTypes();
173
- // If entityType is explicitly set on the query, use it directly
174
- if (query.entityType) {
175
- const type = entityTypes.find(t => t.name === query.entityType || t.alias === query.entityType?.toLowerCase());
176
- return { entityType: type || null, entityTypes };
177
- }
178
- // For Database.X sources, extract the property name and try to match
179
- const sourceName = this.extractSourceName(query.source);
180
- const singularName = this.singularize(sourceName);
181
- const type = entityTypes.find(t => t.name === sourceName ||
182
- t.name === singularName ||
183
- t.clrType.endsWith(singularName));
184
- return { entityType: type || null, entityTypes };
185
- }
186
- extractSourceName(source) {
187
- const dotIndex = source.indexOf('.');
188
- return dotIndex >= 0 ? source.substring(dotIndex + 1) : source;
189
- }
190
- singularize(plural) {
191
- const irregulars = {
192
- 'People': 'Person',
193
- 'Children': 'Child',
194
- 'Men': 'Men',
195
- 'Women': 'Woman'
196
- };
197
- if (irregulars[plural])
198
- return irregulars[plural];
199
- if (plural.endsWith('ies')) {
200
- return plural.slice(0, -3) + 'y';
201
- }
202
- if (plural.endsWith('es')) {
203
- return plural.slice(0, -2);
204
- }
205
- if (plural.endsWith('s')) {
206
- return plural.slice(0, -1);
207
- }
208
- return plural;
209
- }
210
- /**
211
- * Builds the server-side fetch callback the datatable invokes per page/sort.
212
- * Reads `searchTerm` live, so a settings change (or a new fetchFn identity)
213
- * refetches with the current search term.
214
- */
215
- makeFetch(query) {
216
- return (req) => this.sparkService.executeQuery(query.id, {
217
- sortColumns: req.sortColumns,
218
- skip: (req.page - 1) * req.perPage,
219
- take: req.perPage,
220
- search: this.searchTerm || undefined,
221
- }).then(r => {
222
- this.errorMessage.set(null);
223
- this.resultCount.set(r.totalRecords);
224
- return {
225
- data: r.data,
226
- totalRecords: r.totalRecords,
227
- totalPages: Math.ceil(r.totalRecords / req.perPage) || 1,
228
- perPage: req.perPage,
229
- page: req.page,
230
- };
231
- }).catch((e) => {
232
- this.errorMessage.set(e.error?.error || e.message || 'An unexpected error occurred');
233
- this.resultCount.set(0);
234
- return { data: [], totalRecords: 0, totalPages: 1, perPage: req.perPage, page: req.page };
235
- });
236
- }
237
- /** Force a refetch (e.g. after a custom action) without changing page/sort. */
238
- refresh() {
239
- if (this.isStreaming()) {
240
- this.applyFilter();
143
+ this.queryId.set(queryId);
241
144
  return;
242
145
  }
243
- const q = this.query();
244
- if (q)
245
- this.fetchFn.set(this.makeFetch(q));
246
- }
247
- onSearchChange() {
248
- if (this.isStreaming()) {
249
- this.applyFilter();
146
+ if (!typeParam)
147
+ return;
148
+ // `po/:type` — the route names an entity type, so find the query that lists it. The grid takes
149
+ // a query, and this translation is the only reason it cannot take the route directly.
150
+ const [entityTypes, queries] = await Promise.all([
151
+ this.sparkService.getEntityTypes(),
152
+ this.sparkService.getQueries(),
153
+ ]);
154
+ const entityType = entityTypes.find(t => t.id === typeParam || t.alias === typeParam);
155
+ if (!entityType) {
156
+ this.reportLoadFailure({ status: 404 });
250
157
  return;
251
158
  }
252
- // Reset to page 1 for the new search.
253
- const s = this.settings();
254
- this.settings.set(new DatatableSettings({
255
- perPage: { values: s.perPage.values, selected: s.perPage.selected },
256
- page: { values: [1], selected: 1 },
257
- sortColumns: s.sortColumns,
258
- }));
259
- // Re-assign the fetch callback so the datatable refetches even when
260
- // page/perPage/sort are unchanged. ng-bootstrap 22.4's web component dedupes
261
- // reloads by {sortColumns, perPage, page}; setting a new fetch identity resets
262
- // that key (set fetch → _lastReloadKey = null) and forces the reload. makeFetch
263
- // reads searchTerm live (mirrors refresh()).
264
- const q = this.query();
265
- if (q)
266
- this.fetchFn.set(this.makeFetch(q));
267
- }
268
- clearSearch() {
269
- this.searchTerm = '';
270
- this.onSearchChange();
271
- }
272
- isVirtualScrolling = computed(() => this.query()?.renderMode === 'VirtualScrolling', /* @ts-ignore */
273
- ...(ngDevMode ? [{ debugName: "isVirtualScrolling" }] : /* istanbul ignore next */ []));
274
- visibleAttributes = computed(() => {
275
- return this.entityType()?.attributes
276
- .filter(a => a.isVisible && hasShowedOnFlag(a.showedOn, ShowedOn.Query))
277
- .sort((a, b) => a.order - b.order) || [];
278
- }, /* @ts-ignore */
279
- ...(ngDevMode ? [{ debugName: "visibleAttributes" }] : /* istanbul ignore next */ []));
280
- getColumnRendererComponent(attr) {
281
- if (!attr.renderer)
282
- return null;
283
- return this.rendererRegistry.find(r => r.name === attr.renderer)?.columnComponent ?? null;
284
- }
285
- getColumnRendererInputs(component, item, attr) {
286
- const itemAttr = item.attributes.find(a => a.name === attr.name);
287
- return withDeclaredInputs(component, {
288
- value: rendererValue(itemAttr),
289
- attribute: attr,
290
- options: attr.rendererOptions,
291
- item,
159
+ const singularName = entityType.name;
160
+ const match = queries.find(q => {
161
+ if (q.entityType === singularName)
162
+ return true;
163
+ const sourceName = q.source.includes('.') ? q.source.substring(q.source.indexOf('.') + 1) : q.source;
164
+ return sourceName === singularName || sourceName === singularName + 's';
292
165
  });
166
+ if (match)
167
+ this.queryId.set(match.alias || match.id);
168
+ else
169
+ this.reportLoadFailure({ status: 404 });
293
170
  }
294
- async loadLookupReferenceOptions() {
295
- const lookupAttrs = this.visibleAttributes().filter(a => a.lookupReferenceType);
296
- if (lookupAttrs.length === 0)
297
- return;
298
- const lookupNames = [...new Set(lookupAttrs.map(a => a.lookupReferenceType))];
299
- const entries = await Promise.all(lookupNames.map(async (name) => {
300
- const result = await this.sparkService.getLookupReference(name);
301
- return [name, result];
302
- }));
303
- this.lookupReferenceOptions.set(entries.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}));
171
+ /**
172
+ * A load failure has to render, not just be swallowed: a denied query answers 404 (audit M-3, so
173
+ * existence is not leaked), which is indistinguishable from a missing one — hence a deliberately
174
+ * generic message rather than a guess at which it was.
175
+ */
176
+ reportLoadFailure(err) {
177
+ this.errorMessage.set(err?.status === 404
178
+ ? (this.lang.t('spark.query.unavailable') || 'This list is not available.')
179
+ : (err?.error?.error || err?.message || 'An unexpected error occurred'));
180
+ }
181
+ async onCustomAction(action) {
182
+ await this.grid()?.onCustomAction(action);
304
183
  }
305
184
  onCreate() {
306
185
  this.createClicked.emit();
307
186
  const et = this.entityType();
308
- if (et) {
187
+ if (et)
309
188
  this.router.navigate(['/po', et.alias || et.id, 'new']);
310
- }
189
+ }
190
+ clearSearch() {
191
+ this.searchTerm.set('');
311
192
  }
312
193
  connectStreaming(queryId) {
313
- this.disconnectStreaming();
194
+ if (this.streamingSub)
195
+ return;
314
196
  this.isStreaming.set(true);
315
197
  this.streamingSub = this.streamingService.connectToStreamingQuery(queryId).subscribe({
316
198
  next: (message) => this.handleStreamingMessage(message),
@@ -318,9 +200,7 @@ class SparkQueryListComponent {
318
200
  this.errorMessage.set(err?.message || 'Streaming connection failed');
319
201
  this.isStreaming.set(false);
320
202
  },
321
- complete: () => {
322
- this.isStreaming.set(false);
323
- }
203
+ complete: () => this.isStreaming.set(false),
324
204
  });
325
205
  }
326
206
  disconnectStreaming() {
@@ -335,26 +215,18 @@ class SparkQueryListComponent {
335
215
  case 'snapshot':
336
216
  this.errorMessage.set(null);
337
217
  this.allItems.set(message.data);
338
- this.applyFilter();
339
218
  break;
340
219
  case 'patch':
341
220
  if (message.updated.length > 0) {
342
- const currentItems = this.allItems();
343
- const updatedItems = currentItems.map(item => {
221
+ this.allItems.update(items => items.map(item => {
344
222
  const patch = message.updated.find(u => u.id === item.id);
345
223
  if (!patch)
346
224
  return item;
347
- // Clone the item and update only changed attribute values
348
- const updatedAttributes = item.attributes.map(attr => {
349
- if (attr.name in patch.attributes) {
350
- return { ...attr, value: patch.attributes[attr.name] };
351
- }
352
- return attr;
353
- });
354
- return { ...item, attributes: updatedAttributes };
355
- });
356
- this.allItems.set(updatedItems);
357
- this.applyFilter();
225
+ return {
226
+ ...item,
227
+ attributes: item.attributes.map(attr => attr.name in patch.attributes ? { ...attr, value: patch.attributes[attr.name] } : attr),
228
+ };
229
+ }));
358
230
  }
359
231
  break;
360
232
  case 'error':
@@ -364,14 +236,14 @@ class SparkQueryListComponent {
364
236
  }
365
237
  applyFilter() {
366
238
  let items = this.allItems();
367
- // Apply search filter
368
- if (this.searchTerm) {
369
- const term = this.searchTerm.toLowerCase();
239
+ const term = this.searchTerm().toLowerCase();
240
+ if (term) {
370
241
  items = items.filter(item => item.attributes.some(a => String(a.value ?? '').toLowerCase().includes(term)));
371
242
  }
372
- // Apply sorting (client-side for the streaming snapshot; the datatable in
373
- // [data] mode also auto-sorts on header clicks).
374
- const sortCols = this.settings().sortColumns;
243
+ // The datatable in `[data]` mode also sorts on header clicks, but the sort must survive a
244
+ // patch: re-deriving from `allItems` without re-applying it would silently reorder the grid
245
+ // under the user on every update.
246
+ const sortCols = this.grid()?.settings().sortColumns ?? [];
375
247
  if (sortCols.length > 0) {
376
248
  items = [...items].sort((a, b) => {
377
249
  for (const col of sortCols) {
@@ -385,17 +257,16 @@ class SparkQueryListComponent {
385
257
  });
386
258
  }
387
259
  this.streamItems.set(items);
388
- this.resultCount.set(items.length);
389
260
  }
390
261
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: SparkQueryListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
391
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.0", type: SparkQueryListComponent, isStandalone: true, selector: "spark-query-list", inputs: { extraActionsTemplate: { classPropertyName: "extraActionsTemplate", publicName: "extraActionsTemplate", isSignal: true, isRequired: false, transformFunction: null }, showCustomActions: { classPropertyName: "showCustomActions", publicName: "showCustomActions", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { rowClicked: "rowClicked", createClicked: "createClicked", customActionExecuted: "customActionExecuted" }, host: { properties: { "class.virtual-scrolling": "isVirtualScrolling()" } }, ngImport: i0, template: "<div class=\"d-flex flex-column h-100\">\n <div class=\"spark-actionbar py-2 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\" [class.px-3]=\"!isVirtualScrolling()\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n @if (canCreate()) {\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-primary\" (click)=\"onCreate()\">\n <spark-icon name=\"plus-lg\" /> {{ 'common.new' | t }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of customActions(); track action.name) {\n <button *bsPriorityNavItem=\"10 + action.offset\" class=\"btn btn-outline-primary\" (click)=\"onCustomAction(action)\">\n {{ action.displayName | 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 </bs-priority-nav>\n </div>\n <h2 class=\"mb-4 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n {{ (query()?.description | resolveTranslation) || query()?.name || ('common.loading' | t) }}\n @if (isStreaming()) {\n <span class=\"badge bg-success ms-2\" style=\"font-size: 0.5em; vertical-align: middle;\">LIVE</span>\n }\n </h2>\n\n @if (entityType()) {\n <!-- Search box -->\n <div class=\"flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n <bs-form>\n <bs-grid>\n <div bsRow class=\"mb-3\">\n <div [md]=\"4\">\n <bs-input-group>\n <span class=\"input-group-text\">\n <spark-icon name=\"search\" />\n </span>\n <input\n type=\"text\"\n [placeholder]=\"'common.search' | t\"\n [(ngModel)]=\"searchTerm\"\n (ngModelChange)=\"onSearchChange()\">\n @if (searchTerm) {\n <button\n type=\"button\"\n class=\"btn btn-outline-secondary\"\n (click)=\"clearSearch()\">\n <spark-icon name=\"x-lg\" />\n </button>\n }\n </bs-input-group>\n </div>\n <div [md]=\"8\" class=\"text-end\">\n @if (searchTerm && resultCount() !== null) {\n <span class=\"text-muted\">\n {{ resultCount() }} {{ resultCount() === 1 ? ('common.resultFound' | t) : ('common.resultsFound' | t) }}\n </span>\n }\n </div>\n </div>\n </bs-grid>\n </bs-form>\n\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n }\n </div>\n\n @if (isStreaming()) {\n <bs-datatable class=\"flex-grow-1\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [data]=\"streamItems()\"\n [(settings)]=\"settings\">\n @for (attr of visibleAttributes(); track attr.id) {\n <div *bsDatatableColumn=\"attr.name; sortable: true\">\n {{ (attr.label | resolveTranslation) || attr.name }}\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (attr of visibleAttributes(); track attr.id; let first = $first) {\n <td>\n @if (row) {\n @if (first && canRead()) {\n <a [routerLink]=\"['/po', entityType()!.alias || entityType()!.id, row.id]\" (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n }\n } @else {\n &nbsp;\n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n } @else {\n <bs-datatable class=\"flex-grow-1\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [fetch]=\"fetchFn()\"\n [(settings)]=\"settings\">\n @for (attr of visibleAttributes(); track attr.id) {\n <div *bsDatatableColumn=\"attr.name; sortable: true\">\n {{ (attr.label | resolveTranslation) || attr.name }}\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (attr of visibleAttributes(); track attr.id; let first = $first) {\n <td>\n @if (row) {\n @if (first && canRead()) {\n <a [routerLink]=\"['/po', entityType()!.alias || entityType()!.id, row.id]\" (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n }\n } @else {\n &nbsp;\n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n }\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n\n<ng-template #cellContent let-item let-attr=\"attr\">\n @if (getColumnRendererComponent(attr); as rendererType) {\n <ng-container *ngComponentOutlet=\"rendererType; inputs: getColumnRendererInputs(rendererType, item, attr)\"></ng-container>\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 } @else if (attr.dataType === 'color') {\n @let colorVal = (attr.name | attributeValue:item:entityType():lookupReferenceOptions():allEntityTypes());\n @if (colorVal) {\n <span class=\"d-inline-block align-middle border rounded\" [style.background-color]=\"colorVal\" style=\"width: 1.5em; height: 1.5em;\"></span>\n }\n } @else if (attr.dataType === 'Reference' && attr.isArray) {\n <span class=\"d-inline-flex flex-wrap gap-1\">\n @for (chip of (attr.name | referenceChips:item); track chip.id) {\n <span class=\"badge rounded-pill border bg-body-secondary text-body px-3 py-2\">{{ chip.label }}</span>\n }\n </span>\n } @else {\n {{ (attr.name | attributeValue:item:entityType():lookupReferenceOptions():allEntityTypes()) }}\n }\n</ng-template>\n", styles: [".spark-actionbar{position:sticky;top:0;z-index:400;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);margin-bottom:1rem}.spark-actionbar .btn{border-radius:0}:host{display:flex;flex-direction:column;flex:1;min-height:0}:host.virtual-scrolling{margin:0 -1.5rem -1.5rem}:host.virtual-scrolling ::ng-deep bs-datatable{display:flex;flex-direction:column;min-height:0;--mp-datatable-virtual-max-height: 100%}:host.virtual-scrolling ::ng-deep bs-datatable mp-datatable{flex:1 1 auto;min-height:0}tr:hover{background-color:#0000000d}td input[type=checkbox]:disabled{opacity:1;pointer-events:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: RouterModule }, { kind: "directive", type: i3.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "browserUrl", "routerLink"] }, { kind: "component", type: BsAlertComponent, selector: "bs-alert", inputs: ["type", "announce", "isVisible"], outputs: ["isVisibleChange", "afterOpenedOrClosed"] }, { kind: "component", type: BsDatatableComponent, selector: "bs-datatable", inputs: ["columns", "data", "fetch", "settings", "selectionMode", "selectable", "selection", "rowKey", "resizableColumns", "pagination", "virtualScroll", "itemSize", "virtualBuffer", "isResponsive", "compareWith", "tree", "idKey", "childCountKey", "treeIndent", "expandedIds", "selectionStrategy"], outputs: ["settingsChange", "selectionChange", "rowClick", "rowDblClick", "rowContextMenu", "expandedIdsChange", "rowExpand", "rowCollapse"] }, { kind: "directive", type: BsDatatableColumnDirective, selector: "[bsDatatableColumn]", inputs: ["bsDatatableColumn", "bsDatatableColumnSortable"] }, { kind: "directive", type: BsRowTemplateDirective, selector: "[bsRowTemplate]" }, { kind: "component", type: BsFormComponent, selector: "bs-form", inputs: ["action", "method"], outputs: ["submitted"] }, { kind: "directive", type: BsFormControlDirective, selector: "bs-form input:not(.no-form-control), bs-form textarea:not(.no-form-control)" }, { kind: "component", type: BsGridComponent, selector: "bs-grid", inputs: ["stopFullWidthAt"] }, { kind: "directive", type: BsGridRowDirective, selector: "[bsRow]" }, { kind: "directive", type: BsGridColumnDirective, selector: "[xxs],[xs],[sm],[md],[lg],[xl],[xxl]", inputs: ["xxs", "xs", "sm", "md", "lg", "xl", "xxl"] }, { kind: "component", type: BsInputGroupComponent, selector: "bs-input-group", inputs: ["size"] }, { kind: "component", type: BsPriorityNavComponent, selector: "bs-priority-nav", inputs: ["moreLabel", "moreLabelTemplate", "collapseAt", "overflowFrom", "hideEmptyMore", "ariaLabel"], outputs: ["overflowChange"] }, { kind: "directive", type: BsPriorityNavItemDirective, selector: "[bsPriorityNavItem]", inputs: ["bsPriorityNavItem", "bsPriorityNavItemHideBelow"] }, { kind: "component", type: BsSpinnerComponent, selector: "bs-spinner", inputs: ["type", "color"] }, { kind: "component", type: SparkIconComponent, selector: "spark-icon", inputs: ["name"] }, { kind: "pipe", type: ResolveTranslationPipe, name: "resolveTranslation" }, { kind: "pipe", type: TranslateKeyPipe, name: "t" }, { kind: "pipe", type: AttributeValuePipe, name: "attributeValue" }, { kind: "pipe", type: ReferenceChipsPipe, name: "referenceChips" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
262
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.0", type: SparkQueryListComponent, isStandalone: true, selector: "spark-query-list", inputs: { extraActionsTemplate: { classPropertyName: "extraActionsTemplate", publicName: "extraActionsTemplate", isSignal: true, isRequired: false, transformFunction: null }, showCustomActions: { classPropertyName: "showCustomActions", publicName: "showCustomActions", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { rowClicked: "rowClicked", createClicked: "createClicked", customActionExecuted: "customActionExecuted" }, host: { properties: { "class.virtual-scrolling": "isVirtualScrolling()" } }, viewQueries: [{ propertyName: "grid", first: true, predicate: SparkQueryGridComponent, descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"d-flex flex-column h-100\">\n <div class=\"spark-actionbar py-2 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\" [class.px-3]=\"!isVirtualScrolling()\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n @if (canCreate()) {\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-primary\" (click)=\"onCreate()\">\n <spark-icon name=\"plus-lg\" /> {{ 'common.new' | t }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of customActions(); track action.name) {\n <!-- Disabled, not hidden: hiding makes the affordance undiscoverable and reflows the\n priority nav on every selection change. The server enforces the same rule. -->\n <button *bsPriorityNavItem=\"10 + action.offset\" class=\"btn btn-outline-primary\"\n [disabled]=\"!isActionEnabled(action)\" (click)=\"onCustomAction(action)\">\n {{ action.displayName | 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 </bs-priority-nav>\n </div>\n\n <h2 class=\"mb-4 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n {{ (query()?.description | resolveTranslation) || query()?.name || ('common.loading' | t) }}\n @if (isStreaming()) {\n <bs-badge [type]=\"colors.success\" class=\"ms-2 align-middle spark-live-badge\">LIVE</bs-badge>\n }\n </h2>\n\n @if (errorMessage(); as err) {\n <!-- The route could not be resolved to a query at all. A failure INSIDE the grid renders in\n the grid; this is the one it cannot report, because it never got a query id. -->\n <bs-alert [type]=\"colors.danger\" class=\"m-3 d-block\">\n {{ err }}\n </bs-alert>\n } @else if (queryId(); as qId) {\n <div class=\"flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n <bs-form>\n <bs-grid>\n <div bsRow class=\"mb-3\">\n <div [md]=\"12\">\n <bs-input-group>\n <span class=\"addon\">\n <spark-icon name=\"search\" />\n </span>\n <input\n type=\"text\"\n [placeholder]=\"'common.search' | t\"\n [ngModel]=\"searchTerm()\"\n (ngModelChange)=\"searchTerm.set($event)\">\n @if (searchTerm()) {\n <button\n type=\"button\"\n class=\"btn btn-outline-secondary\"\n (click)=\"clearSearch()\">\n <spark-icon name=\"x-lg\" />\n </button>\n }\n </bs-input-group>\n </div>\n <!-- Its own full-width row rather than a column beside the box: the count is\n occasional, and reserving 8 columns for it shrank the search permanently. The\n @if moved out of the column so nothing reserves vertical space when absent. -->\n @if (searchTerm() && resultCount() !== null) {\n <div [md]=\"12\" class=\"text-end mt-1\">\n <span class=\"text-muted\">\n {{ resultCount() }} {{ resultCount() === 1 ? ('common.resultFound' | t) : ('common.resultsFound' | t) }}\n </span>\n </div>\n }\n </div>\n </bs-grid>\n </bs-form>\n </div>\n\n <!--\n One grid, both transports. `data` is null for a normal query, so the grid fetches and pages\n server-side; for a streaming query this page owns the socket and hands the filtered snapshot\n in. This used to be two near-identical <bs-datatable> blocks with a shared row template.\n -->\n <spark-query-grid class=\"flex-grow-1\"\n [queryId]=\"qId\"\n [data]=\"gridData()\"\n [search]=\"searchTerm()\"\n (rowClicked)=\"rowClicked.emit($event)\"\n (customActionExecuted)=\"customActionExecuted.emit($event)\" />\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n", styles: [".spark-actionbar{position:sticky;top:0;z-index:400;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);margin-bottom:1rem}.spark-actionbar .btn{border-radius:0}:host{display:flex;flex-direction:column;flex:1;min-height:0}:host.virtual-scrolling{margin:0 -1.5rem -1.5rem}:host.virtual-scrolling spark-query-grid{display:flex;flex-direction:column;min-height:0}:host.virtual-scrolling ::ng-deep bs-datatable{display:flex;flex-direction:column;min-height:0;--mp-datatable-virtual-max-height: 100%}:host.virtual-scrolling ::ng-deep bs-datatable mp-datatable{flex:1 1 auto;min-height:0}tr:hover{background-color:#0000000d}td input[type=checkbox]:disabled{opacity:1;pointer-events:none}.spark-live-badge{font-size:.6em}\n"], dependencies: [{ kind: "component", type: BsBadgeComponent, selector: "bs-badge", inputs: ["type", "unit", "decorative"] }, { kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.DefaultValueAccessor, selector: "input:not([type=checkbox]):not([ngNoCva])[formControlName],textarea:not([ngNoCva])[formControlName],input:not([type=checkbox]):not([ngNoCva])[formControl],textarea:not([ngNoCva])[formControl],input:not([type=checkbox]):not([ngNoCva])[ngModel],textarea:not([ngNoCva])[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: BsAlertComponent, selector: "bs-alert", inputs: ["type", "announce", "isVisible"], outputs: ["isVisibleChange", "afterOpenedOrClosed"] }, { kind: "component", type: BsFormComponent, selector: "bs-form", inputs: ["action", "method"], outputs: ["submitted"] }, { kind: "directive", type: BsFormControlDirective, selector: "bs-form input:not(.no-form-control), bs-form textarea:not(.no-form-control)" }, { kind: "component", type: BsGridComponent, selector: "bs-grid", inputs: ["stopFullWidthAt"] }, { kind: "directive", type: BsGridRowDirective, selector: "[bsRow]" }, { kind: "directive", type: BsGridColumnDirective, selector: "[xxs],[xs],[sm],[md],[lg],[xl],[xxl]", inputs: ["xxs", "xs", "sm", "md", "lg", "xl", "xxl"] }, { kind: "component", type: BsInputGroupComponent, selector: "bs-input-group", inputs: ["size"] }, { kind: "component", type: BsPriorityNavComponent, selector: "bs-priority-nav", inputs: ["moreLabel", "moreLabelTemplate", "collapseAt", "overflowFrom", "hideEmptyMore", "ariaLabel"], outputs: ["overflowChange"] }, { kind: "directive", type: BsPriorityNavItemDirective, selector: "[bsPriorityNavItem]", inputs: ["bsPriorityNavItem", "bsPriorityNavItemHideBelow"] }, { kind: "component", type: BsSpinnerComponent, selector: "bs-spinner", inputs: ["type", "color"] }, { kind: "component", type: SparkIconComponent, selector: "spark-icon", inputs: ["name"] }, { kind: "component", type: SparkQueryGridComponent, selector: "spark-query-grid", inputs: ["queryId", "parentId", "parentType", "data", "search", "reloadToken", "settings", "selection"], outputs: ["settingsChange", "selectionChange", "error", "rowClicked", "customActionExecuted"] }, { kind: "pipe", type: ResolveTranslationPipe, name: "resolveTranslation" }, { kind: "pipe", type: TranslateKeyPipe, name: "t" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
392
263
  }
393
264
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: SparkQueryListComponent, decorators: [{
394
265
  type: Component,
395
- args: [{ selector: 'spark-query-list', imports: [CommonModule, NgTemplateOutlet, NgComponentOutlet, FormsModule, RouterModule, BsAlertComponent, BsDatatableComponent, BsDatatableColumnDirective, BsRowTemplateDirective, BsFormComponent, BsFormControlDirective, BsGridComponent, BsGridRowDirective, BsGridColumnDirective, BsInputGroupComponent, BsPriorityNavComponent, BsPriorityNavItemDirective, BsSpinnerComponent, SparkIconComponent, ResolveTranslationPipe, TranslateKeyPipe, AttributeValuePipe, ReferenceChipsPipe], changeDetection: ChangeDetectionStrategy.OnPush, host: {
266
+ args: [{ selector: 'spark-query-list', imports: [BsBadgeComponent, CommonModule, NgTemplateOutlet, FormsModule, BsAlertComponent, BsFormComponent, BsFormControlDirective, BsGridComponent, BsGridRowDirective, BsGridColumnDirective, BsInputGroupComponent, BsPriorityNavComponent, BsPriorityNavItemDirective, BsSpinnerComponent, SparkIconComponent, SparkQueryGridComponent, ResolveTranslationPipe, TranslateKeyPipe], changeDetection: ChangeDetectionStrategy.OnPush, host: {
396
267
  '[class.virtual-scrolling]': 'isVirtualScrolling()'
397
- }, template: "<div class=\"d-flex flex-column h-100\">\n <div class=\"spark-actionbar py-2 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\" [class.px-3]=\"!isVirtualScrolling()\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n @if (canCreate()) {\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-primary\" (click)=\"onCreate()\">\n <spark-icon name=\"plus-lg\" /> {{ 'common.new' | t }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of customActions(); track action.name) {\n <button *bsPriorityNavItem=\"10 + action.offset\" class=\"btn btn-outline-primary\" (click)=\"onCustomAction(action)\">\n {{ action.displayName | 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 </bs-priority-nav>\n </div>\n <h2 class=\"mb-4 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n {{ (query()?.description | resolveTranslation) || query()?.name || ('common.loading' | t) }}\n @if (isStreaming()) {\n <span class=\"badge bg-success ms-2\" style=\"font-size: 0.5em; vertical-align: middle;\">LIVE</span>\n }\n </h2>\n\n @if (entityType()) {\n <!-- Search box -->\n <div class=\"flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n <bs-form>\n <bs-grid>\n <div bsRow class=\"mb-3\">\n <div [md]=\"4\">\n <bs-input-group>\n <span class=\"input-group-text\">\n <spark-icon name=\"search\" />\n </span>\n <input\n type=\"text\"\n [placeholder]=\"'common.search' | t\"\n [(ngModel)]=\"searchTerm\"\n (ngModelChange)=\"onSearchChange()\">\n @if (searchTerm) {\n <button\n type=\"button\"\n class=\"btn btn-outline-secondary\"\n (click)=\"clearSearch()\">\n <spark-icon name=\"x-lg\" />\n </button>\n }\n </bs-input-group>\n </div>\n <div [md]=\"8\" class=\"text-end\">\n @if (searchTerm && resultCount() !== null) {\n <span class=\"text-muted\">\n {{ resultCount() }} {{ resultCount() === 1 ? ('common.resultFound' | t) : ('common.resultsFound' | t) }}\n </span>\n }\n </div>\n </div>\n </bs-grid>\n </bs-form>\n\n @if (errorMessage(); as err) {\n <bs-alert [type]=\"colors.danger\" class=\"mb-3\">\n {{ err }}\n </bs-alert>\n }\n </div>\n\n @if (isStreaming()) {\n <bs-datatable class=\"flex-grow-1\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [data]=\"streamItems()\"\n [(settings)]=\"settings\">\n @for (attr of visibleAttributes(); track attr.id) {\n <div *bsDatatableColumn=\"attr.name; sortable: true\">\n {{ (attr.label | resolveTranslation) || attr.name }}\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (attr of visibleAttributes(); track attr.id; let first = $first) {\n <td>\n @if (row) {\n @if (first && canRead()) {\n <a [routerLink]=\"['/po', entityType()!.alias || entityType()!.id, row.id]\" (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n }\n } @else {\n &nbsp;\n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n } @else {\n <bs-datatable class=\"flex-grow-1\"\n [virtualScroll]=\"isVirtualScrolling()\"\n [itemSize]=\"40\"\n [isResponsive]=\"isVirtualScrolling()\"\n [fetch]=\"fetchFn()\"\n [(settings)]=\"settings\">\n @for (attr of visibleAttributes(); track attr.id) {\n <div *bsDatatableColumn=\"attr.name; sortable: true\">\n {{ (attr.label | resolveTranslation) || attr.name }}\n </div>\n }\n\n <ng-container *bsRowTemplate=\"let item\">\n @let row = $any(item);\n @for (attr of visibleAttributes(); track attr.id; let first = $first) {\n <td>\n @if (row) {\n @if (first && canRead()) {\n <a [routerLink]=\"['/po', entityType()!.alias || entityType()!.id, row.id]\" (click)=\"rowClicked.emit(row)\">\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n </a>\n } @else {\n <ng-container *ngTemplateOutlet=\"cellContent; context: { $implicit: row, attr: attr }\"></ng-container>\n }\n } @else {\n &nbsp;\n }\n </td>\n }\n </ng-container>\n </bs-datatable>\n }\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n\n<ng-template #cellContent let-item let-attr=\"attr\">\n @if (getColumnRendererComponent(attr); as rendererType) {\n <ng-container *ngComponentOutlet=\"rendererType; inputs: getColumnRendererInputs(rendererType, item, attr)\"></ng-container>\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 } @else if (attr.dataType === 'color') {\n @let colorVal = (attr.name | attributeValue:item:entityType():lookupReferenceOptions():allEntityTypes());\n @if (colorVal) {\n <span class=\"d-inline-block align-middle border rounded\" [style.background-color]=\"colorVal\" style=\"width: 1.5em; height: 1.5em;\"></span>\n }\n } @else if (attr.dataType === 'Reference' && attr.isArray) {\n <span class=\"d-inline-flex flex-wrap gap-1\">\n @for (chip of (attr.name | referenceChips:item); track chip.id) {\n <span class=\"badge rounded-pill border bg-body-secondary text-body px-3 py-2\">{{ chip.label }}</span>\n }\n </span>\n } @else {\n {{ (attr.name | attributeValue:item:entityType():lookupReferenceOptions():allEntityTypes()) }}\n }\n</ng-template>\n", styles: [".spark-actionbar{position:sticky;top:0;z-index:400;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);margin-bottom:1rem}.spark-actionbar .btn{border-radius:0}:host{display:flex;flex-direction:column;flex:1;min-height:0}:host.virtual-scrolling{margin:0 -1.5rem -1.5rem}:host.virtual-scrolling ::ng-deep bs-datatable{display:flex;flex-direction:column;min-height:0;--mp-datatable-virtual-max-height: 100%}:host.virtual-scrolling ::ng-deep bs-datatable mp-datatable{flex:1 1 auto;min-height:0}tr:hover{background-color:#0000000d}td input[type=checkbox]:disabled{opacity:1;pointer-events:none}\n"] }]
398
- }], ctorParameters: () => [], propDecorators: { extraActionsTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "extraActionsTemplate", required: false }] }], showCustomActions: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCustomActions", required: false }] }], rowClicked: [{ type: i0.Output, args: ["rowClicked"] }], createClicked: [{ type: i0.Output, args: ["createClicked"] }], customActionExecuted: [{ type: i0.Output, args: ["customActionExecuted"] }] } });
268
+ }, template: "<div class=\"d-flex flex-column h-100\">\n <div class=\"spark-actionbar py-2 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\" [class.px-3]=\"!isVirtualScrolling()\">\n <bs-priority-nav [moreLabel]=\"lang.t('common.more')\" [collapseAt]=\"'sm'\">\n @if (canCreate()) {\n <button *bsPriorityNavItem=\"1\" class=\"btn btn-primary\" (click)=\"onCreate()\">\n <spark-icon name=\"plus-lg\" /> {{ 'common.new' | t }}\n </button>\n }\n @if (showCustomActions()) {\n @for (action of customActions(); track action.name) {\n <!-- Disabled, not hidden: hiding makes the affordance undiscoverable and reflows the\n priority nav on every selection change. The server enforces the same rule. -->\n <button *bsPriorityNavItem=\"10 + action.offset\" class=\"btn btn-outline-primary\"\n [disabled]=\"!isActionEnabled(action)\" (click)=\"onCustomAction(action)\">\n {{ action.displayName | 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 </bs-priority-nav>\n </div>\n\n <h2 class=\"mb-4 flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n {{ (query()?.description | resolveTranslation) || query()?.name || ('common.loading' | t) }}\n @if (isStreaming()) {\n <bs-badge [type]=\"colors.success\" class=\"ms-2 align-middle spark-live-badge\">LIVE</bs-badge>\n }\n </h2>\n\n @if (errorMessage(); as err) {\n <!-- The route could not be resolved to a query at all. A failure INSIDE the grid renders in\n the grid; this is the one it cannot report, because it never got a query id. -->\n <bs-alert [type]=\"colors.danger\" class=\"m-3 d-block\">\n {{ err }}\n </bs-alert>\n } @else if (queryId(); as qId) {\n <div class=\"flex-shrink-0\" [class.px-4]=\"isVirtualScrolling()\">\n <bs-form>\n <bs-grid>\n <div bsRow class=\"mb-3\">\n <div [md]=\"12\">\n <bs-input-group>\n <span class=\"addon\">\n <spark-icon name=\"search\" />\n </span>\n <input\n type=\"text\"\n [placeholder]=\"'common.search' | t\"\n [ngModel]=\"searchTerm()\"\n (ngModelChange)=\"searchTerm.set($event)\">\n @if (searchTerm()) {\n <button\n type=\"button\"\n class=\"btn btn-outline-secondary\"\n (click)=\"clearSearch()\">\n <spark-icon name=\"x-lg\" />\n </button>\n }\n </bs-input-group>\n </div>\n <!-- Its own full-width row rather than a column beside the box: the count is\n occasional, and reserving 8 columns for it shrank the search permanently. The\n @if moved out of the column so nothing reserves vertical space when absent. -->\n @if (searchTerm() && resultCount() !== null) {\n <div [md]=\"12\" class=\"text-end mt-1\">\n <span class=\"text-muted\">\n {{ resultCount() }} {{ resultCount() === 1 ? ('common.resultFound' | t) : ('common.resultsFound' | t) }}\n </span>\n </div>\n }\n </div>\n </bs-grid>\n </bs-form>\n </div>\n\n <!--\n One grid, both transports. `data` is null for a normal query, so the grid fetches and pages\n server-side; for a streaming query this page owns the socket and hands the filtered snapshot\n in. This used to be two near-identical <bs-datatable> blocks with a shared row template.\n -->\n <spark-query-grid class=\"flex-grow-1\"\n [queryId]=\"qId\"\n [data]=\"gridData()\"\n [search]=\"searchTerm()\"\n (rowClicked)=\"rowClicked.emit($event)\"\n (customActionExecuted)=\"customActionExecuted.emit($event)\" />\n } @else {\n <div class=\"text-center p-5\">\n <bs-spinner />\n </div>\n }\n</div>\n", styles: [".spark-actionbar{position:sticky;top:0;z-index:400;background-color:var(--bs-tertiary-bg);border-bottom:1px solid var(--bs-border-color);margin-bottom:1rem}.spark-actionbar .btn{border-radius:0}:host{display:flex;flex-direction:column;flex:1;min-height:0}:host.virtual-scrolling{margin:0 -1.5rem -1.5rem}:host.virtual-scrolling spark-query-grid{display:flex;flex-direction:column;min-height:0}:host.virtual-scrolling ::ng-deep bs-datatable{display:flex;flex-direction:column;min-height:0;--mp-datatable-virtual-max-height: 100%}:host.virtual-scrolling ::ng-deep bs-datatable mp-datatable{flex:1 1 auto;min-height:0}tr:hover{background-color:#0000000d}td input[type=checkbox]:disabled{opacity:1;pointer-events:none}.spark-live-badge{font-size:.6em}\n"] }]
269
+ }], ctorParameters: () => [], propDecorators: { extraActionsTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "extraActionsTemplate", required: false }] }], showCustomActions: [{ type: i0.Input, args: [{ isSignal: true, alias: "showCustomActions", required: false }] }], rowClicked: [{ type: i0.Output, args: ["rowClicked"] }], createClicked: [{ type: i0.Output, args: ["createClicked"] }], customActionExecuted: [{ type: i0.Output, args: ["customActionExecuted"] }], grid: [{ type: i0.ViewChild, args: [i0.forwardRef(() => SparkQueryGridComponent), { isSignal: true }] }] } });
399
270
 
400
271
  /**
401
272
  * Generated bundle index. Do not edit.