Card Views
Replace a module's table with your own card grid, and drive the query - projection, sort, filters, paging - from your own UI.
The file to edit is src/overrides/crud/cards.tsx. A card view replaces the table on a module's list page. Registering one adds a table/cards toggle to the page header and makes cards the default:
import type { TCardsOverride } from "@/core/types"
import JobsList from "@/pages/jobs/JobsList"
export const cards: TCardsOverride = { jobs: JobsList }That header row can itself be extended with a button, or replaced wholesale - see List Actions and List Toolbar. Note that replacing the row removes the table/cards toggle along with it.
Table vs Card List
The two list views fetch differently, and that difference is the reason to choose one over the other:
| Table (default) | Card view (an override) | |
|---|---|---|
| Fetching | the whole collection, in one request | one page at a time, offset/limit |
| Paging, sorting | client-side, owned by the table itself | server-side; core renders the pager below your component |
| Fetch trigger | automatic | your component must call fetcher() |
The table loads the full set on purpose: it then pages, sorts and filters client-side with no further round trips. Card views take the server-paginated path instead, which is what you want when the view should fetch incrementally.
Critical: a Card View Must Call fetcher()
In card mode the list page's fetch hooks are created with manualTrigger: true, so nothing is requested until your component asks for it. Call fetcher() from an effect, or your cards render forever against an empty data array.
React.useEffect(() => {
fetcher(zodToMongoProjection(jobSchema))
}, [fetcher])fetcher(projection?, sorting?) both triggers the request and - if you pass them - sets the projection and sort for it. Projections matter here: a card usually needs a handful of fields, not the whole document.
zodToMongoProjection (from @/core/lib/zodToMongoProjection) walks a zod schema and returns { field: 1 } for every leaf, flattening nested objects to dot paths (education.fieldOfStudy) and arrays without an index segment. Declaring the schema once gives you the projection and a parser for the unknown[] you receive back.
A Minimal Card View
Everything a card view must do, and nothing else:
import React from "react"
import z from "zod"
import type { TCardProps } from "@/core/types"
import { zodToMongoProjection } from "@/core/lib/zodToMongoProjection"
import { Card, CardContent } from "@/components/ui/card"
// Declared once: drives the projection AND parses the response.
const jobSchema = z.object({
_id: z.string(),
title: z.string(),
salary: z.number().optional(),
})
export default function JobsList({ data, isLoading, fetcher }: TCardProps) {
// REQUIRED: card mode does not fetch on its own.
React.useEffect(() => {
fetcher(zodToMongoProjection(jobSchema))
}, [fetcher])
if (isLoading) return <JobsSkeleton />
// `data` is `unknown[]` - parse it with the same schema.
const jobs = jobSchema.array().parse(data)
return (
<div className="grid gap-3 p-2 sm:grid-cols-2 xl:grid-cols-3">
{jobs.map((job) => (
<Card key={job._id}>
<CardContent className="p-4">
<h3 className="font-semibold">{job.title}</h3>
{job.salary && <small className="text-muted-foreground">{job.salary}</small>}
</CardContent>
</Card>
))}
</div>
)
}That is the whole contract: fetch on mount, parse what comes back, render it. Everything below is optional.
Adding the Extras
Let people select rows
Selection is shared with the core action bar - call toggleSelect and the delete/edit bar at the bottom of the page lights up with no extra work:
<Checkbox
checked={selectedIds.includes(job._id)}
onCheckedChange={() => toggleSelect(job._id)}
/>Pull selectedIds and toggleSelect from props. With no argument, toggleSelect() toggles the whole page.
Drive the query from your own controls
Each of these triggers a refetch through the same cached query the table uses:
<Button onClick={() => setSort({ salary: -1 })}>Highest paid</Button>
<Button onClick={() => setFilters({ isOpen: true })}>Open only</Button>Set the detail page's breadcrumb
state.name becomes the trailing crumb on the page you navigate to:
navigate(`./${job._id}`, { state: { name: job.title } })useBreadcrumbTitle() does the same thing from inside a page.
Virtualise long lists
react-virtuoso is already a dependency. Swap the plain .map() for a grid, and pass customScrollParent so the cards scroll inside the page shell rather than the window:
const [scrollParent, setScrollParent] = React.useState<HTMLDivElement | null>(null)
<div ref={setScrollParent} className="h-full overflow-y-auto">
<VirtuosoGrid
customScrollParent={scrollParent ?? undefined}
data={jobs}
computeItemKey={(_, job) => job._id}
listClassName="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"
itemContent={(_, job) => <JobCard job={job} />}
/>
</div>The Props
TCardProps:
| Prop | Use |
|---|---|
data: unknown[] | the records - parse them yourself |
isLoading | first load; show a skeleton |
isRefetching | a later load; usually a subtler indicator |
fetcher(projection?, sorting?) | trigger a fetch, optionally setting projection and sort |
setProject(projection) | change the projection without fetching |
setSort({ field: 1 }) | change sort order (1 or -1); triggers a refetch |
setFilters(filters) | set the SDK-level filters for the query |
setPage(index) | zero-based page index |
selectedIds | the _ids currently selected |
toggleSelect(id?) | toggle one row; with no argument, toggles the whole page |
name | the module name, for cache keys or labels |
Pagination is rendered by the list page below your component, driven by the same page/pageSize it passes into the query. Page size comes from VITE_DEFAULT_PAGINATION_LIMIT. You only need setPage if you want to drive paging yourself, for example from an infinite scroller.