@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,
|
|
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
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
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'.
|