Claude
Skills
Sign in
Back

entity-driven-ui

Included with Lifetime
$97 forever

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

Design

What this skill does


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

```ts
// 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]`

```tsx
// 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

```tsx
'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

```ts
// 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

```tsx
// 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

Related in Design