@masterteam/client-components 0.0.84 → 0.0.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/client-components.css +1 -1
- package/fesm2022/masterteam-client-components-client-list.mjs +390 -16
- package/fesm2022/masterteam-client-components-client-list.mjs.map +1 -1
- package/fesm2022/masterteam-client-components-escalation-runtime.mjs +1 -1
- package/fesm2022/masterteam-client-components-escalation-runtime.mjs.map +1 -1
- package/package.json +5 -5
- package/types/masterteam-client-components-client-list.d.ts +166 -3
|
@@ -7,7 +7,7 @@ import { TranslocoService, TranslocoModule, TranslocoPipe } from '@jsverse/trans
|
|
|
7
7
|
import { Button } from '@masterteam/components/button';
|
|
8
8
|
import { RuntimeActionRunner, resolveActionLabel } from '@masterteam/components/runtime-action';
|
|
9
9
|
import { HttpClient } from '@angular/common/http';
|
|
10
|
-
import { switchMap,
|
|
10
|
+
import { switchMap, map, of, Observable, share, take } from 'rxjs';
|
|
11
11
|
import { resolveLocalizedLabel, serializeEntityRawValue, isSectionKey, EntitiesPreview } from '@masterteam/components/entities';
|
|
12
12
|
import { Table, TableExportService, TableGroupingController, TableValueResolver, TableCaption } from '@masterteam/components/table';
|
|
13
13
|
import * as i2 from 'primeng/skeleton';
|
|
@@ -23,22 +23,65 @@ import * as i3 from 'primeng/popover';
|
|
|
23
23
|
import { PopoverModule } from 'primeng/popover';
|
|
24
24
|
import { DashboardViewer } from '@masterteam/dashboard-builder';
|
|
25
25
|
|
|
26
|
-
const
|
|
26
|
+
const CLIENT_LIST_RECORD_STATE_KEY = '__clientListRecordState';
|
|
27
|
+
/** Record state every list request asks for. */
|
|
28
|
+
const CLIENT_LIST_DEFAULT_INCLUDE_STATE = ['escalation'];
|
|
29
|
+
/**
|
|
30
|
+
* `fetch/query` filter key that selects a single record by its own id. Reading
|
|
31
|
+
* one record back after a write filters on this, never on the list's scope
|
|
32
|
+
* filters alone — those match a whole page, not the row that just changed.
|
|
33
|
+
*/
|
|
34
|
+
const CLIENT_LIST_RECORD_ID_FILTER_KEY = 'id';
|
|
35
|
+
|
|
36
|
+
const DEFAULT_INCLUDE_STATE = CLIENT_LIST_DEFAULT_INCLUDE_STATE;
|
|
27
37
|
class ClientListApiService {
|
|
28
38
|
http = inject(HttpClient);
|
|
29
39
|
runtimeFetchBaseUrl = 'fetch/query';
|
|
30
40
|
informativeBaseUrl = 'LevelData';
|
|
41
|
+
/**
|
|
42
|
+
* Fetches a table page. Resolves to the response **and** the `propertyKeys`
|
|
43
|
+
* the request carried, because a write that wants its record projected the
|
|
44
|
+
* same way has to repeat them and cannot recover them from the response.
|
|
45
|
+
*/
|
|
31
46
|
getRows(contextKey, query, filters = []) {
|
|
32
|
-
return this.resolveTablePropertyKeys(contextKey, query).pipe(switchMap((
|
|
47
|
+
return this.resolveTablePropertyKeys(contextKey, query).pipe(switchMap((resolvedKeys) => {
|
|
48
|
+
const propertyKeys = resolvedKeys?.length ? resolvedKeys : undefined;
|
|
49
|
+
return this.queryRuntime({
|
|
50
|
+
contextKey,
|
|
51
|
+
projection: 'Table',
|
|
52
|
+
surfaceKey: 'table',
|
|
53
|
+
includeState: [...DEFAULT_INCLUDE_STATE],
|
|
54
|
+
propertyKeys,
|
|
55
|
+
filters,
|
|
56
|
+
page: this.toPage(query.skip, query.take),
|
|
57
|
+
pageSize: query.take,
|
|
58
|
+
}).pipe(map((response) => ({ response, propertyKeys })));
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Reads a single record back with the list's own query settings — the path
|
|
63
|
+
* taken when a write reports `recordProjectionStatus: 'Unavailable'`. The
|
|
64
|
+
* write itself already succeeded; this only reads, and must never be served
|
|
65
|
+
* by re-submitting.
|
|
66
|
+
*/
|
|
67
|
+
getRecord(contextKey, request, filters) {
|
|
68
|
+
return this.queryRuntime({
|
|
33
69
|
contextKey,
|
|
34
|
-
projection:
|
|
35
|
-
surfaceKey:
|
|
36
|
-
includeState:
|
|
37
|
-
|
|
70
|
+
projection: request.projection,
|
|
71
|
+
surfaceKey: request.surfaceKey,
|
|
72
|
+
includeState: request.includeState
|
|
73
|
+
? [...request.includeState]
|
|
74
|
+
: [...DEFAULT_INCLUDE_STATE],
|
|
75
|
+
propertyKeys: request.propertyKeys?.length
|
|
76
|
+
? request.propertyKeys
|
|
77
|
+
: undefined,
|
|
38
78
|
filters,
|
|
39
|
-
page:
|
|
40
|
-
pageSize:
|
|
41
|
-
|
|
79
|
+
page: 1,
|
|
80
|
+
pageSize: 1,
|
|
81
|
+
...(request.projection === 'Card'
|
|
82
|
+
? { display: { areas: ['card'] } }
|
|
83
|
+
: {}),
|
|
84
|
+
});
|
|
42
85
|
}
|
|
43
86
|
getCards(contextKey, filters = [], skip, take) {
|
|
44
87
|
// `page` and `pageSize` must be sent together or not at all (fetch/query
|
|
@@ -108,8 +151,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
|
|
|
108
151
|
args: [{ providedIn: 'root' }]
|
|
109
152
|
}] });
|
|
110
153
|
|
|
111
|
-
const CLIENT_LIST_RECORD_STATE_KEY = '__clientListRecordState';
|
|
112
|
-
|
|
113
154
|
const UNSORTABLE_ENTITY_VIEW_TYPES = new Set([
|
|
114
155
|
'Attachment',
|
|
115
156
|
'Checkbox',
|
|
@@ -306,6 +347,146 @@ class ClientListStateService {
|
|
|
306
347
|
},
|
|
307
348
|
});
|
|
308
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Splices a canonical record into the data the list already holds, so a
|
|
352
|
+
* create or update lands without re-fetching the page.
|
|
353
|
+
*
|
|
354
|
+
* Tables replay the whole page through {@link setRowsResult}: for a table
|
|
355
|
+
* the rendered page *is* the raw response, so replaying it keeps the catalog
|
|
356
|
+
* lookup, the column build and the host `transformResult` hook on exactly
|
|
357
|
+
* one code path — a second mapper for single rows would drift from the one
|
|
358
|
+
* that built the rest of the table. Cards cannot replay: with infinite
|
|
359
|
+
* scroll `rawData` holds only the last page while `cards` holds every page,
|
|
360
|
+
* so a single card is built and merged into the accumulated set instead.
|
|
361
|
+
*
|
|
362
|
+
* Returns `false` when there is nothing to splice into — an informative
|
|
363
|
+
* dashboard, or a list that never completed a load. The caller then falls
|
|
364
|
+
* back to a real fetch rather than inventing state.
|
|
365
|
+
*/
|
|
366
|
+
upsertRecord(key, record, config) {
|
|
367
|
+
const target = this.itemsByKey()[key];
|
|
368
|
+
if (!target || target.type !== 'form')
|
|
369
|
+
return false;
|
|
370
|
+
const raw = target.rawData;
|
|
371
|
+
if (!raw)
|
|
372
|
+
return false;
|
|
373
|
+
return target.areaType === 'cards'
|
|
374
|
+
? this.upsertCardRecord(key, target, raw, record)
|
|
375
|
+
: this.upsertRowRecord(key, target, raw, record, config);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Drops a record the backend has deleted, without re-fetching. Returns
|
|
379
|
+
* `false` when the record is not in the loaded set, so the caller can decide
|
|
380
|
+
* whether a reload is warranted.
|
|
381
|
+
*/
|
|
382
|
+
removeRecord(key, recordId, config) {
|
|
383
|
+
const target = this.itemsByKey()[key];
|
|
384
|
+
if (!target || target.type !== 'form')
|
|
385
|
+
return false;
|
|
386
|
+
const raw = target.rawData;
|
|
387
|
+
if (!raw)
|
|
388
|
+
return false;
|
|
389
|
+
const records = (raw.records ?? []).filter((item) => item.id !== recordId);
|
|
390
|
+
if (target.areaType === 'cards') {
|
|
391
|
+
const cards = target.cards.filter((card) => card.id !== recordId);
|
|
392
|
+
if (cards.length === target.cards.length)
|
|
393
|
+
return false;
|
|
394
|
+
this.patchItem(key, {
|
|
395
|
+
...target,
|
|
396
|
+
cards,
|
|
397
|
+
totalCount: Math.max(0, target.totalCount - 1),
|
|
398
|
+
error: null,
|
|
399
|
+
rawData: this.withRecords(raw, records, -1),
|
|
400
|
+
});
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
if (records.length === (raw.records ?? []).length)
|
|
404
|
+
return false;
|
|
405
|
+
this.setRowsResult(key, this.withRecords(raw, records, -1), config, target.skip, target.take);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
upsertRowRecord(key, target, raw, record, config) {
|
|
409
|
+
// No columns means no completed table load to splice into.
|
|
410
|
+
if (!target.columns.length)
|
|
411
|
+
return false;
|
|
412
|
+
const records = [...(raw.records ?? [])];
|
|
413
|
+
const index = records.findIndex((item) => item.id === record.id);
|
|
414
|
+
if (index >= 0) {
|
|
415
|
+
records[index] = this.mergeRecord(records[index], record);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
// The backend owns the real ordering and does not say where the new
|
|
419
|
+
// record landed, so it goes to the top of the page in view: the user
|
|
420
|
+
// has to see what they just created. The next real load puts it in its
|
|
421
|
+
// true place.
|
|
422
|
+
records.unshift(record);
|
|
423
|
+
// Keep the page the size the paginator promises — the row pushed off
|
|
424
|
+
// the end now lives on the next page.
|
|
425
|
+
if (config.isPaginated &&
|
|
426
|
+
target.take > 0 &&
|
|
427
|
+
records.length > target.take) {
|
|
428
|
+
records.length = target.take;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
this.setRowsResult(key, this.withRecords(raw, records, index >= 0 ? 0 : 1), config, target.skip, target.take);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
upsertCardRecord(key, target, raw, record) {
|
|
435
|
+
if (!raw.projectionMeta)
|
|
436
|
+
return false;
|
|
437
|
+
const previous = (raw.records ?? []).find((item) => item.id === record.id);
|
|
438
|
+
const merged = previous ? this.mergeRecord(previous, record) : record;
|
|
439
|
+
const [card] = this.toCards({ ...raw, records: [merged] });
|
|
440
|
+
if (!card)
|
|
441
|
+
return false;
|
|
442
|
+
const index = target.cards.findIndex((item) => item.id === card.id);
|
|
443
|
+
const isInsert = index < 0;
|
|
444
|
+
const cards = isInsert
|
|
445
|
+
? [card, ...target.cards]
|
|
446
|
+
: target.cards.map((item, position) => position === index ? card : item);
|
|
447
|
+
const records = [...(raw.records ?? [])];
|
|
448
|
+
const recordIndex = records.findIndex((item) => item.id === merged.id);
|
|
449
|
+
if (recordIndex >= 0)
|
|
450
|
+
records[recordIndex] = merged;
|
|
451
|
+
else
|
|
452
|
+
records.unshift(merged);
|
|
453
|
+
this.patchItem(key, {
|
|
454
|
+
...target,
|
|
455
|
+
cards,
|
|
456
|
+
totalCount: isInsert ? target.totalCount + 1 : target.totalCount,
|
|
457
|
+
error: null,
|
|
458
|
+
rawData: this.withRecords(raw, records, isInsert ? 1 : 0),
|
|
459
|
+
});
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Overlays the written record on the one being replaced, key by key. A
|
|
464
|
+
* cleared property comes back explicitly (`{ raw: null, value: null }`) and
|
|
465
|
+
* overwrites; only a key the backend left out entirely keeps its previous
|
|
466
|
+
* cell, which beats blanking a value that did not actually change.
|
|
467
|
+
*/
|
|
468
|
+
mergeRecord(previous, next) {
|
|
469
|
+
return {
|
|
470
|
+
...previous,
|
|
471
|
+
...next,
|
|
472
|
+
values: { ...previous.values, ...next.values },
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
withRecords(raw, records, totalCountDelta) {
|
|
476
|
+
return {
|
|
477
|
+
...raw,
|
|
478
|
+
records,
|
|
479
|
+
pagination: raw.pagination
|
|
480
|
+
? {
|
|
481
|
+
...raw.pagination,
|
|
482
|
+
totalCount: Math.max(0, raw.pagination.totalCount + totalCountDelta),
|
|
483
|
+
}
|
|
484
|
+
: raw.pagination,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
patchItem(key, next) {
|
|
488
|
+
this.itemsByKey.set({ ...this.itemsByKey(), [key]: next });
|
|
489
|
+
}
|
|
309
490
|
/**
|
|
310
491
|
* Appends `next` to `current`, dropping any card whose id is already
|
|
311
492
|
* present. Ids are the grid's `@for` track key, so duplicates are a runtime
|
|
@@ -1618,7 +1799,7 @@ class ClientListCardsView {
|
|
|
1618
1799
|
});
|
|
1619
1800
|
}
|
|
1620
1801
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: ClientListCardsView, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1621
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: ClientListCardsView, isStandalone: true, selector: "mt-client-list-cards-view", inputs: { state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: true, transformFunction: null }, rowActions: { classPropertyName: "rowActions", publicName: "rowActions", isSignal: true, isRequired: false, transformFunction: null }, rowActionsLoadingFn: { classPropertyName: "rowActionsLoadingFn", publicName: "rowActionsLoadingFn", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { lazyLoad: "lazyLoad", loadMore: "loadMore", cardClick: "cardClick", rowActionsRequested: "rowActionsRequested" }, providers: [MTDateFormatPipe], viewQueries: [{ propertyName: "loadMoreSentinel", first: true, predicate: ["loadMoreSentinel"], descendants: true, isSignal: true }], ngImport: i0, template: "<mt-table-caption\n [generalSearch]=\"true\"\n [showFilters]=\"true\"\n filterMode=\"popover\"\n [printable]=\"true\"\n [groupable]=\"true\"\n [exportable]=\"true\"\n [columns]=\"columns()\"\n [data]=\"rowRepresentations()\"\n [groupColumns]=\"grouping.groupableColumns()\"\n [actions]=\"toolbarActions()\"\n [captionStart]=\"cardsState().config.toolbarStart ?? null\"\n [captionEnd]=\"cardsState().config.toolbarEnd ?? null\"\n [filters]=\"bucket().filters()\"\n (filtersChange)=\"bucket().filters.set($event)\"\n [filterTerm]=\"bucket().filterTerm()\"\n (filterTermChange)=\"bucket().filterTerm.set($event)\"\n [groupBy]=\"bucket().groupBy()\"\n (groupByChange)=\"bucket().groupBy.set($event)\"\n [activeTab]=\"bucket().activeTab()\"\n (activeTabChange)=\"bucket().activeTab.set($event)\"\n (exportRequested)=\"exportExcel()\"\n (printRequested)=\"printPdf()\"\n/>\n\n@if (badgeLegend().length > 0) {\n <!--\n Decodes the coloured count pills on the cards below. Rendered once for the\n whole board rather than per card, so it costs one line of vertical space\n instead of repeating a key inside every tile (finding M-04).\n -->\n <div\n class=\"mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 px-1\"\n role=\"list\"\n [attr.aria-label]=\"\n 'components.clientComponents.list.badgeLegend' | transloco\n \"\n >\n <span class=\"text-xs font-medium text-gray-500 dark:text-gray-400\">\n {{ \"components.clientComponents.list.badgeLegend\" | transloco }}\n </span>\n @for (entry of badgeLegend(); track entry.key) {\n <span class=\"flex items-center gap-1.5\" role=\"listitem\">\n <span\n class=\"inline-flex h-4 min-w-4 items-center justify-center rounded text-[10px] font-bold\"\n [style.background-color]=\"entry.color\"\n [style.color]=\"entry.textColor\"\n aria-hidden=\"true\"\n >\n #\n </span>\n <span class=\"text-xs text-gray-600 dark:text-gray-300\">{{\n entry.label\n }}</span>\n </span>\n }\n </div>\n}\n\n@if (cardsState().loading && cardsState().cards.length === 0) {\n <div class=\"mt-cards-grid\">\n @for (i of [1, 2, 3, 4, 5, 6]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n </div>\n} @else if (cardsState().error) {\n <div class=\"rounded-lg border border-red-200 bg-red-50 p-6 text-red-600\">\n {{ cardsState().error }}\n </div>\n} @else if (visibleCards().length === 0) {\n <!--\n The same empty state the tables use, so a board and a list stop looking like\n two products (finding H-12). A board narrowed by a search or a filter says\n so and offers to clear it, instead of the bare grey \"No cards found.\"\n -->\n <mt-empty-state\n [variant]=\"hasActiveCardFilters() ? 'filtered' : 'empty'\"\n [title]=\"\n hasActiveCardFilters()\n ? ''\n : ('components.clientComponents.list.noCardsFound' | transloco)\n \"\n [actionLabel]=\"\n hasActiveCardFilters()\n ? ('components.emptyState.clearFilters' | transloco)\n : ''\n \"\n actionIcon=\"general.x-close\"\n (actionClick)=\"clearCardFilters()\"\n />\n} @else if (grouping.groupingActive()) {\n <div class=\"flex flex-col gap-6\">\n @for (group of groupedCards(); track group.key) {\n <section class=\"flex flex-col gap-3\">\n <header\n class=\"flex items-center gap-3 px-2 py-2 bg-surface-50 dark:bg-surface-900 border-y border-surface-200 dark:border-surface-700 rounded\"\n [style.--mt-group-accent]=\"group.accent ?? 'var(--p-primary-color)'\"\n >\n <span\n class=\"inline-block w-1 h-5 rounded-full\"\n [style.background]=\"'var(--mt-group-accent)'\"\n ></span>\n <span\n class=\"text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400\"\n >\n {{ group.columnLabel }}\n </span>\n <span class=\"text-sm font-semibold text-gray-900 dark:text-gray-100\">\n {{ group.label }}\n </span>\n <span\n class=\"ml-auto inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300\"\n >\n {{ group.count }}\n </span>\n </header>\n <div class=\"mt-cards-grid\">\n @for (card of group.cards; track card.id) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n }\n </div>\n </section>\n }\n </div>\n} @else {\n <div class=\"mt-cards-grid\">\n @for (card of visibleCards(); track card.id) {\n @defer (on viewport) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n } @placeholder {\n <div class=\"min-h-[20rem]\">\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n </div>\n }\n }\n </div>\n}\n\n<!--\n Infinite-scroll trigger. Deliberately outside the @if/@else chain above:\n inside it the element would be torn down and rebuilt every time the view\n swapped branch (error, empty filter result, group-by), and the observer\n would be left watching a detached node. Kept in the DOM and hidden via CSS\n when there is nothing left to load, so appending a page never recreates it.\n It also has to sit outside so infinite scroll keeps working while a\n group-by is active \u2014 grouping is a pure FE view over whatever has been\n fetched, not a reason to stop fetching.\n-->\n<div\n #loadMoreSentinel\n class=\"mt-cards-grid py-4\"\n [class.hidden]=\"!hasMoreCards()\"\n>\n @for (i of [1, 2, 3]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n</div>\n\n<!--\n Per-card escalation marker and 3-dots actions menu. The compact corner cover\n only indicates active escalation state; its preview action lives in the menu\n so the marker never displaces or competes with the menu trigger.\n-->\n<ng-template #cardActionsMenu let-card>\n @let escalationAction = escalationMarkerAction(card);\n @if (escalationAction) {\n <span\n class=\"mt-escalation-marker absolute end-0 top-0 z-10 h-[18px] w-[20px]\"\n role=\"img\"\n tabindex=\"0\"\n [attr.aria-label]=\"escalationLabel()\"\n [mtTooltip]=\"escalationLabel()\"\n tooltipPosition=\"top\"\n ></span>\n }\n @if (rowActions().length > 0) {\n <div\n class=\"absolute end-2 top-2 z-20 flex items-center gap-1\"\n (click)=\"$event.stopPropagation()\"\n >\n @for (action of inlineActionsForCard(card); track $index) {\n <mt-button\n [icon]=\"action.icon\"\n [severity]=\"action.color\"\n [variant]=\"action.variant\"\n [size]=\"actionSize(action) || 'small'\"\n [tooltip]=\"action.tooltip\"\n [label]=\"action.label\"\n [loading]=\"resolveActionLoading(action, card)\"\n (onClick)=\"runCardAction($event, action, card)\"\n />\n }\n @if (showCardActionsMenu(card)) {\n <button\n type=\"button\"\n class=\"flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-base text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-300 dark:hover:bg-surface-700 dark:hover:text-gray-100\"\n [attr.aria-label]=\"actionsLabel()\"\n [mtTooltip]=\"actionsLabel()\"\n tooltipPosition=\"top\"\n (click)=\"onCardActionsToggle($event, cardPopover, card)\"\n >\n <mt-icon icon=\"general.dots-vertical\" class=\"text-base\" />\n </button>\n <p-popover #cardPopover appendTo=\"body\">\n <div class=\"flex min-w-44 flex-col gap-1 p-1\">\n @if (isCardActionsLoading(card)) {\n <div class=\"flex flex-col gap-2 p-1\">\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n </div>\n } @else {\n @for (action of menuActionsForCard(card); track $index) {\n @if (isFirstDestructiveCardAction(card, $index)) {\n <hr\n class=\"my-1 border-0 border-t border-surface-200 dark:border-surface-700\"\n />\n }\n <button\n type=\"button\"\n class=\"flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-start text-sm transition-colors\"\n [class]=\"\n action.color === 'danger'\n ? 'text-red-600 hover:bg-red-50 dark:hover:bg-red-950'\n : action.color === 'warn'\n ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-950'\n : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-surface-700'\n \"\n (click)=\"\n runCardAction($event, action, card); cardPopover.hide()\n \"\n >\n @if (action.icon) {\n <mt-icon [icon]=\"action.icon\" class=\"text-base\" />\n }\n <span>{{ action.label || action.tooltip }}</span>\n </button>\n }\n }\n </div>\n </p-popover>\n }\n </div>\n }\n</ng-template>\n", styles: [".mt-cards-grid{--mt-cards-gap: 1.25rem;--mt-cards-min: 380px;--mt-cards-track: max( var(--mt-cards-min), (100% - 4 * var(--mt-cards-gap)) / 5 );display:grid;gap:var(--mt-cards-gap);grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--mt-cards-track)),1fr))}.mt-cards-grid.hidden,.mt-cards-grid[hidden]{display:none}.mt-escalation-marker{border-start-end-radius:.5rem;background:var(--p-button-text-warn-color, var(--p-orange-500, #f59e0b));clip-path:polygon(0 0,100% 0,100% 100%);transition:filter .15s ease}.mt-escalation-marker:dir(rtl){clip-path:polygon(0 0,100% 0,0 100%)}.mt-escalation-marker:hover{filter:brightness(.92)}.mt-escalation-marker:focus-visible{filter:brightness(.92) drop-shadow(0 0 2px var(--p-button-text-warn-color, #f59e0b));outline:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: TranslocoModule }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "directive", type: Tooltip, selector: "[mtTooltip]" }, { kind: "component", type: Icon, selector: "mt-icon", inputs: ["icon"] }, { kind: "component", type: EntitiesPreview, selector: "mt-entities-preview", inputs: ["entities", "attachmentShape", "stackOnNarrow", "clickableKeys", "activeKeys", "clickableTooltip", "activeTooltip"], outputs: ["entityClick"] }, { kind: "ngmodule", type: SkeletonModule }, { kind: "component", type: i2.Skeleton, selector: "p-skeleton", inputs: ["styleClass", "shape", "animation", "borderRadius", "size", "width", "height"] }, { kind: "ngmodule", type: PopoverModule }, { kind: "component", type: i3.Popover, selector: "p-popover", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions", "motionOptions"], outputs: ["onShow", "onHide"] }, { kind: "component", type: TableCaption, selector: "mt-table-caption", inputs: ["generalSearch", "showFilters", "filterMode", "exportable", "printable", "groupable", "columns", "data", "groupColumns", "tabs", "tabsOptionLabel", "tabsOptionValue", "actions", "captionStart", "captionEnd", "activeTab", "filters", "filterTerm", "groupBy"], outputs: ["activeTabChange", "filtersChange", "filterTermChange", "groupByChange", "exportRequested", "printRequested", "onTabChange", "searchChange", "filterApplied", "filterReset"] }, { kind: "component", type: EmptyState, selector: "mt-empty-state", inputs: ["variant", "illustration", "title", "description", "actionLabel", "actionIcon"], outputs: ["actionClick"] }, { kind: "pipe", type: i4.TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [i1.NgTemplateOutlet, Card,
|
|
1802
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: ClientListCardsView, isStandalone: true, selector: "mt-client-list-cards-view", inputs: { state: { classPropertyName: "state", publicName: "state", isSignal: true, isRequired: true, transformFunction: null }, rowActions: { classPropertyName: "rowActions", publicName: "rowActions", isSignal: true, isRequired: false, transformFunction: null }, rowActionsLoadingFn: { classPropertyName: "rowActionsLoadingFn", publicName: "rowActionsLoadingFn", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { lazyLoad: "lazyLoad", loadMore: "loadMore", cardClick: "cardClick", rowActionsRequested: "rowActionsRequested" }, providers: [MTDateFormatPipe], viewQueries: [{ propertyName: "loadMoreSentinel", first: true, predicate: ["loadMoreSentinel"], descendants: true, isSignal: true }], ngImport: i0, template: "<mt-table-caption\n [generalSearch]=\"true\"\n [showFilters]=\"true\"\n filterMode=\"popover\"\n [printable]=\"true\"\n [groupable]=\"true\"\n [exportable]=\"true\"\n [columns]=\"columns()\"\n [data]=\"rowRepresentations()\"\n [groupColumns]=\"grouping.groupableColumns()\"\n [actions]=\"toolbarActions()\"\n [captionStart]=\"cardsState().config.toolbarStart ?? null\"\n [captionEnd]=\"cardsState().config.toolbarEnd ?? null\"\n [filters]=\"bucket().filters()\"\n (filtersChange)=\"bucket().filters.set($event)\"\n [filterTerm]=\"bucket().filterTerm()\"\n (filterTermChange)=\"bucket().filterTerm.set($event)\"\n [groupBy]=\"bucket().groupBy()\"\n (groupByChange)=\"bucket().groupBy.set($event)\"\n [activeTab]=\"bucket().activeTab()\"\n (activeTabChange)=\"bucket().activeTab.set($event)\"\n (exportRequested)=\"exportExcel()\"\n (printRequested)=\"printPdf()\"\n/>\n\n@if (badgeLegend().length > 0) {\n <!--\n Decodes the coloured count pills on the cards below. Rendered once for the\n whole board rather than per card, so it costs one line of vertical space\n instead of repeating a key inside every tile (finding M-04).\n -->\n <div\n class=\"mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 px-1\"\n role=\"list\"\n [attr.aria-label]=\"\n 'components.clientComponents.list.badgeLegend' | transloco\n \"\n >\n <span class=\"text-xs font-medium text-gray-500 dark:text-gray-400\">\n {{ \"components.clientComponents.list.badgeLegend\" | transloco }}\n </span>\n @for (entry of badgeLegend(); track entry.key) {\n <span class=\"flex items-center gap-1.5\" role=\"listitem\">\n <span\n class=\"inline-flex h-4 min-w-4 items-center justify-center rounded text-[10px] font-bold\"\n [style.background-color]=\"entry.color\"\n [style.color]=\"entry.textColor\"\n aria-hidden=\"true\"\n >\n #\n </span>\n <span class=\"text-xs text-gray-600 dark:text-gray-300\">{{\n entry.label\n }}</span>\n </span>\n }\n </div>\n}\n\n@if (cardsState().loading && cardsState().cards.length === 0) {\n <div class=\"mt-cards-grid\">\n @for (i of [1, 2, 3, 4, 5, 6]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n </div>\n} @else if (cardsState().error) {\n <div class=\"rounded-lg border border-red-200 bg-red-50 p-6 text-red-600\">\n {{ cardsState().error }}\n </div>\n} @else if (visibleCards().length === 0) {\n <!--\n The same empty state the tables use, so a board and a list stop looking like\n two products (finding H-12). A board narrowed by a search or a filter says\n so and offers to clear it, instead of the bare grey \"No cards found.\"\n -->\n <mt-empty-state\n [variant]=\"hasActiveCardFilters() ? 'filtered' : 'empty'\"\n [title]=\"\n hasActiveCardFilters()\n ? ''\n : ('components.clientComponents.list.noCardsFound' | transloco)\n \"\n [actionLabel]=\"\n hasActiveCardFilters()\n ? ('components.emptyState.clearFilters' | transloco)\n : ''\n \"\n actionIcon=\"general.x-close\"\n (actionClick)=\"clearCardFilters()\"\n />\n} @else if (grouping.groupingActive()) {\n <div class=\"flex flex-col gap-6\">\n @for (group of groupedCards(); track group.key) {\n <section class=\"flex flex-col gap-3\">\n <header\n class=\"flex items-center gap-3 px-2 py-2 bg-surface-50 dark:bg-surface-900 border-y border-surface-200 dark:border-surface-700 rounded\"\n [style.--mt-group-accent]=\"group.accent ?? 'var(--p-primary-color)'\"\n >\n <span\n class=\"inline-block w-1 h-5 rounded-full\"\n [style.background]=\"'var(--mt-group-accent)'\"\n ></span>\n <span\n class=\"text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400\"\n >\n {{ group.columnLabel }}\n </span>\n <span class=\"text-sm font-semibold text-gray-900 dark:text-gray-100\">\n {{ group.label }}\n </span>\n <span\n class=\"ml-auto inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300\"\n >\n {{ group.count }}\n </span>\n </header>\n <div class=\"mt-cards-grid\">\n @for (card of group.cards; track card.id) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n }\n </div>\n </section>\n }\n </div>\n} @else {\n <div class=\"mt-cards-grid\">\n @for (card of visibleCards(); track card.id) {\n @defer (on viewport) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n } @placeholder {\n <div class=\"min-h-[20rem]\">\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n </div>\n }\n }\n </div>\n}\n\n<!--\n Infinite-scroll trigger. Deliberately outside the @if/@else chain above:\n inside it the element would be torn down and rebuilt every time the view\n swapped branch (error, empty filter result, group-by), and the observer\n would be left watching a detached node. Kept in the DOM and hidden via CSS\n when there is nothing left to load, so appending a page never recreates it.\n It also has to sit outside so infinite scroll keeps working while a\n group-by is active \u2014 grouping is a pure FE view over whatever has been\n fetched, not a reason to stop fetching.\n-->\n<div\n #loadMoreSentinel\n class=\"mt-cards-grid py-4\"\n [class.hidden]=\"!hasMoreCards()\"\n>\n @for (i of [1, 2, 3]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n</div>\n\n<!--\n Per-card escalation marker and 3-dots actions menu. The compact corner cover\n only indicates active escalation state; its preview action lives in the menu\n so the marker never displaces or competes with the menu trigger.\n-->\n<ng-template #cardActionsMenu let-card>\n @let escalationAction = escalationMarkerAction(card);\n @if (escalationAction) {\n <span\n class=\"mt-escalation-marker absolute end-0 top-0 z-10 h-[18px] w-[20px]\"\n role=\"img\"\n tabindex=\"0\"\n [attr.aria-label]=\"escalationLabel()\"\n [mtTooltip]=\"escalationLabel()\"\n tooltipPosition=\"top\"\n ></span>\n }\n @if (rowActions().length > 0) {\n <div\n class=\"absolute end-2 top-2 z-20 flex items-center gap-1\"\n (click)=\"$event.stopPropagation()\"\n >\n @for (action of inlineActionsForCard(card); track $index) {\n <mt-button\n [icon]=\"action.icon\"\n [severity]=\"action.color\"\n [variant]=\"action.variant\"\n [size]=\"actionSize(action) || 'small'\"\n [tooltip]=\"action.tooltip\"\n [label]=\"action.label\"\n [loading]=\"resolveActionLoading(action, card)\"\n (onClick)=\"runCardAction($event, action, card)\"\n />\n }\n @if (showCardActionsMenu(card)) {\n <button\n type=\"button\"\n class=\"flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-base text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-300 dark:hover:bg-surface-700 dark:hover:text-gray-100\"\n [attr.aria-label]=\"actionsLabel()\"\n [mtTooltip]=\"actionsLabel()\"\n tooltipPosition=\"top\"\n (click)=\"onCardActionsToggle($event, cardPopover, card)\"\n >\n <mt-icon icon=\"general.dots-vertical\" class=\"text-base\" />\n </button>\n <p-popover #cardPopover appendTo=\"body\">\n <div class=\"flex min-w-44 flex-col gap-1 p-1\">\n @if (isCardActionsLoading(card)) {\n <div class=\"flex flex-col gap-2 p-1\">\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n </div>\n } @else {\n @for (action of menuActionsForCard(card); track $index) {\n @if (isFirstDestructiveCardAction(card, $index)) {\n <hr\n class=\"my-1 border-0 border-t border-surface-200 dark:border-surface-700\"\n />\n }\n <button\n type=\"button\"\n class=\"flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-start text-sm transition-colors\"\n [class]=\"\n action.color === 'danger'\n ? 'text-red-600 hover:bg-red-50 dark:hover:bg-red-950'\n : action.color === 'warn'\n ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-950'\n : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-surface-700'\n \"\n (click)=\"\n runCardAction($event, action, card); cardPopover.hide()\n \"\n >\n @if (action.icon) {\n <mt-icon [icon]=\"action.icon\" class=\"text-base\" />\n }\n <span>{{ action.label || action.tooltip }}</span>\n </button>\n }\n }\n </div>\n </p-popover>\n }\n </div>\n }\n</ng-template>\n", styles: [".mt-cards-grid{--mt-cards-gap: 1.25rem;--mt-cards-min: 380px;--mt-cards-track: max( var(--mt-cards-min), (100% - 3 * var(--mt-cards-gap)) / 4 );display:grid;gap:var(--mt-cards-gap);grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--mt-cards-track)),1fr))}.mt-cards-grid.hidden,.mt-cards-grid[hidden]{display:none}.mt-escalation-marker{border-start-end-radius:.5rem;background:var(--p-button-text-warn-color, var(--p-orange-500, #f59e0b));clip-path:polygon(0 0,100% 0,100% 100%);transition:filter .15s ease}.mt-escalation-marker:dir(rtl){clip-path:polygon(0 0,100% 0,0 100%)}.mt-escalation-marker:hover{filter:brightness(.92)}.mt-escalation-marker:focus-visible{filter:brightness(.92) drop-shadow(0 0 2px var(--p-button-text-warn-color, #f59e0b));outline:none}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: TranslocoModule }, { kind: "component", type: Card, selector: "mt-card", inputs: ["class", "title", "paddingless"] }, { kind: "component", type: Button, selector: "mt-button", inputs: ["icon", "label", "tooltip", "class", "type", "styleClass", "severity", "badge", "variant", "badgeSeverity", "size", "iconPos", "autofocus", "fluid", "raised", "rounded", "text", "plain", "outlined", "link", "disabled", "loading", "pInputs", "ariaLabel"], outputs: ["onClick", "onFocus", "onBlur"] }, { kind: "directive", type: Tooltip, selector: "[mtTooltip]" }, { kind: "component", type: Icon, selector: "mt-icon", inputs: ["icon"] }, { kind: "component", type: EntitiesPreview, selector: "mt-entities-preview", inputs: ["entities", "attachmentShape", "stackOnNarrow", "clickableKeys", "activeKeys", "clickableTooltip", "activeTooltip"], outputs: ["entityClick"] }, { kind: "ngmodule", type: SkeletonModule }, { kind: "component", type: i2.Skeleton, selector: "p-skeleton", inputs: ["styleClass", "shape", "animation", "borderRadius", "size", "width", "height"] }, { kind: "ngmodule", type: PopoverModule }, { kind: "component", type: i3.Popover, selector: "p-popover", inputs: ["ariaLabel", "ariaLabelledBy", "dismissable", "style", "styleClass", "appendTo", "autoZIndex", "ariaCloseLabel", "baseZIndex", "focusOnShow", "showTransitionOptions", "hideTransitionOptions", "motionOptions"], outputs: ["onShow", "onHide"] }, { kind: "component", type: TableCaption, selector: "mt-table-caption", inputs: ["generalSearch", "showFilters", "filterMode", "exportable", "printable", "groupable", "columns", "data", "groupColumns", "tabs", "tabsOptionLabel", "tabsOptionValue", "actions", "captionStart", "captionEnd", "activeTab", "filters", "filterTerm", "groupBy"], outputs: ["activeTabChange", "filtersChange", "filterTermChange", "groupByChange", "exportRequested", "printRequested", "onTabChange", "searchChange", "filterApplied", "filterReset"] }, { kind: "component", type: EmptyState, selector: "mt-empty-state", inputs: ["variant", "illustration", "title", "description", "actionLabel", "actionIcon"], outputs: ["actionClick"] }, { kind: "pipe", type: i4.TranslocoPipe, name: "transloco" }], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [i1.NgTemplateOutlet, Card,
|
|
1622
1803
|
EntitiesPreview]] });
|
|
1623
1804
|
}
|
|
1624
1805
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: ClientListCardsView, decorators: [{
|
|
@@ -1635,7 +1816,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
|
|
|
1635
1816
|
PopoverModule,
|
|
1636
1817
|
TableCaption,
|
|
1637
1818
|
EmptyState,
|
|
1638
|
-
], providers: [MTDateFormatPipe], template: "<mt-table-caption\n [generalSearch]=\"true\"\n [showFilters]=\"true\"\n filterMode=\"popover\"\n [printable]=\"true\"\n [groupable]=\"true\"\n [exportable]=\"true\"\n [columns]=\"columns()\"\n [data]=\"rowRepresentations()\"\n [groupColumns]=\"grouping.groupableColumns()\"\n [actions]=\"toolbarActions()\"\n [captionStart]=\"cardsState().config.toolbarStart ?? null\"\n [captionEnd]=\"cardsState().config.toolbarEnd ?? null\"\n [filters]=\"bucket().filters()\"\n (filtersChange)=\"bucket().filters.set($event)\"\n [filterTerm]=\"bucket().filterTerm()\"\n (filterTermChange)=\"bucket().filterTerm.set($event)\"\n [groupBy]=\"bucket().groupBy()\"\n (groupByChange)=\"bucket().groupBy.set($event)\"\n [activeTab]=\"bucket().activeTab()\"\n (activeTabChange)=\"bucket().activeTab.set($event)\"\n (exportRequested)=\"exportExcel()\"\n (printRequested)=\"printPdf()\"\n/>\n\n@if (badgeLegend().length > 0) {\n <!--\n Decodes the coloured count pills on the cards below. Rendered once for the\n whole board rather than per card, so it costs one line of vertical space\n instead of repeating a key inside every tile (finding M-04).\n -->\n <div\n class=\"mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 px-1\"\n role=\"list\"\n [attr.aria-label]=\"\n 'components.clientComponents.list.badgeLegend' | transloco\n \"\n >\n <span class=\"text-xs font-medium text-gray-500 dark:text-gray-400\">\n {{ \"components.clientComponents.list.badgeLegend\" | transloco }}\n </span>\n @for (entry of badgeLegend(); track entry.key) {\n <span class=\"flex items-center gap-1.5\" role=\"listitem\">\n <span\n class=\"inline-flex h-4 min-w-4 items-center justify-center rounded text-[10px] font-bold\"\n [style.background-color]=\"entry.color\"\n [style.color]=\"entry.textColor\"\n aria-hidden=\"true\"\n >\n #\n </span>\n <span class=\"text-xs text-gray-600 dark:text-gray-300\">{{\n entry.label\n }}</span>\n </span>\n }\n </div>\n}\n\n@if (cardsState().loading && cardsState().cards.length === 0) {\n <div class=\"mt-cards-grid\">\n @for (i of [1, 2, 3, 4, 5, 6]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n </div>\n} @else if (cardsState().error) {\n <div class=\"rounded-lg border border-red-200 bg-red-50 p-6 text-red-600\">\n {{ cardsState().error }}\n </div>\n} @else if (visibleCards().length === 0) {\n <!--\n The same empty state the tables use, so a board and a list stop looking like\n two products (finding H-12). A board narrowed by a search or a filter says\n so and offers to clear it, instead of the bare grey \"No cards found.\"\n -->\n <mt-empty-state\n [variant]=\"hasActiveCardFilters() ? 'filtered' : 'empty'\"\n [title]=\"\n hasActiveCardFilters()\n ? ''\n : ('components.clientComponents.list.noCardsFound' | transloco)\n \"\n [actionLabel]=\"\n hasActiveCardFilters()\n ? ('components.emptyState.clearFilters' | transloco)\n : ''\n \"\n actionIcon=\"general.x-close\"\n (actionClick)=\"clearCardFilters()\"\n />\n} @else if (grouping.groupingActive()) {\n <div class=\"flex flex-col gap-6\">\n @for (group of groupedCards(); track group.key) {\n <section class=\"flex flex-col gap-3\">\n <header\n class=\"flex items-center gap-3 px-2 py-2 bg-surface-50 dark:bg-surface-900 border-y border-surface-200 dark:border-surface-700 rounded\"\n [style.--mt-group-accent]=\"group.accent ?? 'var(--p-primary-color)'\"\n >\n <span\n class=\"inline-block w-1 h-5 rounded-full\"\n [style.background]=\"'var(--mt-group-accent)'\"\n ></span>\n <span\n class=\"text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400\"\n >\n {{ group.columnLabel }}\n </span>\n <span class=\"text-sm font-semibold text-gray-900 dark:text-gray-100\">\n {{ group.label }}\n </span>\n <span\n class=\"ml-auto inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300\"\n >\n {{ group.count }}\n </span>\n </header>\n <div class=\"mt-cards-grid\">\n @for (card of group.cards; track card.id) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n }\n </div>\n </section>\n }\n </div>\n} @else {\n <div class=\"mt-cards-grid\">\n @for (card of visibleCards(); track card.id) {\n @defer (on viewport) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n } @placeholder {\n <div class=\"min-h-[20rem]\">\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n </div>\n }\n }\n </div>\n}\n\n<!--\n Infinite-scroll trigger. Deliberately outside the @if/@else chain above:\n inside it the element would be torn down and rebuilt every time the view\n swapped branch (error, empty filter result, group-by), and the observer\n would be left watching a detached node. Kept in the DOM and hidden via CSS\n when there is nothing left to load, so appending a page never recreates it.\n It also has to sit outside so infinite scroll keeps working while a\n group-by is active \u2014 grouping is a pure FE view over whatever has been\n fetched, not a reason to stop fetching.\n-->\n<div\n #loadMoreSentinel\n class=\"mt-cards-grid py-4\"\n [class.hidden]=\"!hasMoreCards()\"\n>\n @for (i of [1, 2, 3]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n</div>\n\n<!--\n Per-card escalation marker and 3-dots actions menu. The compact corner cover\n only indicates active escalation state; its preview action lives in the menu\n so the marker never displaces or competes with the menu trigger.\n-->\n<ng-template #cardActionsMenu let-card>\n @let escalationAction = escalationMarkerAction(card);\n @if (escalationAction) {\n <span\n class=\"mt-escalation-marker absolute end-0 top-0 z-10 h-[18px] w-[20px]\"\n role=\"img\"\n tabindex=\"0\"\n [attr.aria-label]=\"escalationLabel()\"\n [mtTooltip]=\"escalationLabel()\"\n tooltipPosition=\"top\"\n ></span>\n }\n @if (rowActions().length > 0) {\n <div\n class=\"absolute end-2 top-2 z-20 flex items-center gap-1\"\n (click)=\"$event.stopPropagation()\"\n >\n @for (action of inlineActionsForCard(card); track $index) {\n <mt-button\n [icon]=\"action.icon\"\n [severity]=\"action.color\"\n [variant]=\"action.variant\"\n [size]=\"actionSize(action) || 'small'\"\n [tooltip]=\"action.tooltip\"\n [label]=\"action.label\"\n [loading]=\"resolveActionLoading(action, card)\"\n (onClick)=\"runCardAction($event, action, card)\"\n />\n }\n @if (showCardActionsMenu(card)) {\n <button\n type=\"button\"\n class=\"flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-base text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-300 dark:hover:bg-surface-700 dark:hover:text-gray-100\"\n [attr.aria-label]=\"actionsLabel()\"\n [mtTooltip]=\"actionsLabel()\"\n tooltipPosition=\"top\"\n (click)=\"onCardActionsToggle($event, cardPopover, card)\"\n >\n <mt-icon icon=\"general.dots-vertical\" class=\"text-base\" />\n </button>\n <p-popover #cardPopover appendTo=\"body\">\n <div class=\"flex min-w-44 flex-col gap-1 p-1\">\n @if (isCardActionsLoading(card)) {\n <div class=\"flex flex-col gap-2 p-1\">\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n </div>\n } @else {\n @for (action of menuActionsForCard(card); track $index) {\n @if (isFirstDestructiveCardAction(card, $index)) {\n <hr\n class=\"my-1 border-0 border-t border-surface-200 dark:border-surface-700\"\n />\n }\n <button\n type=\"button\"\n class=\"flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-start text-sm transition-colors\"\n [class]=\"\n action.color === 'danger'\n ? 'text-red-600 hover:bg-red-50 dark:hover:bg-red-950'\n : action.color === 'warn'\n ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-950'\n : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-surface-700'\n \"\n (click)=\"\n runCardAction($event, action, card); cardPopover.hide()\n \"\n >\n @if (action.icon) {\n <mt-icon [icon]=\"action.icon\" class=\"text-base\" />\n }\n <span>{{ action.label || action.tooltip }}</span>\n </button>\n }\n }\n </div>\n </p-popover>\n }\n </div>\n }\n</ng-template>\n", styles: [".mt-cards-grid{--mt-cards-gap: 1.25rem;--mt-cards-min: 380px;--mt-cards-track: max( var(--mt-cards-min), (100% - 4 * var(--mt-cards-gap)) / 5 );display:grid;gap:var(--mt-cards-gap);grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--mt-cards-track)),1fr))}.mt-cards-grid.hidden,.mt-cards-grid[hidden]{display:none}.mt-escalation-marker{border-start-end-radius:.5rem;background:var(--p-button-text-warn-color, var(--p-orange-500, #f59e0b));clip-path:polygon(0 0,100% 0,100% 100%);transition:filter .15s ease}.mt-escalation-marker:dir(rtl){clip-path:polygon(0 0,100% 0,0 100%)}.mt-escalation-marker:hover{filter:brightness(.92)}.mt-escalation-marker:focus-visible{filter:brightness(.92) drop-shadow(0 0 2px var(--p-button-text-warn-color, #f59e0b));outline:none}\n"] }]
|
|
1819
|
+
], providers: [MTDateFormatPipe], template: "<mt-table-caption\n [generalSearch]=\"true\"\n [showFilters]=\"true\"\n filterMode=\"popover\"\n [printable]=\"true\"\n [groupable]=\"true\"\n [exportable]=\"true\"\n [columns]=\"columns()\"\n [data]=\"rowRepresentations()\"\n [groupColumns]=\"grouping.groupableColumns()\"\n [actions]=\"toolbarActions()\"\n [captionStart]=\"cardsState().config.toolbarStart ?? null\"\n [captionEnd]=\"cardsState().config.toolbarEnd ?? null\"\n [filters]=\"bucket().filters()\"\n (filtersChange)=\"bucket().filters.set($event)\"\n [filterTerm]=\"bucket().filterTerm()\"\n (filterTermChange)=\"bucket().filterTerm.set($event)\"\n [groupBy]=\"bucket().groupBy()\"\n (groupByChange)=\"bucket().groupBy.set($event)\"\n [activeTab]=\"bucket().activeTab()\"\n (activeTabChange)=\"bucket().activeTab.set($event)\"\n (exportRequested)=\"exportExcel()\"\n (printRequested)=\"printPdf()\"\n/>\n\n@if (badgeLegend().length > 0) {\n <!--\n Decodes the coloured count pills on the cards below. Rendered once for the\n whole board rather than per card, so it costs one line of vertical space\n instead of repeating a key inside every tile (finding M-04).\n -->\n <div\n class=\"mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 px-1\"\n role=\"list\"\n [attr.aria-label]=\"\n 'components.clientComponents.list.badgeLegend' | transloco\n \"\n >\n <span class=\"text-xs font-medium text-gray-500 dark:text-gray-400\">\n {{ \"components.clientComponents.list.badgeLegend\" | transloco }}\n </span>\n @for (entry of badgeLegend(); track entry.key) {\n <span class=\"flex items-center gap-1.5\" role=\"listitem\">\n <span\n class=\"inline-flex h-4 min-w-4 items-center justify-center rounded text-[10px] font-bold\"\n [style.background-color]=\"entry.color\"\n [style.color]=\"entry.textColor\"\n aria-hidden=\"true\"\n >\n #\n </span>\n <span class=\"text-xs text-gray-600 dark:text-gray-300\">{{\n entry.label\n }}</span>\n </span>\n }\n </div>\n}\n\n@if (cardsState().loading && cardsState().cards.length === 0) {\n <div class=\"mt-cards-grid\">\n @for (i of [1, 2, 3, 4, 5, 6]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n </div>\n} @else if (cardsState().error) {\n <div class=\"rounded-lg border border-red-200 bg-red-50 p-6 text-red-600\">\n {{ cardsState().error }}\n </div>\n} @else if (visibleCards().length === 0) {\n <!--\n The same empty state the tables use, so a board and a list stop looking like\n two products (finding H-12). A board narrowed by a search or a filter says\n so and offers to clear it, instead of the bare grey \"No cards found.\"\n -->\n <mt-empty-state\n [variant]=\"hasActiveCardFilters() ? 'filtered' : 'empty'\"\n [title]=\"\n hasActiveCardFilters()\n ? ''\n : ('components.clientComponents.list.noCardsFound' | transloco)\n \"\n [actionLabel]=\"\n hasActiveCardFilters()\n ? ('components.emptyState.clearFilters' | transloco)\n : ''\n \"\n actionIcon=\"general.x-close\"\n (actionClick)=\"clearCardFilters()\"\n />\n} @else if (grouping.groupingActive()) {\n <div class=\"flex flex-col gap-6\">\n @for (group of groupedCards(); track group.key) {\n <section class=\"flex flex-col gap-3\">\n <header\n class=\"flex items-center gap-3 px-2 py-2 bg-surface-50 dark:bg-surface-900 border-y border-surface-200 dark:border-surface-700 rounded\"\n [style.--mt-group-accent]=\"group.accent ?? 'var(--p-primary-color)'\"\n >\n <span\n class=\"inline-block w-1 h-5 rounded-full\"\n [style.background]=\"'var(--mt-group-accent)'\"\n ></span>\n <span\n class=\"text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400\"\n >\n {{ group.columnLabel }}\n </span>\n <span class=\"text-sm font-semibold text-gray-900 dark:text-gray-100\">\n {{ group.label }}\n </span>\n <span\n class=\"ml-auto inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-primary-50 text-primary-700 dark:bg-primary-950 dark:text-primary-300\"\n >\n {{ group.count }}\n </span>\n </header>\n <div class=\"mt-cards-grid\">\n @for (card of group.cards; track card.id) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n }\n </div>\n </section>\n }\n </div>\n} @else {\n <div class=\"mt-cards-grid\">\n @for (card of visibleCards(); track card.id) {\n @defer (on viewport) {\n <mt-card\n class=\"relative cursor-pointer p-5 shadow-sm transition-shadow hover:shadow-md\"\n (click)=\"cardClick.emit(card)\"\n >\n <ng-template #headless>\n <ng-container\n [ngTemplateOutlet]=\"cardActionsMenu\"\n [ngTemplateOutletContext]=\"{ $implicit: card }\"\n />\n <mt-entities-preview\n [entities]=\"card.entities\"\n attachmentShape=\"compact\"\n [stackOnNarrow]=\"false\"\n [clickableKeys]=\"clickableEntityKeys()\"\n [activeKeys]=\"activeKeysForCard(card)\"\n [clickableTooltip]=\"filterByThisLabel()\"\n [activeTooltip]=\"clearFilterLabel()\"\n (entityClick)=\"onEntityFilterClick(card, $event)\"\n />\n </ng-template>\n </mt-card>\n } @placeholder {\n <div class=\"min-h-[20rem]\">\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n </div>\n }\n }\n </div>\n}\n\n<!--\n Infinite-scroll trigger. Deliberately outside the @if/@else chain above:\n inside it the element would be torn down and rebuilt every time the view\n swapped branch (error, empty filter result, group-by), and the observer\n would be left watching a detached node. Kept in the DOM and hidden via CSS\n when there is nothing left to load, so appending a page never recreates it.\n It also has to sit outside so infinite scroll keeps working while a\n group-by is active \u2014 grouping is a pure FE view over whatever has been\n fetched, not a reason to stop fetching.\n-->\n<div\n #loadMoreSentinel\n class=\"mt-cards-grid py-4\"\n [class.hidden]=\"!hasMoreCards()\"\n>\n @for (i of [1, 2, 3]; track i) {\n <p-skeleton height=\"20rem\" class=\"rounded-lg\" />\n }\n</div>\n\n<!--\n Per-card escalation marker and 3-dots actions menu. The compact corner cover\n only indicates active escalation state; its preview action lives in the menu\n so the marker never displaces or competes with the menu trigger.\n-->\n<ng-template #cardActionsMenu let-card>\n @let escalationAction = escalationMarkerAction(card);\n @if (escalationAction) {\n <span\n class=\"mt-escalation-marker absolute end-0 top-0 z-10 h-[18px] w-[20px]\"\n role=\"img\"\n tabindex=\"0\"\n [attr.aria-label]=\"escalationLabel()\"\n [mtTooltip]=\"escalationLabel()\"\n tooltipPosition=\"top\"\n ></span>\n }\n @if (rowActions().length > 0) {\n <div\n class=\"absolute end-2 top-2 z-20 flex items-center gap-1\"\n (click)=\"$event.stopPropagation()\"\n >\n @for (action of inlineActionsForCard(card); track $index) {\n <mt-button\n [icon]=\"action.icon\"\n [severity]=\"action.color\"\n [variant]=\"action.variant\"\n [size]=\"actionSize(action) || 'small'\"\n [tooltip]=\"action.tooltip\"\n [label]=\"action.label\"\n [loading]=\"resolveActionLoading(action, card)\"\n (onClick)=\"runCardAction($event, action, card)\"\n />\n }\n @if (showCardActionsMenu(card)) {\n <button\n type=\"button\"\n class=\"flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-base text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:text-gray-300 dark:hover:bg-surface-700 dark:hover:text-gray-100\"\n [attr.aria-label]=\"actionsLabel()\"\n [mtTooltip]=\"actionsLabel()\"\n tooltipPosition=\"top\"\n (click)=\"onCardActionsToggle($event, cardPopover, card)\"\n >\n <mt-icon icon=\"general.dots-vertical\" class=\"text-base\" />\n </button>\n <p-popover #cardPopover appendTo=\"body\">\n <div class=\"flex min-w-44 flex-col gap-1 p-1\">\n @if (isCardActionsLoading(card)) {\n <div class=\"flex flex-col gap-2 p-1\">\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n <p-skeleton height=\"1.75rem\" />\n </div>\n } @else {\n @for (action of menuActionsForCard(card); track $index) {\n @if (isFirstDestructiveCardAction(card, $index)) {\n <hr\n class=\"my-1 border-0 border-t border-surface-200 dark:border-surface-700\"\n />\n }\n <button\n type=\"button\"\n class=\"flex cursor-pointer items-center gap-2 rounded px-2 py-2 text-start text-sm transition-colors\"\n [class]=\"\n action.color === 'danger'\n ? 'text-red-600 hover:bg-red-50 dark:hover:bg-red-950'\n : action.color === 'warn'\n ? 'text-orange-600 hover:bg-orange-50 dark:text-orange-400 dark:hover:bg-orange-950'\n : 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-surface-700'\n \"\n (click)=\"\n runCardAction($event, action, card); cardPopover.hide()\n \"\n >\n @if (action.icon) {\n <mt-icon [icon]=\"action.icon\" class=\"text-base\" />\n }\n <span>{{ action.label || action.tooltip }}</span>\n </button>\n }\n }\n </div>\n </p-popover>\n }\n </div>\n }\n</ng-template>\n", styles: [".mt-cards-grid{--mt-cards-gap: 1.25rem;--mt-cards-min: 380px;--mt-cards-track: max( var(--mt-cards-min), (100% - 3 * var(--mt-cards-gap)) / 4 );display:grid;gap:var(--mt-cards-gap);grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--mt-cards-track)),1fr))}.mt-cards-grid.hidden,.mt-cards-grid[hidden]{display:none}.mt-escalation-marker{border-start-end-radius:.5rem;background:var(--p-button-text-warn-color, var(--p-orange-500, #f59e0b));clip-path:polygon(0 0,100% 0,100% 100%);transition:filter .15s ease}.mt-escalation-marker:dir(rtl){clip-path:polygon(0 0,100% 0,0 100%)}.mt-escalation-marker:hover{filter:brightness(.92)}.mt-escalation-marker:focus-visible{filter:brightness(.92) drop-shadow(0 0 2px var(--p-button-text-warn-color, #f59e0b));outline:none}\n"] }]
|
|
1639
1820
|
}], ctorParameters: () => [], propDecorators: { state: [{ type: i0.Input, args: [{ isSignal: true, alias: "state", required: true }] }], rowActions: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowActions", required: false }] }], rowActionsLoadingFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "rowActionsLoadingFn", required: false }] }], lazyLoad: [{ type: i0.Output, args: ["lazyLoad"] }], loadMore: [{ type: i0.Output, args: ["loadMore"] }], cardClick: [{ type: i0.Output, args: ["cardClick"] }], rowActionsRequested: [{ type: i0.Output, args: ["rowActionsRequested"] }], loadMoreSentinel: [{ type: i0.ViewChild, args: ['loadMoreSentinel', { isSignal: true }] }] } });
|
|
1640
1821
|
|
|
1641
1822
|
function toDashboardExtraFilters(filters = []) {
|
|
@@ -1791,6 +1972,14 @@ class ClientList {
|
|
|
1791
1972
|
inFlightRequestSignatures = new Map();
|
|
1792
1973
|
fulfilledRequestSignatures = new Map();
|
|
1793
1974
|
runtimeFilters = new Map();
|
|
1975
|
+
/**
|
|
1976
|
+
* The `propertyKeys` the last table request carried, per item. `undefined`
|
|
1977
|
+
* means the request left the projection to the backend, which is a distinct
|
|
1978
|
+
* state from "not loaded yet" — hence the map rather than a nullable field.
|
|
1979
|
+
*/
|
|
1980
|
+
requestPropertyKeys = new Map();
|
|
1981
|
+
/** Single-record read-backs, kept apart from the page loads they must not cancel. */
|
|
1982
|
+
recordSubscriptions = new Map();
|
|
1794
1983
|
constructor() {
|
|
1795
1984
|
effect(() => {
|
|
1796
1985
|
const configs = this.configurations();
|
|
@@ -1860,6 +2049,93 @@ class ClientList {
|
|
|
1860
2049
|
return;
|
|
1861
2050
|
this.loadItem(item, true);
|
|
1862
2051
|
}
|
|
2052
|
+
/**
|
|
2053
|
+
* The query settings this list renders with, shaped as the `returnRecord`
|
|
2054
|
+
* block of a `process-submit` payload. Send it with a create/update so the
|
|
2055
|
+
* backend projects the written record the way this list reads records, then
|
|
2056
|
+
* hand the response to {@link applyWriteResult} — that pair replaces the
|
|
2057
|
+
* full page re-fetch a write used to cost.
|
|
2058
|
+
*
|
|
2059
|
+
* Returns `null` for an informative dashboard (no records to project) or an
|
|
2060
|
+
* unknown key.
|
|
2061
|
+
*/
|
|
2062
|
+
getReturnRecordRequest(itemKey) {
|
|
2063
|
+
const item = this.state.itemsByKey()[itemKey];
|
|
2064
|
+
if (!item || item.type !== 'form')
|
|
2065
|
+
return null;
|
|
2066
|
+
const includeState = [...CLIENT_LIST_DEFAULT_INCLUDE_STATE];
|
|
2067
|
+
const culture = this.transloco.getActiveLang();
|
|
2068
|
+
if (item.areaType === 'cards') {
|
|
2069
|
+
return { projection: 'Card', includeState, culture };
|
|
2070
|
+
}
|
|
2071
|
+
const propertyKeys = this.requestPropertyKeys.get(itemKey);
|
|
2072
|
+
return {
|
|
2073
|
+
projection: 'Table',
|
|
2074
|
+
surfaceKey: 'table',
|
|
2075
|
+
includeState,
|
|
2076
|
+
// Omitted, not empty, when the table let the backend pick the
|
|
2077
|
+
// projection — an empty array would read as "no columns".
|
|
2078
|
+
...(propertyKeys?.length ? { propertyKeys } : {}),
|
|
2079
|
+
culture,
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Applies a create/update to the list in place, without re-fetching the
|
|
2084
|
+
* page. Pass the `process-submit` response as-is.
|
|
2085
|
+
*
|
|
2086
|
+
* Three outcomes, in the order they are tried:
|
|
2087
|
+
* - the write carried a usable record projection → splice it in, no request;
|
|
2088
|
+
* - it did not (`recordProjectionStatus: 'Unavailable'`, or the list has
|
|
2089
|
+
* nothing to splice into) → read that one record back by id. The write
|
|
2090
|
+
* already succeeded; this only reads, and never re-submits;
|
|
2091
|
+
* - nothing identifies the record at all → reload the page, as before.
|
|
2092
|
+
*
|
|
2093
|
+
* An approval-queued submit is a no-op: nothing has been written yet.
|
|
2094
|
+
*
|
|
2095
|
+
* Note that the list cannot know where the backend would have sorted a new
|
|
2096
|
+
* record, so an insert lands at the top of the page in view.
|
|
2097
|
+
*/
|
|
2098
|
+
applyWriteResult(itemKey, result) {
|
|
2099
|
+
const item = this.state.itemsByKey()[itemKey];
|
|
2100
|
+
if (!item || item.type !== 'form')
|
|
2101
|
+
return;
|
|
2102
|
+
if (this.isPendingApprovalWrite(result))
|
|
2103
|
+
return;
|
|
2104
|
+
const recordId = this.toFiniteNumber(result?.recordId);
|
|
2105
|
+
const record = this.toWriteRecord(result, recordId);
|
|
2106
|
+
if (record) {
|
|
2107
|
+
// A failed splice means the list holds no page to splice into, so
|
|
2108
|
+
// reading the record back would not help either.
|
|
2109
|
+
if (this.state.upsertRecord(itemKey, record, item.config)) {
|
|
2110
|
+
this.afterRecordApplied(itemKey);
|
|
2111
|
+
}
|
|
2112
|
+
else {
|
|
2113
|
+
this.reloadByKey(itemKey);
|
|
2114
|
+
}
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (recordId != null) {
|
|
2118
|
+
this.readRecordBack(item, recordId);
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
this.reloadByKey(itemKey);
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Drops a deleted record from the list in place. Falls back to a reload when
|
|
2125
|
+
* the record is not part of the loaded set, so a stale list still corrects
|
|
2126
|
+
* itself.
|
|
2127
|
+
*/
|
|
2128
|
+
removeRecord(itemKey, recordId) {
|
|
2129
|
+
const item = this.state.itemsByKey()[itemKey];
|
|
2130
|
+
if (!item || item.type !== 'form')
|
|
2131
|
+
return;
|
|
2132
|
+
if (!this.state.removeRecord(itemKey, recordId, item.config)) {
|
|
2133
|
+
this.reloadByKey(itemKey);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
this.runtimeActions.invalidateRow(itemKey, recordId);
|
|
2137
|
+
this.afterRecordApplied(itemKey, { invalidateRuntimeActions: false });
|
|
2138
|
+
}
|
|
1863
2139
|
toggleExpanded(key) {
|
|
1864
2140
|
this.state.toggleExpanded(key);
|
|
1865
2141
|
}
|
|
@@ -2110,6 +2386,89 @@ class ClientList {
|
|
|
2110
2386
|
config.onActionExecuted?.(action, ctx);
|
|
2111
2387
|
}
|
|
2112
2388
|
}
|
|
2389
|
+
/**
|
|
2390
|
+
* A submit that only queued an approval request has written nothing — the
|
|
2391
|
+
* record it describes does not exist yet, so the list must not move.
|
|
2392
|
+
*/
|
|
2393
|
+
isPendingApprovalWrite(result) {
|
|
2394
|
+
return (String(result?.status ?? '')
|
|
2395
|
+
.trim()
|
|
2396
|
+
.toLowerCase() === 'pendingapproval');
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* The canonical record from a write response, or `null` when there is none
|
|
2400
|
+
* to trust. A backend that has not shipped the canonical projection yet
|
|
2401
|
+
* answers with neither `recordProjectionStatus: 'Available'` nor `values`,
|
|
2402
|
+
* and falls through to the read-back path — which is why this is a plain
|
|
2403
|
+
* shape check and not a parse of alternative record shapes.
|
|
2404
|
+
*/
|
|
2405
|
+
toWriteRecord(result, recordId) {
|
|
2406
|
+
if (result?.recordProjectionStatus !== 'Available')
|
|
2407
|
+
return null;
|
|
2408
|
+
const record = result.record;
|
|
2409
|
+
if (!record?.values)
|
|
2410
|
+
return null;
|
|
2411
|
+
const id = this.toFiniteNumber(record.id) ?? recordId;
|
|
2412
|
+
if (id == null)
|
|
2413
|
+
return null;
|
|
2414
|
+
return { ...record, id };
|
|
2415
|
+
}
|
|
2416
|
+
/**
|
|
2417
|
+
* Reads one record back through `fetch/query` with this list's own settings.
|
|
2418
|
+
* Taken when the write could not project the record itself — the write has
|
|
2419
|
+
* already happened, so this reads and never re-submits.
|
|
2420
|
+
*/
|
|
2421
|
+
readRecordBack(item, recordId) {
|
|
2422
|
+
const config = item.config;
|
|
2423
|
+
const request = this.getReturnRecordRequest(item.key);
|
|
2424
|
+
if (!request || !this.hasValidIdentifiers(config)) {
|
|
2425
|
+
this.reloadByKey(item.key);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
// The host's own scope filters plus the record id. Runtime filters (the
|
|
2429
|
+
// search box, a column filter) are deliberately left out: a record the
|
|
2430
|
+
// user just wrote should come back whether or not it matches whatever
|
|
2431
|
+
// they happen to have typed into the list.
|
|
2432
|
+
const filters = [
|
|
2433
|
+
...config.filters,
|
|
2434
|
+
{
|
|
2435
|
+
key: CLIENT_LIST_RECORD_ID_FILTER_KEY,
|
|
2436
|
+
operator: 'Equals',
|
|
2437
|
+
value: recordId,
|
|
2438
|
+
},
|
|
2439
|
+
];
|
|
2440
|
+
this.recordSubscriptions.get(item.key)?.unsubscribe();
|
|
2441
|
+
const sub = this.api
|
|
2442
|
+
.getRecord(config.contextKey, request, filters)
|
|
2443
|
+
.subscribe({
|
|
2444
|
+
next: (response) => {
|
|
2445
|
+
this.recordSubscriptions.delete(item.key);
|
|
2446
|
+
const record = response.data?.records?.[0];
|
|
2447
|
+
if (!record || !this.state.upsertRecord(item.key, record, config)) {
|
|
2448
|
+
this.reloadByKey(item.key);
|
|
2449
|
+
return;
|
|
2450
|
+
}
|
|
2451
|
+
this.afterRecordApplied(item.key);
|
|
2452
|
+
},
|
|
2453
|
+
error: () => {
|
|
2454
|
+
this.recordSubscriptions.delete(item.key);
|
|
2455
|
+
this.reloadByKey(item.key);
|
|
2456
|
+
},
|
|
2457
|
+
});
|
|
2458
|
+
this.recordSubscriptions.set(item.key, sub);
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* Runs the tail of a load for a list that changed without one: the row
|
|
2462
|
+
* action cache is stale (a written record's available actions follow its new
|
|
2463
|
+
* state), and hosts hang work off `dataLoaded` / `loaded`.
|
|
2464
|
+
*/
|
|
2465
|
+
afterRecordApplied(itemKey, options) {
|
|
2466
|
+
if (options?.invalidateRuntimeActions !== false) {
|
|
2467
|
+
this.runtimeActions.invalidateList(itemKey);
|
|
2468
|
+
}
|
|
2469
|
+
this.notifyDataLoaded(itemKey);
|
|
2470
|
+
this.loaded.emit(itemKey);
|
|
2471
|
+
}
|
|
2113
2472
|
toRuntimeActionsContext(item, row, config) {
|
|
2114
2473
|
const recordId = this.resolveRecordId(config, row);
|
|
2115
2474
|
if (recordId == null)
|
|
@@ -2147,6 +2506,9 @@ class ClientList {
|
|
|
2147
2506
|
ngOnDestroy() {
|
|
2148
2507
|
this.subscriptions.forEach((sub) => sub.unsubscribe());
|
|
2149
2508
|
this.subscriptions.clear();
|
|
2509
|
+
this.recordSubscriptions.forEach((sub) => sub.unsubscribe());
|
|
2510
|
+
this.recordSubscriptions.clear();
|
|
2511
|
+
this.requestPropertyKeys.clear();
|
|
2150
2512
|
this.rowActionsLoadingFnCache.clear();
|
|
2151
2513
|
this.runtimeActions.clearAll();
|
|
2152
2514
|
}
|
|
@@ -2290,9 +2652,10 @@ class ClientList {
|
|
|
2290
2652
|
this.state.setLoading(key, true);
|
|
2291
2653
|
this.state.setError(key, null);
|
|
2292
2654
|
const sub = this.api.getRows(config.contextKey, query, filters).subscribe({
|
|
2293
|
-
next: (response) => {
|
|
2655
|
+
next: ({ response, propertyKeys }) => {
|
|
2294
2656
|
let hasLoadedData = false;
|
|
2295
2657
|
if (response.data) {
|
|
2658
|
+
this.requestPropertyKeys.set(key, propertyKeys);
|
|
2296
2659
|
this.state.setRowsResult(key, response.data, config, query.skip, query.take);
|
|
2297
2660
|
this.fulfilledRequestSignatures.set(key, requestSignature);
|
|
2298
2661
|
hasLoadedData = true;
|
|
@@ -2563,11 +2926,22 @@ class ClientList {
|
|
|
2563
2926
|
this.fulfilledRequestSignatures.delete(key);
|
|
2564
2927
|
}
|
|
2565
2928
|
}
|
|
2929
|
+
for (const [key, sub] of this.recordSubscriptions.entries()) {
|
|
2930
|
+
if (!activeKeys.has(key)) {
|
|
2931
|
+
sub.unsubscribe();
|
|
2932
|
+
this.recordSubscriptions.delete(key);
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2566
2935
|
for (const key of this.runtimeFilters.keys()) {
|
|
2567
2936
|
if (!activeKeys.has(key)) {
|
|
2568
2937
|
this.runtimeFilters.delete(key);
|
|
2569
2938
|
}
|
|
2570
2939
|
}
|
|
2940
|
+
for (const key of this.requestPropertyKeys.keys()) {
|
|
2941
|
+
if (!activeKeys.has(key)) {
|
|
2942
|
+
this.requestPropertyKeys.delete(key);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2571
2945
|
for (const key of this.rowActionsLoadingFnCache.keys()) {
|
|
2572
2946
|
if (!activeKeys.has(key)) {
|
|
2573
2947
|
this.rowActionsLoadingFnCache.delete(key);
|
|
@@ -2694,5 +3068,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
|
|
|
2694
3068
|
* Generated bundle index. Do not edit.
|
|
2695
3069
|
*/
|
|
2696
3070
|
|
|
2697
|
-
export { CLIENT_LIST_RECORD_STATE_KEY, ClientList, ClientListApiService, ClientListRuntimeActionsService, ClientListStateService, ClientListToolbarService, defaultResolveRecordId };
|
|
3071
|
+
export { CLIENT_LIST_DEFAULT_INCLUDE_STATE, CLIENT_LIST_RECORD_ID_FILTER_KEY, CLIENT_LIST_RECORD_STATE_KEY, ClientList, ClientListApiService, ClientListRuntimeActionsService, ClientListStateService, ClientListToolbarService, defaultResolveRecordId };
|
|
2698
3072
|
//# sourceMappingURL=masterteam-client-components-client-list.mjs.map
|