@memberjunction/ng-explorer-core 5.38.0 → 5.40.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/app-routing.module.d.ts.map +1 -1
- package/dist/app-routing.module.js +13 -13
- package/dist/app-routing.module.js.map +1 -1
- 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 +3 -2
- package/dist/generated/lazy-feature-config.js.map +1 -1
- package/dist/lib/guards/app-lock-guard.service.d.ts +26 -0
- package/dist/lib/guards/app-lock-guard.service.d.ts.map +1 -0
- package/dist/lib/guards/app-lock-guard.service.js +55 -0
- package/dist/lib/guards/app-lock-guard.service.js.map +1 -0
- package/dist/lib/resource-wrappers/chat-conversations-resource.component.d.ts.map +1 -1
- package/dist/lib/resource-wrappers/chat-conversations-resource.component.js +40 -27
- package/dist/lib/resource-wrappers/chat-conversations-resource.component.js.map +1 -1
- package/dist/lib/resource-wrappers/view-resource.component.d.ts +6 -5
- package/dist/lib/resource-wrappers/view-resource.component.d.ts.map +1 -1
- package/dist/lib/resource-wrappers/view-resource.component.js +19 -24
- package/dist/lib/resource-wrappers/view-resource.component.js.map +1 -1
- package/dist/lib/shell/components/tabs/tab-container.component.d.ts.map +1 -1
- package/dist/lib/shell/components/tabs/tab-container.component.js +9 -0
- package/dist/lib/shell/components/tabs/tab-container.component.js.map +1 -1
- package/dist/lib/shell/shell.component.d.ts +24 -6
- package/dist/lib/shell/shell.component.d.ts.map +1 -1
- package/dist/lib/shell/shell.component.js +360 -191
- package/dist/lib/shell/shell.component.js.map +1 -1
- package/dist/lib/single-record/single-record.component.d.ts +31 -75
- package/dist/lib/single-record/single-record.component.d.ts.map +1 -1
- package/dist/lib/single-record/single-record.component.js +60 -471
- package/dist/lib/single-record/single-record.component.js.map +1 -1
- package/dist/lib/single-search-result/single-search-result.component.d.ts +3 -8
- package/dist/lib/single-search-result/single-search-result.component.d.ts.map +1 -1
- package/dist/lib/single-search-result/single-search-result.component.js +19 -68
- package/dist/lib/single-search-result/single-search-result.component.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 -46
- package/dist/lib/__tests__/form-resolver.service.test.d.ts +0 -2
- package/dist/lib/__tests__/form-resolver.service.test.d.ts.map +0 -1
- package/dist/lib/__tests__/form-resolver.service.test.js +0 -258
- package/dist/lib/__tests__/form-resolver.service.test.js.map +0 -1
- package/dist/lib/services/form-resolver.service.d.ts +0 -139
- package/dist/lib/services/form-resolver.service.d.ts.map +0 -1
- package/dist/lib/services/form-resolver.service.js +0 -235
- package/dist/lib/services/form-resolver.service.js.map +0 -1
|
@@ -1,442 +1,61 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { CompositeKey
|
|
3
|
-
import {
|
|
4
|
-
import { InteractiveFormComponent } from '@memberjunction/ng-base-forms';
|
|
1
|
+
import { Component, EventEmitter, inject, Input, Output } from '@angular/core';
|
|
2
|
+
import { CompositeKey } from '@memberjunction/core';
|
|
3
|
+
import { MJFormPresenterService } from '@memberjunction/ng-base-forms';
|
|
5
4
|
import { NavigationService, RecentAccessService, SharedService } from '@memberjunction/ng-shared';
|
|
6
|
-
import { FormResolverService } from '../services/form-resolver.service';
|
|
7
5
|
import { BaseAngularComponent } from '@memberjunction/ng-base-types';
|
|
8
6
|
import * as i0 from "@angular/core";
|
|
9
|
-
import * as i1 from "@
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
i0.ɵɵtextInterpolate(ctx_r0.errorDetail);
|
|
25
|
-
} }
|
|
26
|
-
function SingleRecordComponent_Conditional_1_Template(rf, ctx) { if (rf & 1) {
|
|
27
|
-
i0.ɵɵelementStart(0, "div", 1)(1, "div", 3);
|
|
28
|
-
i0.ɵɵelement(2, "i", 4);
|
|
29
|
-
i0.ɵɵelementEnd();
|
|
30
|
-
i0.ɵɵelementStart(3, "h2", 5);
|
|
31
|
-
i0.ɵɵtext(4);
|
|
32
|
-
i0.ɵɵelementEnd();
|
|
33
|
-
i0.ɵɵconditionalCreate(5, SingleRecordComponent_Conditional_1_Conditional_5_Template, 2, 1, "p", 6);
|
|
34
|
-
i0.ɵɵelementStart(6, "p", 7);
|
|
35
|
-
i0.ɵɵtext(7, "See browser console for technical details.");
|
|
36
|
-
i0.ɵɵelementEnd()();
|
|
37
|
-
} if (rf & 2) {
|
|
38
|
-
const ctx_r0 = i0.ɵɵnextContext();
|
|
39
|
-
i0.ɵɵadvance(4);
|
|
40
|
-
i0.ɵɵtextInterpolate(ctx_r0.errorTitle);
|
|
41
|
-
i0.ɵɵadvance();
|
|
42
|
-
i0.ɵɵconditional(ctx_r0.errorDetail ? 5 : -1);
|
|
43
|
-
} }
|
|
44
|
-
function SingleRecordComponent_ng_template_2_Template(rf, ctx) { }
|
|
7
|
+
import * as i1 from "@memberjunction/ng-base-forms";
|
|
8
|
+
/**
|
|
9
|
+
* Explorer-side host for a single entity record in the main tab area.
|
|
10
|
+
*
|
|
11
|
+
* This is now a **thin wrapper** around the Generic `<mj-entity-form-host>`
|
|
12
|
+
* (in `@memberjunction/ng-base-forms`), which owns all the mechanics: resolving
|
|
13
|
+
* the form (class / custom / interactive override + variants), loading the
|
|
14
|
+
* record, dynamically creating the form, binding it, and tearing it down.
|
|
15
|
+
*
|
|
16
|
+
* SingleRecordComponent's only remaining job is the **Explorer mapping**:
|
|
17
|
+
* translating the host's framework-agnostic events into Explorer services —
|
|
18
|
+
* `Navigate` → {@link NavigationService}, `Notification` → {@link SharedService},
|
|
19
|
+
* record loads → {@link RecentAccessService} — none of which belong in a Generic
|
|
20
|
+
* component.
|
|
21
|
+
*/
|
|
45
22
|
export class SingleRecordComponent extends BaseAngularComponent {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
_primaryKey = new CompositeKey();
|
|
49
|
-
set PrimaryKey(value) {
|
|
50
|
-
const changed = !this.compositeKeysEqual(this._primaryKey, value);
|
|
51
|
-
this._primaryKey = value ?? new CompositeKey();
|
|
52
|
-
if (!changed || !this._viewInitialized) {
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
// Skip reload when the incoming key just reflects the current record's now-saved PK.
|
|
56
|
-
// After a new-record save, BaseResourceComponent updates parent.Data.ResourceRecordID,
|
|
57
|
-
// the parent's PrimaryKey getter then returns a fresh CK with the saved ID, and Angular
|
|
58
|
-
// pushes it down to us. The form already represents that record in-place — destroying
|
|
59
|
-
// and rebuilding it here would lose edit state, refetch from DB, and spawn a redundant
|
|
60
|
-
// GetRecordFavoriteStatus call. Genuine external swaps (parent rebinds to a different
|
|
61
|
-
// record) still trigger the reload because `value` won't match `_currentRecord.PrimaryKey`.
|
|
62
|
-
if (this._currentRecord && this.compositeKeysEqual(value, this._currentRecord.PrimaryKey)) {
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
this.reloadCurrentForm();
|
|
66
|
-
}
|
|
67
|
-
get PrimaryKey() {
|
|
68
|
-
return this._primaryKey;
|
|
69
|
-
}
|
|
70
|
-
_entityName = '';
|
|
71
|
-
set entityName(value) {
|
|
72
|
-
const changed = this._entityName !== value;
|
|
73
|
-
this._entityName = value;
|
|
74
|
-
if (changed && this._viewInitialized) {
|
|
75
|
-
this.reloadCurrentForm();
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
get entityName() {
|
|
79
|
-
return this._entityName;
|
|
80
|
-
}
|
|
23
|
+
PrimaryKey = new CompositeKey();
|
|
24
|
+
entityName = '';
|
|
81
25
|
newRecordValues = '';
|
|
82
26
|
loadComplete = new EventEmitter();
|
|
83
27
|
recordSaved = new EventEmitter();
|
|
84
|
-
/**
|
|
85
|
-
* Emitted when the hosted form asks to be dismissed (e.g., user clicked Discard on
|
|
86
|
-
* a brand-new record). Parent components should close the tab / route the user back.
|
|
87
|
-
*/
|
|
28
|
+
/** Emitted when the hosted form asks to be dismissed (e.g. Discard on a new record). */
|
|
88
29
|
recordDismissed = new EventEmitter();
|
|
89
|
-
recentAccessService;
|
|
90
30
|
navigationService = inject(NavigationService);
|
|
91
31
|
sharedService = inject(SharedService);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
this.route = route;
|
|
97
|
-
this.recentAccessService = new RecentAccessService();
|
|
98
|
-
}
|
|
99
|
-
appDescription = '';
|
|
100
|
-
useGenericForm = false;
|
|
101
|
-
loading = true;
|
|
102
|
-
errorTitle = null;
|
|
103
|
-
errorDetail = null;
|
|
104
|
-
// Track dynamically created components and entities for cleanup
|
|
105
|
-
_formComponentRef = null;
|
|
106
|
-
_currentRecord = null;
|
|
107
|
-
_eventHandlerSubscription = null;
|
|
108
|
-
_formEventSubscriptions = [];
|
|
109
|
-
_viewInitialized = false;
|
|
110
|
-
ngOnInit() {
|
|
111
|
-
}
|
|
112
|
-
ngAfterViewInit() {
|
|
113
|
-
this._viewInitialized = true;
|
|
114
|
-
this.LoadForm(this._primaryKey, this._entityName);
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Re-run LoadForm when an @Input changes after initial view init.
|
|
118
|
-
*
|
|
119
|
-
* Defense-in-depth: the tab/cache rekey on save (in TabContainerComponent) is the
|
|
120
|
-
* primary fix that prevents stale-form bugs after creating a new record. This setter
|
|
121
|
-
* path ensures we self-heal if a host ever swaps the bound inputs without recreating
|
|
122
|
-
* the component — without it, LoadForm only ever runs once per component instance.
|
|
123
|
-
*
|
|
124
|
-
* Tears down the previous form (component, record, event handlers) before loading the
|
|
125
|
-
* new one so we don't leak references or stack multiple forms in the container.
|
|
126
|
-
*/
|
|
127
|
-
reloadCurrentForm() {
|
|
128
|
-
this.teardownActiveForm();
|
|
129
|
-
if (!this._entityName) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
this.loading = true;
|
|
133
|
-
this.LoadForm(this._primaryKey, this._entityName);
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Compare two CompositeKey instances by their key/value contents.
|
|
137
|
-
* Two different instances representing the same key should NOT count as a change.
|
|
138
|
-
*
|
|
139
|
-
* Filters out KVPs with empty Values before comparing. Different callers represent
|
|
140
|
-
* "no key" differently — some use an empty KeyValuePairs list, others use a single
|
|
141
|
-
* KVP with an empty Value (e.g. `LoadFromURLSegment(entity, '')`). Both mean the
|
|
142
|
-
* same thing semantically and must not trigger a change.
|
|
143
|
-
*/
|
|
144
|
-
compositeKeysEqual(a, b) {
|
|
145
|
-
if (a === b)
|
|
146
|
-
return true;
|
|
147
|
-
if (!a || !b)
|
|
148
|
-
return false;
|
|
149
|
-
const aKvps = (a.KeyValuePairs || []).filter(kvp => String(kvp.Value ?? '').length > 0);
|
|
150
|
-
const bKvps = (b.KeyValuePairs || []).filter(kvp => String(kvp.Value ?? '').length > 0);
|
|
151
|
-
if (aKvps.length !== bKvps.length)
|
|
152
|
-
return false;
|
|
153
|
-
for (let i = 0; i < aKvps.length; i++) {
|
|
154
|
-
if (aKvps[i].FieldName !== bKvps[i].FieldName)
|
|
155
|
-
return false;
|
|
156
|
-
if (String(aKvps[i].Value ?? '') !== String(bKvps[i].Value ?? ''))
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Tear down the currently rendered form so a new one can be loaded in its place.
|
|
163
|
-
* Mirrors the cleanup done in ngOnDestroy, minus the final state reset.
|
|
164
|
-
*/
|
|
165
|
-
teardownActiveForm() {
|
|
166
|
-
this.cleanupFormSubscriptions();
|
|
167
|
-
if (this._eventHandlerSubscription) {
|
|
168
|
-
this._eventHandlerSubscription.unsubscribe();
|
|
169
|
-
this._eventHandlerSubscription = null;
|
|
170
|
-
}
|
|
171
|
-
if (this._formComponentRef) {
|
|
172
|
-
try {
|
|
173
|
-
this._formComponentRef.destroy();
|
|
174
|
-
}
|
|
175
|
-
catch { /* noop */ }
|
|
176
|
-
this._formComponentRef = null;
|
|
177
|
-
}
|
|
178
|
-
this._currentRecord = null;
|
|
179
|
-
if (this.formContainer?.viewContainerRef) {
|
|
180
|
-
this.formContainer.viewContainerRef.clear();
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
async LoadForm(primaryKey, entityName) {
|
|
184
|
-
// Perform any necessary actions with the ViewID, such as fetching data
|
|
185
|
-
if (!entityName || entityName.trim().length === 0) {
|
|
186
|
-
// No entity yet — caller will re-invoke once it has one. Don't emit loadComplete;
|
|
187
|
-
// it would race the real load. The shell's recovery timer will surface the
|
|
188
|
-
// "taking longer than expected" reset if this is the terminal state.
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
// Write through to backing fields directly. Going through the setters here would
|
|
192
|
-
// re-trigger reloadCurrentForm() and recurse — we're already inside LoadForm.
|
|
193
|
-
//
|
|
194
|
-
// Use the parameter as-is rather than substituting `new CompositeKey()` for the
|
|
195
|
-
// !HasValue branch. The parent's getter (e.g. EntityRecordResource.GetPrimaryKey)
|
|
196
|
-
// builds its CK via `LoadFromURLSegment(entity, '')`, which yields `[{FieldName: 'ID',
|
|
197
|
-
// Value: ''}]` for a new record — a single KVP with an empty value, NOT an empty
|
|
198
|
-
// KVP list. Substituting an empty CK here creates a structural mismatch that makes
|
|
199
|
-
// the setter's compositeKeysEqual see a phantom change on the very next CD cycle,
|
|
200
|
-
// which triggers reload → LoadForm → CD → setter → reload (infinite loop).
|
|
201
|
-
this._entityName = entityName;
|
|
202
|
-
this._primaryKey = primaryKey ?? new CompositeKey();
|
|
203
|
-
const md = this.ProviderToUse;
|
|
204
|
-
const entity = md.EntityByName(entityName);
|
|
205
|
-
const permissions = entity?.GetUserPermisions(md.CurrentUser);
|
|
206
|
-
try {
|
|
207
|
-
if (!entity) {
|
|
208
|
-
this.failWithUserError(`Entity not found: "${entityName}"`, `This MemberJunction instance has no metadata for entity "${entityName}". Check that the entity name in the URL matches a row in __mj.Entity, and that the metadata cache is current.`);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
// Resolve which form to render: User/Role/Global EntityFormOverride first,
|
|
212
|
-
// then ClassFactory-registered Angular form, then nothing.
|
|
213
|
-
const resolution = await this.formResolver.ResolveFormForEntity(entity, md.CurrentUser, md);
|
|
214
|
-
if (resolution.kind === 'none') {
|
|
215
|
-
this.failWithUserError(`No form is registered for "${entityName}".`, `MemberJunction could not find an EntityFormOverride or a class-based form for entity "${entityName}". This usually means CodeGen has not generated a form for this entity in the running build (forms live under packages/MJExplorer or your app's entity-form package). Run CodeGen, ensure the generated module is imported, register a custom form via @RegisterClass(BaseFormComponent, '${entityName}'), or create an EntityFormOverride row pointing at a runtime Component.`, { entityId: entity.ID, recordKey: primaryKey?.ToString?.() ?? '(none)' });
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const record = await md.GetEntityObject(entityName);
|
|
219
|
-
if (!record) {
|
|
220
|
-
throw new Error(`Unable to instantiate entity ${entityName} with primary key values: ${primaryKey.ToString()}`);
|
|
221
|
-
}
|
|
222
|
-
if (primaryKey.HasValue) {
|
|
223
|
-
const loadOk = await record.InnerLoad(primaryKey);
|
|
224
|
-
if (!loadOk) {
|
|
225
|
-
this.failWithUserError(`Could not load ${entityName} record.`, record.LatestResult?.Message
|
|
226
|
-
? `Server error: ${record.LatestResult.Message}`
|
|
227
|
-
: `InnerLoad returned false for entity "${entityName}" with key ${primaryKey.ToString()}. The record may not exist, you may lack permission to view it, or the load may have been blocked server-side.`, { recordKey: primaryKey.ToString() });
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
// Log access to existing record (fire-and-forget, don't await)
|
|
231
|
-
this.recentAccessService.logAccess(entityName, primaryKey, 'record');
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
record.NewRecord();
|
|
235
|
-
this.SetNewRecordValues(record);
|
|
236
|
-
}
|
|
237
|
-
// CRITICAL: Track the event handler subscription for cleanup
|
|
238
|
-
this._eventHandlerSubscription = record.RegisterEventHandler((eventType) => {
|
|
239
|
-
if (eventType.type === 'save')
|
|
240
|
-
this.recordSaved.emit(record);
|
|
241
|
-
});
|
|
242
|
-
const viewContainerRef = this.formContainer.viewContainerRef;
|
|
243
|
-
viewContainerRef.clear();
|
|
244
|
-
// Generated forms expose properties (e.g. `userPermissions`) that aren't
|
|
245
|
-
// on the abstract `BaseFormComponent`. Widen the instance type for the
|
|
246
|
-
// setter surface we share across class-based and interactive forms.
|
|
247
|
-
const componentRef = resolution.kind === 'interactive'
|
|
248
|
-
? viewContainerRef.createComponent(InteractiveFormComponent)
|
|
249
|
-
: viewContainerRef.createComponent(resolution.subClass);
|
|
250
|
-
if (resolution.kind === 'interactive') {
|
|
251
|
-
componentRef.instance.ComponentID = resolution.override.ComponentID;
|
|
252
|
-
}
|
|
253
|
-
// Track component and record for cleanup
|
|
254
|
-
this._formComponentRef = componentRef;
|
|
255
|
-
this._currentRecord = record;
|
|
256
|
-
const instance = componentRef.instance;
|
|
257
|
-
instance.record = record;
|
|
258
|
-
instance.userPermissions = permissions;
|
|
259
|
-
instance.EditMode = !primaryKey.HasValue; // for new records go direct into edit mode
|
|
260
|
-
// Push variant list + active selection into the form so the
|
|
261
|
-
// record-form-container's picker renders. The resolver returns
|
|
262
|
-
// every applicable override regardless of status, but the runtime
|
|
263
|
-
// picker should only surface **Active** ones — Inactive rows are
|
|
264
|
-
// historical (e.g. the previous Component version that an agent
|
|
265
|
-
// refinement superseded) and Pending rows are AI-authored work
|
|
266
|
-
// awaiting activation in Form Builder. Picking either does
|
|
267
|
-
// nothing at runtime (pickActive requires Status='Active'), so
|
|
268
|
-
// including them in the picker was misleading the user into
|
|
269
|
-
// thinking "I can switch to this" when they actually can't.
|
|
270
|
-
//
|
|
271
|
-
// Authorship of Pending/Inactive overrides happens in the Form
|
|
272
|
-
// Builder cockpit, which intentionally shows the full lifecycle.
|
|
273
|
-
instance.Variants = (resolution.variants ?? [])
|
|
274
|
-
.filter(v => v.Status === 'Active')
|
|
275
|
-
.map(v => ({
|
|
276
|
-
ID: v.ID,
|
|
277
|
-
Label: v.Name ?? `Override ${v.ID.substring(0, 8)}`,
|
|
278
|
-
Scope: v.Scope,
|
|
279
|
-
Status: v.Status,
|
|
280
|
-
}));
|
|
281
|
-
instance.CurrentVariantID = resolution.kind === 'interactive' ? resolution.override.ID : null;
|
|
282
|
-
// Wire the handler: persist the selection in localStorage and reload
|
|
283
|
-
// the form. Reload uses the existing entry path so all the resolver's
|
|
284
|
-
// tier/priority semantics apply (and the saved choice now overrides).
|
|
285
|
-
instance.OnVariantChanged = (variantID) => {
|
|
286
|
-
// null from the picker = user picked the "Default form" row →
|
|
287
|
-
// store the explicit-default sentinel so the resolver skips ALL
|
|
288
|
-
// overrides and falls back to the CodeGen / @RegisterClass form.
|
|
289
|
-
// Without this, clearing the preference let the resolver auto-pick
|
|
290
|
-
// the first Active override again, making Default unreachable from
|
|
291
|
-
// the UI for entities that have any user-scope overrides.
|
|
292
|
-
if (variantID === null) {
|
|
293
|
-
this.formResolver.SetExplicitDefault(entityName);
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
this.formResolver.SetSelectedVariant(entityName, variantID);
|
|
297
|
-
}
|
|
298
|
-
// Re-run the load with the same key — the resolver will honour the
|
|
299
|
-
// updated session-local selection.
|
|
300
|
-
this.LoadForm(this.PrimaryKey, entityName);
|
|
301
|
-
};
|
|
302
|
-
// Subscribe to form @Output events and map them to Explorer services
|
|
303
|
-
this.subscribeToFormEvents(instance);
|
|
304
|
-
this.useGenericForm = false;
|
|
305
|
-
this.errorTitle = null;
|
|
306
|
-
this.errorDetail = null;
|
|
307
|
-
this.loadComplete.emit();
|
|
308
|
-
}
|
|
309
|
-
catch (err) {
|
|
310
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
311
|
-
this.failWithUserError(`Failed to load ${entityName} record.`, `An unexpected error occurred while loading this record: ${msg}`, { error: err });
|
|
312
|
-
}
|
|
313
|
-
this.loading = false;
|
|
314
|
-
this.cdr.detectChanges();
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Render a user-visible error state inside the record pane AND log a structured
|
|
318
|
-
* console.error for developers. Always emits `loadComplete` so the Explorer shell
|
|
319
|
-
* does not hang on its first-resource-load gate.
|
|
320
|
-
*/
|
|
321
|
-
failWithUserError(title, detail, context) {
|
|
322
|
-
this.errorTitle = title;
|
|
323
|
-
this.errorDetail = detail;
|
|
324
|
-
this.loading = false;
|
|
325
|
-
// Single structured console.error for devs — easy to grep, easy to read.
|
|
326
|
-
console.error(`[SingleRecord] ${title}\n${detail}` +
|
|
327
|
-
(context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''));
|
|
328
|
-
// Clear any prior form/component so the error UI is what shows.
|
|
329
|
-
if (this._formComponentRef) {
|
|
330
|
-
try {
|
|
331
|
-
this._formComponentRef.destroy();
|
|
332
|
-
}
|
|
333
|
-
catch { /* noop */ }
|
|
334
|
-
this._formComponentRef = null;
|
|
335
|
-
}
|
|
336
|
-
if (this.formContainer?.viewContainerRef) {
|
|
337
|
-
this.formContainer.viewContainerRef.clear();
|
|
338
|
-
}
|
|
339
|
-
// Always unblock the shell.
|
|
32
|
+
formPresenter = inject(MJFormPresenterService);
|
|
33
|
+
recentAccessService = new RecentAccessService();
|
|
34
|
+
/** Unblock the shell's first-resource-load gate (success or error). */
|
|
35
|
+
onLoadComplete() {
|
|
340
36
|
this.loadComplete.emit();
|
|
341
|
-
this.cdr.detectChanges();
|
|
342
37
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
// Handle both object and string (URL segment) formats
|
|
348
|
-
if (typeof this.newRecordValues === 'string') {
|
|
349
|
-
if (this.newRecordValues.length === 0) {
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
// we have a URL segment string format: "field1|value1||field2|value2"
|
|
353
|
-
const fv = new FieldValueCollection();
|
|
354
|
-
fv.SimpleLoadFromURLSegment(this.newRecordValues);
|
|
355
|
-
// now apply the values to the record
|
|
356
|
-
fv.KeyValuePairs.filter(kvp => kvp.Value !== null && kvp.Value !== undefined).forEach(kvp => {
|
|
357
|
-
const f = record.Fields.find(f => f.Name.trim().toLowerCase() === kvp.FieldName.trim().toLowerCase());
|
|
358
|
-
if (f) {
|
|
359
|
-
// make sure we set the value to the right type based on the f.TSType property
|
|
360
|
-
switch (f.EntityFieldInfo.TSType) {
|
|
361
|
-
case EntityFieldTSType.String:
|
|
362
|
-
record.Set(kvp.FieldName, kvp.Value);
|
|
363
|
-
break;
|
|
364
|
-
case EntityFieldTSType.Number:
|
|
365
|
-
record.Set(kvp.FieldName, parseFloat(kvp.Value));
|
|
366
|
-
break;
|
|
367
|
-
case EntityFieldTSType.Boolean:
|
|
368
|
-
if (kvp.Value === 'false' || kvp.Value === '0' || kvp.Value.toString().trim().length === 0)
|
|
369
|
-
record.Set(kvp.FieldName, false);
|
|
370
|
-
else
|
|
371
|
-
record.Set(kvp.FieldName, true);
|
|
372
|
-
break;
|
|
373
|
-
case EntityFieldTSType.Date:
|
|
374
|
-
record.Set(kvp.FieldName, new Date(kvp.Value));
|
|
375
|
-
break;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
});
|
|
379
|
-
}
|
|
380
|
-
else {
|
|
381
|
-
// we have a plain object format: { field1: value1, field2: value2 }
|
|
382
|
-
const recordValues = this.newRecordValues;
|
|
383
|
-
Object.keys(recordValues)
|
|
384
|
-
.filter(key => recordValues[key] !== null && recordValues[key] !== undefined)
|
|
385
|
-
.forEach(key => {
|
|
386
|
-
const f = record.Fields.find(f => f.Name.trim().toLowerCase() === key.trim().toLowerCase());
|
|
387
|
-
if (f) {
|
|
388
|
-
const value = recordValues[key];
|
|
389
|
-
// Set the value with proper type conversion
|
|
390
|
-
switch (f.EntityFieldInfo.TSType) {
|
|
391
|
-
case EntityFieldTSType.String:
|
|
392
|
-
record.Set(key, value?.toString() || '');
|
|
393
|
-
break;
|
|
394
|
-
case EntityFieldTSType.Number:
|
|
395
|
-
record.Set(key, typeof value === 'number' ? value : parseFloat(value?.toString() || '0'));
|
|
396
|
-
break;
|
|
397
|
-
case EntityFieldTSType.Boolean:
|
|
398
|
-
if (typeof value === 'boolean') {
|
|
399
|
-
record.Set(key, value);
|
|
400
|
-
}
|
|
401
|
-
else if (typeof value === 'string') {
|
|
402
|
-
record.Set(key, value !== 'false' && value !== '0' && value.trim().length > 0);
|
|
403
|
-
}
|
|
404
|
-
else {
|
|
405
|
-
record.Set(key, !!value);
|
|
406
|
-
}
|
|
407
|
-
break;
|
|
408
|
-
case EntityFieldTSType.Date:
|
|
409
|
-
record.Set(key, value instanceof Date ? value : new Date(value?.toString() || ''));
|
|
410
|
-
break;
|
|
411
|
-
default:
|
|
412
|
-
record.Set(key, value);
|
|
413
|
-
break;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
});
|
|
38
|
+
/** Log access for existing records once the form's record is ready. */
|
|
39
|
+
onRecordReady(record) {
|
|
40
|
+
if (record?.IsSaved) {
|
|
41
|
+
this.recentAccessService.logAccess(record.EntityInfo.Name, record.PrimaryKey, 'record');
|
|
417
42
|
}
|
|
418
43
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
this.
|
|
424
|
-
this._formEventSubscriptions.push(form.Navigate.subscribe((event) => this.handleNavigation(event)), form.Notification.subscribe((event) => {
|
|
425
|
-
this.sharedService.CreateSimpleNotification(event.Message, event.Type, event.Duration);
|
|
426
|
-
}));
|
|
44
|
+
onSaved(record) {
|
|
45
|
+
this.recordSaved.emit(record);
|
|
46
|
+
}
|
|
47
|
+
onNotification(event) {
|
|
48
|
+
this.sharedService.CreateSimpleNotification(event.Message, event.Type, event.Duration);
|
|
427
49
|
}
|
|
50
|
+
/** Map the form's navigation requests onto Explorer's NavigationService. */
|
|
428
51
|
handleNavigation(event) {
|
|
429
52
|
switch (event.Kind) {
|
|
430
53
|
case 'record':
|
|
431
54
|
this.navigationService.OpenEntityRecord(event.EntityName, event.PrimaryKey, { forceNewTab: event.OpenInNewTab });
|
|
432
55
|
break;
|
|
433
56
|
case 'new-record':
|
|
434
|
-
// Creating a
|
|
435
|
-
//
|
|
436
|
-
// intact — otherwise the new-record form silently replaces the parent in
|
|
437
|
-
// single-resource mode and the user loses their context. This is the
|
|
438
|
-
// original intent of dea32401ff, now stated explicitly at the call site
|
|
439
|
-
// instead of as a global navigation heuristic.
|
|
57
|
+
// Creating a related record from inside an open form: force a new tab so the
|
|
58
|
+
// parent record stays intact in single-resource mode.
|
|
440
59
|
this.navigationService.OpenNewEntityRecord(event.EntityName, {
|
|
441
60
|
newRecordValues: event.DefaultValues,
|
|
442
61
|
forceNewTab: true,
|
|
@@ -452,66 +71,36 @@ export class SingleRecordComponent extends BaseAngularComponent {
|
|
|
452
71
|
window.open(`mailto:${event.EmailAddress}`, '_self');
|
|
453
72
|
break;
|
|
454
73
|
case 'dismiss':
|
|
455
|
-
// Form asked to be dismissed (typically Discard on a new record).
|
|
456
|
-
// Re-emit so the parent (EntityRecordResource) can close the tab.
|
|
457
74
|
this.recordDismissed.emit();
|
|
458
75
|
break;
|
|
76
|
+
case 'create-related': {
|
|
77
|
+
// A FK field wants a new related record created. Open the related entity's form
|
|
78
|
+
// as a dialog/slide-in (prefilled), then hand the saved record back so the field
|
|
79
|
+
// can select it.
|
|
80
|
+
const ref = this.formPresenter.Open({
|
|
81
|
+
EntityName: event.EntityName,
|
|
82
|
+
Presentation: event.Presentation ?? 'dialog',
|
|
83
|
+
NewRecordValues: event.NewRecordValues,
|
|
84
|
+
Provider: event.Provider,
|
|
85
|
+
});
|
|
86
|
+
ref.AfterSaved().then(created => event.Complete(created));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
459
89
|
}
|
|
460
90
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
ngOnDestroy() {
|
|
468
|
-
// CRITICAL: Clean up form event subscriptions first
|
|
469
|
-
this.cleanupFormSubscriptions();
|
|
470
|
-
// CRITICAL: Clean up dynamically created form component to prevent zombie components
|
|
471
|
-
if (this._formComponentRef) {
|
|
472
|
-
this._formComponentRef.destroy();
|
|
473
|
-
this._formComponentRef = null;
|
|
474
|
-
}
|
|
475
|
-
// CRITICAL: Unsubscribe from event handler to prevent memory leaks
|
|
476
|
-
if (this._eventHandlerSubscription) {
|
|
477
|
-
this._eventHandlerSubscription.unsubscribe();
|
|
478
|
-
this._eventHandlerSubscription = null;
|
|
479
|
-
}
|
|
480
|
-
// Clean up record reference
|
|
481
|
-
if (this._currentRecord) {
|
|
482
|
-
this._currentRecord = null;
|
|
483
|
-
}
|
|
484
|
-
// Clear the view container to ensure no lingering references
|
|
485
|
-
if (this.formContainer?.viewContainerRef) {
|
|
486
|
-
this.formContainer.viewContainerRef.clear();
|
|
487
|
-
}
|
|
488
|
-
// Reset state
|
|
489
|
-
this.loading = true;
|
|
490
|
-
this.useGenericForm = false;
|
|
491
|
-
}
|
|
492
|
-
static ɵfac = function SingleRecordComponent_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || SingleRecordComponent)(i0.ɵɵdirectiveInject(i1.ActivatedRoute)); };
|
|
493
|
-
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: SingleRecordComponent, selectors: [["mj-single-record"]], viewQuery: function SingleRecordComponent_Query(rf, ctx) { if (rf & 1) {
|
|
494
|
-
i0.ɵɵviewQuery(Container, 7);
|
|
495
|
-
} if (rf & 2) {
|
|
496
|
-
let _t;
|
|
497
|
-
i0.ɵɵqueryRefresh(_t = i0.ɵɵloadQuery()) && (ctx.formContainer = _t.first);
|
|
498
|
-
} }, inputs: { PrimaryKey: "PrimaryKey", entityName: "entityName", newRecordValues: "newRecordValues" }, outputs: { loadComplete: "loadComplete", recordSaved: "recordSaved", recordDismissed: "recordDismissed" }, standalone: false, features: [i0.ɵɵInheritDefinitionFeature], decls: 3, vars: 2, consts: [["size", "large", 3, "showText"], ["role", "alert", 1, "single-record-error"], ["mjContainer", ""], [1, "single-record-error__icon"], [1, "fa-solid", "fa-triangle-exclamation"], [1, "single-record-error__title"], [1, "single-record-error__detail"], [1, "single-record-error__hint"]], template: function SingleRecordComponent_Template(rf, ctx) { if (rf & 1) {
|
|
499
|
-
i0.ɵɵconditionalCreate(0, SingleRecordComponent_Conditional_0_Template, 1, 1, "mj-loading", 0);
|
|
500
|
-
i0.ɵɵconditionalCreate(1, SingleRecordComponent_Conditional_1_Template, 8, 2, "div", 1);
|
|
501
|
-
i0.ɵɵtemplate(2, SingleRecordComponent_ng_template_2_Template, 0, 0, "ng-template", 2);
|
|
91
|
+
static ɵfac = /*@__PURE__*/ (() => { let ɵSingleRecordComponent_BaseFactory; return function SingleRecordComponent_Factory(__ngFactoryType__) { return (ɵSingleRecordComponent_BaseFactory || (ɵSingleRecordComponent_BaseFactory = i0.ɵɵgetInheritedFactory(SingleRecordComponent)))(__ngFactoryType__ || SingleRecordComponent); }; })();
|
|
92
|
+
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: SingleRecordComponent, selectors: [["mj-single-record"]], inputs: { PrimaryKey: "PrimaryKey", entityName: "entityName", newRecordValues: "newRecordValues" }, outputs: { loadComplete: "loadComplete", recordSaved: "recordSaved", recordDismissed: "recordDismissed" }, standalone: false, features: [i0.ɵɵInheritDefinitionFeature], decls: 1, vars: 4, consts: [[3, "LoadComplete", "LoadError", "RecordReady", "Saved", "Navigate", "Notification", "Dismissed", "EntityName", "PrimaryKey", "NewRecordValues", "Provider"]], template: function SingleRecordComponent_Template(rf, ctx) { if (rf & 1) {
|
|
93
|
+
i0.ɵɵelementStart(0, "mj-entity-form-host", 0);
|
|
94
|
+
i0.ɵɵlistener("LoadComplete", function SingleRecordComponent_Template_mj_entity_form_host_LoadComplete_0_listener() { return ctx.onLoadComplete(); })("LoadError", function SingleRecordComponent_Template_mj_entity_form_host_LoadError_0_listener() { return ctx.onLoadComplete(); })("RecordReady", function SingleRecordComponent_Template_mj_entity_form_host_RecordReady_0_listener($event) { return ctx.onRecordReady($event); })("Saved", function SingleRecordComponent_Template_mj_entity_form_host_Saved_0_listener($event) { return ctx.onSaved($event); })("Navigate", function SingleRecordComponent_Template_mj_entity_form_host_Navigate_0_listener($event) { return ctx.handleNavigation($event); })("Notification", function SingleRecordComponent_Template_mj_entity_form_host_Notification_0_listener($event) { return ctx.onNotification($event); })("Dismissed", function SingleRecordComponent_Template_mj_entity_form_host_Dismissed_0_listener() { return ctx.recordDismissed.emit(); });
|
|
95
|
+
i0.ɵɵelementEnd();
|
|
502
96
|
} if (rf & 2) {
|
|
503
|
-
i0.ɵɵ
|
|
504
|
-
|
|
505
|
-
i0.ɵɵconditional(ctx.errorTitle ? 1 : -1);
|
|
506
|
-
} }, dependencies: [i2.Container, i3.LoadingComponent], styles: ["[_nghost-%COMP%] {\n display: block;\n height: 100%;\n width: 100%;\n}\n\n.single-record-error[_ngcontent-%COMP%] {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 48px 32px;\n max-width: 720px;\n margin: 64px auto;\n background: var(--mj-bg-surface);\n border: 1px solid var(--mj-status-error-border);\n border-radius: 8px;\n color: var(--mj-text-primary);\n}\n\n.single-record-error__icon[_ngcontent-%COMP%] {\n font-size: 36px;\n color: var(--mj-status-error);\n margin-bottom: 16px;\n}\n\n.single-record-error__title[_ngcontent-%COMP%] {\n font-size: 18px;\n font-weight: 600;\n margin: 0 0 12px 0;\n color: var(--mj-status-error-text);\n}\n\n.single-record-error__detail[_ngcontent-%COMP%] {\n font-size: 14px;\n line-height: 1.5;\n color: var(--mj-text-secondary);\n margin: 0 0 16px 0;\n white-space: pre-wrap;\n}\n\n.single-record-error__hint[_ngcontent-%COMP%] {\n font-size: 12px;\n color: var(--mj-text-muted);\n margin: 0;\n font-style: italic;\n}"] });
|
|
97
|
+
i0.ɵɵproperty("EntityName", ctx.entityName)("PrimaryKey", ctx.PrimaryKey)("NewRecordValues", ctx.newRecordValues)("Provider", ctx.Provider);
|
|
98
|
+
} }, dependencies: [i1.MjEntityFormHostComponent], styles: ["[_nghost-%COMP%] {\n display: block;\n height: 100%;\n width: 100%;\n}\n\n.single-record-error[_ngcontent-%COMP%] {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 48px 32px;\n max-width: 720px;\n margin: 64px auto;\n background: var(--mj-bg-surface);\n border: 1px solid var(--mj-status-error-border);\n border-radius: 8px;\n color: var(--mj-text-primary);\n}\n\n.single-record-error__icon[_ngcontent-%COMP%] {\n font-size: 36px;\n color: var(--mj-status-error);\n margin-bottom: 16px;\n}\n\n.single-record-error__title[_ngcontent-%COMP%] {\n font-size: 18px;\n font-weight: 600;\n margin: 0 0 12px 0;\n color: var(--mj-status-error-text);\n}\n\n.single-record-error__detail[_ngcontent-%COMP%] {\n font-size: 14px;\n line-height: 1.5;\n color: var(--mj-text-secondary);\n margin: 0 0 16px 0;\n white-space: pre-wrap;\n}\n\n.single-record-error__hint[_ngcontent-%COMP%] {\n font-size: 12px;\n color: var(--mj-text-muted);\n margin: 0;\n font-style: italic;\n}"] });
|
|
507
99
|
}
|
|
508
100
|
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(SingleRecordComponent, [{
|
|
509
101
|
type: Component,
|
|
510
|
-
args: [{ standalone: false, selector: 'mj-single-record', template: "
|
|
511
|
-
}],
|
|
512
|
-
type: ViewChild,
|
|
513
|
-
args: [Container, { static: true }]
|
|
514
|
-
}], PrimaryKey: [{
|
|
102
|
+
args: [{ standalone: false, selector: 'mj-single-record', template: "<mj-entity-form-host\n [EntityName]=\"entityName\"\n [PrimaryKey]=\"PrimaryKey\"\n [NewRecordValues]=\"newRecordValues\"\n [Provider]=\"Provider\"\n (LoadComplete)=\"onLoadComplete()\"\n (LoadError)=\"onLoadComplete()\"\n (RecordReady)=\"onRecordReady($event)\"\n (Saved)=\"onSaved($event)\"\n (Navigate)=\"handleNavigation($event)\"\n (Notification)=\"onNotification($event)\"\n (Dismissed)=\"recordDismissed.emit()\">\n</mj-entity-form-host>\n", styles: [":host {\n display: block;\n height: 100%;\n width: 100%;\n}\n\n.single-record-error {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 48px 32px;\n max-width: 720px;\n margin: 64px auto;\n background: var(--mj-bg-surface);\n border: 1px solid var(--mj-status-error-border);\n border-radius: 8px;\n color: var(--mj-text-primary);\n}\n\n.single-record-error__icon {\n font-size: 36px;\n color: var(--mj-status-error);\n margin-bottom: 16px;\n}\n\n.single-record-error__title {\n font-size: 18px;\n font-weight: 600;\n margin: 0 0 12px 0;\n color: var(--mj-status-error-text);\n}\n\n.single-record-error__detail {\n font-size: 14px;\n line-height: 1.5;\n color: var(--mj-text-secondary);\n margin: 0 0 16px 0;\n white-space: pre-wrap;\n}\n\n.single-record-error__hint {\n font-size: 12px;\n color: var(--mj-text-muted);\n margin: 0;\n font-style: italic;\n}\n"] }]
|
|
103
|
+
}], null, { PrimaryKey: [{
|
|
515
104
|
type: Input
|
|
516
105
|
}], entityName: [{
|
|
517
106
|
type: Input
|
|
@@ -524,5 +113,5 @@ export class SingleRecordComponent extends BaseAngularComponent {
|
|
|
524
113
|
}], recordDismissed: [{
|
|
525
114
|
type: Output
|
|
526
115
|
}] }); })();
|
|
527
|
-
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(SingleRecordComponent, { className: "SingleRecordComponent", filePath: "src/lib/single-record/single-record.component.ts", lineNumber:
|
|
116
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(SingleRecordComponent, { className: "SingleRecordComponent", filePath: "src/lib/single-record/single-record.component.ts", lineNumber: 27 }); })();
|
|
528
117
|
//# sourceMappingURL=single-record.component.js.map
|