React frontend for the MsgOps platform, built with Vite, TanStack Router (file-based routing), TanStack Query, TanStack Table, and shadcn/ui.
pnpm install
pnpm --filter @msgops/frontend-react devThe app supports two auth providers, selected by VITE_AUTH_PROVIDER:
| Mode | When | What runs |
|---|---|---|
local (default) |
Self-hosted instances. | Email + password against POST /auth/login. Refresh via httpOnly cookie bms_refresh. Token stays in memory. |
auth0 |
Hosted Etus tenant or any IdP-fronted deploy. | @auth0/auth0-react SDK with redirect flow. |
The flag MUST match the backend's AUTH_PROVIDER (apps/msgops-api). A mismatched front + back returns 410 / 403 from the auth endpoints and the login form silently fails.
# .env
VITE_AUTH_PROVIDER=local
VITE_API_URL=http://localhost:5001The first admin is created either by the setup wizard (see below) on first boot, or via BOOTSTRAP_ADMIN_EMAIL + BOOTSTRAP_ADMIN_PASSWORD on the backend.
# .env
VITE_AUTH_PROVIDER=auth0
VITE_AUTH0_DOMAIN=<tenant>.us.auth0.com
VITE_AUTH0_CLIENT_ID=<spa-client-id>
VITE_AUTH0_AUDIENCE=<api-audience>
VITE_API_URL=http://localhost:5001In local mode the Auth0 SDK is not included in the entry chunk (Vite DCE removes the if (VITE_AUTH_PROVIDER === 'auth0') branch). To verify:
VITE_AUTH_PROVIDER=local pnpm --filter @msgops/frontend-react build
grep -E "@auth0/auth0-react|auth0-spa-js" dist/assets/index-*.js # should print nothingsrc/features/auth/use-auth.ts— drop-inuseAuth0API; token in memory, single-flightrefresh(),bootstrapAuth()for hard reloadsrc/features/auth/auth0-adapter.tsx— bridge mounted only whenVITE_AUTH_PROVIDER=auth0src/lib/api-client.ts— singleton axios; 401 → refresh → retry; redirects to/login?returnTo=…when refresh failssrc/hooks/use-auth-init.ts— fetches/users/me+/accounts/configsand hydrates the Zustand storesrc/routes/login.tsx— local form (or Auth0 redirect inauth0mode)src/routes/_authenticated.tsx— protected layout guard
On first boot, GET /setup/status returns { configured: false, currentStep: N }. The router gate in src/main.tsx (applySetupGate) redirects any path → /setup until the wizard finishes. After completion, /setup redirects back to /.
The wizard has 6 steps, each persisted server-side (idempotent: re-entering a step ≤ currentStep is a no-op):
- Admin — create the first super-admin (auto-logs in immediately)
- SMTP — host / port / from + a "send test email" button
- Domain —
baseUrlused by webhook URLs and email links - SendGrid — API key + subuser config (skippable; pre-fills webhook URL from step 3)
- IP Pool — first account + IP pool (skippable)
- Health Check — probes Postgres / Redis / ClickHouse / RabbitMQ / S3 / SMTP; can complete with failing services after explicit confirmation
src/features/setup/setup-gateway.ts— isolated axios instance (no auth interceptor) for/setup/*src/features/setup/setup.types.ts— TS types mirroringapps/msgops-api/.../advance-step.dto.tssrc/features/setup/steps/Step{1..6}*.tsx— one component per stepsrc/routes/setup.tsx— standalone layout (no header/sidebar)src/main.tsx::applySetupGate()— pre-mount redirect logic
Each CRUD entity follows the same patterns. Use Tags as the reference implementation.
src/features/<entity>/
types.ts # Entity TypeScript interface
<entity>-schema.ts # Zod validation schema + max length constants
<entity>-columns.tsx # TanStack Table column definitions
<entity>-page.tsx # List page component
<entity>-form.tsx # Form component (shared create & edit)
<entity>-form-page.tsx # Form page component (orchestrates query + mutation + form)
use-<entity>.ts # API hooks (list, detail, create, update, delete)
__tests__/ # Tests
src/routes/_authenticated/_layout/<entity>/
index.tsx # List route
create.tsx # Create route
$<entity>Id.tsx # Edit route
Create src/features/<entity>/types.ts:
export interface CustomField {
id: number;
name: string;
type: string;
createdAt?: string;
updatedAt?: string;
}Create src/features/<entity>/use-custom-fields.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { keepPreviousData } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import { apiClient } from '@/lib/api-client';
import { queryKeys } from '@/lib/query-keys';
import { useAppStore } from '@/stores/app-store';
import { toPaginatedResponse, type PaginatedResponse, type RawPaginatedResponse } from '@/types';
import type { ListSearchParams } from '@/hooks/use-list-search-params';
import type { CustomField } from './types';
import type { CustomFieldFormValues } from './custom-field-schema';
import { toast } from 'sonner';
import i18n from '@/lib/i18n';
async function fetchList(params: ListSearchParams, signal?: AbortSignal): Promise<PaginatedResponse<CustomField>> {
const { data } = await apiClient.get<RawPaginatedResponse<CustomField>>('/custom-fields', {
params: {
page: params.page,
itemsPerPage: params.pageSize,
...(params.search && { title: params.search }),
...(params.sort && { sort: params.sort, order: params.order }),
},
signal,
});
return toPaginatedResponse(data);
}
export function useCustomFieldsList(params: ListSearchParams) {
const auth = useAppStore((s) => s.auth);
const accountId = auth.status === 'authenticated' ? auth.account.id : 0;
return useQuery({
queryKey: queryKeys.customFields.list(accountId, params),
queryFn: ({ signal }) => fetchList(params, signal),
placeholderData: keepPreviousData,
enabled: auth.status === 'authenticated',
});
}
export function useCustomField(id: number) {
const auth = useAppStore((s) => s.auth);
const accountId = auth.status === 'authenticated' ? auth.account.id : 0;
return useQuery({
queryKey: queryKeys.customFields.detail(accountId, id),
queryFn: async ({ signal }) => {
const { data } = await apiClient.get<CustomField>(`/custom-fields/${id}`, { signal });
return data;
},
enabled: auth.status === 'authenticated' && id > 0,
});
}
export function useCreateCustomField() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CustomFieldFormValues) => {
const { data: result } = await apiClient.post<CustomField>('/custom-fields', data);
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.customFields.all });
toast.success(i18n.t('common.createSuccess', { entity: i18n.t('customFields.entityName') }));
},
onError: (error) => {
const apiMessage =
isAxiosError(error) && typeof error.response?.data?.error === 'string' ? error.response.data.error : null;
toast.error(apiMessage ?? i18n.t('common.createError', { entity: i18n.t('customFields.entityName') }));
},
});
}
export function useUpdateCustomField(id: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CustomFieldFormValues) => {
const { data: result } = await apiClient.put<CustomField>(`/custom-fields/${id}`, data);
return result;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.customFields.all });
toast.success(i18n.t('common.updateSuccess', { entity: i18n.t('customFields.entityName') }));
},
onError: (error) => {
const apiMessage =
isAxiosError(error) && typeof error.response?.data?.error === 'string' ? error.response.data.error : null;
toast.error(apiMessage ?? i18n.t('common.updateError', { entity: i18n.t('customFields.entityName') }));
},
});
}
export function useDeleteCustomField() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
await apiClient.delete(`/custom-fields/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.customFields.all });
toast.success(i18n.t('common.deleteSuccess', { entity: i18n.t('customFields.entityName') }));
},
onError: (error) => {
const apiMessage =
isAxiosError(error) && typeof error.response?.data?.error === 'string' ? error.response.data.error : null;
toast.error(apiMessage ?? i18n.t('common.deleteError', { entity: i18n.t('customFields.entityName') }));
},
});
}Important: The backend returns { results, totalItems, page, itemsPerPage }. Always use toPaginatedResponse() from @/types to normalize it to { data, meta: { total, page, pageSize } }.
Create src/features/<entity>/custom-fields-columns.tsx:
import { useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { Pencil, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { CustomField } from './types';
// Define columns outside the hook to avoid re-renders
const staticColumns: ColumnDef<CustomField, unknown>[] = [
{
accessorKey: 'name',
header: 'customFields.name', // translation key, resolved in the hook
enableSorting: true,
cell: ({ row }) => (
<Link
to="/custom-fields/$id"
params={{ id: String(row.original.id) }}
className="text-primary font-medium hover:underline"
>
{row.original.name}
</Link>
),
},
{
accessorKey: 'type',
header: 'customFields.type',
},
];
interface UseColumnsOptions {
onDelete: (item: CustomField) => void;
canDelete: boolean;
}
export function useCustomFieldsColumns({ onDelete, canDelete }: UseColumnsOptions): ColumnDef<CustomField, unknown>[] {
const { t } = useTranslation();
return useMemo(() => {
const translated = staticColumns.map((col) => ({
...col,
header: typeof col.header === 'string' ? t(col.header) : col.header,
}));
return [
...translated,
{
id: 'actions',
cell: ({ row }) => {
const editLabel = t('common.edit');
const deleteLabel = t('common.deleteEntity', {
entity: t('customFields.entityName'),
});
return (
<div className="flex justify-end gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-xs" asChild>
<Link to="/custom-fields/$id" params={{ id: String(row.original.id) }}>
<Pencil className="h-3.5 w-3.5" />
<span className="sr-only">{editLabel}</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{editLabel}</TooltipContent>
</Tooltip>
{canDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-destructive hover:text-destructive"
onClick={() => onDelete(row.original)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="sr-only">{deleteLabel}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{deleteLabel}</TooltipContent>
</Tooltip>
)}
</TooltipProvider>
</div>
);
},
},
];
}, [t, onDelete, canDelete]);
}Tips:
- Define
staticColumnsoutside the hook to prevent TanStack Table re-renders. - Use translation keys as
headerstrings, then resolve them inside the hook witht(). - For the delete tooltip, use the entity type name (e.g., "Delete tag"), not the item's name.
- For 2 actions, use inline icon buttons with tooltips. Only use a kebab menu (dropdown) if there are 3+ actions.
Create src/features/<entity>/custom-fields-page.tsx:
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from '@tanstack/react-router';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { LayoutList, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/data-table/data-table';
import { DataTablePagination } from '@/components/data-table/data-table-pagination';
import { DataTableSearch } from '@/components/data-table/data-table-search';
import { DataTableEmptyState } from '@/components/data-table/data-table-empty-state';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { ListPage } from '@/components/list-page';
import { useListSearchParams, type ListSearchParams } from '@/hooks/use-list-search-params';
import { useCustomFieldsList, useDeleteCustomField } from './use-custom-fields';
import { useCustomFieldsColumns } from './custom-fields-columns';
import type { CustomField } from './types';
const EMPTY_ARRAY: CustomField[] = [];
interface Props {
searchParams: ListSearchParams;
}
export default function CustomFieldsPage({ searchParams }: Props) {
const { t } = useTranslation();
const { pagination, sorting, setPagination, setSorting, setSearch } = useListSearchParams(searchParams);
const query = useCustomFieldsList(searchParams);
const deleteMutation = useDeleteCustomField();
const [deleteTarget, setDeleteTarget] = useState<CustomField | null>(null);
const handleDelete = useCallback((item: CustomField) => {
setDeleteTarget(item);
}, []);
const confirmDelete = useCallback(() => {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
setDeleteTarget(null);
const data = query.data?.data ?? [];
if (data.length === 1 && searchParams.page > 1) {
setPagination((prev) => ({ ...prev, pageIndex: prev.pageIndex - 1 }));
}
},
});
}, [deleteTarget, deleteMutation, query.data?.data, searchParams.page, setPagination]);
const columns = useCustomFieldsColumns({ onDelete: handleDelete, canDelete: true });
const data = query.data?.data ?? EMPTY_ARRAY;
const totalRows = query.data?.meta.total ?? 0;
const totalPages = Math.ceil(totalRows / searchParams.pageSize);
const table = useReactTable({
columns,
data,
rowCount: totalRows,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
});
const isEmpty = !query.isLoading && data.length === 0;
return (
<>
<ListPage.Root>
<ListPage.Header title={t('customFields.title')}>
<Button size="sm" asChild>
<Link to="/custom-fields/create">
<Plus className="mr-1 h-4 w-4" />
{t('customFields.create')}
</Link>
</Button>
</ListPage.Header>
<ListPage.Toolbar>
<DataTableSearch value={searchParams.search} onChange={setSearch} />
</ListPage.Toolbar>
{isEmpty ? (
<ListPage.Empty>
<DataTableEmptyState
entityName={t('customFields.entityNamePlural')}
hasSearch={searchParams.search.length > 0}
onClearSearch={() => setSearch('')}
icon={LayoutList}
/>
</ListPage.Empty>
) : (
<>
<ListPage.Content>
<DataTable
columns={columns}
table={table}
isLoading={query.isLoading}
isFetching={query.isFetching}
error={query.error}
onRetry={() => query.refetch()}
/>
</ListPage.Content>
<ListPage.Pagination>
<DataTablePagination
currentPage={searchParams.page}
totalPages={totalPages}
pageSize={searchParams.pageSize}
totalRows={totalRows}
onPageChange={(page) => setPagination((prev) => ({ ...prev, pageIndex: page - 1 }))}
onPageSizeChange={(size) => setPagination({ pageIndex: 0, pageSize: size })}
/>
</ListPage.Pagination>
</>
)}
</ListPage.Root>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
title={t('common.deleteConfirmTitle', { entity: t('customFields.entityName') })}
description={t('common.deleteConfirmMessage', { name: deleteTarget?.name ?? '' })}
onConfirm={confirmDelete}
loading={deleteMutation.isPending}
/>
</>
);
}Create src/routes/_authenticated/_layout/custom-fields/index.tsx:
import { createFileRoute } from '@tanstack/react-router';
import { listSearchSchema } from '@/hooks/use-list-search-params';
import CustomFieldsPage from '@/features/custom-fields/custom-fields-page';
export const Route = createFileRoute('/_authenticated/_layout/custom-fields/')({
validateSearch: listSearchSchema,
component: CustomFieldsRoute,
});
function CustomFieldsRoute() {
const searchParams = Route.useSearch();
return <CustomFieldsPage searchParams={searchParams} />;
}This gives you URL-persisted state for free. All pagination, search, and sorting changes update the query string and support browser back/forward navigation.
Create src/features/<entity>/custom-field-schema.ts:
import { z } from 'zod';
export const CUSTOM_FIELD_NAME_MAX = 40;
export const CUSTOM_FIELD_DESCRIPTION_MAX = 500;
export const customFieldFormSchema = z.object({
name: z
.string()
.min(1, 'validation.required')
.max(CUSTOM_FIELD_NAME_MAX, `validation.maxLength::${CUSTOM_FIELD_NAME_MAX}`),
description: z
.string()
.max(CUSTOM_FIELD_DESCRIPTION_MAX, `validation.maxLength::${CUSTOM_FIELD_DESCRIPTION_MAX}`)
.optional()
.default(''),
// Add entity-specific fields here:
// type: z.string().min(1, 'validation.required'),
});
export type CustomFieldFormValues = z.infer<typeof customFieldFormSchema>;Conventions:
- Export max length constants — used in both schema and form for
maxLength+ character counters. - Use
'validation.required'and'validation.maxLength::N'i18n keys —FormMessageauto-translates them. - Name field:
max(40)is the default for all entities. - Description field:
max(500), optional with empty string default.
Create src/features/<entity>/custom-field-form.tsx:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { UnsavedChangesDialog } from '@/components/unsaved-changes-dialog';
import {
customFieldFormSchema,
CUSTOM_FIELD_NAME_MAX,
CUSTOM_FIELD_DESCRIPTION_MAX,
type CustomFieldFormValues,
} from './custom-field-schema';
interface CustomFieldFormProps {
defaultValues?: CustomFieldFormValues;
onSubmit: (data: CustomFieldFormValues) => void;
isPending: boolean;
}
export function CustomFieldForm({ defaultValues, onSubmit, isPending }: CustomFieldFormProps) {
const { t } = useTranslation();
const isEditing = defaultValues !== undefined;
const form = useForm<CustomFieldFormValues>({
resolver: zodResolver(customFieldFormSchema),
defaultValues: defaultValues ?? { name: '', description: '' },
});
const nameLength = form.watch('name')?.length ?? 0;
const descriptionLength = form.watch('description')?.length ?? 0;
return (
<>
<UnsavedChangesDialog isDirty={form.formState.isDirty} isPending={isPending} />
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>{t('customFields.name')}</FormLabel>
<span className="text-muted-foreground text-xs">
{t('validation.charCount', { count: nameLength, max: CUSTOM_FIELD_NAME_MAX })}
</span>
</div>
<FormControl>
<Input {...field} maxLength={CUSTOM_FIELD_NAME_MAX} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>{t('customFields.description')}</FormLabel>
<span className="text-muted-foreground text-xs">
{t('validation.charCount', { count: descriptionLength, max: CUSTOM_FIELD_DESCRIPTION_MAX })}
</span>
</div>
<FormControl>
<Textarea rows={3} {...field} maxLength={CUSTOM_FIELD_DESCRIPTION_MAX} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Add entity-specific fields here */}
{form.formState.errors.root?.serverError && (
<div role="alert" className="bg-destructive/10 text-destructive rounded-md p-3 text-sm">
{form.formState.errors.root.serverError.message}
</div>
)}
<Button type="submit" disabled={isPending}>
{isPending ? t('common.loading') : isEditing ? t('common.save') : t('common.create')}
</Button>
</form>
</Form>
</>
);
}Key patterns:
- Single component for create & edit — detect mode via
defaultValuespresence. UnsavedChangesDialogblocks navigation when form is dirty, disabled during submission so post-submit redirect works.maxLengthon inputs hard-caps typing + character counter showscount/max.- Server errors render inline via
form.formState.errors.root?.serverError. - Submit button shows "Create" / "Save" / "Loading..." based on mode and state.
Create src/features/<entity>/custom-field-form-page.tsx:
import { useTranslation } from 'react-i18next';
import { useNavigate } from '@tanstack/react-router';
import { FormPage } from '@/components/form-page';
import { CustomFieldForm } from './custom-field-form';
import { useCustomField, useCreateCustomField, useUpdateCustomField } from './use-custom-fields';
import type { CustomFieldFormValues } from './custom-field-schema';
interface CustomFieldFormPageProps {
customFieldId?: number;
}
export function CustomFieldFormPage({ customFieldId }: CustomFieldFormPageProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const isEditing = customFieldId !== undefined;
const query = useCustomField(isEditing ? customFieldId : 0);
const createMutation = useCreateCustomField();
const updateMutation = useUpdateCustomField(customFieldId ?? 0);
const mutation = isEditing ? updateMutation : createMutation;
const handleSubmit = (data: CustomFieldFormValues) => {
mutation.mutate(data, {
onSuccess: () => {
navigate({ to: '/custom-fields' });
},
});
};
// Loading skeleton while fetching entity for edit
if (isEditing && query.isLoading) {
return (
<FormPage.Root>
<FormPage.Header title={t('customFields.edit')} backTo="/custom-fields" backLabel={t('customFields.title')} />
<FormPage.Content>
<FormPage.LoadingSkeleton />
</FormPage.Content>
</FormPage.Root>
);
}
// Error state (404 / fetch error)
if (isEditing && query.error) {
return (
<FormPage.Root>
<FormPage.Header title={t('customFields.edit')} backTo="/custom-fields" backLabel={t('customFields.title')} />
<FormPage.Content>
<p className="text-muted-foreground text-sm">{t('common.entityNotFound')}</p>
</FormPage.Content>
</FormPage.Root>
);
}
// Map entity data to form defaultValues for edit mode
const defaultValues =
isEditing && query.data ? { name: query.data.name, description: query.data.description ?? '' } : undefined;
return (
<FormPage.Root>
<FormPage.Header
title={isEditing ? t('customFields.edit') : t('customFields.create')}
backTo="/custom-fields"
backLabel={t('customFields.title')}
/>
<FormPage.Content>
<CustomFieldForm defaultValues={defaultValues} onSubmit={handleSubmit} isPending={mutation.isPending} />
</FormPage.Content>
</FormPage.Root>
);
}Create route — src/routes/_authenticated/_layout/custom-fields/create.tsx:
import { createFileRoute } from '@tanstack/react-router';
import { CustomFieldFormPage } from '@/features/custom-fields/custom-field-form-page';
export const Route = createFileRoute('/_authenticated/_layout/custom-fields/create')({
component: CustomFieldCreateRoute,
});
function CustomFieldCreateRoute() {
return <CustomFieldFormPage />;
}Edit route — src/routes/_authenticated/_layout/custom-fields/$customFieldId.tsx:
import { createFileRoute } from '@tanstack/react-router';
import { CustomFieldFormPage } from '@/features/custom-fields/custom-field-form-page';
export const Route = createFileRoute('/_authenticated/_layout/custom-fields/$customFieldId')({
component: CustomFieldEditRoute,
});
function CustomFieldEditRoute() {
const { customFieldId } = Route.useParams();
return <CustomFieldFormPage customFieldId={Number(customFieldId)} />;
}TanStack Router prioritizes static routes (create.tsx) over dynamic segments ($customFieldId.tsx), so /custom-fields/create will never match the dynamic route.
In src/lib/query-keys.ts, add keys for the new entity:
customFields: {
all: ['custom-fields'] as const,
list: (accountId: number, params: ListSearchParams) =>
['custom-fields', 'list', { accountId, ...params }] as const,
detail: (accountId: number, id: number) =>
['custom-fields', 'detail', { accountId, id }] as const,
},Add keys to both src/locales/en-US.json and src/locales/pt-BR.json:
{
"customFields": {
"title": "Custom Fields",
"name": "Name",
"type": "Type",
"description": "Description",
"create": "Create custom field",
"edit": "Edit custom field",
"entityName": "custom field",
"entityNamePlural": "custom fields"
}
}| Component | Location | Purpose |
|---|---|---|
ListPage.* |
src/components/list-page.tsx |
Compound layout (Root, Header, Toolbar, Content, Pagination, Empty) |
FormPage.* |
src/components/form-page.tsx |
Compound layout (Root, Header, Content, Footer, LoadingSkeleton) |
UnsavedChangesDialog |
src/components/unsaved-changes-dialog.tsx |
Navigation blocker (useBlocker + beforeunload) |
DataTable |
src/components/data-table/data-table.tsx |
Table with sortable headers, loading skeleton, error state |
DataTablePagination |
src/components/data-table/data-table-pagination.tsx |
Page navigation, page size selector, results count |
DataTableSearch |
src/components/data-table/data-table-search.tsx |
Debounced search input with clear button |
DataTableEmptyState |
src/components/data-table/data-table-empty-state.tsx |
Empty/no-results state with icon and actions |
ConfirmDialog |
src/components/confirm-dialog.tsx |
Destructive confirmation modal (Radix AlertDialog) |
useListSearchParams |
src/hooks/use-list-search-params.ts |
Bridges URL search params with TanStack Table state |
- URL is the single source of truth for pagination, sorting, and search. No local state for these.
- Page size preference is persisted to
localStorageand shared across all list pages. - Backend response normalization: Always use
toPaginatedResponse()from@/typesto convert the backend's{ results, totalItems, page, itemsPerPage }format. - Delete error handling: Show the API's error message (from
response.data.error) when available, fall back to a generic translated message. - Confirmation dialog: Stays open on error so the user can retry. Closes automatically on success.
- Empty array constant: Define
const EMPTY_ARRAY: Entity[] = []outside the component to avoid re-renders from?? []. - Column definitions: Define
staticColumnsoutside the hook to prevent TanStack Table recalculation. - Single form for create & edit: Detect mode via
defaultValuesprop. No separate components. - Max length fields: Export constants from schema, use
maxLengthon inputs + character counter (validation.charCount). - Validation i18n: Use
'validation.required'and'validation.maxLength::N'keys in zod schemas.FormMessageauto-translates them. - Name max length: 40 characters is the default for all entities.
- Unsaved changes protection:
UnsavedChangesDialoghandles both in-app navigation (useBlocker) and tab close (beforeunload). PassisPendingto disable during submission.
If a page needs extra filters beyond the base listSearchSchema, extend it:
import { listSearchSchema } from '@/hooks/use-list-search-params';
import { z } from 'zod';
const customFieldsSearchSchema = listSearchSchema.extend({
type: z.string().default('').catch(''),
});Use the extended schema in validateSearch and access the extra params via Route.useSearch().