MemberJunction Forms Architecture Guide
How MJ renders and edits entity records — as full-page tabs, modal dialogs, or slide-in panels — from one set of forms, with no per-surface code and no regeneration.
TL;DR — Every entity has a form (generated, custom, or interactive). The Generic
MjEntityFormHostComponentturns “an entity + a record” into a live, bound form on any surface. Wrap it in<mj-form-dialog>/<mj-form-slide-in>, or open it imperatively withMJFormPresenterService. Control toolbar / sections / width / navigation per-instance viaEntityFormConfig— which the form container reads through the form reference, so generated templates never change.
Just want the controls, not the form?
<mj-form-field>,<mj-entity-form-host>,<mj-explorer-entity-data-grid>,<mj-collapsible-panel>, and the overlay shells are general-purpose, data-bindable database controls — drop them into any Angular component with aBaseEntityand an import ofBaseFormsModule. See packages/Angular/Generic/base-forms/STANDALONE_USAGE.md.
1. The big picture
Section titled “1. The big picture”┌─ Layer 4 MJFormPresenterService.Open({...}) → MJFormRef (imperative, 1 call)│ <mj-form-dialog> / <mj-form-slide-in> (declarative)├─ Layer 3 Presentation shells — dialog / slide-in chrome│ (own the title + Save/Cancel; bubble events; proxy inputs)├─ Layer 2 MjEntityFormHostComponent (headless, presentation-agnostic)│ resolve form → load record → create component →│ bind record/EditMode/Config/variants → re-emit events → teardown└─ Layer 1 BaseFormComponent + MjRecordFormContainerComponent (the form itself) + FormResolverService (class / custom / interactive + variants)Every layer lives in @memberjunction/ng-base-forms (package dir
packages/Angular/Generic/base-forms). None of it imports Angular Router or any
@memberjunction/ng-explorer-* package — it is reusable in any MJ Angular app.
Routing is never performed inside these components; they emit events and the
host application (e.g. MJ Explorer) decides what to do.
2. The three kinds of forms (and how they coexist)
Section titled “2. The three kinds of forms (and how they coexist)”When you ask to render entity X, FormResolverService.ResolveFormForEntity()
picks one of three, in priority order:
| Kind | What it is | How it’s chosen |
|---|---|---|
| interactive | A runtime EntityFormOverride (a Component authored in Form Builder / by an AI agent) | A User/Role/Global-scoped, Active override exists for the entity |
| class | The CodeGen-generated form, or a custom *Extended form, registered via @RegisterClass(BaseFormComponent, 'X') | No active override — fall back to the registered class |
| none | No form is registered | Neither of the above — the host shows an error |
Variants. When multiple overrides apply, the resolver returns the whole list
so the toolbar’s variant picker can offer alternates. The user’s choice persists
per-entity via UserInfoEngine (mj.formVariant.<entity>), so it follows them
across browsers and devices. Picking “Default form” stores an explicit sentinel
so the CodeGen form stays reachable. All of this works identically on tabs,
dialogs, and slide-ins — the resolver is Generic and the host always uses it.
The overrides are cached in memory by InteractiveFormsEngine (in
@memberjunction/core-entities) with event-driven invalidation, so resolution is
a sub-millisecond in-memory filter, not a per-open DB round-trip.
3. Layer 1 — the form and its container
Section titled “3. Layer 1 — the form and its container”A generated form is a BaseFormComponent subclass whose template wraps
everything in <mj-record-form-container [Record]="record" [FormComponent]="this">.
The container owns the toolbar, the History / Tags / Lists drawers, the variant
picker, section search / expand-all / width-toggle, and the panel slots. Field
sections and related-entity grids are <mj-collapsible-panel>s.
You rarely touch Layer 1 directly. Two things you should know:
- Extending a form without replacing it: register a
BaseFormPanelagainst a slot — see base-forms/PANELS.md. - Replacing a form entirely: a custom
*Extendedclass — see the “Extending Entity Forms” section of packages/Angular/CLAUDE.md.
4. Layer 2 — MjEntityFormHostComponent
Section titled “4. Layer 2 — MjEntityFormHostComponent”The keystone. Give it an entity + a record (or a key to load), and it does the whole dance: resolve → load → dynamically create the form → bind it → wire its outputs → tear down on destroy. It renders the form into an internal anchor and shows a loading state until the record is ready (and an error state on failure).
<mj-entity-form-host [EntityName]="'Users'" [PrimaryKey]="pk" <!-- omit/empty → new record --> [Record]="preloaded" <!-- OR bind an already-loaded BaseEntity --> [NewRecordValues]="defaults" [EditMode]="null" <!-- null = new→edit, existing→read --> [Config]="myConfig" [Provider]="Provider" (Saved)="onSaved($event)" (Navigate)="onNavigate($event)" (Notification)="onNotify($event)" (RecordReady)="onReady($event)" (Dismissed)="close()" (LoadComplete)="unblock()" (LoadError)="showError($event)" (FormCreated)="grabInstance($event)"></mj-entity-form-host>It exposes Save(), Cancel(), Dirty, and form (the live instance) so chrome
can drive it. It never routes — Navigate is emitted for the consumer to
handle.
MJ Explorer’s SingleRecordComponent is now just a thin wrapper around this
host: it maps Navigate → NavigationService, Notification → SharedService,
and record loads → RecentAccessService. That’s the only Explorer-specific glue;
all mechanics are Generic.
5. Layers 3 + 4 — dialogs, slide-ins, and the presenter
Section titled “5. Layers 3 + 4 — dialogs, slide-ins, and the presenter”Declarative
Section titled “Declarative”<!-- Modal dialog --><mj-form-dialog [EntityName]="'MJ: Query Categories'" [(Visible)]="show" Title="New Category" (Saved)="onCreated($event)"></mj-form-dialog>
<!-- Right-edge slide-in (resizable) --><mj-form-slide-in [EntityName]="'MJ: Credentials'" [RecordID]="id" [(Visible)]="open" (Saved)="refresh()"></mj-form-slide-in>
<!-- Bind a record you already have --><mj-form-dialog [Record]="myEntity" [(Visible)]="show"></mj-form-dialog>
<!-- Floating, non-modal, draggable window (compare/reference while editing) --><mj-form-window [EntityName]="'Accounts'" [RecordID]="id" [(Visible)]="show"></mj-form-window>Three shells ship: <mj-form-dialog> (modal), <mj-form-slide-in>
(right-edge, resizable, width persisted per-entity), and <mj-form-window>
(floating, non-modal, draggable + resizable — good for keeping a record open
while you work elsewhere). All three share the same inputs/outputs (they extend
BaseFormOverlay) and the same MJFormPresenterService imperative path.
Both shells are standalone components — import them directly:
import { MjFormDialogComponent, MjFormSlideInComponent } from '@memberjunction/ng-base-forms';
and add to your component/module imports.
Imperative — one call from anywhere
Section titled “Imperative — one call from anywhere”import { MJFormPresenterService } from '@memberjunction/ng-base-forms';
constructor(private forms: MJFormPresenterService) {}
async edit(id: string) { const ref = this.forms.Open({ EntityName: 'MJ: AI Agents', RecordId: id, // omit for a new record Presentation: 'slide-in', // 'dialog' | 'slide-in' | 'window' Config: { ShowRelatedEntities: false }, Provider: this.ProviderToUse, // multi-provider apps }); const saved = await ref.AfterSaved(); // BaseEntity | null if (saved) { /* refresh */ }}MJFormRef gives you AfterSaved(), AfterClosed(), Close(), and Form. The
presenter mounts the shell on document.body and tears it down after close — no
template wiring, no module registration.
6. EntityFormConfig — per-instance control (no regeneration)
Section titled “6. EntityFormConfig — per-instance control (no regeneration)”The single knob object. Set it on the host / shell / presenter; the
MjRecordFormContainerComponent reads it back through the form reference, so
it takes effect on every existing generated form without re-running CodeGen.
export interface EntityFormConfig { Toolbar?: Partial<FormToolbarConfig> | null; // null = no toolbar (dialog/slide-in default) ShowRelatedEntities?: boolean; // hide related-entity grids CollapsibleSections?: boolean; // false = sections locked open, no chevron HiddenSectionKeys?: string[]; // hide specific sections VisibleSectionKeys?: string[]; // allow-list (wins over hidden) WidthMode?: 'centered' | 'full-width'; EnableRecordLinks?: boolean; // false = in-form links inert (modal default) StartInEditMode?: boolean;}Presets: TAB_FORM_CONFIG (full toolbar, everything on), DIALOG_FORM_CONFIG
(no toolbar, related hidden, links inert), SLIDEIN_FORM_CONFIG (dialog + full-width).
The dialog/slide-in shells default to their presets; spread and override:
Config: { ...DIALOG_FORM_CONFIG, CollapsibleSections: false, Toolbar: { ShowDeleteButton: false } }Why no regeneration?
Section titled “Why no regeneration?”Generated templates hardcode <mj-record-form-container [FormComponent]="this">.
The container already derives state from that this reference (width mode,
variants, dirty state…). Config rides the same channel: the host sets
form.Config; the container reads toolbar config from it
(EffectiveToolbarConfig / EffectiveShowToolbar), and section-visibility +
collapsibility + link rules flow onto form.formContext, which every panel
receives — including slot-injected BaseFormPanels — so they apply uniformly.
The pure resolution helpers (resolveFormShowToolbar, resolveFormToolbarConfig,
isFormSectionHidden in entity-form-config.ts) are unit-tested.
7. Custom sections — injected into a form, or rendered standalone
Section titled “7. Custom sections — injected into a form, or rendered standalone”There are two complementary ways to work with sections (units smaller than a whole form):
7a. Inject a custom section into a generated form (BaseFormPanel slot)
Section titled “7a. Inject a custom section into a generated form (BaseFormPanel slot)”Add a panel to an existing generated form without replacing it — register a
BaseFormPanel against a slot and it mounts at runtime. The canonical real
example is MJ: Content Sources, which has two injected sections in
packages/Angular/Explorer/core-entity-forms/src/lib/panels/content-sources/:
// website-crawler-settings.panel.ts — a typed-config section injected into the// generated MJ: Content Sources form, self-gating on ContentSourceType.@RegisterClassEx(BaseFormPanel, { key: 'content-sources:website-crawler-settings', skipNullKeyWarning: true, metadata: { entity: 'MJ: Content Sources', slot: 'after-fields', sortKey: 80 },})@Component({ standalone: false, selector: 'mj-website-crawler-settings-panel', templateUrl: './website-crawler-settings.panel.html' })export class WebsiteCrawlerSettingsPanel extends BaseFormPanel<MJContentSourceEntity> { public get IsWebsiteSourceType(): boolean { /* gate in template */ }}It renders alongside the broadly-applicable TagPipelineConfigurationPanel
(sortKey: 100) in the same after-fields slot — higher sortKey first. Neither
required touching the generated form. Full authoring contract:
base-forms/PANELS.md.
These injected sections are controllable from the stack. Because every panel — generated, custom, OR slot-injected — receives
FormContext, theEntityFormConfigvisibility rules (HiddenSectionKeys/VisibleSectionKeys/ShowRelatedEntities) apply uniformly. So a dialog can open the Content Sources form and hide the crawler section withConfig: { HiddenSectionKeys: ['websiteCrawlerSettings'] }— no per-panel code.
7b. Render a single section standalone (SectionName)
Section titled “7b. Render a single section standalone (SectionName)”To render just one registered BaseFormSectionComponent (@RegisterClass(BaseFormSectionComponent, '<Entity>.<Section>')) — e.g. a compact quick-edit — pass SectionName:
<mj-form-dialog [EntityName]="'My Entity'" [RecordID]="id" SectionName="QuickEdit" Title="Quick edit" [(Visible)]="show"></mj-form-dialog>this.forms.Open({ EntityName: 'My Entity', RecordId: id, SectionName: 'QuickEdit', Presentation: 'slide-in' });Section mode bypasses the full-form resolver/toolbar/container — the section
renders its own fields and the host saves the record directly. (This is the
capability the legacy EntityFormDialogComponent exposed; the new host now
supports it on every surface.)
8. Navigation from inside a dialog / slide-in
Section titled “8. Navigation from inside a dialog / slide-in”In a modal context, in-form record links are inert by default
(EnableRecordLinks: false) so clicking one doesn’t teleport the user out of the
overlay. Generic code never routes — only Explorer-layer code touches
NavigationService.
To make links live and decide what happens, set EnableRecordLinks: true and
handle the bubbled Navigate event yourself — e.g. open the target in a nested
overlay:
const ref = this.forms.Open({ EntityName: 'Accounts', RecordId: id, Presentation: 'dialog' });// ...but with Config.EnableRecordLinks = true, or via the declarative shell:<mj-form-dialog [EntityName]="'Accounts'" [RecordID]="id" [(Visible)]="show" [Config]="{ EnableRecordLinks: true }" (Navigate)="onNavigate($event)"></mj-form-dialog>onNavigate(e: FormNavigationEvent) { if (e.Kind === 'record') { // open the related record in a nested dialog (overlay stays open) this.forms.Open({ EntityName: e.EntityName, PrimaryKey: e.PrimaryKey, Presentation: 'dialog' }); } // or, in an Explorer-layer component, route via NavigationService instead}The host never decides — it emits, you choose (nested overlay, route, ignore). That keeps the Generic stack routing-free and lets each consumer pick the UX.
9. Decision guide
Section titled “9. Decision guide”| You want to… | Use |
|---|---|
| Show/edit a record in the main tab area | SingleRecordComponent (Explorer) — already host-backed |
| Quick-create/edit a record in a modal | <mj-form-dialog> or forms.Open({Presentation:'dialog'}) |
| Edit a record in a side panel without leaving the page | <mj-form-slide-in> or forms.Open({Presentation:'slide-in'}) |
| Keep a record open (non-modal) while working elsewhere | <mj-form-window> or forms.Open({Presentation:'window'}) |
| Edit just one section of a record in an overlay | SectionName on any shell / forms.Open({SectionName}) |
| Add a custom section into a generated form | BaseFormPanel + slot — PANELS.md (Content Sources is the example) |
| Replace a form’s whole layout | Custom *Extended form — Angular/CLAUDE.md |
| Build a brand-new bespoke editor dialog | Stop — first check if a <mj-form-dialog> covers it |
10. Reference
Section titled “10. Reference”- Package:
@memberjunction/ng-base-forms(packages/Angular/Generic/base-forms) - Host:
host/entity-form-host.component.ts - Shells + presenter:
overlays/* - Config:
types/entity-form-config.ts - Resolver:
resolver/form-resolver.service.ts - Container:
container/record-form-container.component.ts - Panels: PANELS.md
- Custom forms + toolbar pattern: packages/Angular/CLAUDE.md
- Slide-in primitive:
MjSlidePanelComponentin@memberjunction/ng-ui-components