@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.
Files changed (46) hide show
  1. package/dist/app-routing.module.d.ts.map +1 -1
  2. package/dist/app-routing.module.js +13 -13
  3. package/dist/app-routing.module.js.map +1 -1
  4. package/dist/generated/lazy-feature-config.d.ts +1 -1
  5. package/dist/generated/lazy-feature-config.d.ts.map +1 -1
  6. package/dist/generated/lazy-feature-config.js +3 -2
  7. package/dist/generated/lazy-feature-config.js.map +1 -1
  8. package/dist/lib/guards/app-lock-guard.service.d.ts +26 -0
  9. package/dist/lib/guards/app-lock-guard.service.d.ts.map +1 -0
  10. package/dist/lib/guards/app-lock-guard.service.js +55 -0
  11. package/dist/lib/guards/app-lock-guard.service.js.map +1 -0
  12. package/dist/lib/resource-wrappers/chat-conversations-resource.component.d.ts.map +1 -1
  13. package/dist/lib/resource-wrappers/chat-conversations-resource.component.js +40 -27
  14. package/dist/lib/resource-wrappers/chat-conversations-resource.component.js.map +1 -1
  15. package/dist/lib/resource-wrappers/view-resource.component.d.ts +6 -5
  16. package/dist/lib/resource-wrappers/view-resource.component.d.ts.map +1 -1
  17. package/dist/lib/resource-wrappers/view-resource.component.js +19 -24
  18. package/dist/lib/resource-wrappers/view-resource.component.js.map +1 -1
  19. package/dist/lib/shell/components/tabs/tab-container.component.d.ts.map +1 -1
  20. package/dist/lib/shell/components/tabs/tab-container.component.js +9 -0
  21. package/dist/lib/shell/components/tabs/tab-container.component.js.map +1 -1
  22. package/dist/lib/shell/shell.component.d.ts +24 -6
  23. package/dist/lib/shell/shell.component.d.ts.map +1 -1
  24. package/dist/lib/shell/shell.component.js +360 -191
  25. package/dist/lib/shell/shell.component.js.map +1 -1
  26. package/dist/lib/single-record/single-record.component.d.ts +31 -75
  27. package/dist/lib/single-record/single-record.component.d.ts.map +1 -1
  28. package/dist/lib/single-record/single-record.component.js +60 -471
  29. package/dist/lib/single-record/single-record.component.js.map +1 -1
  30. package/dist/lib/single-search-result/single-search-result.component.d.ts +3 -8
  31. package/dist/lib/single-search-result/single-search-result.component.d.ts.map +1 -1
  32. package/dist/lib/single-search-result/single-search-result.component.js +19 -68
  33. package/dist/lib/single-search-result/single-search-result.component.js.map +1 -1
  34. package/dist/public-api.d.ts +1 -0
  35. package/dist/public-api.d.ts.map +1 -1
  36. package/dist/public-api.js +1 -0
  37. package/dist/public-api.js.map +1 -1
  38. package/package.json +46 -46
  39. package/dist/lib/__tests__/form-resolver.service.test.d.ts +0 -2
  40. package/dist/lib/__tests__/form-resolver.service.test.d.ts.map +0 -1
  41. package/dist/lib/__tests__/form-resolver.service.test.js +0 -258
  42. package/dist/lib/__tests__/form-resolver.service.test.js.map +0 -1
  43. package/dist/lib/services/form-resolver.service.d.ts +0 -139
  44. package/dist/lib/services/form-resolver.service.d.ts.map +0 -1
  45. package/dist/lib/services/form-resolver.service.js +0 -235
  46. package/dist/lib/services/form-resolver.service.js.map +0 -1
@@ -1,442 +1,61 @@
1
- import { ChangeDetectorRef, Component, EventEmitter, inject, Input, Output, ViewChild } from '@angular/core';
2
- import { CompositeKey, FieldValueCollection, EntityFieldTSType } from '@memberjunction/core';
3
- import { Container } from '@memberjunction/ng-container-directives';
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 "@angular/router";
10
- import * as i2 from "@memberjunction/ng-container-directives";
11
- import * as i3 from "@memberjunction/ng-shared-generic";
12
- function SingleRecordComponent_Conditional_0_Template(rf, ctx) { if (rf & 1) {
13
- i0.ɵɵelement(0, "mj-loading", 0);
14
- } if (rf & 2) {
15
- i0.ɵɵproperty("showText", false);
16
- } }
17
- function SingleRecordComponent_Conditional_1_Conditional_5_Template(rf, ctx) { if (rf & 1) {
18
- i0.ɵɵelementStart(0, "p", 6);
19
- i0.ɵɵtext(1);
20
- i0.ɵɵelementEnd();
21
- } if (rf & 2) {
22
- const ctx_r0 = i0.ɵɵnextContext(2);
23
- i0.ɵɵadvance();
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
- route;
47
- formContainer;
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
- cdr = inject(ChangeDetectorRef);
93
- formResolver = inject(FormResolverService);
94
- constructor(route) {
95
- super();
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
- SetNewRecordValues(record) {
344
- if (!this.newRecordValues) {
345
- return;
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
- * Subscribe to BaseFormComponent @Output events and map them to Explorer services.
421
- */
422
- subscribeToFormEvents(form) {
423
- this.cleanupFormSubscriptions();
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 new related record from inside an open record form (e.g. + New
435
- // on a related-entity grid). Force a new tab so the parent record stays
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
- cleanupFormSubscriptions() {
462
- for (const sub of this._formEventSubscriptions) {
463
- sub.unsubscribe();
464
- }
465
- this._formEventSubscriptions = [];
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.ɵɵconditional(ctx.loading ? 0 : -1);
504
- i0.ɵɵadvance();
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: "@if (loading) {\n <mj-loading [showText]=\"false\" size=\"large\"></mj-loading>\n}\n@if (errorTitle) {\n <div class=\"single-record-error\" role=\"alert\">\n <div class=\"single-record-error__icon\">\n <i class=\"fa-solid fa-triangle-exclamation\"></i>\n </div>\n <h2 class=\"single-record-error__title\">{{ errorTitle }}</h2>\n @if (errorDetail) {\n <p class=\"single-record-error__detail\">{{ errorDetail }}</p>\n }\n <p class=\"single-record-error__hint\">See browser console for technical details.</p>\n </div>\n}\n<ng-template mjContainer></ng-template>\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"] }]
511
- }], () => [{ type: i1.ActivatedRoute }], { formContainer: [{
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: 18 }); })();
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