-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path07-ui-schema.html
More file actions
381 lines (359 loc) · 19 KB
/
Copy path07-ui-schema.html
File metadata and controls
381 lines (359 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>07 · UI Schema — Multi-Tenant Platform</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,500;9..144,600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" />
<link rel="stylesheet" href="../assets/styles.css" />
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="../index.html" class="brand">
<span class="brand-mark">MTP</span>
<span class="brand-text">Multi-Tenant Platform</span>
</a>
<nav>
<div class="nav-group">
<h4>Foundations</h4>
<ul>
<li><a href="../index.html"><span class="nav-num">00</span>Overview</a></li>
<li><a href="01-findings.html"><span class="nav-num">01</span>Findings</a></li>
<li><a href="02-architecture.html"><span class="nav-num">02</span>Architecture</a></li>
<li><a href="10-tenancy-model.html"><span class="nav-num">10</span>Tenancy Model</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Validation</h4>
<ul>
<li><a href="03-code-sketch.html"><span class="nav-num">03</span>Code Sketch</a></li>
<li><a href="04-ui-rule-config.html"><span class="nav-num">04</span>UI Rule Config</a></li>
<li><a href="05-result-pattern.html"><span class="nav-num">05</span>Result Pattern</a></li>
<li><a href="06-reconciliation.html"><span class="nav-num">06</span>Reconciliation</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Tenant Surface</h4>
<ul>
<li><a href="07-ui-schema.html" class="active"><span class="nav-num">07</span>UI Schema</a></li>
<li><a href="08-i18n-l10n.html"><span class="nav-num">08</span>i18n / l10n</a></li>
<li><a href="09-extension-fields.html"><span class="nav-num">09</span>Extension Fields</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Frontend</h4>
<ul>
<li><a href="../mockups/classic.html"><span class="nav-num">UI1</span>AppShell — Classic</a></li>
<li><a href="../mockups/zoho.html"><span class="nav-num">UI2</span>AppShell — 2-Stage</a></li>
<li><a href="../mockups/exam-setup.html"><span class="nav-num">UI3</span>AppShell — Exam Setup</a></li>
<li><a href="../mockups/ux-showcase-1.html"><span class="nav-num">UI4</span>AppShell — UX Showcase</a></li>
<li><a href="../mockups/ux-showcase-2.html"><span class="nav-num">UI5</span>AppShell — UX Showcase 2</a></li>
<li><a href="../mockups/ux-showcase-3.html"><span class="nav-num">UI6</span>AppShell — UX Showcase 3</a></li>
<li><a href="../mockups/ux-showcase-4.html"><span class="nav-num">UI8</span>AppShell — UX Showcase 4</a></li>
<li><a href="../mockups/ux-catalog.html"><span class="nav-num">UI7</span>UX Pattern Catalog</a></li>
<li><a href="../mockups/notes.html"><span class="nav-num">UIN</span>AppShell Notes</a></li>
</ul>
</div>
<div class="nav-group">
<h4>Decisions</h4>
<ul>
<li><a href="../adr/0001-hybrid-validation-strategy.html"><span class="nav-num">ADR-1</span>Hybrid Validation</a></li>
<li><a href="../adr/0002-database-per-client.html"><span class="nav-num">ADR-2</span>DB-per-Client</a></li>
<li><a href="../adr/0003-frontend-stack.html"><span class="nav-num">ADR-3</span>Frontend Stack</a></li>
<li><a href="../adr/0004-authorization-permission-model.html"><span class="nav-num">ADR-4</span>Authz & Permissions</a></li>
<li><a href="../adr/0005-repo-topology.html"><span class="nav-num">ADR-5</span>Repo Topology</a></li>
<li><a href="../adr/0006-no-module-federation.html"><span class="nav-num">ADR-6</span>No Module Federation</a></li>
<li><a href="../adr/0007-ui-stack-radix-cssmodules.html"><span class="nav-num">ADR-7</span>UI Stack — Radix</a></li>
<li><a href="../adr/0008-mtp-web-folder-structure.html"><span class="nav-num">ADR-8</span>mtp-web Folder Structure</a></li>
<li><a href="../adr/0009-hr-payroll-three-aggregates.html"><span class="nav-num">ADR-9</span>HR-Payroll — 3 Aggregates, 1 Module</a></li>
</ul>
</div>
<div class="nav-group">
<h4>GNUMS — Conventions</h4>
<ul>
<li><a href="../gnums/module-catalog.html"><span class="nav-num">MOD</span>Module Catalog</a></li>
<li><a href="../gnums/all-modules-inventory.html"><span class="nav-num">REF</span>Procedure Inventory</a></li>
<li><a href="../gnums/org-hierarchy.html"><span class="nav-num">ORG</span>Org Hierarchy</a></li>
<li><a href="../gnums/hierarchy-comparison.html"><span class="nav-num">CMP</span>Hierarchy Comparison</a></li>
</ul>
</div>
<div class="nav-group">
<h4>GNUMS — Modules</h4>
<ul>
<li><a href="../gnums/methodology.html"><span class="nav-num">HOW</span>Study Methodology</a></li>
<li><a href="../gnums/accounting/scope.html"><span class="nav-num">ACC</span>Accounting — Scope</a></li>
<li><a href="../gnums/staff/scope.html"><span class="nav-num">STF</span>Staff — Scope</a></li>
<li><a href="../gnums/hr-payroll/scope.html"><span class="nav-num">HRP</span>HR-Payroll — Scope</a></li>
<li><a href="../gnums/exam/scope.html"><span class="nav-num">EXM</span>Exam — Scope</a></li>
<li><a href="../gnums/exam/strategy.html"><span class="nav-num">PLAY</span>Exam — Strategy</a></li>
<li><a href="../gnums/student/scope.html"><span class="nav-num">STU</span>Student — Scope</a></li>
<li><a href="../gnums/student/strategy.html"><span class="nav-num">SPL</span>Student — Strategy</a></li>
<li><a href="../gnums/student/cluster-01-core-identity.html"><span class="nav-num">SC1</span>Student / Core Identity</a></li>
<li><a href="../gnums/student/cluster-02-subordinate-entities.html"><span class="nav-num">SC2</span>Student / Subordinate Entities</a></li>
<li><a href="../gnums/student/cluster-03-master-config.html"><span class="nav-num">SC3</span>Student / Master & Config</a></li>
<li><a href="../gnums/student/cluster-04-certificate-workflow.html"><span class="nav-num">SC4</span>Student / Certificate Workflow</a></li>
<li><a href="../gnums/student/cluster-05-bvm-activities.html"><span class="nav-num">SC5</span>Student / NCC NSS Yoga</a></li>
<li><a href="../gnums/student/cluster-06-mentoring.html"><span class="nav-num">SC6</span>Student / Mentoring</a></li>
</ul>
</div>
</nav>
</aside>
<main>
<span class="eyebrow">07 · UI</span>
<h1>Per-client UI fields, labels, and ordering</h1>
<p class="lede">
Same data, different presentation per client — "Admission No" vs "Roll No" vs "Student Code".
Schema-driven UI metadata, kept narrow. Server is authoritative; React is a dumb renderer
keyed off a stable <code>FieldKey</code>.
</p>
<div class="callout">
<strong>Where the schema lives:</strong> <code>FormSchemaField</code> table sits inside
each client's Postgres DB (database-per-client model — see
<a href="10-tenancy-model.html">page 10</a>). React renders MUI components via
<code>FieldRenderer</code>; React Hook Form binds via <code>Controller</code>; Zod schemas
derive types and client-side validation. See
<a href="../adr/0003-frontend-stack.html">ADR-0003</a>.
</div>
<h2>The eight dimensions of variation</h2>
<ol>
<li><strong>Field label</strong> — same data, different name</li>
<li><strong>Field visibility</strong> — show/hide per tenant</li>
<li><strong>Field order</strong> — Tenant A wants ID first, Tenant B wants Name first</li>
<li><strong>Field grouping / sections</strong> — tabs, accordions, sub-forms</li>
<li>Help text / placeholder</li>
<li>Field widget — same data, different control (date picker vs text)</li>
<li>Localization — labels in tenant's language</li>
<li><strong>Custom / extension fields</strong> — tenant invents new fields entirely</li>
</ol>
<p>
Items 1–5 are 95% of the real need and are covered here. 6–7 are orthogonal — see
future pages on widget strategy and i18n. <strong>Item 8 is a different beast</strong> (EAV /
JSON columns / schema migrations) — explicitly out of scope for v1. Don't conflate.
</p>
<h2>Core principle: <code>FieldKey</code> is forever</h2>
<p>
Every field has a stable internal identifier (<code>FieldKey</code>) that <strong>never</strong>
changes. Backend, validation rules, audit logs, API contracts all speak <code>FieldKey</code>.
Admins rename <em>labels</em>; <code>FieldKey</code> stays put. This is the single most
important rule on this page.
</p>
<div class="callout">
<strong>Rename guard.</strong> If an admin wants to rename "Mobile" to "Phone Number", that's
a label change — <code>FieldKey="MobileNumber"</code> unchanged. If they want truly different
semantics (track home vs work phone), that's a new field, new <code>FieldKey</code>.
</div>
<h2>The metadata model — kept small</h2>
<pre><code class="language-csharp">public sealed record FieldMetadata(
string FieldKey, // stable internal name; never changes
string Label, // tenant-specific display name
bool Visible = true,
int Order = 0,
string? HelpText = null,
string? Group = null,
string? Placeholder = null);
public sealed class FormSchema
{
public string FeatureKey { get; init; } = "";
public Guid FlowVersionId { get; init; }
public IReadOnlyList<FieldMetadata> Fields { get; init; } = Array.Empty<FieldMetadata>();
}</code></pre>
<p>
Six attributes. No widget type, no validation, no conditional visibility. Resist adding fields
until something concrete needs them. Generic schema libraries
(JSON Schema / react-jsonschema-form / Formily) solve problems you don't have yet.
</p>
<h2>API surface</h2>
<pre><code class="language-csharp">[ApiController]
[Route("api/forms")]
public sealed class FormsController(IFormSchemaService schemas) : ApiController<TokenData>
{
[HttpGet("{featureKey}/schema")]
public async Task<ActionResult<FormSchema>> GetSchema(
string featureKey, CancellationToken ct)
{
var schema = await schemas.GetForCurrentTenantAsync(featureKey, ct);
Response.Headers.ETag = $"\"{schema.FlowVersionId:N}\"";
return Ok(schema);
}
}</code></pre>
<p>
Per-tenant. ETag = <code>FlowVersionId</code> — clients can revalidate cheaply with
<code>If-None-Match</code> and get a 304 when nothing changed.
</p>
<h2>Storage</h2>
<div class="mermaid">
erDiagram
FEATURE_FLOW_VERSION ||--o{ FORM_SCHEMA_FIELD : owns
FEATURE_FLOW_VERSION ||--o{ RULE_SET : owns
FEATURE_FLOW_VERSION {
guid Id PK
string FeatureKey
int Version
string StrategyKey
}
FORM_SCHEMA_FIELD {
guid Id PK
guid FlowVersionId FK
string FieldKey
string Label
bool Visible
int OrderIndex
string HelpText
string GroupName
string Placeholder
}
</div>
<p>
One new table. Joins the same <code>FEATURE_FLOW_VERSION</code> already used by the rule
engine. <strong>Same rollout unit</strong>: a flow version owns rules + UI schema +
strategy class. Repointing the binding swaps everything atomically.
</p>
<h2>React renderer</h2>
<pre><code class="language-tsx">function LoanApplicationForm() {
const { data: schema } = useFormSchema('loan-application');
if (!schema) return <Spinner />;
const visible = schema.fields
.filter(f => f.visible)
.sort((a, b) => a.order - b.order);
const groups = groupBy(visible, f => f.group ?? '_default');
return (
<form>
{Object.entries(groups).map(([group, fields]) => (
<Fieldset key={group} legend={group === '_default' ? null : group}>
{fields.map(f => (
<FieldRenderer key={f.fieldKey} meta={f} />
))}
</Fieldset>
))}
<SubmitButton />
</form>
);
}</code></pre>
<h2>The <code>FieldRenderer</code> — switch on <code>FieldKey</code></h2>
<pre><code class="language-tsx">const FIELD_COMPONENTS: Record<string, React.FC<FieldProps>> = {
CustomerId: CustomerPickerField,
Amount: CurrencyField,
TermMonths: TermSelectField,
Purpose: PurposeDropdownField,
// ... every supported field listed here, in code, by FieldKey
};
function FieldRenderer({ meta }: { meta: FieldMetadata }) {
const Component = FIELD_COMPONENTS[meta.fieldKey];
if (!Component) {
console.warn(`Unknown FieldKey: ${meta.fieldKey}`);
return null;
}
return (
<FormRow label={meta.label} helpText={meta.helpText} placeholder={meta.placeholder}>
<Component name={meta.fieldKey} />
</FormRow>
);
}</code></pre>
<p>
<strong>Field universe is defined in code.</strong> Admins control which fields appear, their
labels, order, and grouping — but cannot invent new fields. Adding a new field type = code
change (add to <code>FIELD_COMPONENTS</code>) + add row to <code>FORM_SCHEMA_FIELD</code> for
each flow version that needs it.
</p>
<h2>Authoritative validation, regardless of UI</h2>
<p>
<strong>UI visibility does not relax server validation.</strong> If a field is hidden on
Tenant A's UI but the rule engine for Tenant A's flow version still requires it, the request
fails. This is intentional — UI is presentation, server is truth.
</p>
<p>
To genuinely make a field optional for a tenant, two things must happen in the same flow
version:
</p>
<ol>
<li><code>FORM_SCHEMA_FIELD.Visible = false</code> (UI hides it)</li>
<li>Corresponding rule(s) for that <code>FieldKey</code> removed or set to <code>Visible=false</code> guard (server doesn't require)</li>
</ol>
<div class="callout">
<strong>CI guard.</strong> Add a test that asserts, for every flow version: every
server-required field is marked <code>Visible=true</code> in the form schema. Drift between
the two is the #1 source of "form looks fine but submit fails" bugs.
</div>
<h2>Admin UI for editing the schema</h2>
<div class="mermaid">
graph LR
A[Admin picks Feature + Tenant] --> B[Loads current FormSchema for flow version]
B --> C[Field list: drag to reorder, edit label inline]
C --> D[Toggle Visible per field]
D --> E[Edit help text / group / placeholder]
E --> F[Dry-run preview with sample payload]
F --> G[Save -> forks new FEATURE_FLOW_VERSION]
G --> H[Operator updates binding to new version]
</div>
<p>Same workflow as the rule editor (page 04): schema lives in flow versions, edits fork a new immutable version, binding switch is the deploy.</p>
<h2>Caching</h2>
<ul>
<li><strong>Server:</strong> form schema cached per <code>FlowVersionId</code> (immutable). Loaded once, never invalidated.</li>
<li><strong>Client:</strong> schema fetched via TanStack Query, ETag-revalidated. Stale entry served from cache pending revalidation.</li>
<li><strong>Binding change → version bump → ETag changes → client refetches.</strong> No manual cache busting.</li>
</ul>
<h2>What this does NOT solve (and shouldn't try to)</h2>
<table>
<thead><tr><th>Concern</th><th>Why deferred</th></tr></thead>
<tbody>
<tr>
<td>Admin invents entirely new fields</td>
<td>EAV or JSON-blob storage; index complexity; query gymnastics. Tackle in v2 if real demand emerges.</td>
</tr>
<tr>
<td>Conditional field visibility (show X if Y selected)</td>
<td>Static visibility covers 90%. Conditional logic = rule engine territory, not metadata.</td>
</tr>
<tr>
<td>Per-field widget swap (date picker vs text input)</td>
<td>Widget is React component code. Swapping in DB = same risk as code-as-data. Defer until concrete need.</td>
</tr>
<tr>
<td>Localized labels</td>
<td>Orthogonal — see next page (i18n/l10n/G11n). For now, label is whatever the admin types in the tenant's chosen language.</td>
</tr>
<tr>
<td>Complex layouts (tabs, wizards, multi-column)</td>
<td><code>Group</code> covers basic sectioning. Complex layouts = code.</td>
</tr>
</tbody>
</table>
<h2>Migration path</h2>
<ol>
<li>Add <code>FORM_SCHEMA_FIELD</code> table. Seed from existing hardcoded React forms: one row per visible field per flow version, with default label = current React label.</li>
<li>Build <code>FormsController.GetSchema</code>.</li>
<li>Replace one form's hardcoded markup with the generic renderer. Verify visual parity.</li>
<li>Roll the renderer to all forms one feature at a time.</li>
<li>Build the admin schema editor only after 2–3 forms are renderer-based and stable.</li>
</ol>
<h2>Decisions captured</h2>
<ul>
<li><strong>Narrow schema</strong>, not JSON Schema. Six attributes, grow as needed.</li>
<li><strong>Field universe in code.</strong> Admins toggle/relabel/reorder; cannot invent.</li>
<li><strong>Server is SoT.</strong> No tenant branching in React.</li>
<li><strong>Reuse flow-version machinery.</strong> One rollout unit owns rules + schema + behavior.</li>
<li><strong>FieldKey is forever.</strong> Labels rename freely; FieldKey never does.</li>
<li><strong>UI hide ≠ server optional.</strong> Both must agree in the same flow version.</li>
</ul>
<div class="doc-footer">
<a href="06-reconciliation.html">
<span class="label">← Previous</span>
<span class="title">06 · Reconciliation</span>
</a>
<a href="08-i18n-l10n.html" class="next">
<span class="label">Next</span>
<span class="title">08 · i18n / l10n →</span>
</a>
</div>
</main>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script>mermaid.initialize({ startOnLoad: true, theme: 'default' });</script>
</body>
</html>