Skip to content

@memberjunction/ng-base-forms

The foundation for rendering and editing MemberJunction entity records in Angular — and for presenting any entity form as a full-page tab, a modal dialog, a slide-in panel, or a floating window, from one set of forms with no per-surface code and no regeneration.

Start with the architecture guide: /guides/FORMS_ARCHITECTURE_GUIDE.md — the big picture, how generated/custom/interactive forms coexist, and how to use every surface. This README is a quick map of the package.

Using these as general-purpose database controls (outside a form)? See STANDALONE_USAGE.md — <mj-form-field>, <mj-entity-form-host>, the overlay shells, <mj-explorer-entity-data-grid>, and <mj-collapsible-panel> are all data-bindable controls you can drop into any Angular component with just a BaseEntity and an import of BaseFormsModule.

AreaKey exportsNotes
Form baseBaseFormComponent, BaseFormSectionComponent, MjRecordFormContainerComponent, MjCollapsiblePanelComponent, MjFormFieldComponentThe form itself + its toolbar/section container. Generated forms extend BaseFormComponent.
Form hostMjEntityFormHostComponent (<mj-entity-form-host>)Presentation-agnostic: resolve → load → create → bind → re-emit → teardown. Supports full forms and single sections (SectionName). Standard-form switch: FormMode, ShowFormModeSwitch, FormModeChange, SwitchFormMode() — see Standard-form switch.
Overlay shellsMjFormDialogComponent, MjFormSlideInComponent, MjFormWindowComponent (standalone)Declarative dialog / slide-in / floating-window around the host.
Imperative APIMJFormPresenterService.Open(...) → MJFormRefOne call to open any form on any surface.
Per-instance configEntityFormConfig + TAB_/DIALOG_/SLIDEIN_FORM_CONFIGToolbar / related sections / collapsibility / width / record-links. Applied via the form reference — no regeneration.
Form resolutionFormResolverServicePicks generated / custom / interactive-override form + variants (cached via InteractiveFormsEngine). FormResolution.standard, ResolveStandardForm() and HasStandardFormAlternative() expose the generated form a custom one hides.
Form extensionBaseFormPanel + <mj-form-panel-slot> + <mj-form-contributions>Inject custom sections; claim a related-entity grid; fill in unbaked DisplayInForm relationships. See PANELS.md and /plans/form-contributions.md.
Form chromeResolveFormChrome, BaseFormPolicy, MJ: Form Chrome RulesL0–L4 stack: inclusion, Auto ranker, install overlay, user order. Policy is cosmetics only. Left-nav related grids fill leftover column height (display: contents on slot hosts; no accordion pixel pin). SetSectionRowCount upserts so contribution badges appear. Section search matches title/MatchesSearch, not IsVisible. See Forms Architecture §7d.
Pluggable toolbar actionsBaseFormToolbarAction, @RegisterFormToolbarAction, RecordLinksToolbarAction, ResolveFormToolbarActionsForEntityRegister toolbar buttons with EntityScope ('*', exact entity, glob wildcard, schema:*, or array) and lifecycle hooks (IsApplicable, OnInit, OnPrewarm, OnPinStateChanged, OnRecordSaved, OnRecordRefreshed, OnExecute, OnDestroy). Supports subclassing & prototype-chain metadata inheritance. See Pluggable toolbar actions and Forms Architecture §7e.
Record links & hover cardsMjRecordLinksComponent (<mj-record-links>), MjRecordPreviewPopoverComponent (<mj-record-preview-popover>), MjRecordPreviewDirective ([mjRecordPreview])Bidirectional record links drawer (outbound FKs, inbound related entities, polymorphic links) with pin-aware pre-warming and Sage LinksSummary context; hover preview cards on read-mode FK links. See Forms Architecture §7f.
Time-travel version previewMjRecordFormContainerComponent (ActiveVersionPreview, OnPreviewVersionSelected, OnStepVersionPreview, OnExitVersionPreview), FormSlotCoordinator.VersionPreviewChanged$Rehydrates historical FullRecordJSON + ChangesJSON snapshots directly inside the form with a top Time-Travel Scrubber Bar and changed-field jump chips. See Forms Architecture §7g.
New-record defaultsBaseFormComponent.NewRecordValues / NewRecordValuesForJoinFieldsRelated grids copy every join field onto the child. Explorer persists them as /record/:entity/new?NewRecordValues=....
import { MJFormPresenterService, DIALOG_FORM_CONFIG } from '@memberjunction/ng-base-forms';
const ref = this.forms.Open({
EntityName: 'MJ: AI Agents',
RecordId: id, // omit → new record
Presentation: 'slide-in', // 'dialog' | 'slide-in' | 'window'
Config: { ShowRelatedEntities: false },
});
const saved = await ref.AfterSaved(); // BaseEntity | null
<mj-form-dialog [EntityName]="'Users'" [RecordID]="id"
[(Visible)]="show" (Saved)="onSaved($event)"></mj-form-dialog>

A custom class form registered at a higher priority, or an active interactive override (EntityFormOverride), hides the CodeGen-generated form for its entity. Before MJ#4755 the generated form was unreachable — even a hand-built record URL resolved to the custom form — and an entity whose custom form only handles existing records had no way to create one.

<mj-entity-form-host> now shows a slim “Open standard form” / “Back to custom view” strip whenever a custom form hides the generated one. Entities whose only registration is the generated form (the common case) get no strip.

MemberKindPurpose
FormMode: EntityFormMode@Input'default' (the resolver’s normal pick) or 'standard' (the generated form). A change after the view initializes reloads the form and does not emit FormModeChange. If the entity has no standard form, the host falls back to the default and logs.
ShowFormModeSwitch = true@InputSet false to hide the strip; FormMode is still honoured.
FormModeChange: EventEmitter<EntityFormMode>@OutputEmits when the mode changes from inside the host — the strip, SwitchFormMode(), or picking an interactive variant (which returns to 'default') — so the surface can persist it.
SwitchFormMode(mode): booleanmethodSwitch programmatically. Returns false when refused (see below), true otherwise (including when already in mode).

What “standard” means. The standard form is the lowest-priority BaseFormComponent registration for the entity (FormResolverService.ResolveStandardForm). CodeGen registers its form with no explicit priority, and a custom form either declares a higher one or extends the generated class (so it registers later and gets a higher auto priority). HasStandardFormAlternative(resolution) is true only when that form exists and is not the one already mounted.

Unsaved work is never discarded. A switch remounts the form, so the host refuses it — returning false and emitting a warning Notification — when:

  • a saved record is dirty (record.IsSaved && record.Dirty), or
  • the form holds unsaved work outside the record’s own fields (BaseFormComponent.HasUnsavedChangesBeyondRecord: pending related-record edits/deletes or HasAdditionalUnsavedChanges), for new and saved records alike.

A new record keeps its values. An unsaved new record (with no other pending work) switches freely: the live record instance is carried into the other form, so fields already typed survive the switch.

Permissions are unchanged. The standard form enforces the same entity permissions as any other form; switching grants nothing.

In Explorer the mode lives in the record tab’s form query param (?form=standard), which is the source of truth. NavigationOptions.formMode, a ?form=standard deep link, back/forward and tab re-focus all reach the mounted record through OnQueryParamsChanged, which switches via SwitchFormMode (so the unsaved-work guard still applies; a refused switch writes the on-screen mode back to the URL). A strip switch writes the param back, so the URL records the requested mode, and a record URL without form means the default form. (When the entity has no standard form, the host falls back to the default form even if form=standard is requested.)

Toolbar buttons in <mj-form-toolbar> are extensible through BaseFormToolbarAction and @RegisterFormToolbarAction(metadata, priority?).

import {
BaseFormToolbarAction,
RegisterFormToolbarAction,
type FormToolbarActionLifecycleContext,
} from '@memberjunction/ng-base-forms';
@RegisterFormToolbarAction({
Key: 'audit-verify',
MenuLabel: 'Verify Record',
Tooltip: 'Run verification checks',
Icon: 'fa-solid fa-circle-check',
EntityScope: ['MJ_BizApps_Common: People', 'MJ_BizApps_Orders:*'], // '*', exact name, glob, 'schema:x', or string[]
Order: 50,
Pinnable: true,
Mode: 'action', // 'action' | 'toggle-drawer'
})
export class VerifyRecordToolbarAction extends BaseFormToolbarAction {
public override async OnPrewarm(ctx: FormToolbarActionLifecycleContext): Promise<void> {
if (!ctx.IsPinned) return; // Avoid background queries unless pinned to the toolbar
ctx.SetBadge('✓');
ctx.RequestChangeDetection();
}
public override async OnExecute(ctx: FormToolbarActionLifecycleContext): Promise<void> {
// Handle button click
}
}

2. Subclassing & overriding an existing toolbar button

Section titled “2. Subclassing & overriding an existing toolbar button”

You can subclass an existing toolbar action plugin (such as RecordLinksToolbarAction) to override it either globally (EntityScope: '*') or for specific entities.

  • Prototype metadata inheritance: ResolveFormToolbarActionsForEntity walks the prototype chain and merges ancestor registration metadata ({ ...ancestorMetadata, ...childMetadata }). You only need to declare the properties you are overriding (Key, Icon, Order, Pinnable, and Mode are inherited automatically).
  • 3-tier winner resolution per Key:
    1. A subclass (Sub.prototype instanceof Super) always overrides its superclass.
    2. An entity-specific EntityScope overrides a global ('*') scope of equal priority.
    3. Higher ClassFactory Priority wins.
import {
RecordLinksToolbarAction,
RegisterFormToolbarAction,
} from '@memberjunction/ng-base-forms';
@RegisterFormToolbarAction({
EntityScope: 'MJ_BizApps_Common: People',
MenuLabel: 'Person Graph',
})
export class PersonLinksToolbarAction extends RecordLinksToolbarAction {
// Inherits Key: 'links', Icon, Order, Pinnable, and Mode from RecordLinksToolbarAction
}
Section titled “Declarative Record Links ([mjRecordLink] & <mj-record-link>)”

Any custom form header, BaseFormPanel, or dashboard widget can attach MemberJunction’s full record-linking UX (hover-intent <mj-record-preview-popover>, inline Quick Edit with live propagation back to virtual display fields on Record, standard + polymorphic FK resolution, and click/Ctrl+Click FormNavigationEvent navigation) using [mjRecordLink] or <mj-record-link>:

1. Field-Driven Mode ([mjRecordLink]="Record" FieldName="...")

Section titled “1. Field-Driven Mode ([mjRecordLink]="Record" FieldName="...")”

Resolves the target entity, primary key, and companion virtual display column (e.g., CurrentOrganizationName) directly from Record.EntityInfo.Fields metadata (supporting both standard RelatedEntity FKs and polymorphic EntityIDFieldName pairs):

<button
type="button"
class="biz-hero__stat-val"
[mjRecordLink]="Record"
FieldName="CurrentOrganizationID"
(Navigate)="Navigate.emit($event)">
{{ Record.CurrentOrganizationName }}
</button>
Section titled “2. Direct Mode (mjRecordLink [EntityName]="..." [RecordID]="...")”

Use when linking from a custom projection, DTO, or list row where no BaseEntity field metadata is involved:

<button
type="button"
mjRecordLink
[EntityName]="'MJ_BizApps_Common: People'"
[RecordID]="row.PersonID"
[DisplayName]="row.PersonName"
(Navigate)="Navigate.emit($event)">
{{ row.PersonName }}
</button>

Renders an inline hyperlink, pill chip (Variant="chip"), or link box (Variant="box") and automatically resolves the target record’s display name via GetEntityRecordName when DisplayName is omitted:

<mj-record-link
[Record]="Record"
FieldName="CurrentOrganizationID"
Variant="chip"
(Navigate)="Navigate.emit($event)">
</mj-record-link>

4. Pluggable Hover Preview Card (MjRecordPreviewPopoverComponent & @RegisterClass)

Section titled “4. Pluggable Hover Preview Card (MjRecordPreviewPopoverComponent & @RegisterClass)”

The singleton MjRecordPreviewPopoverService mounts a single <mj-record-preview-popover> at document.body featuring the Option A UX:

  • Header action micro-cluster: Copyable compact Record ID (9FAF60…C577), Quick Edit ([✎]), and Open in New Tab ([↗]) sit in the top-right header.
  • Clickable record title: Clicking the record title (Mathieu Grantham →) navigates directly to the record (Ctrl/Cmd-click or middle-click opens in a new tab), eliminating redundant bottom buttons in Read mode.
  • Auto-spanning wide fields: Wide values (Email, URL, Title, or values > 22 characters) automatically span both columns (grid-column: 1 / -1) so they never truncate prematurely.
  • Contextual Quick Edit footer & cancellable save event: Entering Quick Edit reveals a slim Cancel / Save footer bar and emits BeforeQuickEditSave (cancellable via args.Cancel = true) and AfterQuickEditSaved / RecordUpdated.

You can subclass MjRecordPreviewPopoverComponent and register your custom preview card with @RegisterClass so MjRecordPreviewPopoverService instantiates your subclass dynamically via MJGlobal.Instance.ClassFactory:

  • Global override (all entities): @RegisterClass(MjRecordPreviewPopoverComponent)
  • Schema or wildcard override: @RegisterClass(MjRecordPreviewPopoverComponent, 'MJ_BizApps_Common:*') or @RegisterClass(MjRecordPreviewPopoverComponent, 'schema:mj_bizapps_common')
  • Entity-specific override: @RegisterClass(MjRecordPreviewPopoverComponent, 'MJ_BizApps_Common: People')
import { RegisterClass } from '@memberjunction/global';
import { EntityFieldInfo, EntityInfo } from '@memberjunction/core';
import { MjRecordPreviewPopoverComponent } from '@memberjunction/ng-base-forms';
@RegisterClass(MjRecordPreviewPopoverComponent, 'MJ_BizApps_Common: People')
export class CustomPersonPreviewPopoverComponent extends MjRecordPreviewPopoverComponent {
protected override selectPreviewFields(entityInfo: EntityInfo): EntityFieldInfo[] {
// Customize which summary fields appear on the People hover card
return super.selectPreviewFields(entityInfo);
}
}

Anchored Record Comments (<mj-record-comments>)

Section titled “Anchored Record Comments (<mj-record-comments>)”

Every saved record form automatically provides threaded conversations (MJ: Conversations + MJ: Conversation Details) anchored at the Record, Field, Section, Group (Left-Nav Tab), or Contribution level:

  • Form Toolbar Action (RecordCommentsToolbarAction, key 'comments'): Displays the total open comment count across the record and toggles the slide-over Record Comments Drawer.
  • Field-Level Trigger & Badge (<mj-form-field>): Shows an inline comment count badge on fields with active threads (or a hover trigger to start a thread), capturing a quotedValue snapshot of the field’s value at the time of commenting. Fields with open comments are never collapsed away as empty in read mode.
  • Section & Left-Nav Rail Rollup (<mj-collapsible-panel>, <mj-record-form-container>, FormSectionIndicators): Open field and section comments roll up into section header chips and left-nav group badges. Clicking any anchor chip in the drawer or popover expands the target group/section, scrolls the field into view, and pulses it.
  • RLS & FLS Security + @Mention Notifications: Enforces Row-Level Security and Field-Level Security (including write-time restricted-field classification and AnchorMetadataObject.restrictedFields stamping) and dispatches MJ: User Notifications on @mention only to users permitted to read both the record and the anchored/referenced fields.