do to formatters
This commit is contained in:
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* ESLint rules for Display Object Guardrails
|
||||
*
|
||||
* Enforces display object boundaries and purity
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// Rule 1: No IO in display objects
|
||||
'no-io-in-display-objects': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid IO imports in display objects',
|
||||
category: 'Display Objects',
|
||||
},
|
||||
messages: {
|
||||
message: 'DisplayObjects cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const forbiddenPaths = [
|
||||
'@/lib/api/',
|
||||
'@/lib/services/',
|
||||
'@/lib/page-queries/',
|
||||
'@/lib/view-models/',
|
||||
'@/lib/presenters/',
|
||||
];
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value;
|
||||
if (forbiddenPaths.some(path => importPath.includes(path)) &&
|
||||
!isInComment(node)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Rule 2: No non-class display exports
|
||||
'no-non-class-display-exports': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid non-class exports in display objects',
|
||||
category: 'Display Objects',
|
||||
},
|
||||
messages: {
|
||||
message: 'Display Objects must be class-based and export only classes - see apps/website/lib/contracts/display-objects/DisplayObject.ts',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.declaration &&
|
||||
(node.declaration.type === 'FunctionDeclaration' ||
|
||||
(node.declaration.type === 'VariableDeclaration' &&
|
||||
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
}
|
||||
},
|
||||
ExportDefaultDeclaration(node) {
|
||||
if (node.declaration &&
|
||||
node.declaration.type !== 'ClassDeclaration' &&
|
||||
node.declaration.type !== 'ClassExpression') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function isInComment(node) {
|
||||
return false;
|
||||
}
|
||||
138
apps/website/eslint-rules/formatter-rules.js
Normal file
138
apps/website/eslint-rules/formatter-rules.js
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ESLint rules for Formatter/Display Guardrails
|
||||
*
|
||||
* Enforces boundaries and purity for Formatters and Display Objects
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// Rule 1: No IO in formatters/displays
|
||||
'no-io-in-display-objects': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid IO imports in formatters and displays',
|
||||
category: 'Formatters',
|
||||
},
|
||||
messages: {
|
||||
message: 'Formatters/Displays cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/formatters/Formatter.ts',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const forbiddenPaths = [
|
||||
'@/lib/api/',
|
||||
'@/lib/services/',
|
||||
'@/lib/page-queries/',
|
||||
'@/lib/view-models/',
|
||||
'@/lib/presenters/',
|
||||
];
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const importPath = node.source.value;
|
||||
if (forbiddenPaths.some(path => importPath.includes(path)) &&
|
||||
!isInComment(node)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Rule 2: No non-class display exports
|
||||
'no-non-class-display-exports': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid non-class exports in formatters and displays',
|
||||
category: 'Formatters',
|
||||
},
|
||||
messages: {
|
||||
message: 'Formatters and Displays must be class-based and export only classes - see apps/website/lib/contracts/formatters/Formatter.ts',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
ExportNamedDeclaration(node) {
|
||||
if (node.declaration &&
|
||||
(node.declaration.type === 'FunctionDeclaration' ||
|
||||
(node.declaration.type === 'VariableDeclaration' &&
|
||||
!node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
}
|
||||
},
|
||||
ExportDefaultDeclaration(node) {
|
||||
if (node.declaration &&
|
||||
node.declaration.type !== 'ClassDeclaration' &&
|
||||
node.declaration.type !== 'ClassExpression') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Rule 3: Formatters must return primitives
|
||||
'formatters-must-return-primitives': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce that Formatters return primitive values for ViewData compatibility',
|
||||
category: 'Formatters',
|
||||
},
|
||||
messages: {
|
||||
message: 'Formatters used in ViewDataBuilders must return primitive values (string, number, boolean, null) - see apps/website/lib/contracts/formatters/Formatter.ts',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isViewDataBuilder = filename.includes('/lib/builders/view-data/');
|
||||
|
||||
if (!isViewDataBuilder) return {};
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
// Check if calling a Formatter/Display method
|
||||
if (node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.name &&
|
||||
(node.callee.object.name.endsWith('Formatter') || node.callee.object.name.endsWith('Display'))) {
|
||||
|
||||
// If it's inside a ViewData object literal, it must be a primitive return
|
||||
let parent = node.parent;
|
||||
while (parent) {
|
||||
if (parent.type === 'Property' && parent.parent.type === 'ObjectExpression') {
|
||||
// This is a property in an object literal (likely ViewData)
|
||||
// We can't easily check the return type of the method at lint time without type info,
|
||||
// but we can enforce that it's not the whole object being assigned.
|
||||
if (node.callee.property.name === 'format' || node.callee.property.name.startsWith('format')) {
|
||||
// Good: calling a format method
|
||||
return;
|
||||
}
|
||||
|
||||
// If they are assigning the result of a non-format method, warn
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'message',
|
||||
});
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
function isInComment(node) {
|
||||
return false;
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
const presenterContract = require('./presenter-contract');
|
||||
const rscBoundaryRules = require('./rsc-boundary-rules');
|
||||
const templatePurityRules = require('./template-purity-rules');
|
||||
const displayObjectRules = require('./display-object-rules');
|
||||
const displayObjectRules = require('./formatter-rules');
|
||||
const pageQueryRules = require('./page-query-rules');
|
||||
const servicesRules = require('./services-rules');
|
||||
const clientOnlyRules = require('./client-only-rules');
|
||||
@@ -30,7 +30,6 @@ const mutationContract = require('./mutation-contract');
|
||||
const serverActionsMustUseMutations = require('./server-actions-must-use-mutations');
|
||||
const viewDataLocation = require('./view-data-location');
|
||||
const viewDataBuilderContract = require('./view-data-builder-contract');
|
||||
const viewModelBuilderContract = require('./view-model-builder-contract');
|
||||
const singleExportPerFile = require('./single-export-per-file');
|
||||
const filenameMatchesExport = require('./filename-matches-export');
|
||||
const pageQueryMustUseBuilders = require('./page-query-must-use-builders');
|
||||
@@ -48,7 +47,6 @@ const serverActionsInterface = require('./server-actions-interface');
|
||||
const noDisplayObjectsInUi = require('./no-display-objects-in-ui');
|
||||
const viewDataBuilderImplements = require('./view-data-builder-implements');
|
||||
const viewDataBuilderImports = require('./view-data-builder-imports');
|
||||
const viewModelBuilderImplements = require('./view-model-builder-implements');
|
||||
const viewDataImplements = require('./view-data-implements');
|
||||
const viewModelImplements = require('./view-model-implements');
|
||||
const viewModelTaxonomy = require('./view-model-taxonomy');
|
||||
@@ -85,6 +83,7 @@ module.exports = {
|
||||
// Display Object Rules
|
||||
'display-no-domain-models': displayObjectRules['no-io-in-display-objects'],
|
||||
'display-no-business-logic': displayObjectRules['no-non-class-display-exports'],
|
||||
'formatters-must-return-primitives': displayObjectRules['formatters-must-return-primitives'],
|
||||
'no-display-objects-in-ui': noDisplayObjectsInUi,
|
||||
|
||||
// Page Query Rules
|
||||
@@ -139,8 +138,6 @@ module.exports = {
|
||||
'view-data-implements': viewDataImplements,
|
||||
|
||||
// View Model Rules
|
||||
'view-model-builder-contract': viewModelBuilderContract,
|
||||
'view-model-builder-implements': viewModelBuilderImplements,
|
||||
'view-model-implements': viewModelImplements,
|
||||
'view-model-taxonomy': viewModelTaxonomy,
|
||||
|
||||
@@ -222,6 +219,7 @@ module.exports = {
|
||||
// Display Objects
|
||||
'gridpilot-rules/display-no-domain-models': 'error',
|
||||
'gridpilot-rules/display-no-business-logic': 'error',
|
||||
'gridpilot-rules/formatters-must-return-primitives': 'error',
|
||||
'gridpilot-rules/no-display-objects-in-ui': 'error',
|
||||
|
||||
// Page Queries
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* ESLint rule to enforce View Model Builder contract
|
||||
*
|
||||
* View Model Builders must:
|
||||
* 1. Be classes named *ViewModelBuilder
|
||||
* 2. Have a static build() method
|
||||
* 3. Use 'satisfies ViewModelBuilder<...>' for static enforcement
|
||||
* 4. Accept View Data as parameter
|
||||
* 5. Return View Model
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce View Model Builder contract',
|
||||
category: 'Builders',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
|
||||
missingStaticBuild: 'View Model Builders must have a static build() method',
|
||||
missingSatisfies: 'View Model Builders must use "satisfies ViewModelBuilder<...>" for static type enforcement',
|
||||
invalidBuildSignature: 'build() method must accept View Data and return View Model',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
|
||||
|
||||
if (!isInViewModelBuilders) return {};
|
||||
|
||||
let hasStaticBuild = false;
|
||||
let hasSatisfies = false;
|
||||
let hasCorrectSignature = false;
|
||||
|
||||
return {
|
||||
// Check class declaration
|
||||
ClassDeclaration(node) {
|
||||
const className = node.id?.name;
|
||||
|
||||
if (!className || !className.endsWith('ViewModelBuilder')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'notAClass',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for static build method
|
||||
const staticBuild = node.body.body.find(member =>
|
||||
member.type === 'MethodDefinition' &&
|
||||
member.key.type === 'Identifier' &&
|
||||
member.key.name === 'build' &&
|
||||
member.static === true
|
||||
);
|
||||
|
||||
if (staticBuild) {
|
||||
hasStaticBuild = true;
|
||||
|
||||
// Check signature - should have at least one parameter
|
||||
if (staticBuild.value &&
|
||||
staticBuild.value.params &&
|
||||
staticBuild.value.params.length > 0) {
|
||||
hasCorrectSignature = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check for satisfies expression
|
||||
TSSatisfiesExpression(node) {
|
||||
if (node.typeAnnotation &&
|
||||
node.typeAnnotation.type === 'TSTypeReference' &&
|
||||
node.typeAnnotation.typeName.name === 'ViewModelBuilder') {
|
||||
hasSatisfies = true;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!hasStaticBuild) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingStaticBuild',
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasSatisfies) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingSatisfies',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasStaticBuild && !hasCorrectSignature) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'invalidBuildSignature',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* ESLint rule to enforce View Model Builder contract implementation
|
||||
*
|
||||
* View Model Builders in lib/builders/view-models/ must:
|
||||
* 1. Be classes named *ViewModelBuilder
|
||||
* 2. Implement the ViewModelBuilder<TInput, TOutput> interface
|
||||
* 3. Have a static build() method
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce View Model Builder contract implementation',
|
||||
category: 'Builders',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
notAClass: 'View Model Builders must be classes named *ViewModelBuilder',
|
||||
missingImplements: 'View Model Builders must implement ViewModelBuilder<TInput, TOutput> interface',
|
||||
missingBuildMethod: 'View Model Builders must have a static build() method',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInViewModelBuilders = filename.includes('/lib/builders/view-models/');
|
||||
|
||||
if (!isInViewModelBuilders) return {};
|
||||
|
||||
let hasImplements = false;
|
||||
let hasBuildMethod = false;
|
||||
|
||||
return {
|
||||
// Check class declaration
|
||||
ClassDeclaration(node) {
|
||||
const className = node.id?.name;
|
||||
|
||||
if (!className || !className.endsWith('ViewModelBuilder')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'notAClass',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if class implements ViewModelBuilder interface
|
||||
if (node.implements && node.implements.length > 0) {
|
||||
for (const impl of node.implements) {
|
||||
// Handle GenericTypeAnnotation for ViewModelBuilder<TInput, TOutput>
|
||||
if (impl.expression.type === 'TSInstantiationExpression') {
|
||||
const expr = impl.expression.expression;
|
||||
if (expr.type === 'Identifier' && expr.name === 'ViewModelBuilder') {
|
||||
hasImplements = true;
|
||||
}
|
||||
} else if (impl.expression.type === 'Identifier') {
|
||||
// Handle simple ViewModelBuilder (without generics)
|
||||
if (impl.expression.name === 'ViewModelBuilder') {
|
||||
hasImplements = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for static build method
|
||||
const buildMethod = node.body.body.find(member =>
|
||||
member.type === 'MethodDefinition' &&
|
||||
member.key.type === 'Identifier' &&
|
||||
member.key.name === 'build' &&
|
||||
member.static === true
|
||||
);
|
||||
|
||||
if (buildMethod) {
|
||||
hasBuildMethod = true;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!hasImplements) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingImplements',
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasBuildMethod) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'missingBuildMethod',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
* 1. NOT contain the word "DTO" (DTOs are for API/Services)
|
||||
* 2. NOT define ViewData interfaces (ViewData must be in lib/view-data/)
|
||||
* 3. NOT import from DTO paths (DTOs belong to lib/types/generated/)
|
||||
* 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/display-objects/
|
||||
* 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/formatters/
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
@@ -22,7 +22,7 @@ module.exports = {
|
||||
noDtoInViewModel: 'ViewModels must not use the word "DTO". DTOs belong to the API/Service layer. Use plain logic-rich properties or ViewData types.',
|
||||
noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.',
|
||||
noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.',
|
||||
strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/display-objects/. External imports are allowed. Found: {{importPath}}',
|
||||
strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/formatters/. External imports are allowed. Found: {{importPath}}',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -70,7 +70,7 @@ module.exports = {
|
||||
'@/lib/contracts/',
|
||||
'@/lib/view-models/',
|
||||
'@/lib/view-data/',
|
||||
'@/lib/display-objects/',
|
||||
'@/lib/formatters/',
|
||||
];
|
||||
|
||||
const isAllowed = allowedPaths.some(path => importPath.startsWith(path));
|
||||
@@ -83,12 +83,12 @@ module.exports = {
|
||||
importPath.includes('/lib/contracts/') ||
|
||||
importPath.includes('/lib/view-models/') ||
|
||||
importPath.includes('/lib/view-data/') ||
|
||||
importPath.includes('/lib/display-objects/') ||
|
||||
importPath.includes('/lib/formatters/') ||
|
||||
// Also check for patterns like ../contracts/...
|
||||
importPath.includes('contracts') ||
|
||||
importPath.includes('view-models') ||
|
||||
importPath.includes('view-data') ||
|
||||
importPath.includes('display-objects') ||
|
||||
importPath.includes('formatters') ||
|
||||
// Allow relative imports to view models (e.g., ./InvoiceViewModel, ../ViewModelName)
|
||||
// This matches patterns like ./ViewModelName or ../ViewModelName
|
||||
/^\.\/[A-Z][a-zA-Z0-9]*ViewModel$/.test(importPath) ||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* ViewModel Builder Contract
|
||||
*
|
||||
* Purpose: Transform ViewData into ViewModels for client-side state management
|
||||
*
|
||||
* Rules:
|
||||
* - Deterministic and side-effect free
|
||||
* - No HTTP/API calls
|
||||
* - Input: ViewData (JSON-serializable template-ready data)
|
||||
* - Output: ViewModel (client-only class)
|
||||
* - Must be in lib/builders/view-models/
|
||||
* - Must be named *ViewModelBuilder
|
||||
* - Must have 'use client' directive
|
||||
* - Must implement static build() method
|
||||
* - Must use 'satisfies' for static type enforcement
|
||||
*/
|
||||
|
||||
import { ViewData } from '../view-data/ViewData';
|
||||
import { ViewModel } from '../view-models/ViewModel';
|
||||
|
||||
/**
|
||||
* ViewModel Builder Contract (Static)
|
||||
*
|
||||
* Usage:
|
||||
* export class MyViewModelBuilder {
|
||||
* static build(viewData: MyViewData): MyViewModel { ... }
|
||||
* }
|
||||
* MyViewModelBuilder satisfies ViewModelBuilder<MyViewData, MyViewModel>;
|
||||
*/
|
||||
export interface ViewModelBuilder<TViewData extends ViewData, TViewModel extends ViewModel> {
|
||||
build(viewData: TViewData): TViewModel;
|
||||
}
|
||||
@@ -3,20 +3,38 @@
|
||||
*
|
||||
* Deterministic, reusable, UI-only formatting/mapping logic.
|
||||
*
|
||||
* Based on DISPLAY_OBJECTS.md:
|
||||
* - Class-based
|
||||
* - Immutable
|
||||
* Based on FORMATTERS.md:
|
||||
* - Stateless (Formatters) or Immutable (Display Objects)
|
||||
* - Deterministic
|
||||
* - Side-effect free
|
||||
* - No Intl.* or toLocale*
|
||||
* - No business rules
|
||||
*
|
||||
* Uncle Bob says: "Data structures should not have behavior."
|
||||
* Formatters ensure ViewData remains a dumb container of primitives.
|
||||
*/
|
||||
|
||||
export interface Formatter {
|
||||
/**
|
||||
* Format or map the display object
|
||||
* Format or map the input to a primitive value
|
||||
*
|
||||
* @returns Primitive values only (strings, numbers, booleans)
|
||||
* @returns Primitive values only (strings, numbers, booleans, null)
|
||||
*/
|
||||
format(): unknown;
|
||||
format(): string | number | boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich Display Object contract (Client-only)
|
||||
*
|
||||
* Used by ViewModels to provide a rich API to the UI.
|
||||
*/
|
||||
export interface DisplayObject {
|
||||
/**
|
||||
* Primary primitive output
|
||||
*/
|
||||
format(): string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* Multiple primitive variants
|
||||
*/
|
||||
variants?(): Record<string, string | number | boolean | null>;
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
import { JsonValue } from "../types/primitives";
|
||||
|
||||
/**
|
||||
* Base interface for ViewData objects
|
||||
*
|
||||
* All ViewData must be JSON-serializable for SSR.
|
||||
* This type ensures no class instances or functions are included.
|
||||
*
|
||||
* Note: We use 'any' here to allow complex DTO structures, but the
|
||||
* architectural rule is that these must be plain JSON objects.
|
||||
* Uncle Bob says: "Data structures should not have behavior."
|
||||
* ViewData is a dumb container for primitives and nested JSON only.
|
||||
*/
|
||||
export interface ViewData {
|
||||
[key: string]: JsonValue;
|
||||
[key: string]: any;
|
||||
}
|
||||
/**
|
||||
* Helper type to ensure a type is ViewData-compatible
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ViewData } from '@/lib/contracts/view-data/ViewData';
|
||||
*
|
||||
* ViewData for category icon media rendering.
|
||||
*/
|
||||
import { ViewData } from "../contracts/view-data/ViewData";
|
||||
|
||||
export interface CategoryIconViewData extends ViewData {
|
||||
buffer: string; // base64 encoded
|
||||
|
||||
@@ -2,41 +2,25 @@
|
||||
|
||||
This document defines the **Builder** pattern for `apps/website`.
|
||||
|
||||
Builders exist to transform data between presentation model types.
|
||||
Builders exist to transform raw API data into flat, serializable **ViewData**.
|
||||
|
||||
## 1) Definition
|
||||
|
||||
A **Builder** is a deterministic, side-effect free transformation between website presentation models.
|
||||
A **Builder** is a deterministic, side-effect free transformation that bridges the boundary between the API (DTOs) and the Template (ViewData).
|
||||
|
||||
There are two types of builders:
|
||||
|
||||
### 1.1 ViewModel Builders
|
||||
Transform API Transport DTOs into ViewModels.
|
||||
|
||||
**Purpose**: Prepare raw API data for client-side state management.
|
||||
|
||||
**Location**: `apps/website/lib/builders/view-models/**`
|
||||
|
||||
**Pattern**:
|
||||
```typescript
|
||||
export class AdminViewModelBuilder {
|
||||
static build(dto: UserDto): AdminUserViewModel {
|
||||
return new AdminUserViewModel(dto);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 ViewData Builders
|
||||
### 1.1 ViewData Builders
|
||||
Transform API DTOs directly into ViewData for templates.
|
||||
|
||||
**Purpose**: Prepare API data for server-side rendering without ViewModels.
|
||||
**Purpose**: Prepare API data for server-side rendering. They ensure that logic-rich behavior is stripped away, leaving only a "dumb" JSON structure safe for SSR and hydration.
|
||||
|
||||
**Location**: `apps/website/lib/builders/view-data/**`
|
||||
|
||||
**Pattern**:
|
||||
```typescript
|
||||
import { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder';
|
||||
|
||||
export class LeagueViewDataBuilder {
|
||||
static build(apiDto: LeagueApiDto): LeagueDetailViewData extends ViewData {
|
||||
static build(apiDto: LeagueApiDto): LeagueDetailViewData {
|
||||
return {
|
||||
leagueId: apiDto.id,
|
||||
name: apiDto.name,
|
||||
@@ -44,164 +28,90 @@ export class LeagueViewDataBuilder {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce static compliance without dummy instances
|
||||
LeagueViewDataBuilder satisfies ViewDataBuilder<LeagueApiDto, LeagueDetailViewData>;
|
||||
```
|
||||
|
||||
## 2) Non-negotiable rules
|
||||
|
||||
### ViewModel Builders
|
||||
1. MUST be deterministic
|
||||
2. MUST be side-effect free
|
||||
3. MUST NOT perform HTTP
|
||||
4. MUST NOT call API clients
|
||||
5. MUST NOT access cookies/headers
|
||||
6. Input: API Transport DTO
|
||||
7. Output: ViewModel
|
||||
8. MUST live in `lib/builders/view-models/**`
|
||||
|
||||
### ViewData Builders
|
||||
1. MUST be deterministic
|
||||
2. MUST be side-effect free
|
||||
3. MUST NOT perform HTTP
|
||||
4. MUST NOT call API clients
|
||||
5. MUST NOT access cookies/headers
|
||||
6. Input: API DTO
|
||||
7. Output: ViewData
|
||||
8. MUST live in `lib/builders/view-data/**`
|
||||
1. MUST be deterministic.
|
||||
2. MUST be side-effect free.
|
||||
3. MUST NOT perform HTTP or call API clients.
|
||||
4. Input: API DTO.
|
||||
5. Output: ViewData (Plain JSON).
|
||||
6. MUST live in `lib/builders/view-data/**`.
|
||||
7. MUST use `static build()` and `satisfies ViewDataBuilder`.
|
||||
8. MUST use **Formatters** for primitive output (strings/numbers).
|
||||
|
||||
## 3) Why two builder types?
|
||||
## 3) Why no ViewModel Builders?
|
||||
|
||||
**ViewModel Builders** (API → Client State):
|
||||
- Bridge the API boundary
|
||||
- Convert transport types to client classes
|
||||
- Add client-only fields if needed
|
||||
- Run in client code
|
||||
**ViewModels are self-building.**
|
||||
|
||||
**ViewData Builders** (API → Render Data):
|
||||
- Bridge the presentation boundary
|
||||
- Transform API data directly for templates
|
||||
- Format values for display
|
||||
- Run in server code (RSC)
|
||||
A ViewModel is a class that wraps data to provide behavior. Instead of a separate builder class, ViewModels are instantiated directly from ViewData using their **Constructor**. This removes unnecessary "ceremony" and keeps the API unambiguous.
|
||||
|
||||
**❌ Redundant Pattern (Forbidden):**
|
||||
```typescript
|
||||
// Why have this extra class?
|
||||
export class TeamViewModelBuilder {
|
||||
static build(data: TeamViewData): TeamViewModel {
|
||||
return new TeamViewModel(data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Clean Pattern (Required):**
|
||||
```typescript
|
||||
// Just use the class itself in the ClientWrapper
|
||||
const vm = new TeamViewModel(viewData);
|
||||
```
|
||||
|
||||
## 4) Relationship to other patterns
|
||||
|
||||
```
|
||||
API Transport DTO
|
||||
↓
|
||||
ViewModel Builder (lib/builders/view-models/)
|
||||
↓
|
||||
ViewModel (lib/view-models/)
|
||||
↓
|
||||
(for client components)
|
||||
|
||||
API Transport DTO
|
||||
↓
|
||||
ViewData Builder (lib/builders/view-data/)
|
||||
↓
|
||||
ViewData (lib/templates/)
|
||||
Formatters (lib/display-objects/) -- [primitive output]
|
||||
↓
|
||||
Template (lib/templates/)
|
||||
ViewData (Plain JSON)
|
||||
↓
|
||||
Template (SSR)
|
||||
↓
|
||||
ViewModel (lib/view-models/) -- [new ViewModel(viewData)]
|
||||
↓
|
||||
Display Objects (lib/display-objects/) -- [rich API]
|
||||
```
|
||||
|
||||
## 5) Naming convention
|
||||
|
||||
**ViewModel Builders**: `*ViewModelBuilder`
|
||||
- `AdminViewModelBuilder`
|
||||
- `RaceViewModelBuilder`
|
||||
|
||||
**ViewData Builders**: `*ViewDataBuilder`
|
||||
- `LeagueViewDataBuilder`
|
||||
- `RaceViewDataBuilder`
|
||||
|
||||
## 6) File structure
|
||||
## 6) Usage example (Server Component)
|
||||
|
||||
```
|
||||
lib/
|
||||
builders/
|
||||
view-models/
|
||||
AdminViewModelBuilder.ts
|
||||
RaceViewModelBuilder.ts
|
||||
index.ts
|
||||
|
||||
view-data/
|
||||
LeagueViewDataBuilder.ts
|
||||
RaceViewDataBuilder.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
## 7) Usage examples
|
||||
|
||||
### ViewModel Builder (Client Component)
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { AdminViewModelBuilder } from '@/lib/builders/view-models/AdminViewModelBuilder';
|
||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||
|
||||
export function AdminPage() {
|
||||
const [users, setUsers] = useState<AdminUserViewModel[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const apiClient = new AdminApiClient();
|
||||
const dto = await apiClient.getUsers();
|
||||
const viewModels = dto.map(d => AdminViewModelBuilder.build(d));
|
||||
setUsers(viewModels);
|
||||
}, []);
|
||||
|
||||
// ... render with viewModels
|
||||
}
|
||||
```
|
||||
|
||||
### ViewData Builder (Server Component)
|
||||
```typescript
|
||||
import { LeagueViewDataBuilder } from '@/lib/builders/view-data/LeagueViewDataBuilder';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||
|
||||
export default async function LeagueDetailPage({ params }) {
|
||||
const apiDto = await LeagueDetailPageQuery.execute(params.id);
|
||||
|
||||
// Transform to flat JSON for SSR
|
||||
const viewData = LeagueViewDataBuilder.build(apiDto);
|
||||
|
||||
return <LeagueDetailTemplate viewData={viewData} />;
|
||||
}
|
||||
```
|
||||
|
||||
## 8) Common mistakes
|
||||
|
||||
❌ **Wrong**: Using "Presenter" for DTO → ViewModel
|
||||
```typescript
|
||||
// DON'T
|
||||
export class AdminPresenter {
|
||||
static createViewModel(dto: UserDto): AdminUserViewModel { ... }
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Correct**: Use ViewModelBuilder
|
||||
```typescript
|
||||
export class AdminViewModelBuilder {
|
||||
static build(dto: UserDto): AdminUserViewModel { ... }
|
||||
}
|
||||
```
|
||||
|
||||
❌ **Wrong**: Using "Transformer" for ViewModel → ViewData
|
||||
```typescript
|
||||
// DON'T
|
||||
export class RaceResultsDataTransformer {
|
||||
static transform(...): TransformedData { ... }
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Correct**: Use ViewDataBuilder
|
||||
```typescript
|
||||
export class RaceResultsViewDataBuilder {
|
||||
static build(...): RaceResultsViewData extends ViewData { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## 9) Enforcement
|
||||
## 7) Enforcement
|
||||
|
||||
These rules are enforced by ESLint:
|
||||
- `gridpilot-rules/view-model-builder-contract`
|
||||
- `gridpilot-rules/view-data-builder-contract`
|
||||
- `gridpilot-rules/filename-view-model-builder-match`
|
||||
- `gridpilot-rules/filename-view-data-builder-match`
|
||||
- `gridpilot-rules/formatters-must-return-primitives`
|
||||
|
||||
See [`docs/architecture/website/WEBSITE_GUARDRAILS.md`](WEBSITE_GUARDRAILS.md) for details.
|
||||
See [`docs/architecture/website/WEBSITE_CONTRACT.md`](WEBSITE_CONTRACT.md) for the authoritative contract.
|
||||
@@ -1,230 +0,0 @@
|
||||
# Displays
|
||||
|
||||
## Definition
|
||||
|
||||
A **Display** encapsulates **reusable, UI-only display logic**.
|
||||
|
||||
In this codebase, a Display is a **Frontend Value Object**:
|
||||
|
||||
- class-based
|
||||
- immutable
|
||||
- deterministic
|
||||
- side-effect free
|
||||
|
||||
### Distinction from Domain Value Objects
|
||||
|
||||
While both are "Value Objects", they serve different layers:
|
||||
|
||||
1. **Domain Value Objects (Core):** Encapsulate business truth and invariants (e.g., `Money`, `EmailAddress`). They are pure and never contain formatting logic.
|
||||
2. **Display Objects (Website):** Encapsulate presentation truth and formatting (e.g., `PriceDisplay`, `DateDisplay`). They are used to transform raw data or Domain Value Objects into user-ready strings.
|
||||
|
||||
It answers the question:
|
||||
|
||||
> “How should this specific piece of information be shown?”
|
||||
|
||||
Displays are **not screen-specific**.
|
||||
They exist to avoid duplicating presentation logic across View Models.
|
||||
|
||||
**Naming Convention:**
|
||||
- Displays MUST end with `Display` suffix
|
||||
- Displays MUST be reusable across multiple screens
|
||||
- Valid examples: `PriceDisplay`, `EmailDisplay`, `RatingDisplay`
|
||||
- Invalid examples: `DashboardRatingDisplay`, `UserProfileDisplay`
|
||||
|
||||
---
|
||||
|
||||
## Responsibilities
|
||||
|
||||
A Display MAY:
|
||||
|
||||
- format values (money, dates, durations)
|
||||
- handle localization only when localization inputs are deterministic (for example: mapping stable codes to stable labels)
|
||||
- map codes to labels
|
||||
- encapsulate UI display conventions
|
||||
- be reused across multiple View Models
|
||||
|
||||
In addition, a Display MAY:
|
||||
|
||||
- normalize presentation inputs (for example trimming/casing)
|
||||
- expose multiple explicit display variants (for example `shortLabel`, `longLabel`)
|
||||
|
||||
A Display MUST:
|
||||
|
||||
- be deterministic
|
||||
- be side-effect free
|
||||
- operate only on presentation data
|
||||
|
||||
A Display MUST:
|
||||
|
||||
- be implemented as a **class** with a small, explicit API
|
||||
- accept only primitives/plain data in its constructor (or static factory)
|
||||
- expose only primitive outputs (strings/numbers/booleans)
|
||||
|
||||
---
|
||||
|
||||
## Restrictions
|
||||
|
||||
A Display MUST NOT:
|
||||
|
||||
- contain business logic
|
||||
- enforce domain invariants
|
||||
- perform validation
|
||||
- influence system behavior
|
||||
- be sent back to the server
|
||||
- depend on backend or infrastructure concerns
|
||||
- **depend on environment-specific APIs** (e.g., `window`, `document`, `navigator`)
|
||||
- **be serialized** (they are classes; only their primitive outputs are stored in `ViewData`)
|
||||
|
||||
In this repository, a Display MUST NOT:
|
||||
|
||||
- call `Intl.*`
|
||||
- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
|
||||
|
||||
Reason: these are runtime-locale/timezone dependent and cause SSR/hydration mismatches.
|
||||
|
||||
### Handling Client-Only Formatting
|
||||
|
||||
If a formatting requirement **strictly requires** client-only APIs (e.g., browser-native relative time or local timezone detection):
|
||||
|
||||
1. It MUST NOT live in a `Display Object`.
|
||||
2. It SHOULD live in a **View Model** (which is client-only).
|
||||
3. The Template should handle the transition from server-provided `ViewData` to client-updated `ViewData`.
|
||||
|
||||
### Best Practices for Time/Date Formatting
|
||||
|
||||
To avoid hydration mismatches while still providing good SEO and UX:
|
||||
|
||||
1. **Use UTC methods for Determinism:** In `Display Objects`, prefer `getUTCDate()`, `getUTCMonth()`, etc., over their local counterparts. This ensures the server and client produce the exact same string regardless of their local timezones.
|
||||
2. **Hardcoded Arrays:** Use hardcoded arrays for month/day names instead of `Intl` to ensure consistency across environments.
|
||||
3. **Pass "Now" as an Argument:** For relative time (e.g., "time ago"), pass the reference "now" timestamp as an argument to the `Display Object` instead of calling `Date.now()` inside it.
|
||||
4. **The "Upgrade" Pattern:**
|
||||
- **Server:** `ViewData Builder` uses a `Display Object` to produce a deterministic UTC-based string (e.g., "2024-01-18 15:00 UTC").
|
||||
- **Client:** `View Model` uses client APIs (`Intl`, `toLocale*`) to produce a localized string (e.g., "3:00 PM") and updates the `ViewData`.
|
||||
|
||||
## Localization rule (strict)
|
||||
|
||||
Localization MUST NOT depend on runtime locale APIs.
|
||||
|
||||
Allowed approaches:
|
||||
|
||||
- API returns the exact labels/strings for the current user context.
|
||||
- Website maps stable codes to stable labels using a deterministic table.
|
||||
|
||||
Forbidden approaches:
|
||||
|
||||
- any usage of `Intl.*`
|
||||
- any usage of `toLocale*`
|
||||
|
||||
If a rule affects system correctness or persistence,
|
||||
it does not belong in a Display.
|
||||
|
||||
---
|
||||
|
||||
## Ownership & Placement
|
||||
|
||||
- Displays belong to the **presentation layer**
|
||||
- They are frontend-only
|
||||
- They are not shared with the backend or core
|
||||
|
||||
Placement rule (strict):
|
||||
|
||||
- Displays live under `apps/website/lib/display-objects/*`.
|
||||
- Filenames MUST match the class name with `.tsx` extension (e.g., `RatingDisplay.tsx` contains `class RatingDisplay`)
|
||||
|
||||
---
|
||||
|
||||
## Relationship to View Models and ViewData Builders
|
||||
|
||||
Displays are the **shared source of truth** for formatting logic across the website:
|
||||
|
||||
- **ViewData Builders (Server):** Use Displays to produce deterministic, formatted strings for SEO and initial SSR.
|
||||
- **View Models (Client):** Use Displays to produce formatted strings for interactive UI and client-specific context.
|
||||
|
||||
Additional strict rules:
|
||||
|
||||
- View Models SHOULD compose Displays.
|
||||
- ViewData Builders SHOULD use Displays for all formatting.
|
||||
- **Templates and Components MUST NOT use Displays directly.** They must receive already-formatted primitive outputs (strings, numbers) via their props.
|
||||
|
||||
Reason: This keeps the rendering layer "dumb" and ensures that the `ViewData` remains the single source of truth for what is displayed on the screen.
|
||||
|
||||
- Displays MUST NOT be serialized or passed across boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Displays SHOULD be tested because they often contain:
|
||||
|
||||
- locale-specific behavior
|
||||
- formatting rules
|
||||
- edge cases visible to users
|
||||
|
||||
Additionally:
|
||||
|
||||
- test determinism by running the same inputs under Node and browser contexts (where applicable)
|
||||
- test boundary rules (no `Intl.*`, no `toLocale*`)
|
||||
|
||||
---
|
||||
|
||||
## Common Candidates (Found in Components)
|
||||
|
||||
The following patterns were identified in `apps/website/components` and SHOULD be migrated to Display Objects:
|
||||
|
||||
### 1. Date & Time
|
||||
- **Month/Year:** `new Date().toLocaleDateString('en-US', { month: 'short', year: 'numeric' })` → `DateDisplay.formatMonthYear()`
|
||||
- **Time only:** `new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })` → `DateDisplay.formatTime()`
|
||||
- **Full Date:** `new Date().toLocaleDateString()` → `DateDisplay.formatShort()` (ensure UTC)
|
||||
- **Relative Time:** `timeAgo(timestamp)` logic → `RelativeTimeDisplay.format(timestamp, now)`
|
||||
|
||||
### 2. Currency & Prices
|
||||
- **Price with Symbol:** `$` + `amount.toFixed(2)` → `CurrencyDisplay.format(amount, 'USD')`
|
||||
- **Compact Price:** `$` + `amount.toLocaleString()` → `CurrencyDisplay.formatCompact(amount)`
|
||||
|
||||
### 3. Numbers & Stats
|
||||
- **Ratings:** `Math.round(rating).toLocaleString()` → `RatingDisplay.format(rating)`
|
||||
- **Percentages:** `(val * 100).toFixed(1) + '%'` → `PercentDisplay.format(val)`
|
||||
- **Consistency:** `${stats.consistency}%` → `ConsistencyDisplay.format(stats.consistency)`
|
||||
- **Average Finish:** `avgFinish.toFixed(1)` → `FinishDisplay.format(avgFinish)`
|
||||
- **Durations:** `duration.toFixed(2) + 'ms'` or `minutes:seconds` → `DurationDisplay.format(ms)`
|
||||
- **Memory:** `(bytes / 1024 / 1024).toFixed(1) + 'MB'` → `MemoryDisplay.format(bytes)`
|
||||
|
||||
### 4. Status & Labels
|
||||
- **Race Status:** Mapping `scheduled | running | completed` to labels → `RaceStatusDisplay`
|
||||
- **Protest Status:** Mapping `pending | under_review | resolved` to labels → `ProtestStatusDisplay`
|
||||
- **Action Status:** Mapping `PENDING | COMPLETED | FAILED` to labels → `ActionStatusDisplay`
|
||||
|
||||
### 5. Pluralization
|
||||
- **Member Count:** `${count} ${count === 1 ? 'member' : 'members'}` → `MemberDisplay.formatCount(count)`
|
||||
- **League Count:** `${count} ${count === 1 ? 'league' : 'leagues'}` → `LeagueDisplay.formatCount(count)`
|
||||
|
||||
---
|
||||
|
||||
## Existing Display Objects
|
||||
|
||||
- **[`DateDisplay`](apps/website/lib/display-objects/DateDisplay.ts)**: UTC-based date and time formatting.
|
||||
- **[`CurrencyDisplay`](apps/website/lib/display-objects/CurrencyDisplay.ts)**: Deterministic currency formatting.
|
||||
- **[`RaceStatusDisplay`](apps/website/lib/display-objects/RaceStatusDisplay.ts)**: Race status labels, variants, and icons.
|
||||
- **[`RatingDisplay`](apps/website/lib/display-objects/RatingDisplay.ts)**: Rounded rating formatting with thousands separators.
|
||||
- **[`RelativeTimeDisplay`](apps/website/lib/display-objects/RelativeTimeDisplay.ts)**: Deterministic relative time (requires "now" argument).
|
||||
- **[`MemberDisplay`](apps/website/lib/display-objects/MemberDisplay.ts)**: Member count pluralization.
|
||||
- **[`LeagueDisplay`](apps/website/lib/display-objects/LeagueDisplay.ts)**: League count pluralization.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
- Displays encapsulate **how something looks** (the single source of truth for formatting logic).
|
||||
- View Models encapsulate **what a screen needs** (including client-specific "last mile" formatting).
|
||||
- Both are presentation concerns.
|
||||
- Neither contains business truth.
|
||||
|
||||
In one sentence: **Displays are the shared source of truth for deterministic formatting logic, used by both the server and the client.**
|
||||
|
||||
---
|
||||
|
||||
## Final Rule: Where does formatting go?
|
||||
|
||||
1. **Is it deterministic?** (e.g., currency symbols, fixed date formats, labels) → **Display Object**.
|
||||
2. **Is it client-only?** (e.g., `Intl.*`, `toLocale*`, browser timezone) → **View Model**.
|
||||
3. **Is it for SEO?** → **ViewData Builder** (using a Display Object).
|
||||
87
docs/architecture/website/FORMATTERS.md
Normal file
87
docs/architecture/website/FORMATTERS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Displays & Formatters
|
||||
|
||||
## Definition
|
||||
|
||||
A **Display** encapsulates **reusable, UI-only display logic**.
|
||||
|
||||
In this codebase, we distinguish between **Formatters** (Stateless Logic) and **Display Objects** (Rich Value Objects).
|
||||
|
||||
### 1. Formatters (The "Mouth")
|
||||
Formatters are pure, stateless utilities. They are the "Experts" on how to transform a raw value into a primitive string/number.
|
||||
- **Usage:** Used by `ViewDataBuilders` (Server) and `ViewModels` (Client).
|
||||
- **Output:** MUST return primitive values only (`string`, `number`, `boolean`, `null`).
|
||||
- **Uncle Bob says:** "Data structures (ViewData) should not have behavior. Keep logic in stateless utilities."
|
||||
|
||||
### 2. Display Objects (The "Rich API")
|
||||
Display Objects are logic-rich **Value Objects** that live only on the client. They wrap data and provide multiple ways to look at it.
|
||||
- **Usage:** Used by `ViewModels` (Client) to provide a rich API to the UI.
|
||||
- **Output:** Can return complex objects or variants.
|
||||
- **Uncle Bob says:** "Objects expose behavior, not data. Use them to hide the complexity of the UI."
|
||||
|
||||
---
|
||||
|
||||
## Responsibilities
|
||||
|
||||
A Display/Formatter MAY:
|
||||
- format values (money, dates, durations)
|
||||
- handle deterministic localization (mapping stable codes to labels)
|
||||
- encapsulate UI display conventions
|
||||
|
||||
A Display/Formatter MUST:
|
||||
- be deterministic
|
||||
- be side-effect free
|
||||
- be implemented as a **class** with static methods (Formatters) or as immutable classes (Display Objects)
|
||||
|
||||
---
|
||||
|
||||
## Restrictions
|
||||
|
||||
A Display/Formatter MUST NOT:
|
||||
- contain business logic (e.g., "Team is full if count > 10")
|
||||
- enforce domain invariants
|
||||
- perform validation
|
||||
- **be serialized** (only their primitive outputs are stored in `ViewData`)
|
||||
- call `Intl.*` or `toLocale*` (unless explicitly marked for client-only ViewModels)
|
||||
|
||||
---
|
||||
|
||||
## Relationship to ViewData and ViewModels
|
||||
|
||||
### The "Primitive Compact" (Server-Side)
|
||||
`ViewDataBuilders` MUST use **Formatters** to produce flat, serializable `ViewData`.
|
||||
- **Rule:** `ViewData` properties assigned from a Display/Formatter MUST be primitives.
|
||||
- **Reason:** Ensures `ViewData` remains a "dumb" JSON structure for SSR.
|
||||
|
||||
### The "Rich API" (Client-Side)
|
||||
`ViewModels` MAY use **Display Objects** to provide interactive formatting.
|
||||
- **Rule:** `ViewModels` can return `Display Object` instances to the UI.
|
||||
- **Reason:** Allows the UI to access multiple variants (e.g., `date.short`, `date.relative`) without re-fetching data.
|
||||
|
||||
---
|
||||
|
||||
## Summary of the Flow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
DTO[Raw DTO] -->|ViewDataBuilder| VD[ViewData]
|
||||
subgraph "Server: The Formatter Compact"
|
||||
VD -->|Uses| F[Stateless Formatter]
|
||||
F -->|Returns| S[Primitive string/number]
|
||||
end
|
||||
|
||||
VD -->|SSR Boundary| T[Template]
|
||||
|
||||
subgraph "Client: The DisplayObject Richness"
|
||||
T -->|Props| CW[ClientWrapper]
|
||||
CW -->|new| VM[ViewModel]
|
||||
VM -->|Wraps| DO[Rich Display Object]
|
||||
DO -->|Provides| R[Rich API: .time, .relative, .date]
|
||||
end
|
||||
```
|
||||
|
||||
## Final Rule: Where does logic live?
|
||||
|
||||
1. **Is it a business rule?** (e.g., "Can join?") → **ViewModel**.
|
||||
2. **Is it a formatting rule?** (e.g., "How to show date?") → **Formatter/Display**.
|
||||
3. **Is it for SEO/SSR?** → **ViewDataBuilder** (using a Formatter).
|
||||
4. **Is it for interaction?** → **ViewModel** (using a Display Object).
|
||||
@@ -224,7 +224,7 @@ Components must only expose props that describe **what** the component is or **h
|
||||
- **`components/` components**: **MUST NOT** use `className`, `style`, or any prop that accepts raw styling values. They must only use the semantic APIs provided by `ui/`.
|
||||
- **`templates/`**: Same as `components/`.
|
||||
|
||||
## The Display Object Layer
|
||||
## The Formatter & Display Object Layer
|
||||
|
||||
**Purpose**: Reusable formatting and presentation logic
|
||||
|
||||
@@ -235,11 +235,13 @@ Components must only expose props that describe **what** the component is or **h
|
||||
- Value transformations for display
|
||||
|
||||
**Rules**:
|
||||
- **Formatters**: Stateless utilities for server-side primitive output.
|
||||
- **Display Objects**: Rich Value Objects for client-side interactive APIs.
|
||||
- Class-based
|
||||
- Immutable
|
||||
- Deterministic
|
||||
- No side effects
|
||||
- No `Intl.*` or `toLocale*`
|
||||
- No `Intl.*` or `toLocale*` (unless client-only)
|
||||
|
||||
**Usage**:
|
||||
```typescript
|
||||
@@ -256,11 +258,15 @@ export class RatingDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
// In ViewModel Builder
|
||||
const viewModel = {
|
||||
rating: RatingDisplay.format(dto.rating),
|
||||
ratingColor: RatingDisplay.getColor(dto.rating)
|
||||
// In ViewData Builder (Server)
|
||||
const viewData = {
|
||||
rating: RatingDisplay.format(dto.rating), // Primitive string
|
||||
};
|
||||
|
||||
// In ViewModel (Client)
|
||||
get rating() {
|
||||
return new RatingDisplay(this.data.rating); // Rich API
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Flow
|
||||
@@ -292,7 +298,7 @@ ui/ (generic primitives)
|
||||
- **Component**: Understands app concepts (teams, races, leagues)
|
||||
- **UI**: Generic building blocks (button, card, input)
|
||||
|
||||
### When to use Display Objects?
|
||||
### When to use Formatters/Display Objects?
|
||||
- When formatting is reusable across multiple ViewModels
|
||||
- When mapping codes to labels
|
||||
- When presentation logic needs to be deterministic
|
||||
|
||||
@@ -12,13 +12,15 @@ ViewData is a JSON-serializable, template-ready data structure:
|
||||
- arrays and plain objects
|
||||
- `null` for missing values
|
||||
|
||||
**Uncle Bob says**: "Data structures should not have behavior." ViewData is a dumb container.
|
||||
|
||||
## 2) What ViewData is NOT
|
||||
|
||||
ViewData is not:
|
||||
|
||||
- an API Transport DTO (raw transport)
|
||||
- a ViewModel (client-only class)
|
||||
- a Display Object instance
|
||||
- a Display Object instance (rich API)
|
||||
|
||||
## 3) Construction rules
|
||||
|
||||
@@ -58,7 +60,7 @@ ViewData Builders MUST NOT call the API.
|
||||
|
||||
ViewData is responsible for providing **fully formatted strings** to Templates for Server-Side Rendering (SSR).
|
||||
|
||||
- **SEO Requirement:** All data required for search engines (prices, dates, counts, labels) MUST be formatted in the `ViewData Builder` on the server.
|
||||
- **SEO Requirement:** All data required for search engines (prices, dates, counts, labels) MUST be formatted in the `ViewData Builder` on the server using **Formatters**.
|
||||
- **Template Simplicity:** Templates should simply render the strings provided in `ViewData` without further processing.
|
||||
|
||||
## 5) Determinism rules
|
||||
@@ -75,7 +77,7 @@ Reason: SSR and browser outputs can differ.
|
||||
Localization MUST NOT depend on runtime locale APIs.
|
||||
If localized strings are required, they MUST be provided as deterministic inputs (for example via API-provided labels or a deterministic code-to-label map) and passed through ViewData Builders into ViewData.
|
||||
|
||||
## 5) Relationship to ViewModels
|
||||
## 6) Relationship to ViewModels
|
||||
|
||||
ViewData serves as the stable, serializable contract between the server and client. It is:
|
||||
- The input for Templates (both SSR and Client)
|
||||
@@ -88,11 +90,11 @@ ViewModels are built from ViewData, not from DTOs. This ensures:
|
||||
|
||||
**Important:** ViewData MUST NOT contain ViewModel instances. ViewModels are classes with logic; ViewData is plain JSON.
|
||||
|
||||
## 6) Relationship to Display Objects
|
||||
## 7) Relationship to Formatters & Display Objects
|
||||
|
||||
Display Objects are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData.
|
||||
Formatters are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData.
|
||||
|
||||
Only primitive outputs produced by Display Objects may appear in ViewData.
|
||||
Only primitive outputs produced by Formatters may appear in ViewData.
|
||||
|
||||
### The "Redundancy" Question
|
||||
DTOs, ViewData, and ViewModels might share similar fields, but they are NOT redundant:
|
||||
|
||||
@@ -11,6 +11,8 @@ It answers the question:
|
||||
View Models are **UI-owned** classes.
|
||||
They do not represent business truth and do not enforce domain rules.
|
||||
|
||||
**Uncle Bob says**: "Objects expose behavior, not data." ViewModels encapsulate the behavior of the UI.
|
||||
|
||||
---
|
||||
|
||||
## Responsibilities
|
||||
@@ -22,14 +24,14 @@ A View Model MAY:
|
||||
- combine or reshape data for rendering
|
||||
- perform formatting (dates, numbers, labels), especially for **client-only context** (e.g., local timezone, relative "time ago")
|
||||
- handle localization and presentation logic
|
||||
- use Display Objects for reusable UI concerns
|
||||
- use **Display Objects** for reusable UI concerns and rich APIs
|
||||
|
||||
### Formatting Responsibility
|
||||
|
||||
While `ViewData Builders` handle formatting for SEO and initial render, `View Models` are responsible for:
|
||||
While `ViewData Builders` handle formatting for SEO and initial render using **Formatters**, `View Models` are responsible for:
|
||||
- **Client-specific formatting:** Data that depends on the browser's locale, timezone, or precise location.
|
||||
- **Interactive formatting:** Updating display values in response to user input or state changes.
|
||||
- **Consistency:** Using the same `Display Objects` as the server to ensure a consistent look and feel.
|
||||
- **Rich APIs:** Providing **Display Objects** to the UI for multi-variant formatting (e.g., `date.short`, `date.relative`).
|
||||
|
||||
In the website SSR/RSC architecture, View Models MAY compute view-only derived values, but MUST NOT be the type passed into Templates.
|
||||
|
||||
@@ -78,7 +80,7 @@ Rules (website):
|
||||
1) View Models are created in client code only.
|
||||
2) View Models are created from **ViewData** (not from DTOs).
|
||||
3) Templates MUST NOT accept View Models; Templates accept ViewData only.
|
||||
4) View Models MUST compose Display Objects and produce ViewData (primitive outputs only).
|
||||
4) View Models MUST compose Display Objects and produce rich UI APIs.
|
||||
|
||||
Authoritative reference: [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1).
|
||||
|
||||
|
||||
@@ -51,20 +51,7 @@ Canonical placement in this repo:
|
||||
|
||||
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
|
||||
|
||||
### 3.2 API Transport DTO
|
||||
|
||||
Definition: the shape returned by the backend API over HTTP.
|
||||
|
||||
Rules:
|
||||
|
||||
- API Transport DTOs MUST be contained inside infrastructure.
|
||||
- API Transport DTOs MUST NOT be imported by Templates.
|
||||
|
||||
Canonical placement in this repo:
|
||||
|
||||
- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries)
|
||||
|
||||
### 3.3 ViewData
|
||||
### 3.2 ViewData
|
||||
|
||||
Definition: the only allowed input type for Templates.
|
||||
|
||||
@@ -73,6 +60,7 @@ Rules:
|
||||
- JSON-serializable only.
|
||||
- Contains only template-ready values (mostly strings/numbers/booleans).
|
||||
- MUST NOT contain class instances.
|
||||
- **Uncle Bob says**: "Data structures should not have behavior."
|
||||
|
||||
See [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:1).
|
||||
|
||||
@@ -89,6 +77,7 @@ Rules:
|
||||
- Instantiated only in `'use client'` modules.
|
||||
- Never serialized.
|
||||
- Used for client components that need state management.
|
||||
- **Uncle Bob says**: "Objects expose behavior, not data."
|
||||
|
||||
See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1).
|
||||
|
||||
@@ -112,6 +101,7 @@ Rules:
|
||||
- MUST NOT call the API.
|
||||
- Input: `Result<ApiDto, string>` or `ApiDto`
|
||||
- Output: ViewModel
|
||||
- MUST use `static build()` and `satisfies ViewModelBuilder`.
|
||||
|
||||
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
|
||||
|
||||
@@ -133,6 +123,7 @@ Rules:
|
||||
- MUST NOT call the API.
|
||||
- Input: `Result<ApiDto, string>` or `ApiDto`
|
||||
- Output: ViewData
|
||||
- MUST use `static build()` and `satisfies ViewDataBuilder`.
|
||||
|
||||
See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1).
|
||||
|
||||
@@ -161,18 +152,18 @@ Canonical placement in this repo:
|
||||
|
||||
- `apps/website/lib/contracts/Result.ts`
|
||||
|
||||
### 4.3 Display Object
|
||||
### 4.4 Formatter & Display Object
|
||||
|
||||
Definition: deterministic, reusable, UI-only formatting/mapping logic.
|
||||
|
||||
Rules:
|
||||
|
||||
- Class-based, immutable, deterministic.
|
||||
- MUST NOT call `Intl.*`.
|
||||
- MUST NOT call any `toLocale*`.
|
||||
- **Formatters**: Stateless utilities for server-side primitive output.
|
||||
- **Display Objects**: Rich Value Objects for client-side interactive APIs.
|
||||
- MUST NOT call `Intl.*` or `toLocale*` (unless client-only).
|
||||
- MUST NOT implement business rules.
|
||||
|
||||
See [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1).
|
||||
See [`FORMATTERS.md`](docs/architecture/website/FORMATTERS.md:1).
|
||||
|
||||
Canonical placement in this repo:
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ API Transport DTO
|
||||
↓
|
||||
ViewData Builder (lib/builders/view-data/)
|
||||
↓
|
||||
Formatters (lib/display-objects/) -- [primitive output]
|
||||
↓
|
||||
ViewData (Plain JSON)
|
||||
↓
|
||||
Template
|
||||
@@ -41,7 +43,7 @@ ViewData (from Props)
|
||||
↓
|
||||
ViewModel (lib/view-models/) -- [new ViewModel(viewData)]
|
||||
↓
|
||||
Display Objects (lib/display-objects/) -- [formatting]
|
||||
Display Objects (lib/display-objects/) -- [rich API]
|
||||
↓
|
||||
Interactive UI State
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user