@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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
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
|
-
|
|
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
|
});
|