# 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 **`MjEntityFormHostComponent`** turns "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 with `MJFormPresenterService`.
> Control toolbar / sections / width / navigation per-instance via
> **`EntityFormConfig`** — 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 a `BaseEntity` and an import of
> `BaseFormsModule`. See
> [packages/Angular/Generic/base-forms/STANDALONE_USAGE.md](../packages/Angular/Generic/base-forms/STANDALONE_USAGE.md).

---

## 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)

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

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 `BaseFormPanel` against a
  slot — see [base-forms/PANELS.md](../packages/Angular/Generic/base-forms/PANELS.md)
  and [§7c](#7c-form-contributions--add-replace-or-fill-in-no-regen). Claim
  `relatedEntity` to replace a baked related grid, or `replacesSectionKey` to
  replace a field panel (including a hero that is not a collapsible panel).
  `<mj-form-contributions>` fills in `DisplayInForm` relationships the template
  did not bake. CodeGen keeps emitting those sections; override is runtime.
- **Replacing a form entirely:** a custom `*Extended` class — see the "Extending
  Entity Forms" section of [packages/Angular/CLAUDE.md](../packages/Angular/CLAUDE.md).

---

## 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).

```html
<mj-entity-form-host
  [EntityName]="'Users'"
  [PrimaryKey]="pk"          <!-- omit/empty → new record -->
  [Record]="preloaded"        <!-- OR bind an already-loaded BaseEntity -->
  [NewRecordValues]="defaults"   <!-- object or Field|value||Field2|value2 URL segment -->
  [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.

Related-entity grids pass `NewRecordValues` from the join fields that filter the
grid so **New** opens a child already linked to the parent.

- `BaseFormComponent.NewRecordValues(relatedEntity, joinField?)` — one
  relationship, or every join field when several `EntityRelationship` rows
  share the related entity (Bill-To + Ship-To on the same Orders grid).
- `NewRecordValuesForJoinFields(relatedEntity, fields)` — explicit list when
  the grid already knows the FKs (Person Orders, Organization Orders).
- `EntityInfo.BuildRelationshipNewRecordValues` / `…ForJoinFields` — typed
  core helpers. When `EntityRelationship.Configuration.UI.join.fields` is set,
  every named FK is copied, not only `RelatedEntityJoinField`.

Explorer persists those defaults on the new-record URL
(`/record/:entity/new?NewRecordValues=Field|value||Field2|value2`, using
`NEW_ENTITY_RECORD_URL_ID` and `NEW_RECORD_VALUES_QUERY_PARAM` from
`@memberjunction/core`). Refresh and share keep the child linked. Overlay
hosts accept the same object or URL-segment string on `[NewRecordValues]`.

---

## 5. Layers 3 + 4 — dialogs, slide-ins, and the presenter

### Declarative

```html
<!-- 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

```typescript
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)

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**.

```typescript
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:

```typescript
Config: { ...DIALOG_FORM_CONFIG, CollapsibleSections: false, Toolbar: { ShowDeleteButton: false } }
```

### 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 `BaseFormPanel`s — 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

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)

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/`:

```typescript
// 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](../packages/Angular/Generic/base-forms/PANELS.md).

> **These injected sections are controllable from the stack.** Because every
> panel — generated, custom, OR slot-injected — receives `FormContext`, the
> `EntityFormConfig` visibility rules (`HiddenSectionKeys` / `VisibleSectionKeys`
> / `ShowRelatedEntities`) apply uniformly. So a dialog can open the Content
> Sources form and hide the crawler section with
> `Config: { HiddenSectionKeys: ['websiteCrawlerSettings'] }` — no per-panel code.

### 7c. Form contributions — add, replace, or fill in (no regen)

A form is a list of **contributions**. CodeGen still bakes field panels and
related-entity grids. At runtime, registered `BaseFormPanel`s can:

- **add** a section (existing slot behavior)
- **claim a related-entity grid** (`relatedEntity`) so the baked grid hides and yours mounts
- **fill in** a `DisplayInForm` relationship the template never baked (other OpenApp installed)
- **replace a named field panel** (`replacesSectionKey`) — hide `details` / `personalIdentity` and mount a hero that is **not** a collapsible panel

Discovery is `GetAllRegistrationsByMetadata`. Last-wins is ClassFactory `Priority` per `contributionKey`. Plan: [`/plans/form-contributions.md`](../plans/form-contributions.md). Authoring: [PANELS.md](../packages/Angular/Generic/base-forms/PANELS.md).

`replacesSectionKey` is the CodeGen `SectionKey` on the baked `<mj-collapsible-panel>` (camelCase of the section name — look at the generated form HTML). Must name a concrete `entity`, not `'*'`.

#### Scenario A — Extra settings on a generated form (Content Sources)

```typescript
@RegisterClassEx(BaseFormPanel, {
  key: 'content-sources:website-crawler-settings',
  metadata: { entity: 'MJ: Content Sources', slot: 'after-fields', sortKey: 80 },
})
export class WebsiteCrawlerSettingsPanel extends BaseFormPanel { /* gate in template */ }
```

Generated form untouched. Panel is a normal collapsible section.

#### Scenario B — Form hero that is not a panel (Orders)

The Order Header money strip + Confirm button is not a collapsible section. Register it at `before-fields` (the top of every generated form) and hide the generic Details panel if the hero owns those fields:

```typescript
@RegisterClassEx(BaseFormPanel, {
  key: 'form-panel:OrderHeaders:header',
  metadata: {
    entity: 'MJ_BizApps_Orders: Order Headers',
    slot: 'before-fields',
    sortKey: 100,
    contributionKey: 'header',
    replacesSectionKey: 'details',
  },
})
@Component({ standalone: false, selector: 'mjo-order-header-hero', template: `
  <div class="mjo-oh-hero">
    <h1>{{ Record.OrderNumber }}</h1>
    <span>{{ Record.Status }}</span>
    <button type="button" mjButton variant="primary" (click)="confirm()">Confirm order</button>
  </div>
` })
export class OrderHeaderHeroPanel extends BaseFormPanel<OrderHeaderEntity> {
  public async confirm(): Promise<void> { await this.Record.Confirm(); }
}
```

No `<mj-collapsible-panel>`. The generated Details section disappears. The rest of the generated form (lines, payment, related grids) stays. A second app that also ships a header uses the same `contributionKey: 'header'` and a higher `Priority`.

#### Scenario C — Replace Personal Identity on a Person with a richer header (Common)

```typescript
@RegisterClassEx(BaseFormPanel, {
  key: 'form-panel:People:header',
  metadata: {
    entity: 'MJ_BizApps_Common: People',
    slot: 'before-fields',
    contributionKey: 'header',
    replacesSectionKey: 'personalIdentity',
  },
})
export class PersonHeroPanel extends BaseFormPanel { /* photo, display name, primary org — not a panel */ }
```

Addresses / contacts widgets can stay as later slots or as the custom form's own markup.

#### Scenario D — Orders claims Event tickets on Person (related grid takeover)

```typescript
@RegisterClassEx(BaseFormPanel, {
  key: 'form-panel:People:related:EventOrderLines',
  metadata: {
    entity: 'MJ_BizApps_Common: People',
    slot: 'after-related',
    sortKey: 80,
    relatedEntity: 'MJ_BizApps_Orders: Event Order Lines',
    relatedJoinField: 'PersonID',
  },
})
export class PersonEventTicketsPanel extends BaseFormPanel { /* ticket cards */ }
```

Common does not import Orders. If CodeGen baked a generic Event Order Lines grid, it hides. If it never baked one (OpenApp install), the composer does not add a stock grid either — your panel is the contribution.

Omit `relatedJoinField` only when there is a single FK to that entity. Bill-to vs ship-to on the same Person must pass `BillToPersonID` / `ShipToPersonID`.

#### Scenario E — Another app installed: stock grid appears with no code

Accounting (or Sales) adds `DisplayInForm` from Deals → Person. Person's generated form was CodeGen'd before Sales existed, so it has no Deals panel. `<mj-form-contributions>` in the container mounts the stock related grid. No Common change, no regen.

#### Scenario F — Two apps ship a Person header; highest Priority wins

```typescript
// Common, Priority default 0
metadata: { entity: PEOPLE, slot: 'before-fields', contributionKey: 'header', replacesSectionKey: 'personalIdentity' }

// A vertical app, @RegisterClassEx(..., { priority: 10, metadata: { ..., contributionKey: 'header' } })
```

One header mounts. The loser is not shown. Same rule as related claims.

#### Scenario G — Subscription term waterfall (not a grid, not a header)

```typescript
@RegisterClassEx(BaseFormPanel, {
  key: 'form-panel:Subscriptions:waterfall',
  metadata: {
    entity: 'MJ_BizApps_Orders: Subscriptions',
    slot: 'after-fields',
    sortKey: 60,
    contributionKey: 'rev-rec-waterfall',
  },
})
export class SubscriptionWaterfallPanel extends BaseFormPanel { /* deferred-rev chart */ }
```

Extra pane. Does not replace anything. Generated subscription fields stay.

#### Scenario H — Custom form still uses the container

Orders' full custom form already wraps `<mj-record-form-container>` and emits `before-fields`. A contribution registered for Order Headers still mounts there. You do **not** have to replace the whole form to get a hero — start with B, grow to a custom form only when the line editor / tab strip demand it.

### 7d. Form chrome — accordion, left-nav, and More

Contributions decide *what* is on the form. Chrome decides *which of those
items appear* and *how the container arranges them*. Membership is data.
`BaseFormPolicy.DecorateChrome` may rename groups, swap icons, or wrap
labels. It cannot add, remove, or re-bucket sections.

Five layers, later wins on the same target:

| Layer | What it is | Who writes it |
|---|---|---|
| **L0** | CodeGen: field panels, `DisplayInForm`, `Sequence` | Schema / CodeGen |
| **L1** | Inclusions: `Primary` \| `More` \| `None` | The OpenApp that owns the related entity or contribution |
| **L2** | Ranker over remaining **Auto** leftovers | `Entity.Configuration.UI.Form` |
| **L3** | Install overlay: `MJ: Form Chrome Rules` | Site admin (never app-synced) |
| **L4** | User rail order and More membership | `UserInfoEngine` |

#### L1 — inclusion, keyed by (parent, related entity)

An **inclusion** is one parent-form section, not one FK. Bill-To and Ship-To
are two `EntityRelationship` rows and **one** Orders section.

`EntityRelationship.Configuration.UI`:

- `inclusion`: `'Primary'` — first-class rail
- `inclusion`: `'More'` — candidate, parked in More
- `inclusion`: `'None'` — not a candidate. Not in More. Ranker never sees it
- omit — **Auto** (L2 ranker)
- `join`: `{ mode: 'any', fields: string[] }` — same-table OR of FKs (Bill-To OR Ship-To)
- `FormRole`: `'Primary'` \| `'Detail'` — accepted alias (`Detail` = More)

`None` is how an app keeps satellite records off a hub form (Task Comments
on Person, Sold-To when Orders is already joined on Bill-To/Ship-To).

When one relationship to a related entity carries `join.fields`, sibling
FKs to that same entity with no explicit inclusion are `None`. Through-filters
(junction tables) are not same-table OR — use a contribution widget for those.

The app that **owns the related entity** (or the contribution) ships the L1
row. Downstream may override upstream; the admin pathway shows that.

#### L2 — ranker

`Entity.Configuration.UI.Form`:

- `Layout`: `'accordion'` \| `'left-nav'` \| `'auto'` (omit = auto)
- `AutoLeftNavAt`: first-class section count that flips auto to left-nav (omit = 8)
- `RelatedRolePolicy`: `'smart'` (default) or `'keep-all-primary'`
- `PrimaryRelatedBudget`: max **Auto** related grids that stay first-class under smart (omit = 6). Does not cap explicit `inclusion: 'Primary'`

The ranker only sees Auto leftovers. Same-schema 1:N children, declared
collections, and custom display components score above cross-schema hang-ons
and `__mj` plumbing. If the Auto pool is at or under the budget, every Auto
related stays Primary.

#### L3 — install overlay

`MJ: Form Chrome Rules` is the admin's global default. It is **not** in
OpenApp `metadata/` push filters. A row pins a (parent entity, related entity)
or (parent entity, contribution key) to Primary, More, or None, and may set
`JoinFields` and an optional `Title`. `Title` is the site-specific rail /
accordion label — keyed by RelatedEntityID or contribution key, so an
OpenApp upgrade that renames "Payments" does not overwrite a local "Pmts".
Blank / omitted `Title` keeps the L1 DisplayName. L3 can suppress a
contribution for the site. L4 cannot.

#### L4 — user overlay

Users reorder first-class rail items and move visible items in or out of
More (`UserInfoEngine`). They cannot suppress a contribution.

#### Contributions

No L0 (CodeGen did not emit them). No L2 (they are not the related-grid
pool). Installed package → the contribution exists by `contributionKey`.
L3 can turn it off. L4 rearranges what remains.

#### Policy

Register with `@RegisterClassEx(BaseFormPolicy, { metadata: { entity } })`.
Downstream subclasses upstream (`OrdersPersonFormPolicy extends
CommonPersonFormPolicy`). `DecorateChrome(spec, ctx)` returns cosmetics.
A decorate that changes section membership is ignored.

Cancelable `BeforeLayoutResolve` / `BeforeSectionActivate` live on the
container.

#### Left-nav

The rail picks one group; the body shows only that group. Selected content
has **no accordion chrome** (the rail is the header). **Details** shows every
field panel under one rail item, so the container renders those panels as
**one card** (`.mj-chrome-details`, with `-first` / `-last` on the visual
edges — CSS order, not DOM order): no per-section headers, one surface. The
field rows would otherwise float on the page background. A related grid pinned
into Details with `ChromeGroup: 'details'` is not part of that card: it keeps
the chrome-less grid treatment and sits as its own block. Related grids fill the
**leftover column height** — the selected panel is `flex: 1 1 auto` in the
column, not a pinned pixel height. Accordion-persisted heights are not
applied while the rail is showing the panel.

Slot-mounted contributions (`<mj-form-panel-slot>` / `BaseFormPanel` hosts)
use `display: contents` so they do not sit as an extra wrapper in that flex
column. `SetSectionRowCount` **upserts** the key: contribution sections are
not seeded by generated `initSections()`, so the rail badge still appears
(Orders on Person, Payments, etc.).

**More** is a folder on the rail — click to expand sub-nodes, then pick one
item like any other rail entry. Field panels collapse into one **Details**
item. Related Primary grids stay first-class (same related entity and
same-title grids merge). `System Metadata` and More related always sit in
More. Rail items use the same icon as the accordion header (entity `Icon`
when present). Users reorder first-class items by dragging the rail grip
(or Manage Sections / reset in the toolbar). The centered / full-width
toolbar toggle still applies.

#### Section indicators — unsaved edits and invalid fields, per section

A multi-section form says **which** section holds an edit or a failure, so a user does
not have to open every rail item to find the red field. Two marks, on the rail item,
the accordion header, the More folder, and the collapsed rail spine:

- an **amber dot** — the section has a field modified since the last save (the same
  6px dot an edited field shows after its label; only on saved records, like the field);
- a **red count pill** with `fa-circle-exclamation` — how many fields in the section
  are invalid: a failing validation rule, or a required field left empty in edit mode
  (the same two conditions that paint a field's underline red). A warning-only section
  gets an amber pill instead.

**Derived, not declared.** Every `<mj-collapsible-panel>` computes its
`SectionIndicators` live from the `mj-form-field`s it projects — the same `IsDirty` /
`ShowErrors` / `IsRequiredEmpty` getters the fields use for their own dot and underline
— so the section can never disagree with its fields, and generated forms, custom
`*Extended` forms, and slot-mounted `BaseFormPanel`s that wrap a collapsible panel all
get the marks with no code. Failed-save errors whose `Source` is a graph path
(`Lines[2].Amount`) route to the panel that owns the collection: a `SectionKey` that
matches the leading segment claims it automatically; declare
`ValidationSources="Modifications"` when the names differ. Graph errors are never
matched on their trailing field name, so a child failure cannot land on the header.

**Custom content.** A section whose content is not `mj-form-field` (an inline grid, a
designer) supplies its own counts through the panel's `[Indicators]` input — they are
added to whatever the panel derives:

```html
<mj-collapsible-panel SectionKey="lines" SectionName="Lines" [Form]="this" [FormContext]="formContext"
    [Indicators]="{ DirtyCount: LineEditor.EditedRows, ErrorCount: LineEditor.InvalidRows }">
```

A custom section that is not a collapsible panel at all can implement
`FormSectionIndicatorSource` and register with the container-provided
`FormSectionIndicatorCoordinator` (`inject(FormSectionIndicatorCoordinator, { optional: true })`);
the rail reads it like any other section. Both are exported from `@memberjunction/ng-base-forms`.

The rail reads the coordinator on every pass (pull, not push), and each panel nudges it
on a field `ValueChange`, so the marks follow the keystroke rather than the container's
dirty poll. Form-level errors no section claims stay in the spine's whole-form total, so
a rejected save never leaves the rail looking clean. Host hooks for CSS / tests:
`data-dirty-count`, `data-error-count`, `.mj-panel-dirty`, `.mj-panel-has-errors`,
`.mj-panel-has-warnings` on the panel; `.is-dirty` / `.has-errors` on a rail item.

#### Section search

Search matches a group's **title** and each panel's `MatchesSearch` (title
plus registered keywords). It does **not** use `IsVisible` — chrome hides
inactive left-nav groups, so visibility would make search miss everything
except the selected item. Contribution titles (Orders, Payments) match in
both accordion and left-nav. The rail stays visible whenever more than one
chrome group exists **or** search is active, even if only one group hits.
The empty state is **SearchHasNoMatches** (live match), not a baked
ContentChildren snapshot that would miss slot-mounted panels.

Authoring: `Entity.Configuration` / `EntityRelationship.Configuration`
JSONType interfaces, [`PANELS.md`](../packages/Angular/Generic/base-forms/PANELS.md).

### 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`:

```html
<mj-form-dialog [EntityName]="'My Entity'" [RecordID]="id"
  SectionName="QuickEdit" Title="Quick edit" [(Visible)]="show"></mj-form-dialog>
```

```typescript
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.)

### 7c. Record Attachments & File Storage Linking

MemberJunction provides first-class support for linked file attachments directly on entity records via the `<mj-form-toolbar>` paperclip button and the `<mj-record-attachments>` slide-in drawer.

#### Enabling & Gating Attachments
Attachments are enabled when:
1. **Existing Record**: The record is persisted (`record.IsSaved === true`).
2. **Entity Configuration**: `Entity.Configuration` allows attachments (`Attachments.Enabled !== false`).
3. **Storage Subsystem Active**: At least one storage provider is active in `FileStorageEngineBase.Instance.Providers`.
4. **Permissions**: The user has permissions on `MJ: Files` and `MJ: File Entity Record Links`.

#### Entity-Level Configuration (`IEntityAttachmentsConfiguration`)
Stored in `MJ: Entities.Configuration` (JSONType):

```json
{
  "Attachments": {
    "Enabled": true,
    "MaxFileSizeBytes": 52428800,
    "AllowedContentTypes": ["image/*", "application/pdf", ".docx"],
    "DefaultStorageAccountID": "00000000-0000-0000-0000-000000000001"
  }
}
```

#### Capabilities & Features
- **Paperclip Toolbar Button**: Displays real-time badge count (`5`) of linked files on the toolbar.
- **Slide-in Drawer (`<mj-record-attachments>`)**: Resizable drawer with `UserInfoEngine` width and view mode persistence.
- **Provider Filtering**: Filter attachments across cloud storage accounts (Azure Blob, AWS S3, Box, etc.).
- **Rich Media Preview**: In-app previews for PDFs, Images, Audio, Video, Code/Text, and Office Documents.
- **Drag & Drop Upload**: Direct upload pipeline linked into `MJ: File Entity Record Links`.
- **Cancelable Event Hooks**: Full Before/After lifecycle (`BeforeUpload`, `BeforeDelete`, `BeforeUnlink`, `BeforeDownload`, `BeforePreview`, `BeforeReplace`).

---

## 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**:

```typescript
const ref = this.forms.Open({ EntityName: 'Accounts', RecordId: id, Presentation: 'dialog' });
// ...but with Config.EnableRecordLinks = true, or via the declarative shell:
```

```html
<mj-form-dialog [EntityName]="'Accounts'" [RecordID]="id" [(Visible)]="show"
  [Config]="{ EnableRecordLinks: true }"
  (Navigate)="onNavigate($event)"></mj-form-dialog>
```

```typescript
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

| 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](../packages/Angular/Generic/base-forms/PANELS.md) (Content Sources is the example) |
| Replace a form's whole layout | Custom `*Extended` form — [Angular/CLAUDE.md](../packages/Angular/CLAUDE.md) |
| Build a brand-new bespoke editor dialog | **Stop** — first check if a `<mj-form-dialog>` covers it |

---

## 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](../packages/Angular/Generic/base-forms/PANELS.md)
- **Custom forms + toolbar pattern:** [packages/Angular/CLAUDE.md](../packages/Angular/CLAUDE.md)
- **Slide-in primitive:** `MjSlidePanelComponent` in `@memberjunction/ng-ui-components`
