| name | noir-datatable-page |
| description | Scaffold a new DataTable list page in NOIR following the enterprise table standard (useEnterpriseTable + DataTable + DataTableToolbar + DataTablePagination + audit columns). Use when the user asks to create, build, or add a new list page, admin page, index page, or table view. Covers `.claude/rules/datatable-standard.md`, `table-list-standard.md`, and `audit-columns-standard.md`. |
noir-datatable-page — Enterprise list page scaffold
NOIR has a strict table list standard: TanStack Table + localStorage-persisted settings + actions column first + 4 audit columns last + card layout with gap-0 / pb-3 / space-y-3. This skill walks the scaffold end-to-end.
Prerequisites
- Backend list query exists:
Get{Entity}sQuery in src/NOIR.Application/Features/{Feature}/Queries/Get{Entity}s/
- List DTO includes audit fields:
CreatedAt, ModifiedAt, CreatedByName, ModifiedByName (user names resolved via IUserDisplayNameService.GetDisplayNamesAsync() — batch, not per-row)
- List specification supports sort cases for
createdby/creator and modifiedby/editor (see audit-columns-standard.md)
- Query hook exists in
src/queries/use{Feature}Queries.ts
If any are missing, invoke noir-feature-add first.
Inputs to collect
- Entity / feature name —
Product, Customer, Brand
- Visible columns — domain data (name, status, price) + any image/thumbnail columns
- Filters — status dropdown, type dropdown, date range, etc.
- Row selection / bulk actions? — yes/no
- Grouping? — e.g. by status, by category (optional)
- Actions per row — view, edit, delete, duplicate, specific domain actions
Reference implementations (read before writing — Rule 1)
- Gold standard:
src/portal-app/access/users/UsersPage.tsx
- With filters:
src/portal-app/marketing/promotions/PromotionsPage.tsx
- With grouping:
src/portal-app/ecommerce/orders/OrdersPage.tsx
- With row selection + bulk ops:
src/portal-app/content/blog/BlogPostsPage.tsx
Skeleton
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { createColumnHelper, type ColumnDef } from '@tanstack/react-table'
import { Plus, Eye, Edit, Trash2 } from 'lucide-react'
import { useEnterpriseTable } from '@/hooks/useEnterpriseTable'
import { useTableParams } from '@/hooks/useTableParams'
import { useUrlDialog, useUrlEditDialog } from '@/hooks'
import { useBrandsQuery, useDeleteBrand } from '@/queries/useBrandQueries'
import { useRegionalSettings } from '@/contexts/RegionalSettingsContext'
import {
Card, CardContent, CardHeader, CardTitle, CardDescription,
DataTable, DataTableToolbar, DataTablePagination,
DropdownMenuItem, EmptyState, Button,
} from '@uikit'
import { PageHeader }
{
createActionsColumn, createSelectColumn, createFullAuditColumns,
}
{ }
{ }
{ }
{ }
{
?:
}
= () => {
{ t } = ()
{ formatDateTime } = ()
{
params, defaultPageSize,
searchInput, setSearchInput,
onPaginationChange, onSortingChange,
} = useTableParams<>({ : , : })
{ data, isLoading } = (params)
{ : isCreateOpen, : openCreate, : onCreateOpenChange } = ({ : })
{ editItem, openEdit, onEditOpenChange } = useUrlEditDialog<>(data?. ?? [])
[deleteTarget, setDeleteTarget] = useState< | >()
deleteMutation = ()
ch = createColumnHelper<>()
columns = useMemo<<, >[]>( [
createActionsColumn<>( (
)),
createSelectColumn<>(),
ch.(, {
: (),
: { : () },
: ,
}),
ch.(, {
: (),
: ,
: {
: (),
: v ? () : (),
},
: (
),
}),
...createFullAuditColumns<>(t, formatDateTime),
], [t, formatDateTime, openEdit])
{ table, settings, isCustomized, resetToDefault, setDensity } = ({
: data?. ?? [],
columns,
: ,
: data?. ?? ,
: {
: { : params., : params. },
: params.,
},
onPaginationChange,
onSortingChange,
: ,
: row.,
})
(
)
}
Non-negotiables (cross-cutting rules)
- Column order: Actions (44px fixed,
EllipsisVertical, align="start") → Select (40px fixed) → data columns → createFullAuditColumns(t, formatDateTime) at end
- Card layout:
gap-0 + pb-3 header + space-y-3 content (critical — pagination sits flush without it)
- CardDescription: always
Showing X of Y items via labels.showingCountOfTotal
- Search:
flex-1 min-w-[200px] — never max-width
tableKey: matches between useTableParams and useEnterpriseTable — enables localStorage persistence
- Image columns: use
FilePreviewTrigger (image-preview-in-lists.md)
- Grouping: set
groupedColumnMode: false (already the default in useEnterpriseTable — never change it)
- Group values: enum columns need
meta.groupValueFormatter for i18n
- Date display:
formatDateTime from useRegionalSettings() — never toLocaleString() / formatRelativeTime (date-formatting.md)
- Interactive elements: all have
cursor-pointer
- Icon-only buttons: all have
aria-label
- Destructive actions: confirmation dialog (
DeleteXxxDialog) — never direct onClick={() => delete()}
After-scaffold checklist
Common mistakes this skill prevents
- Custom table with
<table> + ColumnVisibilityDropdown instead of DataTable (pre-2026-03-13 pattern — forbidden)
- Missing
gap-0 on Card → 24px gap between header and content (default)
- Missing
pb-3 on CardHeader → 0px gap (too tight)
- Missing
space-y-3 on CardContent → pagination flush against table (inconsistent with above-table gap)
- Search input with
max-w-[280px] → narrow on wide screens
- Actions column NOT first → UI audit
datatable-actions rule fails
MoreHorizontal icon instead of EllipsisVertical → rule violation
- Missing audit columns →
audit-columns-standard.md violation
- Using
toLocaleString() or formatRelativeTime() in columns → date-formatting.md violation (tables always use formatDateTime)
- Missing
tableKey → no localStorage persistence (user's column order resets every reload)
tableKey differs between useTableParams and useEnterpriseTable → two separate localStorage keys, partially persisted
- Missing
getRowId → selection state breaks on re-fetch
groupedColumnMode: 'reorder' (default if not set) → columns overlap when grouping + column order both active
- Enum columns without
meta.groupValueFormatter → grouped rows show English values in Vietnamese UI
handleSubmit in filter Select causing re-render loop — filter Select uses params.filters.role, not params.role
- Plain div for empty state instead of
<EmptyState> → UI audit fails
- Destructive action without confirmation dialog → accidental data loss