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).