entity-driven-ui
Entity-Driven UI with MUI
Build fully dynamic CRUD interfaces from entity metadata — one schema drives DataGrid columns, FormEngine forms, validation, access control, and wizard flows.
Entity Metadata Model
The foundation: a single TypeScript schema that drives everything.
// entity-metadata.ts
type DataType = 'string' | 'number' | 'boolean' | 'date' | 'enum' | 'json';
type WidgetType =
| 'text'
| 'textarea'
| 'number'
| 'checkbox'
| 'switch'
| 'select'
| 'autocomplete'
| 'date'
| 'datetime'
| 'json-editor'
| 'custom';
interface ValidationRule {
type: 'required' | 'min' | 'max' | 'regex' | 'email' | 'custom';
value?: number | string;
message?: string;
key?: string; // backend validation key or expression
}
interface AccessRule {
roles?: string[];
claims?: string[];
readOnly?: boolean;
hidden?: boolean;
}
interface FieldMetadata {
name: string; // "email"
label: string; // "Email address"
dataType: DataType;
widget?: WidgetType;
enumOptions?: { value: string; label: string }[] | string; // static or lookup key
isPrimaryKey?: boolean;
isFilterable?: boolean;
isSortable?: boolean;
validations?: ValidationRule[];
access?: {
read?: AccessRule;
write?: AccessRule;
};
layout?: {
group?: string; // "Contact info"
columnSpan?: 1 | 2 | 3 | 4;
order?: number;
step?: string; // for wizard flows
};
}
interface EntityMetadata {
name: string; // "User"
label: string; // "Users"
api: {
list: string; // "/api/users"
get: string; // "/api/users/:id"
create: string; // "/api/users"
update: string; // "/api/users/:id"
delete?: string; // "/api/users/:id"
};
fields: FieldMetadata[];
}
This single model drives:
- DataGrid columns (types, sorting, filtering, editing, rendering)
- FormEngine schemas (form fields, validation, layout, wizards)
- Access control (field-level read/write visibility)
- Shared validation (one truth, many consumers)
Server-Driven CRUD Page
Next.js Route: /admin/[entity]
// app/admin/[entity]/page.tsx
import { EntityPage } from '@/components/admin/EntityPage';
export default async function AdminEntityPage({
params,
}: {
params: { entity: string };
}) {
const res = await fetch(
`${process.env.ADMIN_API}/entities/${params.entity}/metadata`,
{ cache: 'no-store' },
);
const metadata: EntityMetadata = await res.json();
return <EntityPage metadata={metadata} />;
}
EntityPage Component
'use client';
import { useState, useMemo, useCallback } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import { DataGrid } from '@mui/x-data-grid';
import { buildColumns } from '@/lib/entity/build-columns';
import { EntityForm } from '@/components/admin/EntityForm';
import { useEntityData } from '@/hooks/useEntityData';
import type { EntityMetadata } from '@/types/entity-metadata';
interface EntityPageProps {
metadata: EntityMetadata;
}
export function EntityPage({ metadata }: EntityPageProps) {
const [formOpen, setFormOpen] = useState(false);
const [editingRow, setEditingRow] = useState<any>(null);
const columns = useMemo(() => buildColumns(metadata), [metadata]);
const { rows, rowCount, loading, paginationModel, setPaginationModel, refetch } =
useEntityData(metadata);
const handleEdit = useCallback((row: any) => {
setEditingRow(row);
setFormOpen(true);
}, []);
const handleCreate = useCallback(() => {
setEditingRow(null);
setFormOpen(true);
}, []);
const handleFormSubmit = useCallback(
async (data: Record<string, unknown>) => {
const isNew = !editingRow;
const url = isNew ? metadata.api.create : metadata.api.update;
const method = isNew ? 'POST' : 'PUT';
await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
setFormOpen(false);
refetch();
},
[editingRow, metadata.api, refetch],
);
return (
<Box sx={{ height: 600, width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<h1>{metadata.label}</h1>
<Button variant="contained" onClick={handleCreate}>
Add {metadata.name}
</Button>
</Box>
<DataGrid
rows={rows}
columns={columns}
loading={loading}
paginationMode="server"
rowCount={rowCount}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
onRowDoubleClick={(params) => handleEdit(params.row)}
pageSizeOptions={[10, 25, 50]}
/>
<Dialog open={formOpen} onClose={() => setFormOpen(false)} maxWidth="md" fullWidth>
<EntityForm
metadata={metadata}
initialValues={editingRow}
onSubmit={handleFormSubmit}
onCancel={() => setFormOpen(false)}
/>
</Dialog>
</Box>
);
}
DataGrid Column Generation from Metadata
// lib/entity/build-columns.ts
import type {
GridColDef,
GridRenderEditCellParams,
GridPreProcessEditCellProps,
} from '@mui/x-data-grid';
import type { EntityMetadata, FieldMetadata } from '@/types/entity-metadata';
import { validateCell } from './validate-cell';
import { renderEditCellForField } from './edit-cells';
export function buildColumns(meta: EntityMetadata): GridColDef[] {
return meta.fields
.filter((f) => !f.access?.read?.hidden)
.map<GridColDef>((field) => {
const col: GridColDef = {
field: field.name,
headerName: field.label,
sortable: field.isSortable !== false,
filterable: field.isFilterable !== false,
editable: !field.access?.write?.readOnly,
flex: field.layout?.columnSpan ?? 1,
};
// Map data types to DataGrid column types
switch (field.dataType) {
case 'number':
col.type = 'number';
break;
case 'boolean':
col.type = 'boolean';
break;
case 'date':
col.type = 'date';
col.valueGetter = (value) => value ? new Date(value) : null;
break;
case 'enum':
col.type = 'singleSelect';
col.valueOptions = Array.isArray(field.enumOptions)
? field.enumOptions
: [];
break;
}
// Custom valueFormatter for enums
if (field.widget === 'select' && Array.isArray(field.enumOptions)) {
col.valueFormatter = (value) => {
const opt = field.enumOptions!.find(
(o: any) => (typeof o === 'string' ? o : o.value) === value,
);
return typeof opt === 'string' ? opt : opt?.label ?? value;
};
}
// Custom edit cell renderers for complex widgets
if (col.editable) {
col.renderEditCell = (params: GridRenderEditCellParams) =>
renderEditCellForField(field, params);
}
// Shared validation via preProcessEditCellProps
if (field.validations?.length) {
col.preProcessEditCellProps = (params: GridPreProcessEditCellProps) =>
validateCell(field, params);
}
return col;
});
}
Custom Edit Cell Renderers
// lib/entity/edit-cells.tsx
import type { GridRenderEditCellParams } from '@mui/x-data-grid';
import type { FieldMetadata } from '@/types/entity-metadata';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import Switch from '@mui/material/Switch';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import dayjs from 'dayjs';
export function renderEditCellForField(
field: FieldMetadata,
params: GridRenderEditCellParams,
) {
const { id, field: colField, value, api } = params;
const updateValue = (newValue: unknown) => {
api.setEditCellValue({ id, field: colField, value: newValue });
};
switch (field.widget) {
case 'select':
case 'autocomplete': {
const options = Array.isArray(field.enumOptions) ? field.enumOptions : [];
return (
<Autocomplete
value={options.find((o) => o.value === value) ?? null}
onChange={(_, opt) => updateValue(opt?.value ?? null)}
options={options}
getOptionLabel={(o) => o.label}
renderInput={(p) => <TextField {...p} size="small" />}
fullWidth
disableClearable={field.validations?.some((v) => v.type === 'required')}
sx={{ minWidth: 150 }}
/>
);
}
case 'date':
case 'datetime':
return (
<DatePicker
value={value ? dayjs(value) : null}
onChange={(d) => updateValue(d?.toISOString() ?? null)}
slotProps={{ textField: { size: 'small', fullWidth: true } }}
/>
);
case 'switch':
case 'checkbox':
return (
<Switch
checked={!!value}
onChange={(e) => updateValue(e.target.checked)}
size="small"
/>
);
default:
return (
<TextField
value={value ?? ''}
onChange={(e) => updateValue(e.target.value)}
size="small"
fullWidth
type={field.dataType === 'number' ? 'number' : 'text'}
multiline={field.widget === 'textarea'}
rows={field.widget === 'textarea' ? 3 : undefined}
/>
);
}
}
Shared Validation Layer
One set of rules, consumed by DataGrid, FormEngine, and backend.
// lib/entity/validate-cell.ts
import type { GridPreProcessEditCellProps } from '@mui/x-data-grid';
import type { FieldMetadata, ValidationRule } from '@/types/entity-metadata';
export function validateCell(
field: FieldMetadata,
params: GridPreProcessEditCellProps,
) {
const { props } = params;
const value = props.value;
const error = runValidation(field.validations ?? [], value, field.label);
return { ...props, error: !!error, helperText: error };
}
export function runValidation(
rules: ValidationRule[],
value: unknown,
label: string,
): string | null {
for (const rule of rules) {
switch (rule.type) {
case 'required':
if (value === '' || value == null) {
return rule.message ?? `${label} is required`;
}
break;
case 'min':
if (typeof value === 'number' && value < Number(rule.value)) {
return rule.message ?? `${label} must be >= ${rule.value}`;
}
if (typeof value === 'string' && value.length < Number(rule.value)) {
return rule.message ?? `${label} must be at least ${rule.value} characters`;
}
break;
case 'max':
if (typeof value === 'number' && value > Number(rule.value)) {
return rule.message ?? `${label} must be <= ${rule.value}`;
}
if (typeof value === 'string' && value.length > Number(rule.value)) {
return rule.message ?? `${label} must be at most ${rule.value} characters`;
}
break;
case 'regex':
if (typeof value === 'string' && !new RegExp(String(rule.value)).test(value)) {
return rule.message ?? `${label} format is invalid`;
}
break;
case 'email':
if (typeof value === 'string' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return rule.message ?? `${label} must be a valid email`;
}
break;
}
}
return null;
}
// For row-level validation (used in processRowUpdate)
export function validateRow(
meta: { fields: FieldMetadata[] },
row: Record<string, unknown>,
): { field: string; message: string }[] {
const errors: { field: string; message: string }[] = [];
for (const field of meta.fields) {
if (!field.validations?.length) continue;
const error = runValidation(field.validations, row[field.name], field.label);
if (error) errors.push({ field: field.name, message: error });
}
return errors;
}
Validation in Row Editing (processRowUpdate)
const processRowUpdate = useCallback(
async (newRow: any, oldRow: any) => {
// Validate entire row
const errors = validateRow(metadata, newRow);
if (errors.length > 0) {
throw new Error(errors.map((e) => e.message).join(', '));
}
// Determine create vs update
const pk = metadata.fields.find((f) => f.isPrimaryKey)?.name ?? 'id';
const isNew = !oldRow[pk];
const url = isNew
? metadata.api.create
: metadata.api.update.replace(':id', String(newRow[pk]));
const res = await fetch(url, {
method: isNew ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newRow),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? 'Failed to save');
}
return await res.json();
},
[metadata],
);
FormEngine MUI Schema Generation
Convert entity metadata to FormEngine JSON schema.
// lib/entity/build-form-schema.ts
import type { EntityMetadata, FieldMetadata, ValidationRule } from '@/types/entity-metadata';
export function buildFormEngineSchema(meta: EntityMetadata) {
const fields = meta.fields
.filter((f) => !f.access?.write?.hidden)
.sort((a, b) => (a.layout?.order ?? 0) - (b.layout?.order ?? 0));
// Group by layout.step for wizard mode
const steps = new Map<string, FieldMetadata[]>();
for (const field of fields) {
const step = field.layout?.step ?? 'default';
if (!steps.has(step)) steps.set(step, []);
steps.get(step)!.push(field);
}
// Single step → flat form; multiple steps → wizard
if (steps.size <= 1) {
return {
tooltipType: 'MuiTooltip',
errorType: 'MuiErrorWrapper',
form: {
key: 'Screen',
type: 'Screen',
children: fields.map(buildFormField),
},
};
}
// Multi-step wizard
return {
tooltipType: 'MuiTooltip',
errorType: 'MuiErrorWrapper',
form: {
key: 'Wizard',
type: 'Wizard',
children: Array.from(steps.entries()).map(([stepName, stepFields]) => ({
key: stepName,
type: 'Screen',
props: { label: { value: stepName } },
children: stepFields.map(buildFormField),
})),
},
};
}
function buildFormField(field: FieldMetadata) {
const node: any = {
key: field.name,
type: widgetToFormEngineType(field),
props: {
label: { value: field.label },
name: { value: field.name },
},
schema: {
validations: (field.validations ?? []).map(toFormEngineValidation),
},
};
// Layout: column span → MUI Grid integration
if (field.layout?.columnSpan) {
node.props.gridColumn = { value: `span ${field.layout.columnSpan}` };
}
// Enum options
if (field.widget === 'select' && Array.isArray(field.enumOptions)) {
node.props.options = { value: field.enumOptions };
}
// Read-only
if (field.access?.write?.readOnly) {
node.props.disabled = { value: true };
}
// Multiline
if (field.widget === 'textarea') {
node.props.multiline = { value: true };
node.props.rows = { value: 4 };
}
return node;
}
function widgetToFormEngineType(field: FieldMetadata): string {
switch (field.widget) {
case 'textarea': return 'MuiTextField'; // with multiline prop
case 'select': return 'MuiSelect';
case 'autocomplete': return 'MuiAutocomplete';
case 'switch': return 'MuiSwitch';
case 'checkbox': return 'MuiCheckbox';
case 'date': return 'MuiDatePicker';
case 'datetime': return 'MuiDateTimePicker';
case 'number': return 'MuiTextField'; // with type=number
default: return 'MuiTextField';
}
}
function toFormEngineValidation(rule: ValidationRule) {
return {
key: rule.type,
args: { value: rule.value, message: rule.message },
};
}
EntityForm Component
// components/admin/EntityForm.tsx
'use client';
import { useMemo, useCallback } from 'react';
import { FormViewer } from '@react-form-builder/core';
import { view as muiView } from '@react-form-builder/components-material-ui';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import { buildFormEngineSchema } from '@/lib/entity/build-form-schema';
import type { EntityMetadata } from '@/types/entity-metadata';
interface EntityFormProps {
metadata: EntityMetadata;
initialValues?: Record<string, unknown>;
onSubmit: (data: Record<string, unknown>) => void;
onCancel: () => void;
}
export function EntityForm({ metadata, initialValues, onSubmit, onCancel }: EntityFormProps) {
const isNew = !initialValues;
const schema = useMemo(
() => buildFormEngineSchema(metadata),
[metadata],
);
const getForm = useCallback(
() => JSON.stringify(schema),
[schema],
);
const actions = useMemo(
() => ({
onSubmit: (e: { data: Record<string, unknown> }) => onSubmit(e.data),
}),
[onSubmit],
);
return (
<>
<DialogTitle>{isNew ? `Create ${metadata.name}` : `Edit ${metadata.name}`}</DialogTitle>
<DialogContent>
<FormViewer
view={muiView}
getForm={getForm}
actions={actions}
initialData={initialValues ?? {}}
/>
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button variant="contained" type="submit" form="form-engine-form">
{isNew ? 'Create' : 'Save'}
</Button>
</DialogActions>
</>
);
}
Access Control
Hide/disable fields based on user roles at three layers:
// lib/entity/apply-access.ts
import type { EntityMetadata, FieldMetadata, AccessRule } from '@/types/entity-metadata';
interface UserContext {
roles: string[];
claims: string[];
}
export function filterFieldsByAccess(
fields: FieldMetadata[],
user: UserContext,
mode: 'read' | 'write',
): FieldMetadata[] {
return fields.filter((field) => {
const rule = mode === 'read' ? field.access?.read : field.access?.write;
if (!rule) return true; // no restriction
if (rule.hidden) return !isRestricted(rule, user);
return true;
});
}
export function isFieldReadOnly(field: FieldMetadata, user: UserContext): boolean {
const rule = field.access?.write;
if (!rule) return false;
if (rule.readOnly) return true;
return isRestricted(rule, user);
}
function isRestricted(rule: AccessRule, user: UserContext): boolean {
if (rule.roles?.length && !rule.roles.some((r) => user.roles.includes(r))) {
return true;
}
if (rule.claims?.length && !rule.claims.some((c) => user.claims.includes(c))) {
return true;
}
return false;
}
Enforcement layers:
- DataGrid:
buildColumnsfilters hidden fields, marks read-only fields aseditable: false - FormEngine:
buildFormEngineSchemaomits hidden fields, setsdisabledon read-only - Backend: Same metadata used server-side to validate write permissions
Wizard Flows
When fields have layout.step, the FormEngine schema becomes multi-step:
// Example entity with wizard steps
const userMetadata: EntityMetadata = {
name: 'User',
label: 'Users',
api: { list: '/api/users', get: '/api/users/:id', create: '/api/users', update: '/api/users/:id' },
fields: [
{ name: 'email', label: 'Email', dataType: 'string', widget: 'text',
validations: [{ type: 'required' }, { type: 'email' }],
layout: { step: 'Account', order: 1 } },
{ name: 'password', label: 'Password', dataType: 'string', widget: 'text',
validations: [{ type: 'required' }, { type: 'min', value: 8 }],
layout: { step: 'Account', order: 2 } },
{ name: 'name', label: 'Full Name', dataType: 'string', widget: 'text',
validations: [{ type: 'required' }],
layout: { step: 'Profile', order: 1 } },
{ name: 'role', label: 'Role', dataType: 'enum', widget: 'select',
enumOptions: [{ value: 'admin', label: 'Admin' }, { value: 'user', label: 'User' }],
layout: { step: 'Profile', order: 2 } },
{ name: 'bio', label: 'Bio', dataType: 'string', widget: 'textarea',
layout: { step: 'Profile', order: 3, columnSpan: 2 } },
],
};
buildFormEngineSchema(userMetadata) produces a two-step wizard: Account → Profile.
Data Fetching Hook
// hooks/useEntityData.ts
import { useQuery } from '@tanstack/react-query';
import { useState, useCallback } from 'react';
import type { GridPaginationModel, GridSortModel, GridFilterModel } from '@mui/x-data-grid';
import type { EntityMetadata } from '@/types/entity-metadata';
export function useEntityData(metadata: EntityMetadata) {
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: 25,
});
const [sortModel, setSortModel] = useState<GridSortModel>([]);
const [filterModel, setFilterModel] = useState<GridFilterModel>({ items: [] });
const { data, isLoading, refetch } = useQuery({
queryKey: [metadata.api.list, paginationModel, sortModel, filterModel],
queryFn: async () => {
const params = new URLSearchParams({
page: String(paginationModel.page),
pageSize: String(paginationModel.pageSize),
...(sortModel[0] && {
sortField: sortModel[0].field,
sortOrder: sortModel[0].sort ?? 'asc',
}),
});
const res = await fetch(`${metadata.api.list}?${params}`);
return res.json();
},
placeholderData: (prev) => prev,
});
return {
rows: data?.rows ?? [],
rowCount: data?.total ?? 0,
loading: isLoading,
paginationModel,
setPaginationModel,
sortModel,
setSortModel,
filterModel,
setFilterModel,
refetch,
};
}
Architecture Summary
┌─────────────────────┐
│ Entity Metadata │ ← Single source of truth
│ (JSON / TypeScript)│
└──────┬──────┬────────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ DataGrid │ │ FormEngine │
│ Columns │ │ MUI Schema │
└────┬─────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ List View│ │ Create/Edit │
│ + Inline │ │ Dialog/Page │
│ Editing │ │ or Wizard │
└────┬─────┘ └──────┬───────┘
│ │
▼ ▼
┌────────────────────────────┐
│ Shared Validation │ ← runValidation()
│ (DataGrid cells + Forms + │
│ Backend API) │
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Access Control Layer │ ← filterFieldsByAccess()
│ (Roles/Claims → hide/ │
│ disable fields) │
└────────────────────────────┘
No per-entity React code needed — the metadata drives everything.
ASP.NET Core Backend Integration
C# Query Model (matches DataGrid sort/filter/pagination)
// Models/DataGridQuery.cs
public class DataGridQuery
{
public int Page { get; set; } = 0;
public int PageSize { get; set; } = 25;
public List<SortItem>? SortModel { get; set; }
public FilterModel? FilterModel { get; set; }
}
public class SortItem
{
public string Field { get; set; } = "";
public string Sort { get; set; } = "asc"; // "asc" | "desc"
}
public class FilterModel
{
public List<FilterItem> Items { get; set; } = new();
public string LogicOperator { get; set; } = "and"; // "and" | "or"
}
public class FilterItem
{
public string Field { get; set; } = "";
public string Operator { get; set; } = ""; // "contains", "equals", "startsWith", ">" etc.
public string? Value { get; set; }
}
public class DataGridResponse<T>
{
public List<T> Rows { get; set; } = new();
public int Total { get; set; }
}
ASP.NET Controller with Dynamic Sort/Filter
// Controllers/UsersController.cs
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _db;
public UsersController(AppDbContext db) => _db = db;
[HttpPost("query")]
public async Task<ActionResult<DataGridResponse<UserDto>>> Query(
[FromBody] DataGridQuery query)
{
IQueryable<User> q = _db.Users.AsNoTracking();
// Apply filters
foreach (var filter in query.FilterModel?.Items ?? new())
{
q = ApplyFilter(q, filter);
}
// Get total before pagination
var total = await q.CountAsync();
// Apply sorting
if (query.SortModel?.Any() == true)
{
var sort = query.SortModel[0];
q = sort.Sort == "desc"
? q.OrderByDescending(e => EF.Property<object>(e, ToPascalCase(sort.Field)))
: q.OrderBy(e => EF.Property<object>(e, ToPascalCase(sort.Field)));
}
else
{
q = q.OrderBy(e => e.Id); // default sort
}
// Apply pagination
var rows = await q
.Skip(query.Page * query.PageSize)
.Take(query.PageSize)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email,
Role = u.Role,
CreatedAt = u.CreatedAt,
})
.ToListAsync();
return Ok(new DataGridResponse<UserDto> { Rows = rows, Total = total });
}
[HttpPost]
public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserDto dto)
{
// Validate using same rules as metadata
var user = new User { Name = dto.Name, Email = dto.Email, Role = dto.Role };
_db.Users.Add(user);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = user.Id }, ToDto(user));
}
[HttpPut("{id}")]
public async Task<ActionResult<UserDto>> Update(int id, [FromBody] UpdateUserDto dto)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
user.Name = dto.Name;
user.Email = dto.Email;
user.Role = dto.Role;
await _db.SaveChangesAsync();
return Ok(ToDto(user));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
_db.Users.Remove(user);
await _db.SaveChangesAsync();
return NoContent();
}
private static IQueryable<User> ApplyFilter(IQueryable<User> q, FilterItem filter)
{
// Map DataGrid filter operators to LINQ
return filter.Operator switch
{
"contains" => q.Where(e =>
EF.Property<string>(e, ToPascalCase(filter.Field)).Contains(filter.Value!)),
"equals" => q.Where(e =>
EF.Property<string>(e, ToPascalCase(filter.Field)) == filter.Value),
"startsWith" => q.Where(e =>
EF.Property<string>(e, ToPascalCase(filter.Field)).StartsWith(filter.Value!)),
"endsWith" => q.Where(e =>
EF.Property<string>(e, ToPascalCase(filter.Field)).EndsWith(filter.Value!)),
"isEmpty" => q.Where(e =>
EF.Property<string>(e, ToPascalCase(filter.Field)) == null ||
EF.Property<string>(e, ToPascalCase(filter.Field)) == ""),
_ => q,
};
}
private static string ToPascalCase(string camelCase) =>
char.ToUpper(camelCase[0]) + camelCase[1..];
}
Entity Metadata Endpoint
// Controllers/EntityMetadataController.cs
[ApiController]
[Route("api/entities")]
public class EntityMetadataController : ControllerBase
{
[HttpGet("{entity}/metadata")]
public ActionResult<EntityMetadata> GetMetadata(string entity)
{
// Return metadata that drives both DataGrid columns and FormEngine forms
return entity switch
{
"users" => Ok(new EntityMetadata
{
Name = "User",
Label = "Users",
Api = new ApiEndpoints
{
List = "/api/users/query",
Get = "/api/users/{id}",
Create = "/api/users",
Update = "/api/users/{id}",
Delete = "/api/users/{id}",
},
Fields = new List<FieldMetadata>
{
new() { Name = "id", Label = "ID", DataType = "number",
IsPrimaryKey = true, Access = new() { Write = new() { Hidden = true } } },
new() { Name = "name", Label = "Full Name", DataType = "string",
Widget = "text", IsSortable = true, IsFilterable = true,
Validations = new() { new() { Type = "required" } },
Layout = new() { Order = 1, Step = "Account" } },
new() { Name = "email", Label = "Email", DataType = "string",
Widget = "text", IsSortable = true, IsFilterable = true,
Validations = new() { new() { Type = "required" }, new() { Type = "email" } },
Layout = new() { Order = 2, Step = "Account" } },
new() { Name = "role", Label = "Role", DataType = "enum",
Widget = "select", IsSortable = true, IsFilterable = true,
EnumOptions = new() {
new() { Value = "admin", Label = "Administrator" },
new() { Value = "editor", Label = "Editor" },
new() { Value = "viewer", Label = "Viewer" },
},
Validations = new() { new() { Type = "required" } },
Layout = new() { Order = 3, Step = "Profile" } },
new() { Name = "createdAt", Label = "Created", DataType = "date",
Widget = "date", IsSortable = true,
Access = new() { Write = new() { ReadOnly = true } },
Layout = new() { Order = 4, Step = "Profile" } },
},
}),
_ => NotFound(),
};
}
}
React Fetch Hook Wired to ASP.NET
// hooks/useEntityData.ts — POST-based fetching for full sort/filter model
export function useEntityData(metadata: EntityMetadata) {
const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 25 });
const [sortModel, setSortModel] = useState<GridSortModel>([]);
const [filterModel, setFilterModel] = useState<GridFilterModel>({ items: [] });
const { data, isLoading, refetch } = useQuery({
queryKey: [metadata.api.list, paginationModel, sortModel, filterModel],
queryFn: async () => {
// POST body matches ASP.NET DataGridQuery model
const res = await fetch(metadata.api.list, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
page: paginationModel.page,
pageSize: paginationModel.pageSize,
sortModel: sortModel.length ? sortModel : undefined,
filterModel: filterModel.items.length ? filterModel : undefined,
}),
});
if (!res.ok) throw new Error('Fetch failed');
return res.json() as Promise<{ rows: any[]; total: number }>;
},
placeholderData: (prev) => prev,
});
return {
rows: data?.rows ?? [],
rowCount: data?.total ?? 0,
loading: isLoading,
paginationModel, setPaginationModel,
sortModel, setSortModel,
filterModel, setFilterModel,
refetch,
};
}
Custom Filter Operators → LINQ Translation
Map DataGrid filter operators to backend LINQ expressions:
| DataGrid Operator | C# LINQ | SQL |
|---|---|---|
contains |
.Contains(value) |
LIKE '%value%' |
equals |
== value |
= 'value' |
startsWith |
.StartsWith(value) |
LIKE 'value%' |
endsWith |
.EndsWith(value) |
LIKE '%value' |
isEmpty |
== null || == "" |
IS NULL OR = '' |
isNotEmpty |
!= null && != "" |
IS NOT NULL AND != '' |
> / < / >= / <= |
Comparison operators | Direct comparison |
isAnyOf |
.Contains(value) on list |
IN (...) |
Validation Sharing: C# → TypeScript
Generate validation rules from C# data annotations and serve via metadata:
// Map [Required], [StringLength], [Range], [EmailAddress] to ValidationRule[]
public static List<ValidationRule> FromDataAnnotations(Type entityType, string propertyName)
{
var prop = entityType.GetProperty(propertyName);
var rules = new List<ValidationRule>();
if (prop?.GetCustomAttribute<RequiredAttribute>() is { } req)
rules.Add(new() { Type = "required", Message = req.ErrorMessage });
if (prop?.GetCustomAttribute<StringLengthAttribute>() is { } len)
{
if (len.MinimumLength > 0)
rules.Add(new() { Type = "min", Value = len.MinimumLength.ToString() });
rules.Add(new() { Type = "max", Value = len.MaximumLength.ToString() });
}
if (prop?.GetCustomAttribute<RangeAttribute>() is { } range)
{
rules.Add(new() { Type = "min", Value = range.Minimum.ToString() });
rules.Add(new() { Type = "max", Value = range.Maximum.ToString() });
}
if (prop?.GetCustomAttribute<EmailAddressAttribute>() != null)
rules.Add(new() { Type = "email" });
if (prop?.GetCustomAttribute<RegularExpressionAttribute>() is { } regex)
rules.Add(new() { Type = "regex", Value = regex.Pattern, Message = regex.ErrorMessage });
return rules;
}
This makes your C# [Required], [EmailAddress], [StringLength(100)] annotations
automatically drive DataGrid cell validation and FormEngine form validation — one truth,
three consumers (backend, DataGrid, FormEngine).
More from lobbi-docs/claude
vision-multimodal
Vision and multimodal capabilities for Claude including image analysis, PDF processing, and document understanding. Activate for image input, base64 encoding, multiple images, and visual analysis.
242design-system
Apply and manage the AI-powered design system with 50+ curated styles
126complex-reasoning
Multi-step reasoning patterns and frameworks for systematic problem solving. Activate for Chain-of-Thought, Tree-of-Thought, hypothesis-driven debugging, and structured analytical approaches that leverage extended thinking.
105gcp
Google Cloud Platform services including GKE, Cloud Run, Cloud Storage, BigQuery, and Pub/Sub. Activate for GCP infrastructure, Google Cloud deployment, and GCP integration.
73kanban
Kanban methodology including boards, WIP limits, flow metrics, and continuous delivery. Activate for Kanban boards, workflow visualization, and lean project management.
62debugging
Debugging techniques for Python, JavaScript, and distributed systems. Activate for troubleshooting, error analysis, log investigation, and performance debugging. Includes extended thinking integration for complex debugging scenarios.
59