Build the standard list/create/update/delete screens for an entity in src/frontend/web — data table with URL-synced paging, sorting, search, filter panel and export, icon-only toolbar actions and a per-row actions menu gated by permission, row state and plan limits, plus Formik + Yup create/update forms. Use when scaffolding CRUD UI for a new entity.
Instal·la
npx skillscat add ma496/easy-for-net/frontend-crud Instal·la des del registre de SkillsCat.
CRUD screens
The users feature is the reference implementation (roles, tenants and editions follow the same
shape); copy its structure:
app/[lang]/(app)/(<group>)/<entity>/
page.tsx (the list, at /<entity>)
_components/<entity>-table.tsx
_components/<entity>-filter-panel.tsx
create/page.tsx
create/_components/<entity>-create-form.tsx
[id]/update/page.tsx
[id]/update/_components/<entity>-update-form.tsxA screen about one record puts the id before the screen: /<entity>/{id}/<action>
(/users/{id}/update), so the id names the record and the last segment the screen. The
filter panel is optional (roles and editions have none); further row screens such as[id]/detail or [id]/members are added the same way and linked from the row actions.
Page components (server) are covered by the frontend-page skill; the API slice byrtk-query-api. This skill covers the client components.
List table
'use client', driven by useTableUrlState + DataTableProvider (TanStack Table under the hood).
const url = useTableUrlState({
filters: {
isActive: parseAsStringEnum(['true', 'false'] as const).withOptions({ clearOnDefault: true, history: 'push' }),
roleId: parseAsString.withOptions({ clearOnDefault: true, history: 'push' }),
},
})useTableUrlState keeps page, size, search, sort, dir and your filters in the query
string (nuqs) and returns TanStack-shaped adapters. Use:
| From the hook | For |
|---|---|
url.page, url.pageSize, url.sortField, url.sortDirection, url.search |
the query payload |
url.pagination / url.setPagination, url.sorting / url.setSorting |
DataTableProvider |
url.searchInput / url.setGlobalFilter |
the toolbar search box (debounced, 500 ms) |
url.filters.<key>, url.filters.setMany({...}), url.filters.clearFilters() |
the filter panel |
url.resetPage() |
after applying or clearing filters |
Fetch, then render (SortDirection comes from @/store/api, the table pieces from@/components/ui/data-table):
const { data, isFetching, error } = useUserListQuery({
page: url.page,
pageSize: url.pageSize,
sortField: url.sortField ?? undefined,
sortDirection: url.sortDirection === 'desc' ? SortDirection.Desc : SortDirection.Asc,
search: url.search || undefined,
isActive: getIsActiveValue(appliedFilters.isActive),
})
if (error) return <div className="py-6"><ApiErrorMessages error={error} /></div>
return (
<DataTableProvider
data={data?.items || []}
rowCount={data?.total || 0}
columns={columns}
enableRowSelection={false}
sorting={url.sorting} setSorting={url.setSorting}
pagination={url.pagination} setPagination={url.setPagination}
globalFilter={url.searchInput} setGlobalFilter={url.setGlobalFilter}
isFetching={isFetching}
>
<DataTableToolbar>{/* DataTableFilterButton, create link, export dropdown */}</DataTableToolbar>
{filtersOpen && <UserFilterPanel filters={pendingFilters} onChange={setPendingFilters} onSearch={handleSearch} onClear={handleClear} />}
<DataTable />
<DataTablePagination siblingCount={1} />
</DataTableProvider>
)Columns are built with createColumnHelper<UserListDto>(); the actions column iscolumnHelper.display({ id: 'actions', header: t('table.actions') || 'Actions', ... }) and rendersDataTableRowActions — a three-dot trigger opening a portaled menu of labelled actions, which
renders nothing when every entry is hidden. Gate each entry with hidden rather than leaving it out
of the array, give it a translated label, and use href for navigation (locale-aware) or onClick
for a mutation; variant is default | primary | success | warning | danger:
cell: (info) => (
<DataTableRowActions
actions={[
{ label: t('common.edit') || 'Edit', icon: <Pencil className="h-4 w-4" />, href: `/users/${info.row.original.id}/update`, hidden: !canUpdate },
{ label: t('common.delete') || 'Delete', icon: <Trash2 className="h-4 w-4" />, variant: 'danger', onClick: () => handleDelete(info.row.original.id), disabled: isDeletingUser, hidden: !canDelete },
]}
/>
),Headers are translated (t('table.columns.email') || 'Email'). Set enableSorting: false on computed columns —
and remember any sortable column must also be whitelisted in the backend list validator.
Below md the same <DataTable /> renders each row as a card instead of a table row, with a sort
select standing in for the clickable headers (cardsBelow="sm" | "lg" moves the breakpoint, false
keeps the table everywhere). The cells keep their renderers and the headers become field labels; theactions column sits top-right and the first data column is the heading. Place the rest withmeta.card so a card reads at a glance — subtitle for the identifying second line (email,
identifier), badge for a status pill at the end of the heading, by the actions menu, wide for long text, hidden for what a
phone can do without, title when the heading is not the first column:
columnHelper.accessor('email', { meta: { card: 'subtitle' }, header: t('table.columns.email') || 'Email', cell: (info) => info.getValue() }),
columnHelper.accessor('isActive', { meta: { card: 'badge' }, header: t('table.columns.isActive') || 'Active', cell: ... }),Gating combines the caller's permissions with facts about the row. A row whose DTO extendsSystemCreatedDto will be refused on update/delete, so hide those actions up front
(hidden: !(canDelete && !row.systemCreated)), and hide any action the row's state rules out (a
tenant's suspend only while it is active, an edition's delete only while no tenant uses it):
const authState = useAppSelector((state) => state.auth)
const canCreate = isAllowed(authState, [Allow.User_Create])
const canUpdate = isAllowed(authState, [Allow.User_Update])
const canDelete = isAllowed(authState, [Allow.User_Delete])A permission whose capability depends on the tenant's plan needs no extra check — it is absent from
the session when the plan withholds it (see feature-management).
Toolbar actions are icon-only DataTableToolbarButtons — the label becomes the tooltip and
the accessible name; pass href for navigation or onClick for an action, and primary on the
screen's main action (create) to fill it with the accent colour. Export isDataTableExportButton (onExport(format, all)), filters DataTableFilterButton:
{canCreate && <DataTableToolbarButton primary label={t('table.createLink') || 'Create'} icon={<Plus size={16} />} href="/users/create" />}
<DataTableExportButton onExport={handleExport} isExporting={isExporting} disabled={isFetching || !data?.total} />When a plan limit stops the action, keep it visible but disabled and let the label explain why,
driven by the same endpoint the API enforces with — the users table reads useUserSeatsQuery():
const { data: seats } = useUserSeatsQuery()
const seatsExhausted = seats?.limit != null && seats.used >= seats.limit
{canCreate &&
(seatsExhausted ? (
<DataTableToolbarButton primary label={t('page.users.seatLimitReached') || "Your plan's user limit has been reached"} icon={<Plus size={16} />} disabled />
) : (
<DataTableToolbarButton primary label={t('table.createLink') || 'Create'} icon={<Plus size={16} />} href="/users/create" />
))}Delete uses the shared alert helpers:
const result = await confirmDeleteAlert({ title: t('page.users.deleteTitle') || 'Delete User', text: t('page.users.deleteConfirm') || 'Are you sure you want to delete this user?' })
if (!result.isConfirmed) return
const response = await deleteUser({ id })
if (response.error) { apiErrorAlert(response.error); return }
if (response.data?.success) successToast.fire({ text: t('page.users.deleteSuccess') || 'User deleted successfully' })
else if (response.data?.message) await errorAlert({ text: response.data.message })Export re-fetches with all: true through the lazy query, maps rows to a flat object, and callsexportData(format, rows, 'Users', 'users') (format is 'excel' | 'csv').
Filter panel
The shared DataTableFilterButton goes in the toolbar (icon-only, shows the active-filter count);
the route supplies an <Entity>FilterPanel rendered between the toolbar and the table. The panel is
controlled (filters, onChange, onSearch, onClear) and edits draft state the table owns —
keep pendingFilters in useState, sync it from the URL in an effect when the panel opens, and only
write to the URL on Search:
const handleSearch = () => {
url.filters.setMany({
isActive: pendingFilters.isActive === '' ? null : pendingFilters.isActive,
roleId: pendingFilters.roleId || null,
})
url.resetPage()
}
const handleClear = () => {
setPendingFilters({ isActive: '', roleId: '' })
url.filters.clearFilters()
url.resetPage()
}null clears a filter from the URL. Export the panel's <Entity>Filters interface so the table can
type its draft state. Its fields are the bare Select from @/components/ui/form (size="sm"),
not the Formik-bound ones. Options that come from the API (a role dropdown, for example) are loaded in
the panel with useRoleListQuery({ all: true }), guarded by <Loader /> and <ApiErrorMessages />.
Create / update forms
Formik + Yup, with the schema built from t so messages are localized:
const createValidationSchema = (t: (key: string, params?: Record<string, string | number>) => string) =>
Yup.object().shape({
username: Yup.string()
.required(t('validation.required') || 'Field is required')
.min(3, t('validation.minLength', { min: 3 }) || 'Field must be at least 3 characters'),
roles: Yup.array().of(Yup.string())
.required(t('validation.required') || 'Field is required')
.min(1, t('validation.atLeastOneSelected') || 'At least one item must be selected'),
})
type FormValues = Yup.InferType<ReturnType<typeof createValidationSchema>>Mirror the backend FluentValidation rules (lengths, required, email) so the user sees the error
before the round trip. Then:
<Formik<FormValues> initialValues={{ /* ... */ }} validationSchema={validationSchema} onSubmit={onSubmit}>
{() => (
<Form noValidate className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormInput name="username" label={t('form.label.username') || 'Username'} placeholder={t('form.placeholder.username') || 'Enter username'} required autoFocus />
<FormLazyMultiSelect<RoleListDto, RoleListRequest>
name="roles"
label={t('form.label.roles') || 'Roles'}
useLazyQuery={useLazyRoleListQuery}
getLabel={(role) => role.name}
getValue={(role) => role.id}
pageSize={20}
required
/>
<FormCheckbox name="isActive" label={t('form.label.isActive') || 'Active'} />
<div className="flex justify-end gap-4 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => router.push('/users')} disabled={isSaving}>
{t('common.cancel') || 'Cancel'}
</Button>
<Button type="submit" isLoading={isSaving}>{t('common.submit') || 'Submit'}</Button>
</div>
</Form>
)}
</Formik>A longer form may group its fields instead: the identity screens wrap them in FormSection
(title, description, columns={1 | 2}) and the buttons in FormActions, both from the group's own(identity)/_components/form-layout.tsx — promote it to components/ (see ui-component) once
another group needs it, rather than importing across groups.
Formik-bound fields come from @/components/ui/form: FormInput, FormPasswordInput,FormTextarea, FormSelect, FormMultiSelect, FormLazySelect, FormLazyMultiSelect,FormCheckbox, FormRadio, FormDatePicker. Use them inside Formik — the bare Input, Select,Checkbox, … are for use outside a form (the filter panel). FileUpload / MultiFileUpload are not
Formik-bound: they upload on selection and hand back the stored file through onUploaded, which the
form then writes with setFieldValue — see the file-storage skill.
Submit handler:
const result = await createUser(payload)
if (result.error) { apiErrorAlert(result.error); return }
successToast.fire({ text: t('page.users.createSuccess') || 'User created successfully' })
router.push('/users') // useLocalizedRouter, unprefixed pathThe update form additionally loads the row and guards the render order:isLoading → <Loader />, error → <ApiErrorMessages error={...} />, no data →t('error.server.userNotFound') || 'User not found' (each centred, in FormState where the form uses form-layout),
and only then the form, with initialValues taken from the
fetched row. A lazy select there takes selectedItemIds={userData.roles} so the already-chosen
options are fetched and labelled. The id travels in the path, so the payload is{ ...values, id: userId } and read-only fields shown in the form are stripped before sending.
Don't forget
- Every label, placeholder, column header, toast and confirm string needs a key in
every backend resource file (Features/Localization/Core/Resources/*.json) — see thelocalizationskill; error copy is keyed by backend error code,
seeapi-error-handling. - A field or widget the shared library does not have yet belongs in
components/ui— see theui-componentskill — not in the route's_components/. - Register list/create/update routes in
auth-urls.ts,nav-items.ts,searchable-items.ts, and
inlib/utils/tenant-routing.tswhen the entity is not tenant-only — seefrontend-page. npm run lint,npx tsc --noEmit,npm run testandnpm run buildbefore calling it done.