From mui-expert
Builds metadata-driven CRUD UIs with MUI X DataGrid and FormEngine from entity metadata, generating columns, forms, validation, access control, and wizard flows in Next.js apps.
How this skill is triggered — by the user, by Claude, or both
Slash command
/mui-expert:entity-driven-uiThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build fully dynamic CRUD interfaces from entity metadata — one schema drives DataGrid
Build fully dynamic CRUD interfaces from entity metadata — one schema drives DataGrid columns, FormEngine forms, validation, access control, and wizard flows.
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:
/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} />;
}
'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>
);
}
// 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;
});
}
// 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}
/>
);
}
}
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;
}
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],
);
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 },
};
}
// 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>
</>
);
}
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:
buildColumns filters hidden fields, marks read-only fields as editable: falsebuildFormEngineSchema omits hidden fields, sets disabled on read-onlyWhen 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.
// 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,
};
}
┌─────────────────────┐
│ 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.
// 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; }
}
// 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..];
}
// 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(),
};
}
}
// 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,
};
}
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 (...) |
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).
npx claudepluginhub markus41/claude --plugin mui-expertConfigures MUI X DataGrid in React/TSX apps with column definitions, sorting, filtering, pagination, custom renderers, and server-side integration tips.
Look up APIs and types for NetSuite UIF SPA components: @uif-js/core and @uif-js/component (constructors, methods, props, enums, hooks, component options).
Generates production-ready CRUD scaffolding and API endpoints for entities, including validation (Zod), authorization, tests, and relational support. Detects and adapts to existing schema (Prisma, Drizzle, raw SQL) and framework (Next.js, Express, Hono).