@memberjunction/ng-explorer-core 5.36.0 → 5.38.0
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.
- package/dist/generated/lazy-feature-config.d.ts +1 -1
- package/dist/generated/lazy-feature-config.d.ts.map +1 -1
- package/dist/generated/lazy-feature-config.js +4 -2
- package/dist/generated/lazy-feature-config.js.map +1 -1
- package/dist/lib/__tests__/form-resolver.service.test.d.ts +2 -0
- package/dist/lib/__tests__/form-resolver.service.test.d.ts.map +1 -0
- package/dist/lib/__tests__/form-resolver.service.test.js +258 -0
- package/dist/lib/__tests__/form-resolver.service.test.js.map +1 -0
- package/dist/lib/conversation-feedback/conversation-feedback.d.ts +108 -0
- package/dist/lib/conversation-feedback/conversation-feedback.d.ts.map +1 -0
- package/dist/lib/conversation-feedback/conversation-feedback.js +809 -0
- package/dist/lib/conversation-feedback/conversation-feedback.js.map +1 -0
- package/dist/lib/conversation-feedback/index.d.ts +2 -0
- package/dist/lib/conversation-feedback/index.d.ts.map +1 -0
- package/dist/lib/conversation-feedback/index.js +2 -0
- package/dist/lib/conversation-feedback/index.js.map +1 -0
- package/dist/lib/resource-wrappers/artifact-resource.component.d.ts +10 -0
- package/dist/lib/resource-wrappers/artifact-resource.component.d.ts.map +1 -1
- package/dist/lib/resource-wrappers/artifact-resource.component.js +15 -5
- package/dist/lib/resource-wrappers/artifact-resource.component.js.map +1 -1
- package/dist/lib/resource-wrappers/record-resource.component.d.ts.map +1 -1
- package/dist/lib/resource-wrappers/record-resource.component.js +22 -6
- package/dist/lib/resource-wrappers/record-resource.component.js.map +1 -1
- package/dist/lib/services/form-resolver.service.d.ts +139 -0
- package/dist/lib/services/form-resolver.service.d.ts.map +1 -0
- package/dist/lib/services/form-resolver.service.js +235 -0
- package/dist/lib/services/form-resolver.service.js.map +1 -0
- package/dist/lib/shell/components/header/app-nav.component.d.ts.map +1 -1
- package/dist/lib/shell/components/header/app-nav.component.js +12 -0
- package/dist/lib/shell/components/header/app-nav.component.js.map +1 -1
- package/dist/lib/shell/components/tabs/component-cache-manager.d.ts +24 -5
- package/dist/lib/shell/components/tabs/component-cache-manager.d.ts.map +1 -1
- package/dist/lib/shell/components/tabs/component-cache-manager.js +58 -14
- package/dist/lib/shell/components/tabs/component-cache-manager.js.map +1 -1
- package/dist/lib/shell/components/tabs/tab-container.component.d.ts +42 -0
- package/dist/lib/shell/components/tabs/tab-container.component.d.ts.map +1 -1
- package/dist/lib/shell/components/tabs/tab-container.component.js +186 -12
- package/dist/lib/shell/components/tabs/tab-container.component.js.map +1 -1
- package/dist/lib/single-record/single-record.component.d.ts +41 -3
- package/dist/lib/single-record/single-record.component.d.ts.map +1 -1
- package/dist/lib/single-record/single-record.component.js +192 -23
- package/dist/lib/single-record/single-record.component.js.map +1 -1
- package/dist/module.d.ts +32 -30
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +15 -6
- package/dist/module.js.map +1 -1
- package/dist/public-api.d.ts +1 -0
- package/dist/public-api.d.ts.map +1 -1
- package/dist/public-api.js +1 -0
- package/dist/public-api.js.map +1 -1
- package/package.json +46 -44
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { LogError } from '@memberjunction/core';
|
|
3
|
+
import { InteractiveFormsEngine } from '@memberjunction/core-entities';
|
|
4
|
+
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
5
|
+
import { BaseFormComponent } from '@memberjunction/ng-base-forms';
|
|
6
|
+
import { UserInfoEngine } from '@memberjunction/core-entities';
|
|
7
|
+
import * as i0 from "@angular/core";
|
|
8
|
+
/**
|
|
9
|
+
* UserInfoEngine setting-key prefix for per-user, per-entity form-variant
|
|
10
|
+
* preferences. Persisted to `MJ: User Settings` so the choice follows the
|
|
11
|
+
* user across browsers and devices — localStorage is intentionally NOT used.
|
|
12
|
+
*
|
|
13
|
+
* Key format: `mj.formVariant.<entityname-lowercased>`.
|
|
14
|
+
*
|
|
15
|
+
* Value is one of:
|
|
16
|
+
* - an `EntityFormOverride.ID` (UUID) → use that specific override
|
|
17
|
+
* - {@link FormResolverService.EXPLICIT_DEFAULT_SENTINEL} → user picked
|
|
18
|
+
* the CodeGen Angular fallback explicitly; resolver skips all overrides
|
|
19
|
+
* - (key absent) → no preference, apply auto-pick rules
|
|
20
|
+
*/
|
|
21
|
+
const VARIANT_SETTING_PREFIX = 'mj.formVariant.';
|
|
22
|
+
/**
|
|
23
|
+
* Picks the form to render for an entity record and exposes the full list of
|
|
24
|
+
* applicable variants so the toolbar's variant switcher can offer alternates.
|
|
25
|
+
*
|
|
26
|
+
* Tier order for the **default** pick:
|
|
27
|
+
* 1. `EntityFormOverride` row matching the caller's User/Role/Global scope,
|
|
28
|
+
* Status='Active', ordered by scope tier (User > Role > Global), then
|
|
29
|
+
* Priority DESC, then `__mj_CreatedAt DESC`. First row wins.
|
|
30
|
+
* 2. Existing `ClassFactory.GetRegistration(BaseFormComponent, entityName)` —
|
|
31
|
+
* the @RegisterClass + CodeGen-generated path used today.
|
|
32
|
+
* 3. None — caller surfaces the "no form registered" error.
|
|
33
|
+
*
|
|
34
|
+
* **Session selection.** If the user previously chose a non-default variant
|
|
35
|
+
* via the variant switcher and that choice is still applicable + Active, that
|
|
36
|
+
* choice wins over the default. Choice is keyed by entity name in
|
|
37
|
+
* `localStorage`. Honoring this is part of the variant-switcher contract;
|
|
38
|
+
* without it, switching variants would only last for the lifetime of the
|
|
39
|
+
* record-form view.
|
|
40
|
+
*
|
|
41
|
+
* **Performance.** Backed by `InteractiveFormsEngine` (in MJCoreEntities)
|
|
42
|
+
* — overrides are cached in memory with BaseEntity event-driven
|
|
43
|
+
* invalidation, so resolution is an O(N) JS filter against a small
|
|
44
|
+
* set instead of a per-LoadForm RunView round-trip. Cold latency
|
|
45
|
+
* dropped from ~50ms to <1ms after the first load. The engine is
|
|
46
|
+
* lazy-Config'd here on first resolution; users who never open a
|
|
47
|
+
* record pay nothing.
|
|
48
|
+
*/
|
|
49
|
+
export class FormResolverService {
|
|
50
|
+
async ResolveFormForEntity(entity, user, provider) {
|
|
51
|
+
const variants = await this.listApplicableVariants(entity, user, provider);
|
|
52
|
+
const active = this.pickActive(entity, variants);
|
|
53
|
+
if (active) {
|
|
54
|
+
return { kind: 'interactive', override: active, variants };
|
|
55
|
+
}
|
|
56
|
+
const reg = MJGlobal.Instance.ClassFactory.GetRegistration(BaseFormComponent, entity.Name);
|
|
57
|
+
return reg
|
|
58
|
+
? { kind: 'class', subClass: reg.SubClass, variants }
|
|
59
|
+
: { kind: 'none', variants };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Public list-API for the variant switcher UI. Returns all variants
|
|
63
|
+
* applicable to (entity, user). Includes Active, Pending, and Inactive
|
|
64
|
+
* rows so the picker can offer "switch back to v1.0.0" alongside the
|
|
65
|
+
* current active variant.
|
|
66
|
+
*/
|
|
67
|
+
async ListVariantsForEntity(entity, user, provider) {
|
|
68
|
+
return this.listApplicableVariants(entity, user, provider);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Sentinel stored in localStorage when the user **explicitly picks the
|
|
72
|
+
* "Default form"** option from the variant picker. Distinct from a
|
|
73
|
+
* missing localStorage key (= "no preference, use auto-pick rules")
|
|
74
|
+
* and from an override UUID. Without this sentinel, picking "Default"
|
|
75
|
+
* cleared the preference and `pickActive` re-applied the auto-pick —
|
|
76
|
+
* which always selects the first Active override, making the
|
|
77
|
+
* CodeGen/Angular fallback unreachable from the UI.
|
|
78
|
+
*
|
|
79
|
+
* Format: a leading `__` makes it visually distinct from a UUID and
|
|
80
|
+
* impossible to collide with one (UUIDs don't contain underscores).
|
|
81
|
+
*/
|
|
82
|
+
static EXPLICIT_DEFAULT_SENTINEL = '__codegen-default__';
|
|
83
|
+
/**
|
|
84
|
+
* Build the per-entity setting key. Lowercased so case variants of
|
|
85
|
+
* the entity name collapse onto a single record in `MJ: User Settings`.
|
|
86
|
+
*/
|
|
87
|
+
settingKey(entityName) {
|
|
88
|
+
return VARIANT_SETTING_PREFIX + entityName.toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Persist a specific override choice. Pass the override UUID. Writes
|
|
92
|
+
* via `UserInfoEngine.SetSettingDebounced` so rapid successive picks
|
|
93
|
+
* don't hammer the DB. Use {@link SetExplicitDefault} to record "user
|
|
94
|
+
* wants the CodeGen Angular fallback" and {@link ClearSelectedVariant}
|
|
95
|
+
* to wipe the preference entirely (revert to auto-pick).
|
|
96
|
+
*/
|
|
97
|
+
SetSelectedVariant(entityName, overrideID) {
|
|
98
|
+
UserInfoEngine.Instance.SetSettingDebounced(this.settingKey(entityName), overrideID);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Record that the user explicitly picked the "Default form" row in
|
|
102
|
+
* the picker. `pickActive` reads this sentinel and returns null even
|
|
103
|
+
* when Active overrides exist for the entity, so the form-loading
|
|
104
|
+
* path falls back to CodeGen's `@RegisterClass` lookup.
|
|
105
|
+
*/
|
|
106
|
+
SetExplicitDefault(entityName) {
|
|
107
|
+
UserInfoEngine.Instance.SetSettingDebounced(this.settingKey(entityName), FormResolverService.EXPLICIT_DEFAULT_SENTINEL);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Wipe the user's stored preference for this entity. Next load
|
|
111
|
+
* applies the auto-pick rules (first Active override in tier order).
|
|
112
|
+
* Called internally when a saved override ID is no longer valid.
|
|
113
|
+
* Fire-and-forget — the resolver doesn't need to await the delete.
|
|
114
|
+
*/
|
|
115
|
+
ClearSelectedVariant(entityName) {
|
|
116
|
+
void UserInfoEngine.Instance.DeleteSetting(this.settingKey(entityName))
|
|
117
|
+
.catch(err => LogError(`FormResolverService.ClearSelectedVariant: ${err instanceof Error ? err.message : String(err)}`));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Read the user's previously-saved variant choice for the entity.
|
|
121
|
+
* Synchronous because `UserInfoEngine` keeps the user-settings table
|
|
122
|
+
* in memory after bootstrap. Returns the stored UUID, the explicit-
|
|
123
|
+
* default sentinel, or null when no preference exists.
|
|
124
|
+
*/
|
|
125
|
+
GetSelectedVariant(entityName) {
|
|
126
|
+
return UserInfoEngine.Instance.GetSetting(this.settingKey(entityName)) ?? null;
|
|
127
|
+
}
|
|
128
|
+
// ── internals ────────────────────────────────────────────────────────
|
|
129
|
+
async listApplicableVariants(entity, user, provider) {
|
|
130
|
+
// Lazy-load the form-metadata engine on first call. No-op when
|
|
131
|
+
// already loaded. BaseEngine's event subscription keeps the
|
|
132
|
+
// cache fresh through save / delete / remote-invalidate — we
|
|
133
|
+
// don't need `BypassCache: true` (the previous RunView path's
|
|
134
|
+
// escape hatch) because the engine's invalidation IS that
|
|
135
|
+
// freshness guarantee. See `InteractiveFormsEngine` doc-block.
|
|
136
|
+
try {
|
|
137
|
+
await InteractiveFormsEngine.Instance.Config(false, user, provider);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
LogError(`FormResolverService: InteractiveFormsEngine.Config failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const userRoleIds = new Set((user.UserRoles ?? []).map(r => r.RoleID).filter((id) => !!id));
|
|
144
|
+
// Filter cached overrides for this (entity, user, roles) tuple.
|
|
145
|
+
// Includes Active + Pending + Inactive (the variant picker shows
|
|
146
|
+
// all three; pickActive() filters to Active separately). Replaces
|
|
147
|
+
// a per-LoadForm RunView with an in-memory predicate — sub-ms.
|
|
148
|
+
const applicable = InteractiveFormsEngine.Instance.Overrides.filter(o => {
|
|
149
|
+
if (!o.EntityID || !UUIDsEqual(o.EntityID, entity.ID))
|
|
150
|
+
return false;
|
|
151
|
+
if (o.Scope === 'User')
|
|
152
|
+
return !!o.UserID && o.UserID.toLowerCase() === user.ID.toLowerCase();
|
|
153
|
+
if (o.Scope === 'Role')
|
|
154
|
+
return !!o.RoleID && userRoleIds.has(o.RoleID);
|
|
155
|
+
if (o.Scope === 'Global')
|
|
156
|
+
return true;
|
|
157
|
+
return false;
|
|
158
|
+
});
|
|
159
|
+
// Sort: User > Role > Global, then Priority DESC (higher beats),
|
|
160
|
+
// then newest first. Matches the prior SQL ORDER BY semantics so
|
|
161
|
+
// pickActive() / the variant switcher see the same order.
|
|
162
|
+
const scopeRank = (s) => {
|
|
163
|
+
if (s === 'User')
|
|
164
|
+
return 1;
|
|
165
|
+
if (s === 'Role')
|
|
166
|
+
return 2;
|
|
167
|
+
if (s === 'Global')
|
|
168
|
+
return 3;
|
|
169
|
+
return 4;
|
|
170
|
+
};
|
|
171
|
+
applicable.sort((a, b) => {
|
|
172
|
+
const sa = scopeRank(a.Scope);
|
|
173
|
+
const sb = scopeRank(b.Scope);
|
|
174
|
+
if (sa !== sb)
|
|
175
|
+
return sa - sb;
|
|
176
|
+
const pa = a.Priority ?? 0;
|
|
177
|
+
const pb = b.Priority ?? 0;
|
|
178
|
+
if (pa !== pb)
|
|
179
|
+
return pb - pa; // Priority DESC
|
|
180
|
+
const ca = a.__mj_CreatedAt instanceof Date ? a.__mj_CreatedAt.getTime() : 0;
|
|
181
|
+
const cb = b.__mj_CreatedAt instanceof Date ? b.__mj_CreatedAt.getTime() : 0;
|
|
182
|
+
return cb - ca; // newest first
|
|
183
|
+
});
|
|
184
|
+
// Project to the slim row shape the resolver returns. Keeps the
|
|
185
|
+
// contract identical to the old RunView return so callers don't
|
|
186
|
+
// change shape.
|
|
187
|
+
return applicable.map(o => ({
|
|
188
|
+
ID: o.ID,
|
|
189
|
+
EntityID: o.EntityID,
|
|
190
|
+
ComponentID: o.ComponentID,
|
|
191
|
+
Scope: o.Scope,
|
|
192
|
+
UserID: o.UserID,
|
|
193
|
+
RoleID: o.RoleID,
|
|
194
|
+
Priority: o.Priority,
|
|
195
|
+
Status: o.Status,
|
|
196
|
+
Name: o.Name,
|
|
197
|
+
Description: o.Description,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Pick the override that should actually render given the variant list
|
|
202
|
+
* and the user's stored selection (if any).
|
|
203
|
+
*
|
|
204
|
+
* Selection rules:
|
|
205
|
+
* - If the user explicitly picked the "Default form" row in the
|
|
206
|
+
* variant picker (sentinel in localStorage) → return null so the
|
|
207
|
+
* form-loading path falls back to CodeGen's `@RegisterClass` lookup.
|
|
208
|
+
* This is what makes the Angular fallback reachable from the UI.
|
|
209
|
+
* - Else if the user has a saved variant ID AND that variant is in
|
|
210
|
+
* the applicable list AND it's Active → use it.
|
|
211
|
+
* - Else → first Active row in tier+priority order (auto-pick).
|
|
212
|
+
* - Else → null (fall back to CodeGen/@RegisterClass path).
|
|
213
|
+
*/
|
|
214
|
+
pickActive(entity, variants) {
|
|
215
|
+
const selectedID = this.GetSelectedVariant(entity.Name);
|
|
216
|
+
if (selectedID === FormResolverService.EXPLICIT_DEFAULT_SENTINEL) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
if (selectedID) {
|
|
220
|
+
const sel = variants.find(v => v.Status === 'Active' && UUIDsEqual(v.ID, selectedID));
|
|
221
|
+
if (sel)
|
|
222
|
+
return sel;
|
|
223
|
+
// Selection no longer valid — wipe it so future loads auto-pick.
|
|
224
|
+
this.ClearSelectedVariant(entity.Name);
|
|
225
|
+
}
|
|
226
|
+
return variants.find(v => v.Status === 'Active') ?? null;
|
|
227
|
+
}
|
|
228
|
+
static ɵfac = function FormResolverService_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || FormResolverService)(); };
|
|
229
|
+
static ɵprov = /*@__PURE__*/ i0.ɵɵdefineInjectable({ token: FormResolverService, factory: FormResolverService.ɵfac, providedIn: 'root' });
|
|
230
|
+
}
|
|
231
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(FormResolverService, [{
|
|
232
|
+
type: Injectable,
|
|
233
|
+
args: [{ providedIn: 'root' }]
|
|
234
|
+
}], null, null); })();
|
|
235
|
+
//# sourceMappingURL=form-resolver.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"form-resolver.service.js","sourceRoot":"","sources":["../../../src/lib/services/form-resolver.service.ts"],"names":[],"mappings":"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","sourcesContent":["import { Injectable, Type } from '@angular/core';\nimport { IMetadataProvider, UserInfo, EntityInfo, LogError } from '@memberjunction/core';\nimport { InteractiveFormsEngine } from '@memberjunction/core-entities';\nimport { MJGlobal, UUIDsEqual } from '@memberjunction/global';\nimport { BaseFormComponent } from '@memberjunction/ng-base-forms';\nimport { UserInfoEngine } from '@memberjunction/core-entities';\n\n/**\n * Slim row shape for an `EntityFormOverride` lookup. Resolution doesn't need\n * the full BaseEntity object — `simple` ResultType is faster and avoids a\n * compile-time dependency on the generated entity class. The row is only ever\n * read by the resolver; mutation goes through the generated entity in other\n * code paths (Studio, AI authoring).\n */\nexport interface EntityFormOverrideRow {\n ID: string;\n EntityID: string;\n ComponentID: string;\n Scope: 'User' | 'Role' | 'Global';\n UserID: string | null;\n RoleID: string | null;\n Priority: number;\n Status: 'Active' | 'Inactive' | 'Pending';\n Name?: string;\n Description?: string | null;\n}\n\n/** Resolved form kind for a given (entity, user, roles) tuple. */\nexport type FormResolution =\n | { kind: 'interactive'; override: EntityFormOverrideRow; variants: EntityFormOverrideRow[] }\n | { kind: 'class'; subClass: Type<BaseFormComponent>; variants: EntityFormOverrideRow[] }\n | { kind: 'none'; variants: EntityFormOverrideRow[] };\n\n/**\n * UserInfoEngine setting-key prefix for per-user, per-entity form-variant\n * preferences. Persisted to `MJ: User Settings` so the choice follows the\n * user across browsers and devices — localStorage is intentionally NOT used.\n *\n * Key format: `mj.formVariant.<entityname-lowercased>`.\n *\n * Value is one of:\n * - an `EntityFormOverride.ID` (UUID) → use that specific override\n * - {@link FormResolverService.EXPLICIT_DEFAULT_SENTINEL} → user picked\n * the CodeGen Angular fallback explicitly; resolver skips all overrides\n * - (key absent) → no preference, apply auto-pick rules\n */\nconst VARIANT_SETTING_PREFIX = 'mj.formVariant.';\n\n/**\n * Picks the form to render for an entity record and exposes the full list of\n * applicable variants so the toolbar's variant switcher can offer alternates.\n *\n * Tier order for the **default** pick:\n * 1. `EntityFormOverride` row matching the caller's User/Role/Global scope,\n * Status='Active', ordered by scope tier (User > Role > Global), then\n * Priority DESC, then `__mj_CreatedAt DESC`. First row wins.\n * 2. Existing `ClassFactory.GetRegistration(BaseFormComponent, entityName)` —\n * the @RegisterClass + CodeGen-generated path used today.\n * 3. None — caller surfaces the \"no form registered\" error.\n *\n * **Session selection.** If the user previously chose a non-default variant\n * via the variant switcher and that choice is still applicable + Active, that\n * choice wins over the default. Choice is keyed by entity name in\n * `localStorage`. Honoring this is part of the variant-switcher contract;\n * without it, switching variants would only last for the lifetime of the\n * record-form view.\n *\n * **Performance.** Backed by `InteractiveFormsEngine` (in MJCoreEntities)\n * — overrides are cached in memory with BaseEntity event-driven\n * invalidation, so resolution is an O(N) JS filter against a small\n * set instead of a per-LoadForm RunView round-trip. Cold latency\n * dropped from ~50ms to <1ms after the first load. The engine is\n * lazy-Config'd here on first resolution; users who never open a\n * record pay nothing.\n */\n@Injectable({ providedIn: 'root' })\nexport class FormResolverService {\n\n async ResolveFormForEntity(\n entity: EntityInfo,\n user: UserInfo,\n provider: IMetadataProvider,\n ): Promise<FormResolution> {\n const variants = await this.listApplicableVariants(entity, user, provider);\n const active = this.pickActive(entity, variants);\n\n if (active) {\n return { kind: 'interactive', override: active, variants };\n }\n\n const reg = MJGlobal.Instance.ClassFactory.GetRegistration(BaseFormComponent, entity.Name);\n return reg\n ? { kind: 'class', subClass: reg.SubClass as Type<BaseFormComponent>, variants }\n : { kind: 'none', variants };\n }\n\n /**\n * Public list-API for the variant switcher UI. Returns all variants\n * applicable to (entity, user). Includes Active, Pending, and Inactive\n * rows so the picker can offer \"switch back to v1.0.0\" alongside the\n * current active variant.\n */\n async ListVariantsForEntity(\n entity: EntityInfo,\n user: UserInfo,\n provider: IMetadataProvider,\n ): Promise<EntityFormOverrideRow[]> {\n return this.listApplicableVariants(entity, user, provider);\n }\n\n /**\n * Sentinel stored in localStorage when the user **explicitly picks the\n * \"Default form\"** option from the variant picker. Distinct from a\n * missing localStorage key (= \"no preference, use auto-pick rules\")\n * and from an override UUID. Without this sentinel, picking \"Default\"\n * cleared the preference and `pickActive` re-applied the auto-pick —\n * which always selects the first Active override, making the\n * CodeGen/Angular fallback unreachable from the UI.\n *\n * Format: a leading `__` makes it visually distinct from a UUID and\n * impossible to collide with one (UUIDs don't contain underscores).\n */\n public static readonly EXPLICIT_DEFAULT_SENTINEL = '__codegen-default__';\n\n /**\n * Build the per-entity setting key. Lowercased so case variants of\n * the entity name collapse onto a single record in `MJ: User Settings`.\n */\n private settingKey(entityName: string): string {\n return VARIANT_SETTING_PREFIX + entityName.toLowerCase();\n }\n\n /**\n * Persist a specific override choice. Pass the override UUID. Writes\n * via `UserInfoEngine.SetSettingDebounced` so rapid successive picks\n * don't hammer the DB. Use {@link SetExplicitDefault} to record \"user\n * wants the CodeGen Angular fallback\" and {@link ClearSelectedVariant}\n * to wipe the preference entirely (revert to auto-pick).\n */\n SetSelectedVariant(entityName: string, overrideID: string): void {\n UserInfoEngine.Instance.SetSettingDebounced(this.settingKey(entityName), overrideID);\n }\n\n /**\n * Record that the user explicitly picked the \"Default form\" row in\n * the picker. `pickActive` reads this sentinel and returns null even\n * when Active overrides exist for the entity, so the form-loading\n * path falls back to CodeGen's `@RegisterClass` lookup.\n */\n SetExplicitDefault(entityName: string): void {\n UserInfoEngine.Instance.SetSettingDebounced(\n this.settingKey(entityName),\n FormResolverService.EXPLICIT_DEFAULT_SENTINEL,\n );\n }\n\n /**\n * Wipe the user's stored preference for this entity. Next load\n * applies the auto-pick rules (first Active override in tier order).\n * Called internally when a saved override ID is no longer valid.\n * Fire-and-forget — the resolver doesn't need to await the delete.\n */\n ClearSelectedVariant(entityName: string): void {\n void UserInfoEngine.Instance.DeleteSetting(this.settingKey(entityName))\n .catch(err => LogError(`FormResolverService.ClearSelectedVariant: ${err instanceof Error ? err.message : String(err)}`));\n }\n\n /**\n * Read the user's previously-saved variant choice for the entity.\n * Synchronous because `UserInfoEngine` keeps the user-settings table\n * in memory after bootstrap. Returns the stored UUID, the explicit-\n * default sentinel, or null when no preference exists.\n */\n GetSelectedVariant(entityName: string): string | null {\n return UserInfoEngine.Instance.GetSetting(this.settingKey(entityName)) ?? null;\n }\n\n // ── internals ────────────────────────────────────────────────────────\n\n private async listApplicableVariants(\n entity: EntityInfo,\n user: UserInfo,\n provider: IMetadataProvider,\n ): Promise<EntityFormOverrideRow[]> {\n // Lazy-load the form-metadata engine on first call. No-op when\n // already loaded. BaseEngine's event subscription keeps the\n // cache fresh through save / delete / remote-invalidate — we\n // don't need `BypassCache: true` (the previous RunView path's\n // escape hatch) because the engine's invalidation IS that\n // freshness guarantee. See `InteractiveFormsEngine` doc-block.\n try {\n await InteractiveFormsEngine.Instance.Config(false, user, provider);\n } catch (err) {\n LogError(`FormResolverService: InteractiveFormsEngine.Config failed: ${err instanceof Error ? err.message : String(err)}`);\n return [];\n }\n\n const userRoleIds = new Set(\n (user.UserRoles ?? []).map(r => r.RoleID).filter((id): id is string => !!id),\n );\n\n // Filter cached overrides for this (entity, user, roles) tuple.\n // Includes Active + Pending + Inactive (the variant picker shows\n // all three; pickActive() filters to Active separately). Replaces\n // a per-LoadForm RunView with an in-memory predicate — sub-ms.\n const applicable = InteractiveFormsEngine.Instance.Overrides.filter(o => {\n if (!o.EntityID || !UUIDsEqual(o.EntityID, entity.ID)) return false;\n if (o.Scope === 'User') return !!o.UserID && o.UserID.toLowerCase() === user.ID.toLowerCase();\n if (o.Scope === 'Role') return !!o.RoleID && userRoleIds.has(o.RoleID);\n if (o.Scope === 'Global') return true;\n return false;\n });\n\n // Sort: User > Role > Global, then Priority DESC (higher beats),\n // then newest first. Matches the prior SQL ORDER BY semantics so\n // pickActive() / the variant switcher see the same order.\n const scopeRank = (s: string | null | undefined): number => {\n if (s === 'User') return 1;\n if (s === 'Role') return 2;\n if (s === 'Global') return 3;\n return 4;\n };\n applicable.sort((a, b) => {\n const sa = scopeRank(a.Scope);\n const sb = scopeRank(b.Scope);\n if (sa !== sb) return sa - sb;\n const pa = a.Priority ?? 0;\n const pb = b.Priority ?? 0;\n if (pa !== pb) return pb - pa; // Priority DESC\n const ca = a.__mj_CreatedAt instanceof Date ? a.__mj_CreatedAt.getTime() : 0;\n const cb = b.__mj_CreatedAt instanceof Date ? b.__mj_CreatedAt.getTime() : 0;\n return cb - ca; // newest first\n });\n\n // Project to the slim row shape the resolver returns. Keeps the\n // contract identical to the old RunView return so callers don't\n // change shape.\n return applicable.map(o => ({\n ID: o.ID,\n EntityID: o.EntityID,\n ComponentID: o.ComponentID,\n Scope: o.Scope as 'User' | 'Role' | 'Global',\n UserID: o.UserID,\n RoleID: o.RoleID,\n Priority: o.Priority,\n Status: o.Status as 'Active' | 'Inactive' | 'Pending',\n Name: o.Name,\n Description: o.Description,\n }));\n }\n\n /**\n * Pick the override that should actually render given the variant list\n * and the user's stored selection (if any).\n *\n * Selection rules:\n * - If the user explicitly picked the \"Default form\" row in the\n * variant picker (sentinel in localStorage) → return null so the\n * form-loading path falls back to CodeGen's `@RegisterClass` lookup.\n * This is what makes the Angular fallback reachable from the UI.\n * - Else if the user has a saved variant ID AND that variant is in\n * the applicable list AND it's Active → use it.\n * - Else → first Active row in tier+priority order (auto-pick).\n * - Else → null (fall back to CodeGen/@RegisterClass path).\n */\n private pickActive(\n entity: EntityInfo,\n variants: EntityFormOverrideRow[],\n ): EntityFormOverrideRow | null {\n const selectedID = this.GetSelectedVariant(entity.Name);\n if (selectedID === FormResolverService.EXPLICIT_DEFAULT_SENTINEL) {\n return null;\n }\n if (selectedID) {\n const sel = variants.find(v => v.Status === 'Active' && UUIDsEqual(v.ID, selectedID));\n if (sel) return sel;\n // Selection no longer valid — wipe it so future loads auto-pick.\n this.ClearSelectedVariant(entity.Name);\n }\n return variants.find(v => v.Status === 'Active') ?? null;\n }\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-nav.component.d.ts","sourceRoot":"","sources":["../../../../../src/lib/shell/components/header/app-nav.component.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,iBAAiB,EAA2B,MAAM,eAAe,CAAC;AACtI,OAAO,EAAE,eAAe,EAAkB,OAAO,EAAE,qBAAqB,EAA0B,MAAM,qCAAqC,CAAC;AAC9I,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;;AAG1D;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,qBAOa,eAAgB,YAAW,MAAM,EAAE,SAAS;IA8BrD,OAAO,CAAC,gBAAgB;IACxB,OAAO,CAAC,aAAa;IACrB,OAAO,CAAC,GAAG;IA/Bb,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,IAAI,CAAgC;IAC5C,OAAO,CAAC,eAAe,CAAiB;IACxC,OAAO,CAAC,eAAe,CAAqC;IAC5D,OAAO,CAAC,iBAAiB,CAAS;IAElC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,iBAAiB,CAAK;IAG9B,OAAO,CAAC,cAAc,CAA8B;IAE1C,YAAY,kCAAyC;IACrD,cAAc,wBAA+B;gBAG7C,gBAAgB,EAAE,qBAAqB,EACvC,aAAa,EAAE,aAAa,EAC5B,GAAG,EAAE,iBAAiB;IAGhC;;OAEG;IACH,IACI,GAAG,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,EASpC;IAED,IAAI,GAAG,IAAI,eAAe,GAAG,IAAI,CAEhC;IAED,QAAQ,IAAI,IAAI;IAahB,WAAW,IAAI,IAAI;IAKnB;;OAEG;YACW,gBAAgB;
|
|
1
|
+
{"version":3,"file":"app-nav.component.d.ts","sourceRoot":"","sources":["../../../../../src/lib/shell/components/header/app-nav.component.ts"],"names":[],"mappings":"AAAA,OAAO,EAA4B,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,iBAAiB,EAA2B,MAAM,eAAe,CAAC;AACtI,OAAO,EAAE,eAAe,EAAkB,OAAO,EAAE,qBAAqB,EAA0B,MAAM,qCAAqC,CAAC;AAC9I,OAAO,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;;AAG1D;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;GAGG;AACH,qBAOa,eAAgB,YAAW,MAAM,EAAE,SAAS;IA8BrD,OAAO,CAAC,gBAAgB;IACxB,OAAO,CAAC,aAAa;IACrB,OAAO,CAAC,GAAG;IA/Bb,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,IAAI,CAAgC;IAC5C,OAAO,CAAC,eAAe,CAAiB;IACxC,OAAO,CAAC,eAAe,CAAqC;IAC5D,OAAO,CAAC,iBAAiB,CAAS;IAElC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,iBAAiB,CAAK;IAG9B,OAAO,CAAC,cAAc,CAA8B;IAE1C,YAAY,kCAAyC;IACrD,cAAc,wBAA+B;gBAG7C,gBAAgB,EAAE,qBAAqB,EACvC,aAAa,EAAE,aAAa,EAC5B,GAAG,EAAE,iBAAiB;IAGhC;;OAEG;IACH,IACI,GAAG,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI,EASpC;IAED,IAAI,GAAG,IAAI,eAAe,GAAG,IAAI,CAEhC;IAED,QAAQ,IAAI,IAAI;IAahB,WAAW,IAAI,IAAI;IAKnB;;OAEG;YACW,gBAAgB;IAyD9B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAoB1B;;;OAGG;IACH,OAAO,CAAC,UAAU;IAIlB;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO;IAIjC;;OAEG;IACH,OAAO,CAAC,eAAe;IA+BvB;;OAEG;IACH,IAAI,QAAQ,IAAI,OAAO,EAAE,CAExB;IAED;;OAEG;IACH,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED;;OAEG;IACH,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO;IAKhC;;OAEG;IACH,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM;IAIrD;;OAEG;IACH,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,CAAC,EAAE,UAAU,GAAG,IAAI;IAOnD;;;;OAIG;IACH,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI;yCAxPtC,eAAe;2CAAf,eAAe;CAyQ3B"}
|
|
@@ -155,6 +155,18 @@ export class AppNavComponent {
|
|
|
155
155
|
const config = this.workspaceManager.GetConfiguration();
|
|
156
156
|
this.updateActiveStates(config);
|
|
157
157
|
this.cdr.markForCheck();
|
|
158
|
+
// In Angular 21 zoneless mode, markForCheck() alone is unreliable when the trigger
|
|
159
|
+
// is an RxJS subscription (workspaceManager.Configuration here) not tracked by the
|
|
160
|
+
// zoneless scheduler — the dirty flag is set but no follow-up tick is scheduled.
|
|
161
|
+
// detectChanges() runs CD synchronously on this view, rendering the new data
|
|
162
|
+
// immediately. Wrapped because detectChanges throws if invoked re-entrantly during
|
|
163
|
+
// another in-flight CD pass — harmless if so.
|
|
164
|
+
try {
|
|
165
|
+
this.cdr.detectChanges();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Re-entrant CD — harmless, the in-flight pass picks up our markForCheck.
|
|
169
|
+
}
|
|
158
170
|
}
|
|
159
171
|
/**
|
|
160
172
|
* Update active state map based on current workspace configuration
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app-nav.component.js","sourceRoot":"","sources":["../../../../../src/lib/shell/components/header/app-nav.component.ts","../../../../../src/lib/shell/components/header/app-nav.component.html"],"names":[],"mappings":"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","sourcesContent":["import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';\nimport { BaseApplication, DynamicNavItem, NavItem, WorkspaceStateManager, WorkspaceConfiguration } from '@memberjunction/ng-base-application';\nimport { SharedService } from '@memberjunction/ng-shared';\nimport { Subject, takeUntil } from 'rxjs';\n\n/**\n * Event emitted when a nav item is clicked\n */\nexport interface NavItemClickEvent {\n item: NavItem;\n shiftKey: boolean;\n}\n\n/**\n * Horizontal navigation items for the current app.\n * Uses OnPush change detection and reactive state management for optimal performance.\n */\n@Component({\n standalone: false,\n selector: 'mj-app-nav',\n templateUrl: './app-nav.component.html',\n styleUrls: ['./app-nav.component.css'],\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class AppNavComponent implements OnInit, OnDestroy {\n private destroy$ = new Subject<void>();\n private _app: BaseApplication | null = null;\n private _cachedNavItems: NavItem[] = [];\n private _cachedAppColor: string = 'var(--mj-brand-primary)';\n private _servicesInjected = false;\n\n /**\n * Monotonically increasing counter used to detect and discard stale async results.\n *\n * Because GetNavItems() is async (HomeApplication does a DB lookup for record names),\n * and RxJS subscribe() does NOT serialize async callbacks, multiple calls to\n * updateCachedData() can overlap. Without this guard, a slow call (e.g., Home app\n * doing a DB lookup) that started BEFORE a fast call (e.g., switching to App B)\n * could resolve AFTER the fast call and overwrite the correct nav items with stale ones.\n *\n * How it works:\n * 1. Each updateCachedData() call increments this counter and captures it as `gen`\n * 2. After the await, it checks: does `gen` still match `_updateGeneration`?\n * 3. If not, a newer call started while we were waiting — discard our stale results\n */\n private _updateGeneration = 0;\n\n // Map of nav item key (Route or Label) to active state\n private activeStateMap = new Map<string, boolean>();\n\n @Output() navItemClick = new EventEmitter<NavItemClickEvent>();\n @Output() navItemDismiss = new EventEmitter<NavItem>();\n\n constructor(\n private workspaceManager: WorkspaceStateManager,\n private sharedService: SharedService,\n private cdr: ChangeDetectorRef\n ) {}\n\n /**\n * Input setter for app - triggers cache update when app changes\n */\n @Input()\n set app(value: BaseApplication | null) {\n if (this._app !== value) {\n this._app = value;\n this._cachedNavItems = []; // Clear stale items immediately so previous app's items don't flash\n this.activeStateMap.clear();\n this._servicesInjected = false; // Reset injection flag\n this.updateCachedData();\n this.cdr.markForCheck();\n }\n }\n\n get app(): BaseApplication | null {\n return this._app;\n }\n\n ngOnInit(): void {\n // Subscribe to workspace configuration changes.\n // Must rebuild nav items (not just active states) because dynamic nav items\n // are generated based on the currently active tab - when a user navigates\n // from one record to another (e.g., via OpenEntityRecord), the active tab\n // changes and the dynamic nav item needs to reflect the new record.\n this.workspaceManager.Configuration\n .pipe(takeUntil(this.destroy$))\n .subscribe(async () => {\n await this.updateCachedData();\n });\n }\n\n ngOnDestroy(): void {\n this.destroy$.next();\n this.destroy$.complete();\n }\n\n /**\n * Update cached nav items and app color when app changes\n */\n private async updateCachedData(): Promise<void> {\n // Capture the current generation before any async work.\n // See _updateGeneration JSDoc for full explanation of the race condition this prevents.\n const gen = ++this._updateGeneration;\n\n if (this._app) {\n // Inject services once for apps that need them (e.g., HomeApplication for dynamic nav items)\n if (!this._servicesInjected) {\n const appWithServices = this._app as BaseApplication & {\n SetWorkspaceManager?: (manager: WorkspaceStateManager) => void;\n SetSharedService?: (service: SharedService) => void;\n };\n\n if (typeof appWithServices.SetWorkspaceManager === 'function') {\n appWithServices.SetWorkspaceManager(this.workspaceManager);\n }\n if (typeof appWithServices.SetSharedService === 'function') {\n appWithServices.SetSharedService(this.sharedService);\n }\n this._servicesInjected = true;\n }\n\n const items = await this._app.GetNavItems() || [];\n\n // If a newer call started while we were awaiting, our results are stale — bail out\n // so we don't overwrite the newer call's (correct) results.\n if (gen !== this._updateGeneration) {\n return;\n }\n\n // Only show items with Status 'Active' or undefined (default to Active)\n this._cachedNavItems = items.filter(item => !item.Status || item.Status === 'Active');\n\n this._cachedAppColor = this._app.GetColor() || 'var(--mj-brand-primary)';\n } else {\n this._cachedNavItems = [];\n this._cachedAppColor = 'var(--mj-brand-primary)';\n }\n\n // Update active states after nav items change\n const config = this.workspaceManager.GetConfiguration();\n this.updateActiveStates(config);\n this.cdr.markForCheck();\n }\n\n /**\n * Update active state map based on current workspace configuration\n */\n private updateActiveStates(config: WorkspaceConfiguration | null): void {\n this.activeStateMap.clear();\n\n if (!config || !this._app) {\n return;\n }\n\n const activeTab = config.tabs.find(t => t.id === config.activeTabId);\n if (!activeTab || activeTab.applicationId !== this._app.ID) {\n return;\n }\n\n // Compute active state for each nav item once\n for (const item of this._cachedNavItems) {\n const key = this.getItemKey(item);\n const isActive = this.computeIsActive(item, activeTab);\n this.activeStateMap.set(key, isActive);\n }\n }\n\n /**\n * Get unique key for nav item (used for tracking and active state).\n * Prefers RecordID for dynamic items to avoid label collisions.\n */\n private getItemKey(item: NavItem): string {\n return item.RecordID || item.Route || item.Label || '';\n }\n\n /**\n * Check if a nav item is dynamic (generated from recent orphan resources)\n */\n isDynamic(item: NavItem): boolean {\n return (item as DynamicNavItem).isDynamic === true;\n }\n\n /**\n * Compute if nav item is active based on active tab\n */\n private computeIsActive(item: NavItem, activeTab: any): boolean {\n // Check if nav item has a custom matching function (for dynamic items)\n const dynamicItem = item as NavItem & { isActiveMatch?: (tab: unknown) => boolean };\n if (dynamicItem.isActiveMatch && typeof dynamicItem.isActiveMatch === 'function') {\n return dynamicItem.isActiveMatch(activeTab);\n }\n\n const config = activeTab.configuration || {};\n\n // Match by DriverClass (most reliable for Custom resource types — always set correctly)\n if (item.DriverClass && (config['driverClass'] === item.DriverClass || config['resourceTypeDriverClass'] === item.DriverClass)) {\n return true;\n }\n\n // Match by navItemName from config (reliable — set when nav item opens)\n if (config['navItemName'] && config['navItemName'] === item.Label) {\n return true;\n }\n\n // Match by route (for route-based nav items)\n if (item.Route && config['route'] === item.Route) {\n return true;\n }\n\n // NOTE: We intentionally do NOT match by activeTab.title here.\n // Tab titles can be stale (updated asynchronously by DisplayNameChangedEvent\n // from cached components) and cause double-matches where two nav items\n // both appear active. DriverClass and navItemName are sufficient.\n return false;\n }\n\n /**\n * Get cached navigation items (no computation in getter)\n */\n get navItems(): NavItem[] {\n return this._cachedNavItems;\n }\n\n /**\n * Get cached app color (no computation in getter)\n */\n get appColor(): string {\n return this._cachedAppColor;\n }\n\n /**\n * Check if nav item is active (uses cached state from Map)\n */\n isActive(item: NavItem): boolean {\n const key = this.getItemKey(item);\n return this.activeStateMap.get(key) || false;\n }\n\n /**\n * Track function for @for to optimize rendering\n */\n trackByNavItem(_index: number, item: NavItem): string {\n return this.getItemKey(item);\n }\n\n /**\n * Handle nav item click\n */\n onNavClick(item: NavItem, event?: MouseEvent): void {\n this.navItemClick.emit({\n item,\n shiftKey: event?.shiftKey || false\n });\n }\n\n /**\n * Handle dismiss click on a dynamic nav item.\n * Removes from the app's recent stack and refreshes nav items immediately.\n * Stops propagation so the nav click handler doesn't fire.\n */\n onDismiss(item: NavItem, event: MouseEvent): void {\n event.stopPropagation();\n\n // Remove from the app's recent stack directly so we can refresh immediately\n if (this._app) {\n const appWithRemove = this._app as BaseApplication & {\n RemoveDynamicNavItem?: (navItem: NavItem) => void;\n };\n if (typeof appWithRemove.RemoveDynamicNavItem === 'function') {\n appWithRemove.RemoveDynamicNavItem(item);\n this.updateCachedData();\n }\n }\n\n this.navItemDismiss.emit(item);\n }\n\n}\n","<nav class=\"nav-list\" [style.--app-color]=\"appColor\">\n @for (item of navItems; track trackByNavItem($index, item)) {\n <div\n class=\"nav-item\"\n [class.active]=\"isActive(item)\"\n [class.dynamic]=\"isDynamic(item)\"\n [class.no-icon]=\"!item.Icon\"\n (click)=\"onNavClick(item, $event)\">\n @if (item.Icon) {\n <i [class]=\"item.Icon\"></i>\n }\n <span>{{ item.Label }}</span>\n @if (item.Badge) {\n <span class=\"badge\">{{ item.Badge }}</span>\n }\n @if (isDynamic(item)) {\n <button class=\"dismiss-btn\" title=\"Remove\" (click)=\"onDismiss(item, $event)\">\n <i class=\"fa-solid fa-xmark\"></i>\n </button>\n }\n </div>\n }\n</nav>\n"]}
|
|
1
|
+
{"version":3,"file":"app-nav.component.js","sourceRoot":"","sources":["../../../../../src/lib/shell/components/header/app-nav.component.ts","../../../../../src/lib/shell/components/header/app-nav.component.html"],"names":[],"mappings":"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","sourcesContent":["import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';\nimport { BaseApplication, DynamicNavItem, NavItem, WorkspaceStateManager, WorkspaceConfiguration } from '@memberjunction/ng-base-application';\nimport { SharedService } from '@memberjunction/ng-shared';\nimport { Subject, takeUntil } from 'rxjs';\n\n/**\n * Event emitted when a nav item is clicked\n */\nexport interface NavItemClickEvent {\n item: NavItem;\n shiftKey: boolean;\n}\n\n/**\n * Horizontal navigation items for the current app.\n * Uses OnPush change detection and reactive state management for optimal performance.\n */\n@Component({\n standalone: false,\n selector: 'mj-app-nav',\n templateUrl: './app-nav.component.html',\n styleUrls: ['./app-nav.component.css'],\n changeDetection: ChangeDetectionStrategy.OnPush\n})\nexport class AppNavComponent implements OnInit, OnDestroy {\n private destroy$ = new Subject<void>();\n private _app: BaseApplication | null = null;\n private _cachedNavItems: NavItem[] = [];\n private _cachedAppColor: string = 'var(--mj-brand-primary)';\n private _servicesInjected = false;\n\n /**\n * Monotonically increasing counter used to detect and discard stale async results.\n *\n * Because GetNavItems() is async (HomeApplication does a DB lookup for record names),\n * and RxJS subscribe() does NOT serialize async callbacks, multiple calls to\n * updateCachedData() can overlap. Without this guard, a slow call (e.g., Home app\n * doing a DB lookup) that started BEFORE a fast call (e.g., switching to App B)\n * could resolve AFTER the fast call and overwrite the correct nav items with stale ones.\n *\n * How it works:\n * 1. Each updateCachedData() call increments this counter and captures it as `gen`\n * 2. After the await, it checks: does `gen` still match `_updateGeneration`?\n * 3. If not, a newer call started while we were waiting — discard our stale results\n */\n private _updateGeneration = 0;\n\n // Map of nav item key (Route or Label) to active state\n private activeStateMap = new Map<string, boolean>();\n\n @Output() navItemClick = new EventEmitter<NavItemClickEvent>();\n @Output() navItemDismiss = new EventEmitter<NavItem>();\n\n constructor(\n private workspaceManager: WorkspaceStateManager,\n private sharedService: SharedService,\n private cdr: ChangeDetectorRef\n ) {}\n\n /**\n * Input setter for app - triggers cache update when app changes\n */\n @Input()\n set app(value: BaseApplication | null) {\n if (this._app !== value) {\n this._app = value;\n this._cachedNavItems = []; // Clear stale items immediately so previous app's items don't flash\n this.activeStateMap.clear();\n this._servicesInjected = false; // Reset injection flag\n this.updateCachedData();\n this.cdr.markForCheck();\n }\n }\n\n get app(): BaseApplication | null {\n return this._app;\n }\n\n ngOnInit(): void {\n // Subscribe to workspace configuration changes.\n // Must rebuild nav items (not just active states) because dynamic nav items\n // are generated based on the currently active tab - when a user navigates\n // from one record to another (e.g., via OpenEntityRecord), the active tab\n // changes and the dynamic nav item needs to reflect the new record.\n this.workspaceManager.Configuration\n .pipe(takeUntil(this.destroy$))\n .subscribe(async () => {\n await this.updateCachedData();\n });\n }\n\n ngOnDestroy(): void {\n this.destroy$.next();\n this.destroy$.complete();\n }\n\n /**\n * Update cached nav items and app color when app changes\n */\n private async updateCachedData(): Promise<void> {\n // Capture the current generation before any async work.\n // See _updateGeneration JSDoc for full explanation of the race condition this prevents.\n const gen = ++this._updateGeneration;\n\n if (this._app) {\n // Inject services once for apps that need them (e.g., HomeApplication for dynamic nav items)\n if (!this._servicesInjected) {\n const appWithServices = this._app as BaseApplication & {\n SetWorkspaceManager?: (manager: WorkspaceStateManager) => void;\n SetSharedService?: (service: SharedService) => void;\n };\n\n if (typeof appWithServices.SetWorkspaceManager === 'function') {\n appWithServices.SetWorkspaceManager(this.workspaceManager);\n }\n if (typeof appWithServices.SetSharedService === 'function') {\n appWithServices.SetSharedService(this.sharedService);\n }\n this._servicesInjected = true;\n }\n\n const items = await this._app.GetNavItems() || [];\n\n // If a newer call started while we were awaiting, our results are stale — bail out\n // so we don't overwrite the newer call's (correct) results.\n if (gen !== this._updateGeneration) {\n return;\n }\n\n // Only show items with Status 'Active' or undefined (default to Active)\n this._cachedNavItems = items.filter(item => !item.Status || item.Status === 'Active');\n\n this._cachedAppColor = this._app.GetColor() || 'var(--mj-brand-primary)';\n } else {\n this._cachedNavItems = [];\n this._cachedAppColor = 'var(--mj-brand-primary)';\n }\n\n // Update active states after nav items change\n const config = this.workspaceManager.GetConfiguration();\n this.updateActiveStates(config);\n this.cdr.markForCheck();\n\n // In Angular 21 zoneless mode, markForCheck() alone is unreliable when the trigger\n // is an RxJS subscription (workspaceManager.Configuration here) not tracked by the\n // zoneless scheduler — the dirty flag is set but no follow-up tick is scheduled.\n // detectChanges() runs CD synchronously on this view, rendering the new data\n // immediately. Wrapped because detectChanges throws if invoked re-entrantly during\n // another in-flight CD pass — harmless if so.\n try {\n this.cdr.detectChanges();\n } catch {\n // Re-entrant CD — harmless, the in-flight pass picks up our markForCheck.\n }\n }\n\n /**\n * Update active state map based on current workspace configuration\n */\n private updateActiveStates(config: WorkspaceConfiguration | null): void {\n this.activeStateMap.clear();\n\n if (!config || !this._app) {\n return;\n }\n\n const activeTab = config.tabs.find(t => t.id === config.activeTabId);\n if (!activeTab || activeTab.applicationId !== this._app.ID) {\n return;\n }\n\n // Compute active state for each nav item once\n for (const item of this._cachedNavItems) {\n const key = this.getItemKey(item);\n const isActive = this.computeIsActive(item, activeTab);\n this.activeStateMap.set(key, isActive);\n }\n }\n\n /**\n * Get unique key for nav item (used for tracking and active state).\n * Prefers RecordID for dynamic items to avoid label collisions.\n */\n private getItemKey(item: NavItem): string {\n return item.RecordID || item.Route || item.Label || '';\n }\n\n /**\n * Check if a nav item is dynamic (generated from recent orphan resources)\n */\n isDynamic(item: NavItem): boolean {\n return (item as DynamicNavItem).isDynamic === true;\n }\n\n /**\n * Compute if nav item is active based on active tab\n */\n private computeIsActive(item: NavItem, activeTab: any): boolean {\n // Check if nav item has a custom matching function (for dynamic items)\n const dynamicItem = item as NavItem & { isActiveMatch?: (tab: unknown) => boolean };\n if (dynamicItem.isActiveMatch && typeof dynamicItem.isActiveMatch === 'function') {\n return dynamicItem.isActiveMatch(activeTab);\n }\n\n const config = activeTab.configuration || {};\n\n // Match by DriverClass (most reliable for Custom resource types — always set correctly)\n if (item.DriverClass && (config['driverClass'] === item.DriverClass || config['resourceTypeDriverClass'] === item.DriverClass)) {\n return true;\n }\n\n // Match by navItemName from config (reliable — set when nav item opens)\n if (config['navItemName'] && config['navItemName'] === item.Label) {\n return true;\n }\n\n // Match by route (for route-based nav items)\n if (item.Route && config['route'] === item.Route) {\n return true;\n }\n\n // NOTE: We intentionally do NOT match by activeTab.title here.\n // Tab titles can be stale (updated asynchronously by DisplayNameChangedEvent\n // from cached components) and cause double-matches where two nav items\n // both appear active. DriverClass and navItemName are sufficient.\n return false;\n }\n\n /**\n * Get cached navigation items (no computation in getter)\n */\n get navItems(): NavItem[] {\n return this._cachedNavItems;\n }\n\n /**\n * Get cached app color (no computation in getter)\n */\n get appColor(): string {\n return this._cachedAppColor;\n }\n\n /**\n * Check if nav item is active (uses cached state from Map)\n */\n isActive(item: NavItem): boolean {\n const key = this.getItemKey(item);\n return this.activeStateMap.get(key) || false;\n }\n\n /**\n * Track function for @for to optimize rendering\n */\n trackByNavItem(_index: number, item: NavItem): string {\n return this.getItemKey(item);\n }\n\n /**\n * Handle nav item click\n */\n onNavClick(item: NavItem, event?: MouseEvent): void {\n this.navItemClick.emit({\n item,\n shiftKey: event?.shiftKey || false\n });\n }\n\n /**\n * Handle dismiss click on a dynamic nav item.\n * Removes from the app's recent stack and refreshes nav items immediately.\n * Stops propagation so the nav click handler doesn't fire.\n */\n onDismiss(item: NavItem, event: MouseEvent): void {\n event.stopPropagation();\n\n // Remove from the app's recent stack directly so we can refresh immediately\n if (this._app) {\n const appWithRemove = this._app as BaseApplication & {\n RemoveDynamicNavItem?: (navItem: NavItem) => void;\n };\n if (typeof appWithRemove.RemoveDynamicNavItem === 'function') {\n appWithRemove.RemoveDynamicNavItem(item);\n this.updateCachedData();\n }\n }\n\n this.navItemDismiss.emit(item);\n }\n\n}\n","<nav class=\"nav-list\" [style.--app-color]=\"appColor\">\n @for (item of navItems; track trackByNavItem($index, item)) {\n <div\n class=\"nav-item\"\n [class.active]=\"isActive(item)\"\n [class.dynamic]=\"isDynamic(item)\"\n [class.no-icon]=\"!item.Icon\"\n (click)=\"onNavClick(item, $event)\">\n @if (item.Icon) {\n <i [class]=\"item.Icon\"></i>\n }\n <span>{{ item.Label }}</span>\n @if (item.Badge) {\n <span class=\"badge\">{{ item.Badge }}</span>\n }\n @if (isDynamic(item)) {\n <button class=\"dismiss-btn\" title=\"Remove\" (click)=\"onDismiss(item, $event)\">\n <i class=\"fa-solid fa-xmark\"></i>\n </button>\n }\n </div>\n }\n</nav>\n"]}
|
|
@@ -10,6 +10,7 @@ export interface CachedComponentInfo {
|
|
|
10
10
|
resourceType: string;
|
|
11
11
|
resourceRecordId: string;
|
|
12
12
|
applicationId: string;
|
|
13
|
+
keyDiscriminator?: string;
|
|
13
14
|
isAttached: boolean;
|
|
14
15
|
attachedToTabId: string | null;
|
|
15
16
|
lastUsed: Date;
|
|
@@ -54,17 +55,23 @@ export declare class ComponentCacheManager {
|
|
|
54
55
|
/**
|
|
55
56
|
* Generate a unique cache key from resource identity.
|
|
56
57
|
* This is the ONE canonical key format used by ALL cache operations.
|
|
58
|
+
*
|
|
59
|
+
* `discriminator` is appended into the recordId slot only when recordId is empty.
|
|
60
|
+
* It exists to prevent collisions between distinct "new record" tabs that share
|
|
61
|
+
* an empty recordId — e.g., a new MJ:Companies form and a new MJ:Employees form
|
|
62
|
+
* would otherwise both cache at `appId::RecordResource::__no_record__` and clobber
|
|
63
|
+
* each other. Passing the entity name as discriminator keeps them separate.
|
|
57
64
|
*/
|
|
58
65
|
private getCacheKey;
|
|
59
66
|
/**
|
|
60
67
|
* Check if a component exists in cache and is available for reuse.
|
|
61
68
|
*/
|
|
62
|
-
hasAvailableComponent(resourceType: string, recordId: string, appId: string): boolean;
|
|
69
|
+
hasAvailableComponent(resourceType: string, recordId: string, appId: string, discriminator?: string): boolean;
|
|
63
70
|
/**
|
|
64
71
|
* Get a cached component if available (not currently attached).
|
|
65
72
|
* Lookup is by resource identity, not tab ID.
|
|
66
73
|
*/
|
|
67
|
-
getCachedComponent(resourceType: string, recordId: string, appId: string): CachedComponentInfo | null;
|
|
74
|
+
getCachedComponent(resourceType: string, recordId: string, appId: string, discriminator?: string): CachedComponentInfo | null;
|
|
68
75
|
/**
|
|
69
76
|
* Store a component in the cache and mark as attached.
|
|
70
77
|
*/
|
|
@@ -72,14 +79,26 @@ export declare class ComponentCacheManager {
|
|
|
72
79
|
/**
|
|
73
80
|
* Mark a component as attached. Lookup by resource identity.
|
|
74
81
|
*/
|
|
75
|
-
markAsAttached(resourceType: string, recordId: string, appId: string, tabId: string): void;
|
|
82
|
+
markAsAttached(resourceType: string, recordId: string, appId: string, tabId: string, discriminator?: string): void;
|
|
76
83
|
/**
|
|
77
84
|
* Mark a component as detached (available for reuse). Lookup by resource identity.
|
|
78
85
|
*
|
|
79
86
|
* This is the ONLY way to detach a component. Both single-resource mode and
|
|
80
87
|
* Golden Layout mode use this same method to ensure consistent cache behavior.
|
|
81
88
|
*/
|
|
82
|
-
markAsDetached(resourceType: string, recordId: string, appId: string): CachedComponentInfo | null;
|
|
89
|
+
markAsDetached(resourceType: string, recordId: string, appId: string, discriminator?: string): CachedComponentInfo | null;
|
|
90
|
+
/**
|
|
91
|
+
* Re-key a cached component when its underlying record identity changes.
|
|
92
|
+
*
|
|
93
|
+
* Used when a "new record" component (cached at `recordId = ''`) becomes a saved record
|
|
94
|
+
* (now identified by its new PK). Without re-keying, the next "new record" request would
|
|
95
|
+
* still resolve to this same cache entry, surfacing the previously-saved record on a form
|
|
96
|
+
* the user expects to be blank.
|
|
97
|
+
*
|
|
98
|
+
* Preserves the live component instance — only the cache key + stored identity change.
|
|
99
|
+
* Returns true if a matching entry was found and re-keyed, false otherwise.
|
|
100
|
+
*/
|
|
101
|
+
rekeyComponent(resourceType: string, oldRecordId: string, newRecordId: string, appId: string, oldDiscriminator?: string, newDiscriminator?: string): boolean;
|
|
83
102
|
/**
|
|
84
103
|
* Find a cached component by tab ID and detach it.
|
|
85
104
|
* This is a convenience wrapper for callers that only know the tab ID
|
|
@@ -100,7 +119,7 @@ export declare class ComponentCacheManager {
|
|
|
100
119
|
/**
|
|
101
120
|
* Remove and destroy a specific component from cache by resource identity.
|
|
102
121
|
*/
|
|
103
|
-
destroyComponent(resourceType: string, recordId: string, appId: string): void;
|
|
122
|
+
destroyComponent(resourceType: string, recordId: string, appId: string, discriminator?: string): void;
|
|
104
123
|
/**
|
|
105
124
|
* Remove and destroy component by tab ID (convenience for Golden Layout tab close).
|
|
106
125
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"component-cache-manager.d.ts","sourceRoot":"","sources":["../../../../../src/lib/shell/components/tabs/component-cache-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAE7D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAElC,YAAY,EAAE,YAAY,CAAC,qBAAqB,CAAC,CAAC;IAGlD,cAAc,EAAE,WAAW,CAAC;IAG5B,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"component-cache-manager.d.ts","sourceRoot":"","sources":["../../../../../src/lib/shell/components/tabs/component-cache-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -30,16 +30,23 @@ export class ComponentCacheManager {
|
|
|
30
30
|
/**
|
|
31
31
|
* Generate a unique cache key from resource identity.
|
|
32
32
|
* This is the ONE canonical key format used by ALL cache operations.
|
|
33
|
+
*
|
|
34
|
+
* `discriminator` is appended into the recordId slot only when recordId is empty.
|
|
35
|
+
* It exists to prevent collisions between distinct "new record" tabs that share
|
|
36
|
+
* an empty recordId — e.g., a new MJ:Companies form and a new MJ:Employees form
|
|
37
|
+
* would otherwise both cache at `appId::RecordResource::__no_record__` and clobber
|
|
38
|
+
* each other. Passing the entity name as discriminator keeps them separate.
|
|
33
39
|
*/
|
|
34
|
-
getCacheKey(resourceType, recordId, appId) {
|
|
35
|
-
const normalizedRecordId = recordId
|
|
40
|
+
getCacheKey(resourceType, recordId, appId, discriminator) {
|
|
41
|
+
const normalizedRecordId = recordId
|
|
42
|
+
|| (discriminator ? `__new__::${discriminator}` : '__no_record__');
|
|
36
43
|
return `${appId}::${resourceType}::${normalizedRecordId}`;
|
|
37
44
|
}
|
|
38
45
|
/**
|
|
39
46
|
* Check if a component exists in cache and is available for reuse.
|
|
40
47
|
*/
|
|
41
|
-
hasAvailableComponent(resourceType, recordId, appId) {
|
|
42
|
-
const key = this.getCacheKey(resourceType, recordId, appId);
|
|
48
|
+
hasAvailableComponent(resourceType, recordId, appId, discriminator) {
|
|
49
|
+
const key = this.getCacheKey(resourceType, recordId, appId, discriminator);
|
|
43
50
|
const info = this.cache.get(key);
|
|
44
51
|
return info !== undefined && !info.isAttached;
|
|
45
52
|
}
|
|
@@ -47,8 +54,8 @@ export class ComponentCacheManager {
|
|
|
47
54
|
* Get a cached component if available (not currently attached).
|
|
48
55
|
* Lookup is by resource identity, not tab ID.
|
|
49
56
|
*/
|
|
50
|
-
getCachedComponent(resourceType, recordId, appId) {
|
|
51
|
-
const key = this.getCacheKey(resourceType, recordId, appId);
|
|
57
|
+
getCachedComponent(resourceType, recordId, appId, discriminator) {
|
|
58
|
+
const key = this.getCacheKey(resourceType, recordId, appId, discriminator);
|
|
52
59
|
const info = this.cache.get(key);
|
|
53
60
|
if (!info) {
|
|
54
61
|
return null;
|
|
@@ -69,13 +76,17 @@ export class ComponentCacheManager {
|
|
|
69
76
|
const resolvedResourceType = resourceData.Configuration?.resourceTypeDriverClass
|
|
70
77
|
|| resourceData.Configuration?.driverClass
|
|
71
78
|
|| resourceData.ResourceType;
|
|
72
|
-
|
|
79
|
+
// Entity name discriminates between distinct "new record" tabs (empty recordId).
|
|
80
|
+
// For non-Records resource types this is undefined, leaving the key unchanged.
|
|
81
|
+
const discriminator = resourceData.Configuration?.Entity;
|
|
82
|
+
const key = this.getCacheKey(resolvedResourceType, resourceData.ResourceRecordID || '', resourceData.Configuration?.applicationId || '', discriminator);
|
|
73
83
|
const info = {
|
|
74
84
|
componentRef,
|
|
75
85
|
wrapperElement,
|
|
76
86
|
resourceType: resolvedResourceType,
|
|
77
87
|
resourceRecordId: resourceData.ResourceRecordID || '',
|
|
78
88
|
applicationId: resourceData.Configuration?.applicationId || '',
|
|
89
|
+
keyDiscriminator: discriminator,
|
|
79
90
|
isAttached: true,
|
|
80
91
|
attachedToTabId: tabId,
|
|
81
92
|
lastUsed: new Date(),
|
|
@@ -87,8 +98,8 @@ export class ComponentCacheManager {
|
|
|
87
98
|
/**
|
|
88
99
|
* Mark a component as attached. Lookup by resource identity.
|
|
89
100
|
*/
|
|
90
|
-
markAsAttached(resourceType, recordId, appId, tabId) {
|
|
91
|
-
const key = this.getCacheKey(resourceType, recordId, appId);
|
|
101
|
+
markAsAttached(resourceType, recordId, appId, tabId, discriminator) {
|
|
102
|
+
const key = this.getCacheKey(resourceType, recordId, appId, discriminator);
|
|
92
103
|
const info = this.cache.get(key);
|
|
93
104
|
if (info) {
|
|
94
105
|
info.isAttached = true;
|
|
@@ -102,8 +113,8 @@ export class ComponentCacheManager {
|
|
|
102
113
|
* This is the ONLY way to detach a component. Both single-resource mode and
|
|
103
114
|
* Golden Layout mode use this same method to ensure consistent cache behavior.
|
|
104
115
|
*/
|
|
105
|
-
markAsDetached(resourceType, recordId, appId) {
|
|
106
|
-
const key = this.getCacheKey(resourceType, recordId, appId);
|
|
116
|
+
markAsDetached(resourceType, recordId, appId, discriminator) {
|
|
117
|
+
const key = this.getCacheKey(resourceType, recordId, appId, discriminator);
|
|
107
118
|
const info = this.cache.get(key);
|
|
108
119
|
if (!info)
|
|
109
120
|
return null;
|
|
@@ -113,6 +124,39 @@ export class ComponentCacheManager {
|
|
|
113
124
|
this.EvictIfNeeded();
|
|
114
125
|
return info;
|
|
115
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Re-key a cached component when its underlying record identity changes.
|
|
129
|
+
*
|
|
130
|
+
* Used when a "new record" component (cached at `recordId = ''`) becomes a saved record
|
|
131
|
+
* (now identified by its new PK). Without re-keying, the next "new record" request would
|
|
132
|
+
* still resolve to this same cache entry, surfacing the previously-saved record on a form
|
|
133
|
+
* the user expects to be blank.
|
|
134
|
+
*
|
|
135
|
+
* Preserves the live component instance — only the cache key + stored identity change.
|
|
136
|
+
* Returns true if a matching entry was found and re-keyed, false otherwise.
|
|
137
|
+
*/
|
|
138
|
+
rekeyComponent(resourceType, oldRecordId, newRecordId, appId, oldDiscriminator, newDiscriminator) {
|
|
139
|
+
const oldKey = this.getCacheKey(resourceType, oldRecordId, appId, oldDiscriminator);
|
|
140
|
+
const info = this.cache.get(oldKey);
|
|
141
|
+
if (!info) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
// Once a record has a real id, the discriminator no longer matters (the id is unique).
|
|
145
|
+
// Default the new discriminator to undefined unless explicitly provided.
|
|
146
|
+
const newKey = this.getCacheKey(resourceType, newRecordId, appId, newDiscriminator);
|
|
147
|
+
if (oldKey === newKey) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
info.resourceRecordId = newRecordId;
|
|
151
|
+
info.keyDiscriminator = newDiscriminator;
|
|
152
|
+
info.lastUsed = new Date();
|
|
153
|
+
if (info.resourceData) {
|
|
154
|
+
info.resourceData.ResourceRecordID = newRecordId;
|
|
155
|
+
}
|
|
156
|
+
this.cache.delete(oldKey);
|
|
157
|
+
this.cache.set(newKey, info);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
116
160
|
/**
|
|
117
161
|
* Find a cached component by tab ID and detach it.
|
|
118
162
|
* This is a convenience wrapper for callers that only know the tab ID
|
|
@@ -125,7 +169,7 @@ export class ComponentCacheManager {
|
|
|
125
169
|
if (!entry)
|
|
126
170
|
return null;
|
|
127
171
|
const [_, info] = entry;
|
|
128
|
-
return this.markAsDetached(info.resourceType, info.resourceRecordId, info.applicationId);
|
|
172
|
+
return this.markAsDetached(info.resourceType, info.resourceRecordId, info.applicationId, info.keyDiscriminator);
|
|
129
173
|
}
|
|
130
174
|
/**
|
|
131
175
|
* Evict least-recently-used detached components when over the limit.
|
|
@@ -156,8 +200,8 @@ export class ComponentCacheManager {
|
|
|
156
200
|
/**
|
|
157
201
|
* Remove and destroy a specific component from cache by resource identity.
|
|
158
202
|
*/
|
|
159
|
-
destroyComponent(resourceType, recordId, appId) {
|
|
160
|
-
const key = this.getCacheKey(resourceType, recordId, appId);
|
|
203
|
+
destroyComponent(resourceType, recordId, appId, discriminator) {
|
|
204
|
+
const key = this.getCacheKey(resourceType, recordId, appId, discriminator);
|
|
161
205
|
const info = this.cache.get(key);
|
|
162
206
|
if (!info)
|
|
163
207
|
return;
|