@elasticias/screens 1.0.39 → 1.0.41

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';
@@ -86,6 +86,15 @@ class ScreenContext extends AbstractEntity {
86
86
  entity;
87
87
  grants;
88
88
  duplicateMode = false;
89
+ /**
90
+ * Whether the screen may write the record it shows, for its current mode.
91
+ * Set by `AbstractDetailScreenV2` to its own `canWrite` signal, so shared
92
+ * components bound to `[context]` (ef-detail-toolbar's Save and its
93
+ * `requiresWrite` custom actions) follow the same rule without each
94
+ * screen passing it again. Undefined on screens that have no write mode
95
+ * (search, report), where those components keep their default behaviour.
96
+ */
97
+ canWrite;
89
98
  constructor(state, group, entity, refDataProvider, bundles, grants, screenName = '', language = 'fr') {
90
99
  super();
91
100
  this.state = state;
@@ -175,6 +184,12 @@ const SCREEN_REF_DATA_SERVICE = new InjectionToken('SCREEN_REF_DATA_SERVICE');
175
184
  class AbstractScreenComponent extends AbstractComponent {
176
185
  context;
177
186
  connectedUser;
187
+ /** V1 only: the NgForm a V1 detail template declares as `#entityForm`.
188
+ * V2 templates bind `[value]` / `(valueChangeEvent)` with no NgForm, so
189
+ * this stays undefined there and everything below that walks it
190
+ * (`disableAllControls`, `setFormErrors`, `isFormValid`) does nothing.
191
+ * V2 screens use `AbstractDetailScreenV2.readOnlyMode()` bound as
192
+ * `[efReadOnly]` on the form container, and `canWrite()` for saving. */
178
193
  entityForm;
179
194
  serverErrors = signal({}, ...(ngDevMode ? [{ debugName: "serverErrors" }] : /* istanbul ignore next */ []));
180
195
  refDataLoaded$ = new Subject();
@@ -196,6 +211,10 @@ class AbstractScreenComponent extends AbstractComponent {
196
211
  this.context.screenName = this.getBundleName();
197
212
  this.processGrants();
198
213
  }
214
+ /** The `isReadOnly()` branch below is the V1 read-only path. It never
215
+ * fires usefully on a V2 screen (see `entityForm`), and its predicate
216
+ * ignores the mode: Read+Create on an existing record counts as
217
+ * writable. V2 detail screens rely on `readOnlyMode()` instead. */
199
218
  ngAfterViewInit() {
200
219
  this.changeDetector.detectChanges();
201
220
  if (this.context && this.context.isReadOnly()) {
@@ -454,6 +473,8 @@ class AbstractScreenComponent extends AbstractComponent {
454
473
  clearServerErrors() {
455
474
  this.serverErrors.set({});
456
475
  }
476
+ /** V1 only: disables the controls of `entityForm`. A no-op on V2
477
+ * screens, which use `readOnlyMode()` + `[efReadOnly]`. */
457
478
  disableAllControls() {
458
479
  setTimeout(() => {
459
480
  if (this.entityForm && Object.keys(this.entityForm.controls).length > 0) {
@@ -1487,7 +1508,13 @@ class AbstractSearchScreenV2 extends AbstractScreenComponent {
1487
1508
  }
1488
1509
  /* ── Convenience criteria mutators ───────────────────────────── */
1489
1510
  setSearchText(text) {
1490
- this.criteria.update((c) => this.cloneCriteria(c, { searchText: text || undefined, page: 1 }));
1511
+ // Templates bind this to ef-smart-bar's (search), and that binding has
1512
+ // received the native DOM 'search' Event, which reached the API as
1513
+ // {"isTrusted":true} and failed. ef-smart-bar now stops the event, but
1514
+ // whatever arrives that is not text, the field's own value is the
1515
+ // search the user meant.
1516
+ const value = typeof text === 'string' ? text : this.searchQuery();
1517
+ this.criteria.update((c) => this.cloneCriteria(c, { searchText: value || undefined, page: 1 }));
1491
1518
  this.search();
1492
1519
  }
1493
1520
  setPage(pageNumber, pageSize) {
@@ -1826,6 +1853,12 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1826
1853
  destroyRef = inject(DestroyRef);
1827
1854
  auditHistoryService = inject(AUDIT_HISTORY_SERVICE, { optional: true });
1828
1855
  shortcuts = inject(EfShortcutService);
1856
+ /** The in-flight entity / history requests. A newer load drops the
1857
+ * older one, so a slow response for an id the screen has moved off
1858
+ * can never overwrite the record it now shows, and destroying the
1859
+ * screen drops both. */
1860
+ loadSub;
1861
+ auditSub;
1829
1862
  /** Distinguishes this instance's registration from any other detail
1830
1863
  * screen alive at once (the outgoing and incoming routed component
1831
1864
  * can briefly coexist), the same convention ef-row-actions uses:
@@ -1836,6 +1869,12 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1836
1869
  serviceInstance;
1837
1870
  constructor() {
1838
1871
  super();
1872
+ // A response landing after the screen is gone would still run
1873
+ // afterLoad() and start a history request for a screen nobody sees.
1874
+ this.destroyRef.onDestroy(() => {
1875
+ this.loadSub?.unsubscribe();
1876
+ this.auditSub?.unsubscribe();
1877
+ });
1839
1878
  // Every detail screen inherits mod+s with no per-screen wiring, and
1840
1879
  // it doubles as the fix for the browser's own Save Page dialog
1841
1880
  // otherwise popping up on top of it.
@@ -1844,7 +1883,13 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1844
1883
  keys: 'mod+s',
1845
1884
  labelKey: 'common_save',
1846
1885
  group: 'shortcut_group_screen',
1847
- handler: () => this.save(),
1886
+ // Gated here as well as inside save(): a subclass that overrides
1887
+ // save() without calling super must still not be reachable from
1888
+ // the keyboard by a user who cannot write.
1889
+ handler: () => {
1890
+ if (this.canWrite())
1891
+ this.save();
1892
+ },
1848
1893
  });
1849
1894
  }
1850
1895
  /**
@@ -1866,6 +1911,36 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1866
1911
  * the `?mode=duplicate` query param.
1867
1912
  */
1868
1913
  duplicateMode = signal(false, ...(ngDevMode ? [{ debugName: "duplicateMode" }] : /* istanbul ignore next */ []));
1914
+ /**
1915
+ * Bumped every time `processGrants()` (re)reads the user's grants.
1916
+ * `context` is a plain object, not a signal, so without this a
1917
+ * `canWrite()` read before the grants arrive would memoize `false`
1918
+ * and the route subscription re-setting `editionState(false)` to the
1919
+ * same value would never invalidate it.
1920
+ */
1921
+ grantsVersion = signal(0, ...(ngDevMode ? [{ debugName: "grantsVersion" }] : /* istanbul ignore next */ []));
1922
+ /**
1923
+ * Whether the user may write the record on screen, for the mode it is
1924
+ * in: an existing record (edit mode, not duplicating) needs Edit; a new
1925
+ * record or a duplicate needs Create. Read is not enough for either,
1926
+ * which is how a Read-only user got a fully editable, savable form.
1927
+ *
1928
+ * Bind its negation on the form container to disable every ef-* control:
1929
+ * `<div class="form" [efReadOnly]="readOnlyMode()">`. ef-detail-toolbar
1930
+ * reads it through `context.canWrite` to hide Save, so screens pass
1931
+ * nothing for that. The backend still enforces the same grants; this is
1932
+ * the UI agreeing with it.
1933
+ */
1934
+ canWrite = computed(() => {
1935
+ this.grantsVersion();
1936
+ const ctx = this.context;
1937
+ if (!ctx)
1938
+ return false;
1939
+ const editingExisting = this.editionState() && !this.duplicateMode();
1940
+ return editingExisting ? ctx.hasEditPermission : ctx.hasCreatePermission;
1941
+ }, ...(ngDevMode ? [{ debugName: "canWrite" }] : /* istanbul ignore next */ []));
1942
+ /** `!canWrite()`, named for the `[efReadOnly]` binding it feeds. */
1943
+ readOnlyMode = computed(() => !this.canWrite(), ...(ngDevMode ? [{ debugName: "readOnlyMode" }] : /* istanbul ignore next */ []));
1869
1944
  /** True while service.get / create / update / delete is in flight. */
1870
1945
  loading = signal(false, ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
1871
1946
  /** Last load / save error message — empty string when none. */
@@ -1923,11 +1998,21 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1923
1998
  this.loadReferenceData(cfg.REF_DATA_OPTIONS);
1924
1999
  }
1925
2000
  // Wire route params → load (or reset to empty on /details).
1926
- combineLatest([this.route.paramMap, this.route.queryParamMap])
1927
- .pipe(takeUntilDestroyed(this.destroyRef))
1928
- .subscribe(([params, queryParams]) => {
1929
- const id = params.get('id');
1930
- const isDuplicate = queryParams.get('mode') === 'duplicate';
2001
+ //
2002
+ // One navigation can emit on both maps: the router pushes
2003
+ // queryParams first, then params. Combining the two emissions saw
2004
+ // a half-updated route on details/SRC?mode=duplicate → details/NEW
2005
+ // (the old id without the mode) and loaded SRC on a screen that
2006
+ // was moving to NEW. The router sets `snapshot` to the next one
2007
+ // before either emits, so each emission reads id and mode together
2008
+ // from it, and the second emission of the same navigation (same
2009
+ // snapshot object) is dropped. Any navigation that changes params
2010
+ // or query still gets a new snapshot, so it still reloads once.
2011
+ merge(this.route.paramMap, this.route.queryParamMap)
2012
+ .pipe(map(() => this.route.snapshot), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
2013
+ .subscribe(snapshot => {
2014
+ const id = snapshot.paramMap.get('id');
2015
+ const isDuplicate = snapshot.queryParamMap.get('mode') === 'duplicate';
1931
2016
  this.entityId.set(id);
1932
2017
  this.duplicateMode.set(isDuplicate);
1933
2018
  this.context.duplicateMode = isDuplicate;
@@ -1937,12 +2022,30 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1937
2022
  }
1938
2023
  else {
1939
2024
  // New mode — reset to a fresh empty entity, no history yet.
2025
+ // A load still in flight for the previous id is dropped.
2026
+ if (this.loadSub && !this.loadSub.closed) {
2027
+ this.loadSub.unsubscribe();
2028
+ this.loading.set(false);
2029
+ }
2030
+ this.auditSub?.unsubscribe();
1940
2031
  this.entity.set({});
1941
2032
  this.auditEntries.set([]);
1942
2033
  this.afterLoad();
1943
2034
  }
1944
2035
  });
1945
2036
  }
2037
+ processGrants() {
2038
+ // Stamp before super: it ends in detectChanges(), which renders an
2039
+ // ef-detail-toolbar bound to this context. A toolbar that first sees
2040
+ // the context without canWrite memoizes "unknown" (Save shown) and,
2041
+ // the context being the same object, never re-reads it.
2042
+ this.context.canWrite = this.canWrite;
2043
+ // Bumped before super too, so the render inside it recomputes
2044
+ // canWrite from the grants super is about to set rather than from a
2045
+ // value memoized on an earlier read.
2046
+ this.grantsVersion.update(v => v + 1);
2047
+ super.processGrants();
2048
+ }
1946
2049
  /**
1947
2050
  * Merge a partial patch into the `entity` signal — the canonical
1948
2051
  * way for form inputs to write back. Spread-immutably so OnPush
@@ -1966,7 +2069,15 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1966
2069
  return;
1967
2070
  this.loading.set(true);
1968
2071
  this.errorMsg.set(null);
1969
- this.serviceInstance.get(id).subscribe({
2072
+ // Moving to another record: the previous one's history must not
2073
+ // land under it while this load is in flight.
2074
+ const showsAnotherRecord = this.entity()?.id !== id;
2075
+ if (showsAnotherRecord) {
2076
+ this.auditSub?.unsubscribe();
2077
+ this.auditEntries.set([]);
2078
+ }
2079
+ this.loadSub?.unsubscribe();
2080
+ this.loadSub = this.serviceInstance.get(id).subscribe({
1970
2081
  next: (result) => {
1971
2082
  const vm = new ViewModelEntity(result);
1972
2083
  vm.id = id;
@@ -1979,6 +2090,13 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1979
2090
  console.error('AbstractDetailScreenV2.loadData failed', err);
1980
2091
  this.errorMsg.set(err?.message ?? 'Erreur de chargement');
1981
2092
  this.loading.set(false);
2093
+ // Save and the status actions target entityId(). Leaving the
2094
+ // previous record on screen would have them act on a record
2095
+ // other than the one shown. A failed reload of the same
2096
+ // record keeps it: what is shown is still what is targeted.
2097
+ if (showsAnotherRecord) {
2098
+ this.entity.set({});
2099
+ }
1982
2100
  },
1983
2101
  });
1984
2102
  }
@@ -1986,8 +2104,14 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
1986
2104
  * Persist the entity. Routes to `service.create()` when in new
1987
2105
  * or duplicate mode, `service.update(id, entity)` when editing.
1988
2106
  * Subclass `beforeSave()` can return `false` to cancel.
2107
+ *
2108
+ * A no-op when `canWrite()` is false, checked before `beforeSave()` so
2109
+ * a screen's validation never toasts at a user who could not save
2110
+ * anyway.
1989
2111
  */
1990
2112
  save() {
2113
+ if (!this.canWrite())
2114
+ return;
1991
2115
  const proceed = this.beforeSave();
1992
2116
  if (proceed === false)
1993
2117
  return;
@@ -2039,6 +2163,8 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2039
2163
  return undefined;
2040
2164
  }
2041
2165
  delete() {
2166
+ if (!this.context?.hasDeletePermission)
2167
+ return;
2042
2168
  const id = this.entityId();
2043
2169
  if (!id) {
2044
2170
  this.recordNotFound();
@@ -2069,8 +2195,12 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2069
2195
  * via `service.create()` after the user hits Save.
2070
2196
  * Without the id, the duplicate route would have nothing to
2071
2197
  * fetch — `entity` would be empty and the clone-as-template
2072
- * flow would fall back to creating a blank record. */
2198
+ * flow would fall back to creating a blank record.
2199
+ * Needs Create: a duplicate is saved through `service.create()`, so
2200
+ * opening one without Create would only lead to a read-only form. */
2073
2201
  duplicate() {
2202
+ if (!this.context?.hasCreatePermission)
2203
+ return;
2074
2204
  const id = this.entityId();
2075
2205
  const base = this.detailsBaseUrl();
2076
2206
  if (id == null) {
@@ -2120,6 +2250,7 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2120
2250
  * an empty history rather than blocking the screen.
2121
2251
  */
2122
2252
  loadAuditHistory() {
2253
+ this.auditSub?.unsubscribe();
2123
2254
  this.auditEntries.set([]);
2124
2255
  const entityType = this.getConfig()?.AUDIT_ENTITY_TYPE;
2125
2256
  const id = this.entityId();
@@ -2135,7 +2266,7 @@ class AbstractDetailScreenV2 extends AbstractScreenComponent {
2135
2266
  if (!result$ || typeof result$.subscribe !== 'function') {
2136
2267
  return;
2137
2268
  }
2138
- result$.subscribe({
2269
+ this.auditSub = result$.subscribe({
2139
2270
  next: (entries) => this.auditEntries.set(entries ?? []),
2140
2271
  error: () => this.auditEntries.set([]),
2141
2272
  });