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.Entitiesform / detail builders); granit-front (@granit/entities-reactrenderer) 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.
Context
Section titled “Context”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
fieldtypestrings (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 toCodefields 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.
Decision
Section titled “Decision”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.
| Name | Semantics | Rendered as | Default config |
|---|---|---|---|
text | Single-line free text | <input type="text"> | maxLength from validator |
textarea | Multi-line free text | <textarea> | rows: 4 (configurable) |
richtext | WYSIWYG long-form text | rich editor (e.g. Tiptap) | toolbar: standard |
password | Masked sensitive input | <input type="password"> | autocomplete off |
email | Email address | <input type="email"> | format validator |
url | Web URL | <input type="url"> | format validator |
phone | E.164 phone number | phone input with country picker | locale-aware |
number | Generic numeric | <input type="number"> | min/max/step from validator |
integer | Whole number | <input type="number" step="1"> | strict integer parsing |
decimal | Fixed-precision decimal | numeric input | precision/scale config |
money | Currency amount | numeric + symbol | currencyCode config (ISO 4217) |
percent | Percentage 0..1 | numeric + % suffix | precision config |
boolean | True / false | <input type="checkbox"> | nullable: tri-state |
switch | Boolean as toggle | toggle component | same as boolean |
select | Single choice from fixed options | <select> or radio | options[] config |
multiselect | Multiple choices from fixed options | multi-select | options[] config |
status | Distinct semantic for kanban-aware enum | colored badge / lane-able select | options[] with color hints |
lookup | Reference to another entity | typeahead with async fetch | targetEntity, displayField config |
multilookup | Multiple references | multi-typeahead | same as lookup |
date | Date without time | date picker | min/max config |
datetime | Date + time | datetime picker | timezone config |
time | Time without date | time picker | format config |
daterange | Date range | range picker | same as date |
file | Single file (Granit.BlobStorage ref) | file picker + preview | accept, maxSize config |
multifile | Multiple files | multi-upload | same as file |
image | Image (BlobStorage ref) | image picker + thumbnail | same as file, accept: image/* |
signature | Hand-drawn signature | canvas + save-as-image | stored as BlobStorage ref |
color | Hex color | color picker | format: "#rrggbb" |
code | Source code with syntax highlighting | code editor (e.g. Monaco) | language config |
markdown | Markdown source with preview | split editor | toolbar: standard |
json | Raw JSON value | JSON editor | schema validation when known |
tags | Free-form tag set | chip input | suggestions from completed values |
relation_inline | Embedded child collection (e.g. invoice lines) | inline grid | relationName 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 inGranit.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
customComponentRegistryis 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.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- 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).
Negative / accepted trade-offs
Section titled “Negative / accepted trade-offs”- The catalog is opinionated: there’s no
WYSIWYG-with-track-changes, nosignature-with-handwriting-recognition, novoice-input. Apps that need those usecustom:— 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-frontPhase 1.
Cross-references
Section titled “Cross-references”- 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-047 —
EntityView. User-saved views may not change a collection’skind, mirroring the rule that user-saved field state may not change a component.
References
Section titled “References”- Frappe DocField fieldtypes — research compiled in PR #1599 conversation. Closed set, no extension point ⇒ tenant apps work around with
Codefields 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.