Skip to content

ADR-041: Field component catalog and naming convention

Date: 2026-04-30 (component rename: 2026-05-01) Authors: Jean-Francois Meyers Scope: granit-dotnet (Granit.Entities form / detail builders); granit-front (@granit/entities-react renderer) Epic: #1506 — Refonte UI Hybride Story: #1521 — ADR-041 component catalog Status: Accepted

Naming note (2026-05-01). The original ADR called these “widgets”. Renamed to “components” at the field level to avoid collision with dashboard panels (KPI / Chart / Table / …) which keep the “Widget” naming because they are page-level units. Field-level renderers are small UI controls bound to a single property and “Component” matches the React mental model the front consumes.

ADR-040 locked the rule: the backend declares a typed manifest, the React renderer consumes it. For each form field and detail-panel cell, the manifest needs to communicate how to render without describing HTML, CSS, or React internals — the backend never knows the front’s implementation.

Two anti-patterns to avoid:

  • Frappe ships ~30 fieldtype strings (Data, Currency, Link, Table, Long Text, …) baked into the platform. Adding a new fieldtype requires patching the framework. Custom apps that need novel renderers either monkey-patch the JS bundle or fall back to Code fields with hand-rolled scripts — both routes broken.
  • Odoo uses XML widget="..." attributes that map to JS components in a registry, but the registry is open and untyped: any string works at write time, only fails at render time. Misspellings silently render the default component; tenant customization through Studio adds new component references that may not exist on every install.

Granit needs the closed-set ergonomics of Frappe (predictable, type-safe, testable) and an open extension point for app-specific components — without inviting the runtime-script Pandora’s box.

1. Standard component catalog (closed set, framework-owned)

Section titled “1. Standard component catalog (closed set, framework-owned)”

The framework ships a fixed catalog of standard component names that every Granit React renderer is expected to support out of the box. The backend declares a name; the front maps it to a concrete component. Both sides own this catalog as a contract.

NameSemanticsRendered asDefault config
textSingle-line free text<input type="text">maxLength from validator
textareaMulti-line free text<textarea>rows: 4 (configurable)
richtextWYSIWYG long-form textrich editor (e.g. Tiptap)toolbar: standard
passwordMasked sensitive input<input type="password">autocomplete off
emailEmail address<input type="email">format validator
urlWeb URL<input type="url">format validator
phoneE.164 phone numberphone input with country pickerlocale-aware
numberGeneric numeric<input type="number">min/max/step from validator
integerWhole number<input type="number" step="1">strict integer parsing
decimalFixed-precision decimalnumeric inputprecision/scale config
moneyCurrency amountnumeric + symbolcurrencyCode config (ISO 4217)
percentPercentage 0..1numeric + % suffixprecision config
booleanTrue / false<input type="checkbox">nullable: tri-state
switchBoolean as toggletoggle componentsame as boolean
selectSingle choice from fixed options<select> or radiooptions[] config
multiselectMultiple choices from fixed optionsmulti-selectoptions[] config
statusDistinct semantic for kanban-aware enumcolored badge / lane-able selectoptions[] with color hints
lookupReference to another entitytypeahead with async fetchtargetEntity, displayField config
multilookupMultiple referencesmulti-typeaheadsame as lookup
dateDate without timedate pickermin/max config
datetimeDate + timedatetime pickertimezone config
timeTime without datetime pickerformat config
daterangeDate rangerange pickersame as date
fileSingle file (Granit.BlobStorage ref)file picker + previewaccept, maxSize config
multifileMultiple filesmulti-uploadsame as file
imageImage (BlobStorage ref)image picker + thumbnailsame as file, accept: image/*
signatureHand-drawn signaturecanvas + save-as-imagestored as BlobStorage ref
colorHex colorcolor pickerformat: "#rrggbb"
codeSource code with syntax highlightingcode editor (e.g. Monaco)language config
markdownMarkdown source with previewsplit editortoolbar: standard
jsonRaw JSON valueJSON editorschema validation when known
tagsFree-form tag setchip inputsuggestions from completed values
relation_inlineEmbedded child collection (e.g. invoice lines)inline gridrelationName config

This is the v1 catalog. Adding a new standard name requires an ADR amendment and coordinated change in @granit/entities-react. Removing a name is a breaking change.

2. Custom component namespacing — custom:<app-prefix>-<name>

Section titled “2. Custom component namespacing — custom:<app-prefix>-<name>”

When an app needs a component that isn’t in the standard catalog, the backend declares custom:<app-prefix>-<name> and the front-end registers a matching component in its customComponentRegistry. Examples:

  • custom:invoicing-line-editor (the showcase app’s bespoke invoice-line grid)
  • custom:crm-opportunity-pipeline (a Kanban-of-Kanbans for sales)
  • custom:hr-org-chart (org-chart picker)

Rules:

  • The custom: prefix is mandatory. Any non-prefixed name not in the standard catalog is a hard error in Granit.Entities.Endpoints (rejected at boot via the integrity check from story #1541).
  • The <app-prefix> segment must match ^[a-z][a-z0-9]*$ and identify the owning module / app (avoid collisions across customers).
  • The <name> segment must match ^[a-z][a-z0-9-]*$ (kebab-case, no underscores).
  • The frontend customComponentRegistry is a typed map in TypeScript — missing-key access is a compile-time error in the showcase app, not a runtime fallthrough.
  • Manifest payload includes component: "custom:..." exactly as-declared; the front looks up the registry, falls back to a deliberately ugly placeholder + console error if missing (so the gap is loud in dev and CI screenshots).

3. Component config — typed via JSON Schema, optional per component

Section titled “3. Component config — typed via JSON Schema, optional per component”

A component can carry a config payload alongside its name:

{
"field": "amount",
"component": "money",
"config": { "currencyCode": "EUR", "precision": 2 }
}

Each standard component has a documented config schema (lives next to the component in @granit/entities-react). The C# fluent builder exposes typed wrappers — f.Field(x => x.Amount).Money("EUR", precision: 2) — so the developer never types a JSON blob by hand.

For custom: components, the backend may pass any opaque JSON object; the front’s typed registry validates it.

4. Validation surfaces in the schema, not the component config

Section titled “4. Validation surfaces in the schema, not the component config”

Per ADR-040’s Golden Rule, validation lives in Tier A (FluentValidation). The component config carries rendering hints only (currency code, precision, accepted file types); it never declares required, maxLength, pattern, or other constraints. Those flow through the schema facet of the manifest (JSON Schema generated by IJsonSchemaWriter per #1517 / PR #1599).

This split keeps the contract clean:

  • The component tells the front how to ask.
  • The schema tells the front (and the back) what to accept.

Both are sourced from the same EntityDefinition; the manifest emits them in parallel sections.

  • The standard catalog is 35 names long and covers the vast majority of admin-CRUD needs (form fields, detail cells, kanban cards). Most apps will never need custom:.
  • Adding a new standard component is a deliberate, reviewed decision — caught at ADR review, never accidentally introduced via “let me just hardcode this string.”
  • The custom: namespace is a clean escape hatch with a hard prefix check; misspelling a standard name fails at boot, not at render time.
  • Backend and frontend can evolve the catalog in lockstep via shared schema files (a future codegen story can emit TypeScript types from the C# enum).
  • The catalog is opinionated: there’s no WYSIWYG-with-track-changes, no signature-with-handwriting-recognition, no voice-input. Apps that need those use custom: — which means writing a React component, not just changing config.
  • Tenant admins (Tier B Layer 1, ADR-040) cannot change a field’s component. They reorder, regroup, hide, but the component choice is locked at compile time. Right boundary for ISO 27001 (“the bookkeeper sees a money input, not a code editor”); restrictive for unstructured personal-productivity scenarios (which Granit doesn’t target — see ADR-040).
  • Frontend renderer must be implemented for every standard name in v1 before the showcase ships Phase 1. The cobaye selection (Party + Invoice — see #1530) covers ~14 of the 35 standard components in real use; the remaining ~21 ship with placeholder renderers and integration tests in granit-front Phase 1.
  • ADR-040 — Three-tier metadata architecture. Establishes that component choice is a Tier A concern (compiled), not Tier B (runtime customization).
  • ADR-042 — View catalog. Same closed-set + namespaced-extension pattern, applied to collection display strategies (List, Kanban, Calendar, …).
  • ADR-047EntityView. User-saved views may not change a collection’s kind, mirroring the rule that user-saved field state may not change a component.
  • Frappe DocField fieldtypes — research compiled in PR #1599 conversation. Closed set, no extension point ⇒ tenant apps work around with Code fields and JS overrides.
  • Odoo widget registry — open string-based, no compile-time check. Misspellings fall back to the default widget silently.
  • Notion property types — fully closed (~25 types), no extension. Same ergonomic dividend as Granit’s standard catalog; no story for app-custom components — Notion users live within the catalog.