Agent Skills: Entity-Driven UI with MUI

Metadata-driven CRUD with MUI X DataGrid + FormEngine — entity metadata model, server-driven UI, column/form generation, shared validation, access control, wizard flows

UncategorizedID: lobbi-docs/claude/entity-driven-ui

Install this agent skill to your local

pnpm dlx add-skill https://github.com/markus41/claude/tree/HEAD/plugins/mui-expert/skills/entity-driven-ui

Skill Files

Browse the full folder contents for entity-driven-ui.

Download Skill

Loading file tree…

plugins/mui-expert/skills/entity-driven-ui/SKILL.md

Skill Metadata

Name
entity-driven-ui
Description
Metadata-driven CRUD with MUI X DataGrid + FormEngine — entity metadata model, server-driven UI, column/form generation, shared validation, access control, wizard flows

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:

  1. DataGrid: buildColumns filters hidden fields, marks read-only fields as editable: false
  2. FormEngine: buildFormEngineSchema omits hidden fields, sets disabled on read-only
  3. 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).