Components
Tables
Tables in @wayvo-ai/core are built on TanStack Table with automatic integration with Stores. They provide sorting, filtering, pagination, and reactive updates.
PageLayoutTemplate
PageLayoutTemplate provides a complete table solution:
import { PageLayoutTemplate } from '@wayvo-ai/core/ui';
<PageLayoutTemplate
title="Entities"
subTitle="Manage your entities"
store={store}
smartSearchColumns={smartSearchColumns}
tableColumns={tableColumns}
pageId="entity-page"
itemId="entity"
editForm={<EditForm store={store} />}
getDefaultRow={() => ({})}
addNewButtonText="Add Entity"
/>
PageLayoutTemplate props
Required: store, smartSearchColumns, tableColumns, pageId, itemId, title, subTitle, icon.
Optional (grouped by purpose):
- Layout / filter bar:
filterStartContent(ReactNode) — Renders before Smart Search in the filter bar.filterEndContent(ReactNode) — Renders after filters (e.g. extra checkboxes).toolbarContent(ReactNode) — Renders in toolbar next to "Add New".hideColumnsMenu(boolean, defaultfalse) — Hides the columns visibility menu.hideFilters(boolean, defaultfalse) — Hides the Smart Search filter bar.hidePagination(boolean, defaultfalse) — Hides table pagination. - Edit form:
editForm(ReactNode).getDefaultRow() => NewRow<T>.addNewButtonText(string, default"Add New").editFormLayout('sheet'|'popup', default'sheet').popupWidth(number) — Width of edit popup wheneditFormLayout="popup".popupHeight(number) — Height of edit popup wheneditFormLayout="popup".rowClickToEdit(boolean, defaultfalse) — Clicking a row opens the edit form.allowDelete(boolean) — Shows Delete button in edit form footer.handleSave((onClose: () => void) => Promise<void>) — Custom save handler; receivesonCloseso you can close after save.onSaveSuccess(() => void) — Callback after successful save. - Table:
defaultVisibleColumnOrder(StringKeyof<T>[]) — Initial visible columns and their order.loadingRows(number) — Skeleton row count while loading.disableHeaderFilters(boolean) — Disables header filters on table columns (can also come from app context). - Smart Search:
searchOnBlur(boolean) — Run search on blur instead of on every change.updateFilters((filters: FiltersType<T>) => FiltersType<T>) — Transform or merge filters before apply.stickyFilters((keyof T)[]) — Attribute codes that are "sticky": read-only in Smart Search, not cleared by "Clear", and kept when merging. Use when the page is opened in a context (e.g.customerIdwhen viewing projects under a customer):stickyFilters={customerId ? ['customerId'] : undefined}.
Table Columns
Critical: Understanding row.original
IMPORTANT: In Wayvo tables, row.original only contains the row id, NOT the full data object.
// ❌ WRONG - row.original only has { id: "..." }
cell: ({ row }) => {
const name = row.original.name; // undefined!
return <span>{name}</span>;
}
// ✅ CORRECT - Use TableCell component
cell: (props) => <TableCell type="Text" attributeCode="name" {...props} />
// ✅ ALTERNATIVE - Use useRowValue hook
function NameCell({ store, rowId }: { store: Store<Entity>; rowId: string }) {
const name = useRowValue(store, rowId, 'name');
return <span>{name || '-'}</span>;
}
Column Definition Pattern
// hooks/use-table-columns.tsx
import type { Store } from '@wayvo-ai/core/common';
import type { Entity } from '@/lib/common/ds/types/module/Entity';
import type { AccessorKeyColumnDef } from '@tanstack/react-table';
import { HeaderCell, TableCell } from '@wayvo-ai/core/ui';
import { useMemo } from 'react';
export default function useTableColumns(store: Store<Entity>): AccessorKeyColumnDef<Entity>[] {
return useMemo(
() => [
{
accessorKey: 'name',
header: (props) => <HeaderCell {...props} type="Text" store={store} accessorKey="name" title="Name" />,
cell: (props) => <TableCell type="Text" attributeCode="name" {...props} />,
},
{
accessorKey: 'status',
header: (props) => <HeaderCell {...props} type="Text" store={store} accessorKey="status" title="Status" />,
cell: (props) => <StatusBadgeCell attributeCode="status" {...props} />,
},
],
[store],
);
}
Styled Cell Components
Pre-built styled cells are available:
import {
EntityNameCell,
StatusBadgeCell,
CodeCell,
BadgeOutlineCell,
NumericWithUnitsCell,
PercentageCell,
CompoundCell,
} from '@wayvo-ai/core/ui';
// Entity name with icon
cell: (props) => <EntityNameCell attributeCode="name" preset="customer" useTableOnEdit {...props} />
// Status with auto-colors
cell: (props) => <StatusBadgeCell attributeCode="status" {...props} />
// Monospace code/ID
cell: (props) => <CodeCell attributeCode="taxId" {...props} />
// Short values (currency)
cell: (props) => <BadgeOutlineCell attributeCode="currency" {...props} />
// Number with unit
cell: (props) => <NumericWithUnitsCell attributeCode="amount" unit="$" {...props} />
// Percentage
cell: (props) => <PercentageCell attributeCode="allocationPercent" {...props} />
// Primary + secondary text
cell: (props) => (
<CompoundCell
primary="name"
secondary="description"
preset="customer"
{...props}
/>
)
Custom Cell Components
For custom rendering, use useRowValue:
function CustomCell({ store, rowId }: { store: Store<Entity>; rowId: string }) {
const status = useRowValue(store, rowId, 'status');
const amount = useRowValue(store, rowId, 'amount');
if (status == null) {
return <span className="text-muted-foreground text-xs">-</span>;
}
return (
<div className="flex items-center gap-2">
<Badge variant={status === 'active' ? 'default' : 'secondary'}>
{status}
</Badge>
{amount && <span className="font-mono">${amount.toLocaleString()}</span>}
</div>
);
}
// Use in column definition
{
accessorKey: 'status',
cell: ({ row }) => <CustomCell store={store} rowId={row.id} />,
}
Entity Presets
EntityNameCell and CompoundCell support presets:
customer- Building2 icon, primary coloruser- User icon, blueproject- FolderKanban icon, purplevendor- Store icon, emeralddocument- FileText icon, ambertask- CheckSquare icon, cyan
Row Click Handling
Use rowClickToEdit to open the edit form when the user clicks a row:
<PageLayoutTemplate
store={store}
tableColumns={tableColumns}
rowClickToEdit={true}
editForm={<EditForm store={store} />}
// ... other props
/>
Example: Sticky filters and filter bar content
When the page is opened in a context (e.g. projects under a customer), pass stickyFilters so that context filter cannot be removed or cleared. Use filterEndContent for extra controls like "Include archived":
const customerId = searchParams.get('customerId');
const [includeArchived, setIncludeArchived] = useState(false);
useEffect(() => {
if (customerId) {
store.setSmartSearchFilters([{ customerId: { is: customerId } }]);
}
store.executeQuery();
}, [customerId, includeArchived, store]);
<PageLayoutTemplate
// ... required props
stickyFilters={customerId ? ['customerId'] : undefined}
filterEndContent={
<div className="flex items-center gap-2 pr-2">
<Checkbox
id="include-archived"
checked={includeArchived}
onCheckedChange={(checked) => setIncludeArchived(checked === true)}
/>
<Label htmlFor="include-archived">Include archived</Label>
</div>
}
/>
Example: Custom save and close
Use handleSave when you need to run logic after save and then close the form yourself (e.g. refresh another row). The handler receives onClose; call it after a successful save:
const handleSave = useCallback(
async (onClose: () => void) => {
const success = await store.save({ feedback: 'Address saved successfully' });
if (success) {
// Optional: refresh related data, then close
onClose();
}
},
[store],
);
<PageLayoutTemplate
// ... required props
handleSave={handleSave}
/>
Use onSaveSuccess when you only need a callback after save (e.g. invalidate queries); the form closes automatically:
const handleSaveSuccess = useCallback(() => {
invalidateQuery('getCustomerDashboardStats');
}, []);
<PageLayoutTemplate
// ... required props
onSaveSuccess={handleSaveSuccess}
/>
Next Steps
- Smart Search - Add filtering to tables
- Page Pattern - Complete page examples