entity-driven-ui

v2026.09.25

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

GitHub
Install command
npx skhub add thelobbi/entity-driven-ui
Markdown
SKILL.md

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 OperatorC# LINQSQL
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 operatorsDirect comparison
isAnyOf.Contains(value) on listIN (...)

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

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

MIT

Source path

plugins/mui-expert/skills/entity-driven-ui

Default branch

main

Latest commit

2f1269c

Tree SHA

629e050