@elasticias/screens 1.0.40 → 1.0.42

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.
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { Input, Component, signal, InjectionToken, inject, ChangeDetectorRef, ViewChild, Injector, computed, DestroyRef } from '@angular/core';
3
3
  import { PersistedState, StorageUtils, AppUtils, CsvUtils } from '@elasticias/utils';
4
- import { Subject, combineLatest, take } from 'rxjs';
4
+ import { Subject, combineLatest, take, merge, map, distinctUntilChanged } from 'rxjs';
5
5
  import { Permissions } from '@elasticias/types';
6
6
  import { Router, ActivatedRoute } from '@angular/router';
7
7
  import { CacheService, ToastService, ConfirmDialogService, EfShortcutService } from '@elasticias/core';
@@ -458,6 +458,13 @@ class AbstractScreenComponent extends AbstractComponent {
458
458
  const v = raw;
459
459
  if (!v || typeof v !== 'object' || !v.presetKey)
460
460
  return null;
461
+ // An open range stores null bounds. `new Date(null)` is 1970, not
462
+ // NaN, so it must be handled before parsing, not after.
463
+ if (v.start == null && v.end == null) {
464
+ return { start: null, end: null, presetKey: v.presetKey, label: v.label ?? '', labelKey: v.labelKey };
465
+ }
466
+ if (v.start == null || v.end == null)
467
+ return null;
461
468
  const start = new Date(v.start);
462
469
  const end = new Date(v.end);
463
470
  if (isNaN(start.getTime()) || isNaN(end.getTime()))
@@ -1075,14 +1082,27 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1075
1082
  criteria = signal(new SearchEntity({}), ...(ngDevMode ? [{ debugName: "criteria" }] : /* istanbul ignore next */ []));
1076
1083
  /**
1077
1084
  * Active date-range filter shown in `ef-datepicker-advanced`'s
1078
- * trigger. Display-only at construction time — actually filters
1079
- * the search once `onDateRangeChange()` fires (or a screen wires
1080
- * one in `ngOnInit`).
1085
+ * trigger, and the single source of the criteria's `start`/`end`.
1086
+ * The first search, `clear()` and a picked range all send exactly
1087
+ * what this holds, and a cache restore rebuilds it from the restored
1088
+ * dates, so the chip never reads one period while the list shows
1089
+ * another.
1081
1090
  *
1082
- * Override `buildDefaultDateRange()` per screen to ship a
1083
- * different starting preset.
1091
+ * Defaults to `'all_time'` (no dates sent). Override
1092
+ * `buildDefaultDateRange()` per screen to open on a window instead.
1084
1093
  */
1085
1094
  dateRange = signal(this.buildDefaultDateRange(), ...(ngDevMode ? [{ debugName: "dateRange" }] : /* istanbul ignore next */ []));
1095
+ /**
1096
+ * Whether this screen offers the date chip at all. Set it to `false`
1097
+ * (`override readonly showDateFilter = false;`) on a screen whose
1098
+ * backend query has no date field, and guard the template's
1099
+ * `<ef-datepicker-advanced>` with `@if (showDateFilter)` (or drop it).
1100
+ * The base then never sends `start`/`end`: not on first load, not
1101
+ * after `clear()`, not from criteria cached by an older session, and
1102
+ * not from `onDateRangeChange()` / `setDateRange()`, which become
1103
+ * no-ops, so a picker left in the template cannot filter either.
1104
+ */
1105
+ showDateFilter = true;
1086
1106
  /* ── Filter-bar UI state (DRY) ──────────────────────────────────
1087
1107
  Identical across every list screen — lifted up so subclasses
1088
1108
  don't re-declare. `clear()` resets all four signals. */
@@ -1196,14 +1216,16 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1196
1216
  }
1197
1217
  /* ── Date-range filter (DRY) ────────────────────────────────────
1198
1218
  Wired to ef-datepicker-advanced. The default range is
1199
- `last_30_days` — override `buildDefaultDateRange()` per screen
1200
- if a different starting preset is needed. */
1219
+ `all_time`, which sends no dates. Override
1220
+ `buildDefaultDateRange()` per screen if a window is needed. */
1201
1221
  /**
1202
1222
  * Wired to `<ef-datepicker-advanced (rangeChange)>` — stores the
1203
1223
  * range for trigger display and pushes it into the search criteria
1204
1224
  * so the next `search()` filters by it.
1205
1225
  */
1206
1226
  onDateRangeChange(range) {
1227
+ if (!this.showDateFilter)
1228
+ return;
1207
1229
  this.dateRange.set(range);
1208
1230
  this.setDateRange(range.start, range.end);
1209
1231
  }
@@ -1216,19 +1238,52 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1216
1238
  this.dateRange.set(this.buildDefaultDateRange());
1217
1239
  }
1218
1240
  /**
1219
- * Build the default `EfDateRange`. Override per screen to ship a
1220
- * different default — e.g., a 90-day window for low-velocity
1221
- * catalogues. Default: last 30 days.
1241
+ * Build the default `EfDateRange`. Default: `all_time`, no bounds,
1242
+ * so a typed search finds an old record instead of silently missing
1243
+ * it behind a window nobody chose. Override per screen to open on a
1244
+ * window (e.g. `daysAgo(89)` to `startOfToday()` for `last_90_days`);
1245
+ * the first search and `clear()` then send that window.
1222
1246
  */
1223
1247
  buildDefaultDateRange() {
1224
1248
  return {
1225
- start: this.daysAgo(29),
1226
- end: this.startOfToday(),
1227
- presetKey: 'last_30_days',
1228
- labelKey: 'date_preset_last_30_days',
1229
- label: '30 derniers jours',
1249
+ start: null,
1250
+ end: null,
1251
+ presetKey: 'all_time',
1252
+ labelKey: 'date_preset_all_time',
1253
+ label: '',
1230
1254
  };
1231
1255
  }
1256
+ /** Copy the chip's range onto fresh criteria, so what is shown is
1257
+ * what is sent. Sends nothing when the screen has no date chip. */
1258
+ seedDateCriteria(criteria) {
1259
+ const range = this.showDateFilter ? this.dateRange() : null;
1260
+ criteria.start = range?.start ?? null;
1261
+ criteria.end = range?.end ?? null;
1262
+ }
1263
+ /** Rebuild the chip from restored criteria: both dates are shown as
1264
+ * the custom range they filter on; anything less is `all_time`, and
1265
+ * `bootstrapInitialSearch()` drops a lone bound so nothing is sent.
1266
+ * No literal label: the picker formats a custom range itself. */
1267
+ dateRangeFromCriteria(startRaw, endRaw) {
1268
+ const start = this.toDateOrNull(startRaw);
1269
+ const end = this.toDateOrNull(endRaw);
1270
+ if (!start || !end) {
1271
+ return {
1272
+ start: null,
1273
+ end: null,
1274
+ presetKey: 'all_time',
1275
+ labelKey: 'date_preset_all_time',
1276
+ label: '',
1277
+ };
1278
+ }
1279
+ return { start, end, presetKey: 'custom', label: '' };
1280
+ }
1281
+ toDateOrNull(raw) {
1282
+ if (raw == null || raw === '')
1283
+ return null;
1284
+ const d = raw instanceof Date ? raw : new Date(raw);
1285
+ return isNaN(d.getTime()) ? null : d;
1286
+ }
1232
1287
  /** Today at 00:00 local time. */
1233
1288
  startOfToday() {
1234
1289
  const d = new Date();
@@ -1430,7 +1485,17 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1430
1485
  bootstrapInitialSearch() {
1431
1486
  const cached = this.cacheService.getCache(this.screenStateKey);
1432
1487
  if (cached) {
1433
- this.criteria.set(new SearchEntity(cached));
1488
+ const restored = new SearchEntity(cached);
1489
+ const bothBounds = this.toDateOrNull(cached['start']) && this.toDateOrNull(cached['end']);
1490
+ if (!this.showDateFilter || !bothBounds) {
1491
+ // Criteria cached while this screen still had a chip may carry
1492
+ // dates its backend ignores at best; and a lone bound would be
1493
+ // sent under a chip reading "all time". Drop them.
1494
+ restored.start = null;
1495
+ restored.end = null;
1496
+ restored.startEndDateRanges = null;
1497
+ }
1498
+ this.criteria.set(restored);
1434
1499
  this.restoreFilterUiFromCriteria(cached);
1435
1500
  }
1436
1501
  else {
@@ -1448,6 +1513,7 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1448
1513
  pageNumber: PaginationEnum.DEFAULT_PAGE,
1449
1514
  pageSize: PaginationEnum.DEFAULT_PAGE_SIZE,
1450
1515
  };
1516
+ this.seedDateCriteria(fresh);
1451
1517
  this.criteria.set(fresh);
1452
1518
  }
1453
1519
  this.search();
@@ -1458,11 +1524,15 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1458
1524
  * what the drawer / search box displays matches what the search will
1459
1525
  * actually send (otherwise a stale criteria filter keeps applying
1460
1526
  * while every control reads "Tous"). Base handles the free-text
1461
- * input and the declared advanced filters; override (calling super)
1462
- * to sync screen-specific state — status pill, custom switches, ….
1527
+ * input, the date chip and the declared advanced filters; override
1528
+ * (calling super) to sync screen-specific state, such as a status pill
1529
+ * or custom switches.
1463
1530
  */
1464
1531
  restoreFilterUiFromCriteria(cached) {
1465
1532
  this.searchQuery.set(cached['searchText'] ?? '');
1533
+ if (this.showDateFilter) {
1534
+ this.dateRange.set(this.dateRangeFromCriteria(cached['start'], cached['end']));
1535
+ }
1466
1536
  const next = {};
1467
1537
  for (const f of this.advancedFilters) {
1468
1538
  const value = cached[f.key];
@@ -1508,7 +1578,13 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1508
1578
  }
1509
1579
  /* ── Convenience criteria mutators ───────────────────────────── */
1510
1580
  setSearchText(text) {
1511
- this.criteria.update((c) => this.cloneCriteria(c, { searchText: text || undefined, page: 1 }));
1581
+ // Templates bind this to ef-smart-bar's (search), and that binding has
1582
+ // received the native DOM 'search' Event, which reached the API as
1583
+ // {"isTrusted":true} and failed. ef-smart-bar now stops the event, but
1584
+ // whatever arrives that is not text, the field's own value is the
1585
+ // search the user meant.
1586
+ const value = typeof text === 'string' ? text : this.searchQuery();
1587
+ this.criteria.update((c) => this.cloneCriteria(c, { searchText: value || undefined, page: 1 }));
1512
1588
  this.search();
1513
1589
  }
1514
1590
  setPage(pageNumber, pageSize) {
@@ -1529,6 +1605,8 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1529
1605
  this.search();
1530
1606
  }
1531
1607
  setDateRange(start, end) {
1608
+ if (!this.showDateFilter)
1609
+ return;
1532
1610
  this.criteria.update((c) => this.cloneCriteria(c, { start, end, page: 1 }));
1533
1611
  this.search();
1534
1612
  }
@@ -1551,6 +1629,8 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1551
1629
  },
1552
1630
  ];
1553
1631
  }
1632
+ this.resetDateRange();
1633
+ this.seedDateCriteria(fresh);
1554
1634
  this.criteria.set(fresh);
1555
1635
  // Reset all the shared filter-bar / selection signals so
1556
1636
  // subclasses don't have to track each one individually.
@@ -1560,7 +1640,6 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1560
1640
  this.activeFilters.set([]);
1561
1641
  this.advancedValues.set({});
1562
1642
  this.selected.set(new Set());
1563
- this.resetDateRange();
1564
1643
  this.cacheService.setCache(this.screenStateKey, fresh);
1565
1644
  this.search();
1566
1645
  }
@@ -1847,6 +1926,12 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1847
1926
  destroyRef = inject(DestroyRef);
1848
1927
  auditHistoryService = inject(AUDIT_HISTORY_SERVICE, { optional: true });
1849
1928
  shortcuts = inject(EfShortcutService);
1929
+ /** The in-flight entity / history requests. A newer load drops the
1930
+ * older one, so a slow response for an id the screen has moved off
1931
+ * can never overwrite the record it now shows, and destroying the
1932
+ * screen drops both. */
1933
+ loadSub;
1934
+ auditSub;
1850
1935
  /** Distinguishes this instance's registration from any other detail
1851
1936
  * screen alive at once (the outgoing and incoming routed component
1852
1937
  * can briefly coexist), the same convention ef-row-actions uses:
@@ -1857,6 +1942,12 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1857
1942
  serviceInstance;
1858
1943
  constructor() {
1859
1944
  super();
1945
+ // A response landing after the screen is gone would still run
1946
+ // afterLoad() and start a history request for a screen nobody sees.
1947
+ this.destroyRef.onDestroy(() => {
1948
+ this.loadSub?.unsubscribe();
1949
+ this.auditSub?.unsubscribe();
1950
+ });
1860
1951
  // Every detail screen inherits mod+s with no per-screen wiring, and
1861
1952
  // it doubles as the fix for the browser's own Save Page dialog
1862
1953
  // otherwise popping up on top of it.
@@ -1980,11 +2071,21 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1980
2071
  this.loadReferenceData(cfg.REF_DATA_OPTIONS);
1981
2072
  }
1982
2073
  // Wire route params → load (or reset to empty on /details).
1983
- combineLatest([this.route.paramMap, this.route.queryParamMap])
1984
- .pipe(takeUntilDestroyed(this.destroyRef))
1985
- .subscribe(([params, queryParams]) => {
1986
- const id = params.get('id');
1987
- const isDuplicate = queryParams.get('mode') === 'duplicate';
2074
+ //
2075
+ // One navigation can emit on both maps: the router pushes
2076
+ // queryParams first, then params. Combining the two emissions saw
2077
+ // a half-updated route on details/SRC?mode=duplicate → details/NEW
2078
+ // (the old id without the mode) and loaded SRC on a screen that
2079
+ // was moving to NEW. The router sets `snapshot` to the next one
2080
+ // before either emits, so each emission reads id and mode together
2081
+ // from it, and the second emission of the same navigation (same
2082
+ // snapshot object) is dropped. Any navigation that changes params
2083
+ // or query still gets a new snapshot, so it still reloads once.
2084
+ merge(this.route.paramMap, this.route.queryParamMap)
2085
+ .pipe(map(() => this.route.snapshot), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
2086
+ .subscribe(snapshot => {
2087
+ const id = snapshot.paramMap.get('id');
2088
+ const isDuplicate = snapshot.queryParamMap.get('mode') === 'duplicate';
1988
2089
  this.entityId.set(id);
1989
2090
  this.duplicateMode.set(isDuplicate);
1990
2091
  this.context.duplicateMode = isDuplicate;
@@ -1994,6 +2095,12 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1994
2095
  }
1995
2096
  else {
1996
2097
  // New mode — reset to a fresh empty entity, no history yet.
2098
+ // A load still in flight for the previous id is dropped.
2099
+ if (this.loadSub && !this.loadSub.closed) {
2100
+ this.loadSub.unsubscribe();
2101
+ this.loading.set(false);
2102
+ }
2103
+ this.auditSub?.unsubscribe();
1997
2104
  this.entity.set({});
1998
2105
  this.auditEntries.set([]);
1999
2106
  this.afterLoad();
@@ -2035,7 +2142,15 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2035
2142
  return;
2036
2143
  this.loading.set(true);
2037
2144
  this.errorMsg.set(null);
2038
- this.serviceInstance.get(id).subscribe({
2145
+ // Moving to another record: the previous one's history must not
2146
+ // land under it while this load is in flight.
2147
+ const showsAnotherRecord = this.entity()?.id !== id;
2148
+ if (showsAnotherRecord) {
2149
+ this.auditSub?.unsubscribe();
2150
+ this.auditEntries.set([]);
2151
+ }
2152
+ this.loadSub?.unsubscribe();
2153
+ this.loadSub = this.serviceInstance.get(id).subscribe({
2039
2154
  next: (result) => {
2040
2155
  const vm = new ViewModelEntity(result);
2041
2156
  vm.id = id;
@@ -2048,6 +2163,13 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2048
2163
  console.error('AbstractDetailScreenV2.loadData failed', err);
2049
2164
  this.errorMsg.set(err?.message ?? 'Erreur de chargement');
2050
2165
  this.loading.set(false);
2166
+ // Save and the status actions target entityId(). Leaving the
2167
+ // previous record on screen would have them act on a record
2168
+ // other than the one shown. A failed reload of the same
2169
+ // record keeps it: what is shown is still what is targeted.
2170
+ if (showsAnotherRecord) {
2171
+ this.entity.set({});
2172
+ }
2051
2173
  },
2052
2174
  });
2053
2175
  }
@@ -2201,6 +2323,7 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2201
2323
  * an empty history rather than blocking the screen.
2202
2324
  */
2203
2325
  loadAuditHistory() {
2326
+ this.auditSub?.unsubscribe();
2204
2327
  this.auditEntries.set([]);
2205
2328
  const entityType = this.getConfig()?.AUDIT_ENTITY_TYPE;
2206
2329
  const id = this.entityId();
@@ -2216,7 +2339,7 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2216
2339
  if (!result$ || typeof result$.subscribe !== 'function') {
2217
2340
  return;
2218
2341
  }
2219
- result$.subscribe({
2342
+ this.auditSub = result$.subscribe({
2220
2343
  next: (entries) => this.auditEntries.set(entries ?? []),
2221
2344
  error: () => this.auditEntries.set([]),
2222
2345
  });
@@ -2351,7 +2474,7 @@ class AbstractReportScreenV2 extends AbstractScreenComponent {
2351
2474
  this.reportsService = this.injector.get(this.getConfig().SERVICE);
2352
2475
  // Reinstate the period the user last chose before anything loads, so the
2353
2476
  // widgets fetch it once instead of fetching the default and refetching.
2354
- this.dateRange.set(this.restorePersistedDateRange(this.dateRange(), key => this.rangeFromPreset(key)));
2477
+ this.dateRange.set(this.boundedOrDefault(this.restorePersistedDateRange(this.dateRange(), key => this.rangeFromPreset(key))));
2355
2478
  const lists = this.getConfig()?.REPORT_STATIC_LISTS ?? [];
2356
2479
  if (lists.length > 0) {
2357
2480
  this.initializeStaticLists(lists);
@@ -2365,10 +2488,22 @@ class AbstractReportScreenV2 extends AbstractScreenComponent {
2365
2488
  /* ── Period ─────────────────────────────────────────────────── */
2366
2489
  /** Wired to `<ef-datepicker-advanced (rangeChange)>`. */
2367
2490
  onDateRangeChange(range) {
2368
- this.dateRange.set(range);
2369
- this.persistDateRange(range);
2491
+ const bounded = this.boundedOrDefault(range);
2492
+ this.dateRange.set(bounded);
2493
+ this.persistDateRange(bounded);
2370
2494
  this.loadAll();
2371
2495
  }
2496
+ /**
2497
+ * Every widget query is a period, so an open range (`all_time`) has no
2498
+ * meaning here. The picker only offers it with `allowAllTime`, which a
2499
+ * report should not set; if one arrives anyway, fall back to the
2500
+ * default period rather than send a null bound.
2501
+ */
2502
+ boundedOrDefault(range) {
2503
+ return range.start && range.end
2504
+ ? range
2505
+ : this.buildDefaultDateRange();
2506
+ }
2372
2507
  /** Default period from the config's `DEFAULT_PERIOD` preset. */
2373
2508
  buildDefaultDateRange() {
2374
2509
  return this.rangeFromPreset(this.getConfig()?.DEFAULT_PERIOD ?? 'last_30_days');