@elasticias/screens 1.0.48 → 1.0.50

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,13 +1,13 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Input, Component, signal, InjectionToken, inject, ChangeDetectorRef, ViewChild, Injector, computed, DestroyRef } from '@angular/core';
2
+ import { Input, Component, signal, InjectionToken, inject, ChangeDetectorRef, ViewChild, Injector, DestroyRef, computed } from '@angular/core';
3
3
  import { PersistedState, StorageUtils, AppUtils, CsvUtils } from '@elasticias/utils';
4
- import { Subject, combineLatest, take, merge, map, distinctUntilChanged } from 'rxjs';
4
+ import { Subject, combineLatest, take, from, merge, map, distinctUntilChanged } from 'rxjs';
5
5
  import { Permissions } from '@elasticias/types';
6
6
  import { Router, ActivatedRoute } from '@angular/router';
7
- import { CacheService, ToastService, ConfirmDialogService, EfShortcutService } from '@elasticias/core';
7
+ import { CacheService, ToastService, ConfirmDialogService, EfShortcutService, isServerReported, extractProblem } from '@elasticias/core';
8
8
  import { Location } from '@angular/common';
9
- import { TranslateService } from '@ngx-translate/core';
10
9
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
10
+ import { TranslateService } from '@ngx-translate/core';
11
11
 
12
12
  class AbstractEntity {
13
13
  clone(entity) {
@@ -1054,6 +1054,15 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.11", ngImpo
1054
1054
  class AbstractSearchScreenV2 extends AbstractScreenComponent {
1055
1055
  screenState = ScreenStateEnum.SEARCH;
1056
1056
  injector = inject(Injector);
1057
+ destroyRef = inject(DestroyRef);
1058
+ /**
1059
+ * A delete confirm is loading or open. A second click (or the row
1060
+ * shortcut) meanwhile is ignored, so one gesture cannot start two impact
1061
+ * reads or stack two confirms. Cleared when the confirm is answered
1062
+ * (PrimeNG calls reject on close and Escape too) or when the read
1063
+ * yields no confirm.
1064
+ */
1065
+ deleteConfirmBusy = false;
1057
1066
  serviceInstance;
1058
1067
  /**
1059
1068
  * Translate lazily, through the injector.
@@ -1566,6 +1575,7 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1566
1575
  this.totalCount.set(result?.totalCount ?? 0);
1567
1576
  this.cacheService.setCache(this.screenStateKey, this.criteria());
1568
1577
  this.loading.set(false);
1578
+ this.afterSearch(result);
1569
1579
  },
1570
1580
  error: (err) => {
1571
1581
  if (seq !== this.searchSeq)
@@ -1576,6 +1586,15 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1576
1586
  },
1577
1587
  });
1578
1588
  }
1589
+ /**
1590
+ * Hook for a screen whose search returns more than a page of rows (counts,
1591
+ * facets, a summary): called with the raw response after `items` and
1592
+ * `totalCount` are set, for the latest issued search only. A stale or
1593
+ * failed search never reaches it.
1594
+ */
1595
+ afterSearch(_result) {
1596
+ /* no-op by default */
1597
+ }
1579
1598
  /* ── Convenience criteria mutators ───────────────────────────── */
1580
1599
  setSearchText(text) {
1581
1600
  // Templates bind this to ef-smart-bar's (search), and that binding has
@@ -1818,29 +1837,93 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1818
1837
  getDeleteConfirmName(_id) {
1819
1838
  return undefined;
1820
1839
  }
1840
+ /**
1841
+ * Async details for the delete confirmation, read before it opens. Use
1842
+ * it when the confirm has to say what the delete will take with it, such
1843
+ * as how many products lose a collection:
1844
+ *
1845
+ * ```ts
1846
+ * protected override getDeleteConfirmOptions(id: any) {
1847
+ * return this.collectionsClient.deleteImpact(id).pipe(
1848
+ * map(impact => ({
1849
+ * messageKey: impact.onHome ? 'x_delete_message_home' : 'x_delete_message',
1850
+ * messageParams: { count: impact.productCount ?? 0 },
1851
+ * })),
1852
+ * );
1853
+ * }
1854
+ * ```
1855
+ *
1856
+ * The returned options are merged over `{ intent: 'delete', name:
1857
+ * getDeleteConfirmName(id) }`. Only the first value of an Observable is
1858
+ * used. If the read fails (the HTTP interceptor toasts it) or completes
1859
+ * without a value, no confirm opens and nothing is deleted. Return
1860
+ * `undefined`, the default, to open the confirm at once. While the read
1861
+ * is in flight or the confirm is open, another delete is ignored, and a
1862
+ * screen destroyed during the read opens nothing.
1863
+ */
1864
+ getDeleteConfirmOptions(_id) {
1865
+ return undefined;
1866
+ }
1821
1867
  delete(id) {
1822
1868
  if (!id) {
1823
1869
  this.recordNotFound();
1824
1870
  return;
1825
1871
  }
1872
+ if (this.deleteConfirmBusy)
1873
+ return;
1874
+ this.deleteConfirmBusy = true;
1875
+ const details = this.getDeleteConfirmOptions(id);
1876
+ if (!details) {
1877
+ this.openDeleteConfirm(id, {});
1878
+ return;
1879
+ }
1880
+ let opened = false;
1881
+ from(details)
1882
+ // A screen left while the read was in flight opens nothing.
1883
+ .pipe(take(1), takeUntilDestroyed(this.destroyRef))
1884
+ .subscribe({
1885
+ next: (extra) => {
1886
+ opened = true;
1887
+ this.openDeleteConfirm(id, extra ?? {});
1888
+ },
1889
+ // A failed read is the interceptor's to report; without its details
1890
+ // the confirm would understate what the delete takes with it.
1891
+ error: () => {
1892
+ this.deleteConfirmBusy = false;
1893
+ },
1894
+ complete: () => {
1895
+ if (!opened)
1896
+ this.deleteConfirmBusy = false;
1897
+ },
1898
+ });
1899
+ }
1900
+ openDeleteConfirm(id, extra) {
1901
+ const { accept: _a, reject: _r, intent: _i, ...details } = extra;
1826
1902
  this.confirmDialogService.confirm({
1827
1903
  intent: 'delete',
1828
1904
  name: this.getDeleteConfirmName(id),
1829
- accept: () => this.serviceInstance.delete(id).subscribe({
1830
- next: (result) => {
1831
- if (result?.errors?.length) {
1832
- this.handleErrors(result.errors);
1833
- }
1834
- else {
1835
- this.search();
1836
- this.toastService.showSuccess();
1837
- }
1838
- },
1839
- // The HTTP error interceptor surfaces the message (toast). Swallow
1840
- // here so a rejected delete (e.g. a 422 business-rule violation)
1841
- // doesn't bubble up as an unhandled error.
1842
- error: () => undefined,
1843
- }),
1905
+ ...details,
1906
+ reject: () => {
1907
+ this.deleteConfirmBusy = false;
1908
+ },
1909
+ accept: () => {
1910
+ this.deleteConfirmBusy = false;
1911
+ this.serviceInstance.delete(id).subscribe({
1912
+ next: (result) => {
1913
+ if (result?.errors?.length) {
1914
+ this.handleErrors(result.errors);
1915
+ }
1916
+ else {
1917
+ this.search();
1918
+ this.toastService.showSuccess();
1919
+ }
1920
+ },
1921
+ // The HTTP error interceptor surfaces the message (toast). Swallow
1922
+ // here so a rejected delete (e.g. a 422 business-rule violation)
1923
+ // doesn't bubble up as an unhandled error.
1924
+ error: () => undefined,
1925
+ });
1926
+ },
1844
1927
  });
1845
1928
  }
1846
1929
  duplicate(id) {
@@ -2151,14 +2234,7 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2151
2234
  }
2152
2235
  this.loadSub?.unsubscribe();
2153
2236
  this.loadSub = this.serviceInstance.get(id).subscribe({
2154
- next: (result) => {
2155
- const vm = new ViewModelEntity(result);
2156
- vm.id = id;
2157
- this.entity.set(vm);
2158
- this.loading.set(false);
2159
- this.afterLoad();
2160
- this.loadAuditHistory();
2161
- },
2237
+ next: (result) => this.applyLoaded(result, id),
2162
2238
  error: (err) => {
2163
2239
  console.error('AbstractDetailScreenV2.loadData failed', err);
2164
2240
  this.errorMsg.set(err?.message ?? 'Erreur de chargement');
@@ -2173,6 +2249,15 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2173
2249
  },
2174
2250
  });
2175
2251
  }
2252
+ /** Install a fetched (or save-returned) record as the current entity. */
2253
+ applyLoaded(result, id) {
2254
+ const vm = new ViewModelEntity(result);
2255
+ vm.id = id;
2256
+ this.entity.set(vm);
2257
+ this.loading.set(false);
2258
+ this.afterLoad();
2259
+ this.loadAuditHistory();
2260
+ }
2176
2261
  /**
2177
2262
  * Persist the entity. Routes to `service.create()` when in new
2178
2263
  * or duplicate mode, `service.update(id, entity)` when editing.
@@ -2214,9 +2299,44 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2214
2299
  this.setFormErrors(response.errors);
2215
2300
  this.onSaveError(response.errors);
2216
2301
  }
2302
+ if (!isCreate && this.isStaleSave(response))
2303
+ this.offerStaleReload();
2217
2304
  },
2218
2305
  });
2219
2306
  }
2307
+ /**
2308
+ * True when a failed save means the record changed since it was loaded:
2309
+ * a refusal whose `ruleCode` is in the config's `STALE_RULE_CODES`. A bare
2310
+ * 409 is a duplicate (e.g. a slug already taken), not a stale record, so
2311
+ * it never offers a reload. Only a refusal
2312
+ * the app's interceptor already toasted counts, so the user is never
2313
+ * told twice.
2314
+ */
2315
+ isStaleSave(error) {
2316
+ if (!isServerReported(error))
2317
+ return false;
2318
+ const problem = extractProblem(error);
2319
+ if (!problem)
2320
+ return false;
2321
+ const codes = this.getConfig()?.STALE_RULE_CODES ?? [];
2322
+ return !!problem.ruleCode && codes.includes(problem.ruleCode);
2323
+ }
2324
+ /**
2325
+ * Asks, in an accessible confirm dialog (focus trapped, Escape cancels),
2326
+ * whether to reload the record. Accepting discards the unsaved edits,
2327
+ * and the message says so. The refusal itself was already toasted.
2328
+ * App keys: `ef_stale_reload` (button), `ef_stale_reload_confirm`
2329
+ * (message) and optionally `ef_stale_reload_header`.
2330
+ */
2331
+ offerStaleReload() {
2332
+ this.confirmDialogService.confirm({
2333
+ messageKey: 'ef_stale_reload_confirm',
2334
+ headerKey: 'ef_stale_reload_header',
2335
+ acceptLabelKey: 'ef_stale_reload',
2336
+ icon: 'pi pi-refresh',
2337
+ accept: () => this.loadData(),
2338
+ });
2339
+ }
2220
2340
  /**
2221
2341
  * Soft-delete-then-navigate-back. Confirms with the user first.
2222
2342
  */
@@ -2379,9 +2499,24 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2379
2499
  this.router.navigate([this.detailsBaseUrl(), newId]);
2380
2500
  }
2381
2501
  else {
2382
- // Update succeeded in place — refresh the change-history so the new
2383
- // entry shows without a manual reload.
2384
- this.loadAuditHistory();
2502
+ if (cfg?.RELOAD_AFTER_SAVE) {
2503
+ // The record carries server-owned state (a concurrency
2504
+ // revision): take the fresh copy. An update response that is
2505
+ // the saved record itself (same id) is used as is, anything
2506
+ // else (void, id, flag) triggers one GET. Both refresh history.
2507
+ const id = this.entityId();
2508
+ if (result && typeof result === 'object' && String(result.id) === String(id)) {
2509
+ this.applyLoaded(result, id);
2510
+ }
2511
+ else {
2512
+ this.loadData();
2513
+ }
2514
+ }
2515
+ else {
2516
+ // Update succeeded in place — refresh the change-history so the new
2517
+ // entry shows without a manual reload.
2518
+ this.loadAuditHistory();
2519
+ }
2385
2520
  }
2386
2521
  }
2387
2522
  /** Override to surface form-level validation errors after a 4xx
@@ -2738,6 +2873,24 @@ class ScreenConfig {
2738
2873
  * to opt out. Must match the backend IAuditable.AuditEntityType (e.g. 'Client').
2739
2874
  */
2740
2875
  AUDIT_ENTITY_TYPE;
2876
+ /**
2877
+ * Detail screens: business-rule codes (422 `ruleCode`) meaning "the record
2878
+ * changed since you loaded it", e.g. ERP `['BR_SO_0123']`. A failed update
2879
+ * with one of them offers a "reload" confirm; accepting
2880
+ * reloads the record and drops the unsaved edits. The app must define the
2881
+ * i18n keys `ef_stale_reload` (button), `ef_stale_reload_confirm` (message,
2882
+ * say that unsaved edits are lost) and `ef_stale_reload_header`.
2883
+ */
2884
+ STALE_RULE_CODES;
2885
+ /**
2886
+ * Detail screens: after a successful in-place update (not a create or
2887
+ * duplicate) refresh the record, so server-owned fields such as a
2888
+ * concurrency revision are current for the next save. Uses the update
2889
+ * response when it is the saved record (an object whose `id` is the
2890
+ * screen's entity id), otherwise one `get(id)`. Change history reloads
2891
+ * either way. Default false: the record is left as edited.
2892
+ */
2893
+ RELOAD_AFTER_SAVE;
2741
2894
  /**
2742
2895
  * Report screens (AbstractReportScreenV2): starting period preset for the
2743
2896
  * screen's ef-datepicker-advanced. Unset → 'last_30_days'.