@masterteam/client-components 0.0.89 → 0.0.90

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.
@@ -8,7 +8,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
10
  import { switchMap, map, of, Observable, share, take } from 'rxjs';
11
- import { resolveLocalizedLabel, serializeEntityRawValue, isSectionKey, EntitiesPreview } from '@masterteam/components/entities';
11
+ import { resolveLocalizedLabel, serializeEntityRawValue, isSectionKey, buildDisplayEntities, 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';
14
14
  import { SkeletonModule } from 'primeng/skeleton';
@@ -24,6 +24,13 @@ import { PopoverModule } from 'primeng/popover';
24
24
  import { DashboardViewer } from '@masterteam/dashboard-builder';
25
25
 
26
26
  const CLIENT_LIST_RECORD_STATE_KEY = '__clientListRecordState';
27
+ /**
28
+ * Row key carrying the owning record of a hierarchy row. Sits beside the row's
29
+ * cells rather than in a column so it travels with the row through
30
+ * `transformResult`, row actions and click handlers without ever rendering.
31
+ * Read it with {@link readClientListRowSource}.
32
+ */
33
+ const CLIENT_LIST_RECORD_SOURCE_KEY = '__clientListRecordSource';
27
34
  /** Record state every list request asks for. */
28
35
  const CLIENT_LIST_DEFAULT_INCLUDE_STATE = ['escalation'];
29
36
  /**
@@ -43,7 +50,7 @@ class ClientListApiService {
43
50
  * the request carried, because a write that wants its record projected the
44
51
  * same way has to repeat them and cannot recover them from the response.
45
52
  */
46
- getRows(contextKey, query, filters = []) {
53
+ getRows(contextKey, query, filters = [], hierarchy) {
47
54
  return this.resolveTablePropertyKeys(contextKey, query).pipe(switchMap((resolvedKeys) => {
48
55
  const propertyKeys = resolvedKeys?.length ? resolvedKeys : undefined;
49
56
  return this.queryRuntime({
@@ -55,6 +62,7 @@ class ClientListApiService {
55
62
  filters,
56
63
  page: this.toPage(query.skip, query.take),
57
64
  pageSize: query.take,
65
+ ...(hierarchy ? { hierarchy: this.toHierarchyScope(hierarchy) } : {}),
58
66
  }).pipe(map((response) => ({ response, propertyKeys })));
59
67
  }));
60
68
  }
@@ -63,6 +71,12 @@ class ClientListApiService {
63
71
  * taken when a write reports `recordProjectionStatus: 'Unavailable'`. The
64
72
  * write itself already succeeded; this only reads, and must never be served
65
73
  * by re-submitting.
74
+ *
75
+ * `contextKey` is the context that *owns* the record, which in a hierarchy
76
+ * table is the row's `source.contextKey` rather than the list's own. The
77
+ * read deliberately carries no `hierarchy` scope: the row is filtered by its
78
+ * own id in its own context, which is both cheaper and immune to the record
79
+ * having moved out of the root's subtree since it was written.
66
80
  */
67
81
  getRecord(contextKey, request, filters) {
68
82
  return this.queryRuntime({
@@ -123,6 +137,21 @@ class ClientListApiService {
123
137
  }
124
138
  return Math.floor(skip / take) + 1;
125
139
  }
140
+ /**
141
+ * Sends the scope with both optional fields resolved rather than omitted.
142
+ * The backend's defaults match these, but `depth` is range-checked against
143
+ * `>= -1` and anything else fails the whole request — so a host that means
144
+ * "everything" gets `-1` written down instead of relying on an absent field
145
+ * surviving serialization.
146
+ */
147
+ toHierarchyScope(scope) {
148
+ const depth = Math.trunc(scope.depth ?? -1);
149
+ return {
150
+ rootId: scope.rootId,
151
+ depth: depth < -1 ? -1 : depth,
152
+ includeRoot: scope.includeRoot ?? false,
153
+ };
154
+ }
126
155
  toEffectiveColumnKeys(columns) {
127
156
  const keys = (columns ?? [])
128
157
  .filter((column) => typeof column === 'string' ? true : column.visible !== false)
@@ -251,6 +280,10 @@ class ClientListStateService {
251
280
  const row = {
252
281
  Id: record.id,
253
282
  [CLIENT_LIST_RECORD_STATE_KEY]: record.state ?? null,
283
+ // Only present on a hierarchy response, where rows come from several
284
+ // owners at once and the list's own context no longer identifies the
285
+ // record a row belongs to.
286
+ [CLIENT_LIST_RECORD_SOURCE_KEY]: record.source ?? null,
254
287
  };
255
288
  columns.forEach((column) => {
256
289
  const property = this.resolvePropertyMeta(column.key, propertyMaps);
@@ -277,6 +310,12 @@ class ClientListStateService {
277
310
  columns: shaped.columns,
278
311
  rows: shaped.rows,
279
312
  cards: [],
313
+ // Pre-pagination aggregates, kept verbatim off the response. They
314
+ // describe every matching row, so they must never be recomputed from
315
+ // the page — and they are absent on an ordinary list, which is a
316
+ // different thing from being zero.
317
+ totals: response.projectionMeta?.totals,
318
+ sourceGroups: response.projectionMeta?.sourceGroups,
280
319
  skip,
281
320
  take,
282
321
  totalCount,
@@ -370,14 +409,22 @@ class ClientListStateService {
370
409
  const raw = target.rawData;
371
410
  if (!raw)
372
411
  return false;
412
+ // A hierarchy table cannot be patched in place. Its totals and per-owner
413
+ // subtotals are summed by the backend over every matching row across all
414
+ // pages, so a written row changes numbers this client cannot recompute —
415
+ // splicing the row would leave the table showing a total that no longer
416
+ // matches its own rows. Refuse, and let the caller reload.
417
+ if (config.hierarchy)
418
+ return false;
373
419
  return target.areaType === 'cards'
374
420
  ? this.upsertCardRecord(key, target, raw, record)
375
421
  : this.upsertRowRecord(key, target, raw, record, config);
376
422
  }
377
423
  /**
378
424
  * 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.
425
+ * `false` when the record is not in the loaded set — or when the list is a
426
+ * hierarchy table, whose server-summed totals a local removal would
427
+ * invalidate — so the caller can decide whether a reload is warranted.
381
428
  */
382
429
  removeRecord(key, recordId, config) {
383
430
  const target = this.itemsByKey()[key];
@@ -386,6 +433,8 @@ class ClientListStateService {
386
433
  const raw = target.rawData;
387
434
  if (!raw)
388
435
  return false;
436
+ if (config.hierarchy)
437
+ return false;
389
438
  const records = (raw.records ?? []).filter((item) => item.id !== recordId);
390
439
  if (target.areaType === 'cards') {
391
440
  const cards = target.cards.filter((card) => card.id !== recordId);
@@ -1495,17 +1544,25 @@ class ClientListCardsView {
1495
1544
  badgeLegend = computed(() => {
1496
1545
  const entries = new Map();
1497
1546
  for (const card of this.visibleCards()) {
1498
- for (const entity of card.entities ?? []) {
1547
+ // A card's entities are unexpanded: a LeafDetails one still carries the
1548
+ // fetch payload (`childSchemas`), not the per-level value the badges are
1549
+ // painted from. Expanding it here — the same split `mt-entities-preview`
1550
+ // does to render the card — is what makes the legend list exactly the
1551
+ // badges on screen, statuses and phases alike.
1552
+ for (const entity of buildDisplayEntities(card.entities ?? [])) {
1499
1553
  if (entity.viewType !== 'LeafDetails')
1500
1554
  continue;
1501
1555
  const value = entity.value;
1502
- for (const status of value?.statuses ?? []) {
1503
- if (!status?.key || entries.has(status.key))
1556
+ const badges = value?.kind === 'phase'
1557
+ ? (value.phases ?? [])
1558
+ : (value?.statuses ?? []);
1559
+ for (const badge of badges) {
1560
+ if (!badge?.key || entries.has(badge.key))
1504
1561
  continue;
1505
- const color = status.color ?? DEFAULT_BADGE_COLOR;
1506
- entries.set(status.key, {
1507
- key: status.key,
1508
- label: status.display || status.key,
1562
+ const color = badge.color ?? DEFAULT_BADGE_COLOR;
1563
+ entries.set(badge.key, {
1564
+ key: badge.key,
1565
+ label: badge.display || badge.key,
1509
1566
  color,
1510
1567
  textColor: readableTextOn(color),
1511
1568
  });
@@ -1978,6 +2035,54 @@ function serializeFilters(filters) {
1978
2035
  return JSON.stringify(filters ?? []);
1979
2036
  }
1980
2037
 
2038
+ /**
2039
+ * Reads the owning record off a hierarchy row. Returns null for a row from an
2040
+ * ordinary list, which has no owner other than the list's own context.
2041
+ *
2042
+ * Use this before addressing anything at a row — an edit, a navigation, a
2043
+ * permission check. The list's configured `contextKey` is the aggregation
2044
+ * scope in a hierarchy table, not the row's home.
2045
+ */
2046
+ function readClientListRowSource(row) {
2047
+ const source = row?.[CLIENT_LIST_RECORD_SOURCE_KEY];
2048
+ return isRecordSource(source) ? source : null;
2049
+ }
2050
+ /**
2051
+ * Looks a total up by property key.
2052
+ *
2053
+ * Always go through this rather than indexing `totals`. The backend fills the
2054
+ * array from the module's configured summary keys — one `amount` entry on the
2055
+ * financial module, nothing at all on every other module — so the array being
2056
+ * present does not mean the key you want is in it, and position means nothing.
2057
+ */
2058
+ function findClientListTotal(totals, propertyKey) {
2059
+ const wanted = propertyKey.trim().toLowerCase();
2060
+ return ((totals ?? []).find((total) => (total.propertyKey ?? '').trim().toLowerCase() === wanted) ?? null);
2061
+ }
2062
+ /**
2063
+ * Whether a total left rows out — some row's value was missing or not a
2064
+ * number, so the sum is over fewer rows than the table reports.
2065
+ *
2066
+ * A total in this state must be presented as incomplete. Rendering it as a
2067
+ * plain figure states a portfolio total the data does not support.
2068
+ */
2069
+ function isClientListTotalIncomplete(total) {
2070
+ return (total?.missingValueCount ?? 0) > 0;
2071
+ }
2072
+ /**
2073
+ * The subtotal group for one owning record, by its level-data id.
2074
+ */
2075
+ function findClientListSourceGroup(groups, levelDataId) {
2076
+ return ((groups ?? []).find((group) => group.source?.levelDataId === levelDataId) ??
2077
+ null);
2078
+ }
2079
+ function isRecordSource(value) {
2080
+ return (!!value &&
2081
+ typeof value === 'object' &&
2082
+ typeof value.levelDataId === 'number' &&
2083
+ typeof value.contextKey === 'string');
2084
+ }
2085
+
1981
2086
  const DEFAULT_AREA_TYPE = 'table';
1982
2087
  const DEFAULT_MODE = 'auto';
1983
2088
  const DEFAULT_TYPE = 'form';
@@ -2138,6 +2243,15 @@ class ClientList {
2138
2243
  return;
2139
2244
  if (this.isPendingApprovalWrite(result))
2140
2245
  return;
2246
+ // A hierarchy table has no in-place path: its totals and per-owner
2247
+ // subtotals are summed server-side over every matching row, and the
2248
+ // written record lives in a descendant context this list's own id filter
2249
+ // would not even match. Reload outright rather than spend a read on a
2250
+ // record that cannot be spliced anyway.
2251
+ if (item.config.hierarchy) {
2252
+ this.reloadByKey(itemKey);
2253
+ return;
2254
+ }
2141
2255
  const recordId = this.toFiniteNumber(result?.recordId);
2142
2256
  const record = this.toWriteRecord(result, recordId);
2143
2257
  if (record) {
@@ -2510,10 +2624,17 @@ class ClientList {
2510
2624
  const recordId = this.resolveRecordId(config, row);
2511
2625
  if (recordId == null)
2512
2626
  return null;
2627
+ // A hierarchy row belongs to a descendant, not to the list's own context.
2628
+ // Its actions, its permissions and the instance they apply to are all the
2629
+ // owner's — asking the aggregation context for them would read the wrong
2630
+ // record's actions, or none at all.
2631
+ const source = readClientListRowSource(row);
2513
2632
  return {
2514
2633
  listKey: item.key,
2515
- contextKey: item.config.contextKey ?? null,
2516
- instanceId: item.config.instanceId ?? null,
2634
+ contextKey: source?.contextKey ?? item.config.contextKey ?? null,
2635
+ listContextKey: item.config.contextKey ?? null,
2636
+ source,
2637
+ instanceId: source?.levelDataId ?? item.config.instanceId ?? null,
2517
2638
  moduleId: item.config.moduleId ?? 0,
2518
2639
  row,
2519
2640
  recordId,
@@ -2689,7 +2810,9 @@ class ClientList {
2689
2810
  this.inFlightRequestSignatures.set(key, requestSignature);
2690
2811
  this.state.setLoading(key, true);
2691
2812
  this.state.setError(key, null);
2692
- const sub = this.api.getRows(config.contextKey, query, filters).subscribe({
2813
+ const sub = this.api
2814
+ .getRows(config.contextKey, query, filters, config.hierarchy)
2815
+ .subscribe({
2693
2816
  next: ({ response, propertyKeys }) => {
2694
2817
  let hasLoadedData = false;
2695
2818
  if (response.data) {
@@ -2840,6 +2963,20 @@ class ClientList {
2840
2963
  return DEFAULT_MODE;
2841
2964
  return (columnKeys ?? []).length > 0 ? 'override' : DEFAULT_MODE;
2842
2965
  }
2966
+ /**
2967
+ * Descendant scope only survives where the backend accepts it: a table, with
2968
+ * a usable root id. Anywhere else it is dropped rather than sent, because
2969
+ * the backend answers an unsupported combination with a 400 that takes the
2970
+ * whole list down — a cards area that happens to share a config object would
2971
+ * otherwise render nothing at all.
2972
+ */
2973
+ resolveHierarchyScope(config, areaType) {
2974
+ const scope = config?.hierarchy;
2975
+ if (!scope || areaType !== 'table')
2976
+ return null;
2977
+ const rootId = this.resolvePositiveInteger(scope.rootId);
2978
+ return rootId == null ? null : { ...scope, rootId };
2979
+ }
2843
2980
  toNormalizedConfig(config, type, areaType, mode, isPaginated, take, showHeader, collapseEnabled, layout) {
2844
2981
  const informativeConfig = this.asInformativeConfig(config);
2845
2982
  const formConfig = this.asFormConfig(config);
@@ -2847,6 +2984,7 @@ class ClientList {
2847
2984
  contextKey: formConfig?.contextKey ?? null,
2848
2985
  instanceId: formConfig?.instanceId ?? informativeConfig?.instanceId ?? null,
2849
2986
  filters: formConfig?.filters ?? informativeConfig?.filters ?? [],
2987
+ hierarchy: this.resolveHierarchyScope(formConfig, areaType),
2850
2988
  levelId: informativeConfig?.levelId ?? null,
2851
2989
  moduleId: informativeConfig?.moduleId ?? 0,
2852
2990
  type,
@@ -3023,6 +3161,10 @@ class ClientList {
3023
3161
  config.areaType,
3024
3162
  config.contextKey ?? 'x',
3025
3163
  this.serializeFilters(filters),
3164
+ // Two lists on the same context and filters are still different fetches
3165
+ // when they are rooted at different records — without this the second
3166
+ // one would be served the first one's rows from the in-flight cache.
3167
+ this.serializeHierarchy(config.hierarchy),
3026
3168
  query.mode,
3027
3169
  query.skip,
3028
3170
  query.take,
@@ -3077,6 +3219,15 @@ class ClientList {
3077
3219
  serializeFilters(filters) {
3078
3220
  return JSON.stringify(filters ?? []);
3079
3221
  }
3222
+ serializeHierarchy(hierarchy) {
3223
+ if (!hierarchy)
3224
+ return 'x';
3225
+ return [
3226
+ hierarchy.rootId,
3227
+ hierarchy.depth ?? -1,
3228
+ hierarchy.includeRoot ?? false,
3229
+ ].join(',');
3230
+ }
3080
3231
  resolveFetchFilters(key, config) {
3081
3232
  return mergeRuntimeFilters(config.filters, this.runtimeFilters.get(key), this.state.itemsByKey()[key]?.columns);
3082
3233
  }
@@ -3106,5 +3257,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
3106
3257
  * Generated bundle index. Do not edit.
3107
3258
  */
3108
3259
 
3109
- export { CLIENT_LIST_DEFAULT_INCLUDE_STATE, CLIENT_LIST_RECORD_ID_FILTER_KEY, CLIENT_LIST_RECORD_STATE_KEY, ClientList, ClientListApiService, ClientListRuntimeActionsService, ClientListStateService, ClientListToolbarService, defaultResolveRecordId };
3260
+ export { CLIENT_LIST_DEFAULT_INCLUDE_STATE, CLIENT_LIST_RECORD_ID_FILTER_KEY, CLIENT_LIST_RECORD_SOURCE_KEY, CLIENT_LIST_RECORD_STATE_KEY, ClientList, ClientListApiService, ClientListRuntimeActionsService, ClientListStateService, ClientListToolbarService, defaultResolveRecordId, findClientListSourceGroup, findClientListTotal, isClientListTotalIncomplete, readClientListRowSource };
3110
3261
  //# sourceMappingURL=masterteam-client-components-client-list.mjs.map