fix: avoid duplicate billing portal link (#330)

* fix: avoid duplicate billing portal link
* fix: improve DataTable API
This commit is contained in:
Giancarlo Buomprisco
2025-08-26 05:30:18 +07:00
committed by GitHub
parent ad427365c9
commit f9ebe2f927
31 changed files with 1706 additions and 1085 deletions

View File

@@ -19,11 +19,14 @@ import {
ColumnPinningState,
DataTable,
VisibilityState,
flexRender,
useColumnManagement,
} from '@kit/ui/enhanced-data-table';
import { Label } from '@kit/ui/label';
import { Separator } from '@kit/ui/separator';
import { Switch } from '@kit/ui/switch';
import { TableCell } from '@kit/ui/table';
import { cn } from '@kit/ui/utils';
import { generatePropsString, useStoryControls } from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
@@ -326,6 +329,7 @@ export function DataTableStory() {
}{' '}
/ {currentPageData.length}
</span>
{Object.keys(rowSelection).length > 0 && (
<Button
onClick={() => setRowSelection({})}
@@ -570,16 +574,43 @@ export function DataTableStory() {
</>
);
const renderExamples = () => (
const renderExamples = () => {
// Example-specific column management state
const exampleColumnManagement = useColumnManagement({
defaultVisibility: {
name: true,
email: true,
status: true,
role: true,
department: true,
salary: true,
},
defaultPinning: { left: [], right: [] },
});
// Example-specific row selection states
const [exampleRowSelection1, setExampleRowSelection1] = useState<
Record<string, boolean>
>({});
const [exampleRowSelection2, setExampleRowSelection2] = useState<
Record<string, boolean>
>({});
const [paginationRowSelection, setPaginationRowSelection] = useState<
Record<string, boolean>
>({});
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Basic Usage</CardTitle>
<CardTitle>Basic Usage with Row Click</CardTitle>
<CardDescription>
Simple data table with minimal setup
Simple data table with row click handlers to navigate or show
details
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<DataTable
columns={[
{ accessorKey: 'name', header: 'Name' },
@@ -596,58 +627,129 @@ export function DataTableStory() {
pageSize={5}
pageCount={1}
getRowId={(row) => row.id}
onClick={({ row, cell }) => {
console.log('Row clicked:', row.original);
console.log('Cell clicked:', cell);
}}
/>
<div className="text-muted-foreground text-xs">
💡 Click on any row to see the onClick handler in action. In a
real application, this might navigate to a user detail page or
open a modal.
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>With Selection & Pinning</CardTitle>
<CardTitle>With Selection, Pinning & Cell Click</CardTitle>
<CardDescription>
Advanced table with selection (checkbox always pinned left) and
action column pinned right
action column pinned right. Demonstrates both row and cell click
handlers.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-4 text-sm">
<span className="font-medium">Column Visibility:</span>
{['name', 'email', 'role', 'department'].map((columnId) => (
<label key={columnId} className="flex items-center gap-2">
<Checkbox
checked={exampleColumnManagement.isColumnVisible(
columnId,
)}
onCheckedChange={(checked) =>
exampleColumnManagement.setColumnVisible(
columnId,
!!checked,
)
}
/>
<span className="capitalize">{columnId}</span>
</label>
))}
</div>
<DataTable
columns={[
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{ accessorKey: 'name', header: 'Name' },
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<button
className="text-left hover:underline focus:underline focus:outline-none"
onClick={(e) => {
e.stopPropagation(); // Prevent row click
alert(
`Cell click: Opening profile for ${row.original.name}`,
);
}}
>
{row.getValue('name')}
</button>
),
},
{ accessorKey: 'email', header: 'Email' },
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => (
<Badge variant="outline">{row.getValue('role')}</Badge>
<Badge
variant="outline"
className="hover:bg-accent cursor-pointer"
onClick={(e) => {
e.stopPropagation(); // Prevent row click
alert(`Filter by role: ${row.getValue('role')}`);
}}
>
{row.getValue('role')}
</Badge>
),
},
{ accessorKey: 'department', header: 'Department' },
{
id: 'actions',
header: 'Actions',
cell: () => (
<Button size="sm" variant="ghost">
cell: ({ row }) => (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation(); // Prevent row click
alert(`Edit user: ${row.original.name}`);
}}
>
Edit
</Button>
),
enableSorting: false,
enableHiding: false,
size: 80,
},
]}
@@ -655,15 +757,45 @@ export function DataTableStory() {
pageSize={5}
pageCount={1}
getRowId={(row) => row.id}
columnPinning={
{
columnPinning={{
left: ['select'],
right: ['actions'],
} satisfies ColumnPinningState
}}
columnVisibility={exampleColumnManagement.columnVisibility}
onColumnVisibilityChange={
exampleColumnManagement.setColumnVisibility
}
rowSelection={{} satisfies Record<string, boolean>}
onRowSelectionChange={(_selection: Record<string, boolean>) => {}}
rowSelection={exampleRowSelection1}
onRowSelectionChange={setExampleRowSelection1}
onRowClick={(row) => {
console.log('Row clicked:', row.original);
// In a real app, might navigate to detail view
alert(`Row click: Viewing details for ${row.original.name}`);
}}
/>
<div className="text-muted-foreground space-y-1 text-xs">
<p>💡 This example demonstrates multiple click handlers:</p>
<ul className="ml-4 list-disc space-y-1">
<li>
<strong>Name cell:</strong> Click to open user profile
</li>
<li>
<strong>Role badge:</strong> Click to filter by role
</li>
<li>
<strong>Edit button:</strong> Click to edit user
</li>
<li>
<strong>Row click:</strong> Click empty space to view
details
</li>
<li>
Cell clicks use <code>e.stopPropagation()</code> to prevent
row click
</li>
</ul>
</div>
</div>
</CardContent>
</Card>
@@ -676,8 +808,9 @@ export function DataTableStory() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-64 rounded-lg border">
<div className="h-64 rounded-lg border p-0.5">
<DataTable
className={''}
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
@@ -717,9 +850,10 @@ export function DataTableStory() {
<Card>
<CardHeader>
<CardTitle>Full-Screen Layout Pattern</CardTitle>
<CardTitle>Full-Screen Layout with Action Handlers</CardTitle>
<CardDescription>
Simulated full-screen table that stretches to fill available space
Simulated full-screen table with toolbar actions and keyboard
navigation
</CardDescription>
</CardHeader>
<CardContent>
@@ -728,10 +862,33 @@ export function DataTableStory() {
<div className="bg-muted/30 flex items-center justify-between border-b p-3">
<h3 className="text-sm font-semibold">Dashboard Table</h3>
<div className="flex gap-2">
<Button size="sm" variant="outline">
Export
<Button
size="sm"
variant="outline"
onClick={() => {
const selectedRows = Object.keys(
exampleRowSelection2,
).filter((key) => exampleRowSelection2[key]);
const selectedCount = selectedRows.length;
if (selectedCount > 0) {
alert(`Exporting ${selectedCount} selected users`);
} else {
alert('Exporting all users');
}
}}
>
Export (
{Object.keys(exampleRowSelection2).filter(
(key) => exampleRowSelection2[key],
).length || 'All'}
)
</Button>
<Button
size="sm"
onClick={() => alert('Opening add user form')}
>
Add User
</Button>
<Button size="sm">Add User</Button>
</div>
</div>
@@ -743,16 +900,24 @@ export function DataTableStory() {
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() &&
'indeterminate')
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onCheckedChange={(value) =>
row.toggleSelected(!!value)
}
aria-label="Select row"
/>
),
enableSorting: false,
@@ -771,13 +936,34 @@ export function DataTableStory() {
{
id: 'actions',
header: 'Actions',
cell: () => (
<Button size="sm" variant="ghost">
cell: ({ row }) => (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
alert(`Editing ${row.original.name}`);
}}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
if (confirm(`Delete ${row.original.name}?`)) {
alert('User deleted (simulated)');
}
}}
>
Delete
</Button>
</div>
),
enableSorting: false,
size: 80,
size: 120,
},
]}
data={data.slice(0, 15)}
@@ -786,14 +972,29 @@ export function DataTableStory() {
getRowId={(row) => row.id}
columnPinning={{ left: ['select'], right: ['actions'] }}
sticky={true}
rowSelection={{}}
onRowSelectionChange={() => {}}
rowSelection={exampleRowSelection2}
onRowSelectionChange={setExampleRowSelection2}
onRowClick={(row) => {
console.log(
'Row clicked in full-screen layout:',
row.original,
);
}}
onRowDoubleClick={(row) => {
alert(`Double-click: Quick edit for ${row.original.name}`);
}}
/>
</div>
</div>
<p className="text-muted-foreground mt-2 text-xs">
💻 Use flex-1 min-h-0 for tables that should fill available space
</p>
<div className="text-muted-foreground mt-2 space-y-1 text-xs">
<p>💻 This example shows common dashboard patterns:</p>
<ul className="ml-4 list-disc space-y-1">
<li>Export button shows selected count dynamically</li>
<li>Action buttons (Edit/Delete) with confirmation dialogs</li>
<li>Double-click rows for quick actions</li>
<li>Flex layout fills available space</li>
</ul>
</div>
</CardContent>
</Card>
@@ -806,8 +1007,11 @@ export function DataTableStory() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-48 rounded-lg border sm:h-64 lg:h-80">
<div className="h-48 sm:h-64 lg:h-80">
<DataTable
tableProps={{
className: 'border border-border',
}}
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
@@ -832,8 +1036,370 @@ export function DataTableStory() {
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Custom Cell Rendering with renderCell</CardTitle>
<CardDescription>
Using the renderCell prop to wrap all cells with custom behavior
and styling
</CardDescription>
</CardHeader>
<CardContent>
<DataTable
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => (
<Badge variant="outline">{row.getValue('role')}</Badge>
),
},
{
accessorKey: 'salary',
header: 'Salary',
cell: ({ row }) => {
const salary = row.getValue('salary') as number;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
}).format(salary);
},
},
{
accessorKey: 'rating',
header: 'Rating',
cell: ({ row }) => {
const rating = row.getValue('rating') as number;
return (
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<span
key={i}
className={
i < rating
? 'text-yellow-400'
: 'text-muted-foreground'
}
>
</span>
))}
<span className="text-muted-foreground ml-1 text-xs">
({rating}/5)
</span>
</div>
);
},
},
]}
data={data.slice(0, 8)}
pageSize={8}
pageCount={1}
getRowId={(row) => row.id}
renderCell={({ cell, style, className }) => {
// Custom cell wrapper that adds hover effects and tooltips
return () => (
<TableCell
key={cell.id}
style={style}
className={cn(
className,
'group hover:bg-accent/30 relative transition-all duration-200',
// Add special styling for salary column
cell.column.id === 'salary' && 'font-mono',
// Add padding for rating column
cell.column.id === 'rating' && 'px-6',
)}
title={`Column: ${cell.column.id} | Value: ${cell.getValue()}`}
>
<div className="relative">
{/* Add a subtle border indicator on hover */}
<div className="bg-primary/20 absolute top-0 -left-2 h-full w-1 scale-y-0 transform rounded transition-transform duration-200 group-hover:scale-y-100" />
<div className="relative z-10">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
</div>
</TableCell>
);
}}
/>
<div className="text-muted-foreground mt-3 space-y-2 text-xs">
<p>💡 This example shows renderCell usage:</p>
<ul className="ml-4 list-disc space-y-1">
<li>Adds custom hover effects to all cells</li>
<li>Shows tooltips with column and value info</li>
<li>Applies conditional styling (monospace font for salary)</li>
<li>Adds animated border indicators on hover</li>
<li>Maintains all original cell content and behavior</li>
</ul>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Pagination Examples</CardTitle>
<CardDescription>
Different pagination scenarios with proper page management
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Small dataset with pagination */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">
Small Dataset (15 items, 5 per page)
</h4>
<DataTable
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge variant="outline">
{row.getValue('status')}
</Badge>
),
},
]}
data={data.slice(0, 15)}
pageSize={5}
pageCount={3}
getRowId={(row) => row.id}
/>
</div>
{/* Medium dataset with pagination */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">
Medium Dataset (30 items, 10 per page)
</h4>
<DataTable
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'department', header: 'Department' },
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => (
<Badge variant="outline">{row.getValue('role')}</Badge>
),
},
{
accessorKey: 'salary',
header: 'Salary',
cell: ({ row }) => {
const salary = row.getValue('salary') as number;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
}).format(salary);
},
},
]}
data={data.slice(0, 30)}
pageSize={10}
pageCount={3}
getRowId={(row) => row.id}
/>
</div>
{/* Large dataset with selection and pagination */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">
Large Dataset with Selection & Context Menu (50 items, 15 per
page)
</h4>
<DataTable
columns={[
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() &&
'indeterminate')
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) =>
row.toggleSelected(!!value)
}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
size: 40,
},
{ accessorKey: 'name', header: 'Name' },
{
accessorKey: 'email',
header: 'Email',
cell: ({ row }) => (
<button
className="text-left text-blue-600 hover:underline focus:underline focus:outline-none"
onClick={(e) => {
e.stopPropagation();
window.open(
`mailto:${row.original.email}`,
'_blank',
);
}}
>
{row.getValue('email')}
</button>
),
},
{ accessorKey: 'department', header: 'Department' },
{ accessorKey: 'location', header: 'Location' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => (
<Badge
variant={
row.getValue('status') === 'Active'
? 'default'
: 'secondary'
}
className="cursor-pointer"
onClick={(e) => {
e.stopPropagation();
const currentStatus = row.getValue('status');
const newStatus =
currentStatus === 'Active'
? 'Inactive'
: 'Active';
alert(
`Status would change from ${currentStatus} to ${newStatus}`,
);
}}
>
{row.getValue('status') as string}
</Badge>
),
},
{
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
alert(
`Opening detailed view for ${row.original.name}`,
);
}}
>
View
</Button>
),
enableSorting: false,
enableHiding: false,
size: 80,
},
]}
data={data.slice(0, 50)}
pageSize={15}
pageCount={Math.ceil(50 / 15)}
getRowId={(row) => row.id}
columnPinning={{
left: ['select'],
right: ['actions'],
}}
rowSelection={paginationRowSelection}
onRowSelectionChange={setPaginationRowSelection}
onRowContextMenu={(row, event) => {
event.preventDefault();
const actions = [
`Edit ${row.original.name}`,
`Send message to ${row.original.name}`,
`View ${row.original.name}'s profile`,
'---',
`Delete ${row.original.name}`,
];
alert(
`Right-click context menu for ${row.original.name}:\n\n${actions.join('\n')}`,
);
}}
onRowClick={(row) => {
console.log(
'Row clicked in pagination example:',
row.original,
);
}}
/>
</div>
{/* Force pagination example */}
<div className="space-y-3">
<h4 className="text-sm font-semibold">
Force Pagination (3 items, but pagination shown)
</h4>
<p className="text-muted-foreground text-xs">
Using `forcePagination={true}` to show pagination controls
even with few items
</p>
<DataTable
columns={[
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{
accessorKey: 'role',
header: 'Role',
cell: ({ row }) => (
<Badge variant="outline">{row.getValue('role')}</Badge>
),
},
]}
data={data.slice(0, 3)}
pageSize={5}
pageCount={1}
getRowId={(row) => row.id}
forcePagination={true}
/>
</div>
</div>
<div className="text-muted-foreground mt-6 space-y-2 text-xs">
<p>💡 Pagination examples demonstrate:</p>
<ul className="ml-4 list-disc space-y-1">
<li>Different page sizes (5, 10, 15 per page)</li>
<li>Proper pageCount calculation based on total items</li>
<li>Selection state preserved across page changes</li>
<li>Force pagination option for consistency</li>
<li>
Real pagination controls (note: URL updates don't work in
stories)
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
);
};
const generateCode = () => {
const propsString = generatePropsString(

View File

@@ -8,12 +8,12 @@
"format": "prettier --check --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\""
},
"dependencies": {
"@ai-sdk/openai": "^2.0.19",
"@ai-sdk/openai": "^2.0.20",
"@faker-js/faker": "^9.9.0",
"@hookform/resolvers": "^5.2.1",
"@tanstack/react-query": "5.85.5",
"ai": "5.0.21",
"lucide-react": "^0.540.0",
"ai": "5.0.23",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"nodemailer": "^7.0.5",
"react": "19.1.1",
@@ -30,7 +30,7 @@
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^24.3.0",
"@types/nodemailer": "7.0.1",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"pino-pretty": "13.0.0",

View File

@@ -62,13 +62,13 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
navigationTimeout: 15_000,
navigationTimeout: 15 * 1000,
},
// test timeout set to 2 minutes
timeout: 120 * 1000,
expect: {
// expect timeout set to 5 seconds
timeout: 5 * 1000,
// expect timeout set to 10 seconds
timeout: 10 * 1000,
},
/* Configure projects for major browsers */
projects: [

View File

@@ -388,11 +388,15 @@ async function filterAccounts(page: Page, email: string) {
.fill(email);
await page.keyboard.press('Enter');
await page.waitForTimeout(250);
await page.waitForTimeout(500);
}
async function selectAccount(page: Page, email: string) {
await page.getByRole('link', { name: email.split('@')[0] }).click();
await page
.locator('tr', { hasText: email.split('@')[0] })
.locator('a')
.click();
await page.waitForURL(new RegExp(`/admin/accounts/[a-z0-9-]+`));
await page.waitForTimeout(500);
}

View File

@@ -60,15 +60,14 @@ async function PersonalAccountBillingPage() {
<PageBody>
<div className={'flex flex-col space-y-4'}>
<If condition={!hasBillingData}>
<If
condition={hasBillingData}
fallback={
<>
<PersonalAccountCheckoutForm customerId={customerId} />
<If condition={customerId}>
<CustomerBillingPortalForm />
</If>
</If>
<If condition={hasBillingData}>
</>
}
>
<div className={'flex w-full max-w-2xl flex-col space-y-6'}>
<If condition={subscription}>
{(subscription) => {

View File

@@ -60,14 +60,14 @@
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"next-sitemap": "^4.2.3",
"next-themes": "0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"recharts": "2.15.3",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.74"
@@ -79,10 +79,10 @@
"@next/bundle-analyzer": "15.5.0",
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^24.3.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"cssnano": "^7.1.0",
"cssnano": "^7.1.1",
"pino-pretty": "13.0.0",
"prettier": "^3.6.2",
"supabase": "2.34.0",

View File

@@ -45,6 +45,7 @@
"skip": "Skip",
"signedInAs": "Signed in as",
"pageOfPages": "Page {{page}} of {{total}}",
"showingRecordCount": "Showing {{pageSize}} of {{totalCount}} rows",
"noData": "No data available",
"pageNotFoundHeading": "Ouch! :|",
"errorPageHeading": "Ouch! :|",

View File

@@ -27,13 +27,13 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.55.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"zod": "^3.25.74"
},
"typesVersions": {

View File

@@ -24,7 +24,7 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"next": "15.5.0",
"react": "19.1.1",
"zod": "^3.25.74"

View File

@@ -27,7 +27,7 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"date-fns": "^4.1.0",
"next": "15.5.0",
"react": "19.1.1",

View File

@@ -27,7 +27,7 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^24.3.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1",
"zod": "^3.25.74"
},

View File

@@ -21,7 +21,7 @@
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "^24.3.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"wp-types": "^4.68.1"
},
"typesVersions": {

View File

@@ -36,15 +36,15 @@
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"next-themes": "0.4.6",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"zod": "^3.25.74"
},
"prettier": "@kit/prettier-config",

View File

@@ -23,8 +23,8 @@
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.10",
"lucide-react": "^0.540.0",
"@types/react": "19.1.11",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react": "19.1.1",
"react-dom": "19.1.1",

View File

@@ -31,11 +31,11 @@
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"lucide-react": "^0.540.0",
"@types/react": "19.1.11",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"sonner": "^2.0.7",
"zod": "^3.25.74"
},

View File

@@ -21,11 +21,11 @@
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"lucide-react": "^0.540.0",
"@types/react": "19.1.11",
"lucide-react": "^0.541.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-i18next": "^15.7.1"
"react-i18next": "^15.7.2"
},
"prettier": "@kit/prettier-config",
"typesVersions": {

View File

@@ -35,16 +35,16 @@
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"next": "15.5.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"zod": "^3.25.74"
},
"prettier": "@kit/prettier-config",

View File

@@ -24,10 +24,10 @@
"next": "15.5.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-i18next": "^15.7.1"
"react-i18next": "^15.7.2"
},
"dependencies": {
"i18next": "25.4.0",
"i18next": "25.4.2",
"i18next-browser-languagedetector": "8.2.0",
"i18next-resources-to-backend": "^1.2.1"
},

View File

@@ -24,7 +24,7 @@
"@kit/sentry": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1",
"zod": "^3.25.74"
},

View File

@@ -24,7 +24,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1",
"zod": "^3.25.74"
},

View File

@@ -17,7 +17,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1"
},
"typesVersions": {

View File

@@ -24,7 +24,7 @@
"@kit/monitoring-core": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"react": "19.1.1"
},
"typesVersions": {

View File

@@ -26,7 +26,7 @@
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.55.0",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"react": "19.1.1",
"react-dom": "19.1.1",

View File

@@ -20,7 +20,7 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/react": "19.1.10"
"@types/react": "19.1.11"
},
"dependencies": {
"pino": "^9.8.0"

View File

@@ -25,10 +25,10 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/ssr": "^0.6.1",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"next": "15.5.0",
"react": "19.1.1",
"server-only": "^0.0.1",

View File

@@ -14,7 +14,7 @@
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"input-otp": "1.4.2",
"lucide-react": "^0.540.0",
"lucide-react": "^0.541.0",
"radix-ui": "1.4.3",
"react-dropzone": "^14.3.8",
"react-top-loading-bar": "3.0.2",
@@ -28,17 +28,17 @@
"@supabase/supabase-js": "2.55.0",
"@tanstack/react-query": "5.85.5",
"@tanstack/react-table": "^8.21.3",
"@types/react": "19.1.10",
"@types/react": "19.1.11",
"@types/react-dom": "19.1.7",
"class-variance-authority": "^0.7.1",
"date-fns": "^4.1.0",
"eslint": "^9.33.0",
"eslint": "^9.34.0",
"next": "15.5.0",
"next-themes": "0.4.6",
"prettier": "^3.6.2",
"react-day-picker": "^9.9.0",
"react-hook-form": "^7.62.0",
"react-i18next": "^15.7.1",
"react-i18next": "^15.7.2",
"sonner": "^2.0.7",
"tailwindcss": "4.1.12",
"tailwindcss-animate": "^1.0.7",

View File

@@ -1,10 +1,11 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Fragment, useCallback, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Cell,
flexRender,
getCoreRowModel,
useReactTable,
@@ -49,6 +50,7 @@ export {
Row,
SortingState,
VisibilityState,
flexRender,
};
interface ReactTableProps<T extends DataItem> {
@@ -57,6 +59,8 @@ interface ReactTableProps<T extends DataItem> {
renderSubComponent?: (props: { row: Row<T> }) => React.ReactElement;
pageIndex?: number;
className?: string;
headerClassName?: string;
footerClassName?: string;
pageSize?: number;
pageCount?: number;
sorting?: SortingState;
@@ -69,14 +73,17 @@ interface ReactTableProps<T extends DataItem> {
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
onColumnPinningChange?: (pinning: ColumnPinningState) => void;
onRowSelectionChange?: (selection: Record<string, boolean>) => void;
onClick?: (row: Row<T>) => void;
onClick?: (props: { row: Row<T>; cell: Cell<T, unknown> }) => void;
tableProps?: React.ComponentProps<typeof Table> &
Record<`data-${string}`, string>;
sticky?: boolean;
renderCell?: (props: {
cell: Cell<T, unknown>;
style?: React.CSSProperties;
className?: string;
}) => (props: React.PropsWithChildren<object>) => React.ReactNode;
renderRow?: (props: {
row: Row<T>;
onClick?: (row: Row<T>) => void;
className?: string;
}) => (props: React.PropsWithChildren<object>) => React.ReactNode;
noResultsMessage?: React.ReactNode;
forcePagination?: boolean; // Force pagination to show even when pageCount <= 1
@@ -97,7 +104,10 @@ export function DataTable<RecordData extends DataItem>({
onClick,
tableProps,
className,
headerClassName,
footerClassName,
renderRow,
renderCell,
noResultsMessage,
sorting: controlledSorting,
columnVisibility: controlledColumnVisibility,
@@ -106,6 +116,9 @@ export function DataTable<RecordData extends DataItem>({
sticky = false,
forcePagination = false,
}: ReactTableProps<RecordData>) {
// TODO: remove when https://github.com/TanStack/table/issues/5567 gets fixed
'use no memo';
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 15,
@@ -127,19 +140,7 @@ export function DataTable<RecordData extends DataItem>({
controlledRowSelection ?? {},
);
// Use props if provided (controlled mode), otherwise use internal state (uncontrolled mode)
const columnVisibility =
controlledColumnVisibility ?? internalColumnVisibility;
const columnPinning = controlledColumnPinning ?? internalColumnPinning;
const rowSelection = controlledRowSelection ?? internalRowSelection;
if (pagination.pageIndex !== pageIndex && pageIndex !== undefined) {
setPagination({
pageIndex,
pageSize: pagination.pageSize,
});
}
// Computed values for table state - computed inline in callbacks for fresh values
const navigateToPage = useNavigateToNewPage();
@@ -155,7 +156,9 @@ export function DataTable<RecordData extends DataItem>({
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(columnVisibility);
const currentVisibility =
controlledColumnVisibility ?? internalColumnVisibility;
const nextState = updater(currentVisibility);
// If controlled mode (callback provided), call it
if (onColumnVisibilityChange) {
@@ -176,7 +179,8 @@ export function DataTable<RecordData extends DataItem>({
},
onColumnPinningChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(columnPinning);
const currentPinning = controlledColumnPinning ?? internalColumnPinning;
const nextState = updater(currentPinning);
// If controlled mode (callback provided), call it
if (onColumnPinningChange) {
@@ -197,7 +201,8 @@ export function DataTable<RecordData extends DataItem>({
},
onRowSelectionChange: (updater) => {
if (typeof updater === 'function') {
const nextState = updater(rowSelection);
const currentSelection = controlledRowSelection ?? internalRowSelection;
const nextState = updater(currentSelection);
// If controlled mode (callback provided), call it
if (onRowSelectionChange) {
@@ -221,9 +226,9 @@ export function DataTable<RecordData extends DataItem>({
pagination,
sorting,
columnFilters,
columnVisibility,
columnPinning,
rowSelection,
columnVisibility: controlledColumnVisibility ?? internalColumnVisibility,
columnPinning: controlledColumnPinning ?? internalColumnPinning,
rowSelection: controlledRowSelection ?? internalRowSelection,
},
onSortingChange: (updater) => {
if (typeof updater === 'function') {
@@ -269,27 +274,12 @@ export function DataTable<RecordData extends DataItem>({
},
});
// Force table to update column pinning when controlled prop changes
useEffect(() => {
if (controlledColumnPinning) {
// Use the table's setColumnPinning method to force an update
table.setColumnPinning(controlledColumnPinning);
if (pagination.pageIndex !== pageIndex && pageIndex !== undefined) {
setPagination({
pageIndex,
pageSize: pagination.pageSize,
});
}
}, [controlledColumnPinning, table]);
// Force table to update column visibility when controlled prop changes
useEffect(() => {
if (controlledColumnVisibility) {
table.setColumnVisibility(controlledColumnVisibility);
}
}, [controlledColumnVisibility, table]);
// Force table to update row selection when controlled prop changes
useEffect(() => {
if (controlledRowSelection) {
table.setRowSelection(controlledRowSelection);
}
}, [controlledRowSelection, table]);
const rows = table.getRowModel().rows;
@@ -302,7 +292,7 @@ export function DataTable<RecordData extends DataItem>({
data-testid="data-table"
{...tableProps}
className={cn(
'bg-background border-separate border-spacing-0',
'bg-background border-collapse border-spacing-0',
className,
{
'h-full': data.length === 0,
@@ -310,8 +300,8 @@ export function DataTable<RecordData extends DataItem>({
)}
>
<TableHeader
className={cn('', {
['bg-background/20 outline-border sticky top-[0px] z-10 outline backdrop-blur-sm transition-all duration-300']:
className={cn(headerClassName, {
['bg-background/20 outline-border sticky top-[0px] z-10 outline backdrop-blur-sm']:
sticky,
})}
>
@@ -344,10 +334,10 @@ export function DataTable<RecordData extends DataItem>({
className={cn(
'text-muted-foreground bg-background/80 border-transparent font-sans font-medium',
{
['border-r-background sticky top-0 z-10 border-r opacity-95 backdrop-blur-sm']:
isPinned === 'left',
['border-l-background sticky top-0 z-10 border-l opacity-95 backdrop-blur-sm']:
isPinned === 'right',
['border-r-background border-r']: isPinned === 'left',
['border-l-background border-l']: isPinned === 'right',
['sticky top-0 z-10 opacity-95 backdrop-blur-sm']:
isPinned,
['relative z-0']: !isPinned,
},
)}
@@ -375,9 +365,7 @@ export function DataTable<RecordData extends DataItem>({
<TableBody>
{rows.map((row) => {
const RowWrapper = renderRow
? renderRow({ row, onClick })
: TableRow;
const RowWrapper = renderRow ? renderRow({ row }) : TableRow;
const children = row.getVisibleCells().map((cell, index) => {
const isPinned = cell.column.getIsPinned();
@@ -417,16 +405,23 @@ export function DataTable<RecordData extends DataItem>({
},
);
return (
<TableCell
style={{
left: left !== undefined ? `${left}px` : undefined,
right: right !== undefined ? `${right}px` : undefined,
const style = {
width: `${size}px`,
minWidth: `${size}px`,
}}
left: left !== undefined ? `${left}px` : undefined,
right: right !== undefined ? `${right}px` : undefined,
};
return renderCell ? (
<Fragment key={cell.id}>
{renderCell({ cell, style, className })({})}
</Fragment>
) : (
<TableCell
key={cell.id}
style={style}
className={className}
onClick={onClick ? () => onClick({ row, cell }) : undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
@@ -435,11 +430,12 @@ export function DataTable<RecordData extends DataItem>({
return (
<RowWrapper
className={cn('active:bg-accent bg-background/80', {
['hover:bg-accent/60 cursor-pointer']: !row.getIsSelected(),
})}
onClick={() => onClick && onClick(row)}
key={row.id}
className={cn('bg-background/80', {
'hover:bg-accent/60': !row.getIsSelected(),
'active:bg-accent': !!onClick,
'cursor-pointer': !!onClick && !row.getIsSelected(),
})}
data-state={row.getIsSelected() && 'selected'}
>
{children}
@@ -460,15 +456,22 @@ export function DataTable<RecordData extends DataItem>({
<If condition={displayPagination}>
<div
className={cn(
'bg-background/80 outline-border sticky bottom-0 z-10 border-b outline backdrop-blur-sm',
'bg-background/80 sticky bottom-0 z-10 border-t backdrop-blur-sm',
{
['sticky bottom-0 z-10 max-w-full rounded-none']: sticky,
},
footerClassName,
)}
>
<div>
<div className={'px-2.5 py-1.5'}>
<Pagination table={table} />
<Pagination
table={table}
pageSize={pageSize}
totalCount={
pageCount && pageSize ? pageCount * pageSize : undefined
}
/>
</div>
</div>
</div>
@@ -479,8 +482,12 @@ export function DataTable<RecordData extends DataItem>({
function Pagination<T>({
table,
totalCount,
pageSize,
}: React.PropsWithChildren<{
table: ReactTable<T>;
totalCount?: number;
pageSize?: number;
}>) {
return (
<div className="flex items-center space-x-4">
@@ -539,6 +546,15 @@ function Pagination<T>({
<ChevronsRight className={'h-4'} />
</Button>
</div>
<If condition={totalCount}>
<span className="text-muted-foreground flex items-center text-xs">
<Trans
i18nKey={'common:showingRecordCount'}
values={{ totalCount, pageSize }}
/>
</span>
</If>
</div>
);
}

View File

@@ -36,7 +36,7 @@ export function DataTable<TData, TValue>({
});
return (
<div className="rounded-md border">
<div className="rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (

1488
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ export default tsEsLint.config(
},
{
rules: {
'@typescript-eslint/triple-slash-reference': 'off',
'react/react-in-jsx-scope': 'off',
'import/no-anonymous-default-export': 'off',
'import/named': 'off',

View File

@@ -22,7 +22,7 @@
"devDependencies": {
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"eslint": "^9.33.0",
"eslint": "^9.34.0",
"typescript": "^5.9.2"
},
"prettier": "@kit/prettier-config"