Skip to content

Commit 200e184

Browse files
committed
feat(examples): integrate TanStack DB for client-side data management
- Replace server functions with TanStack DB collections and live queries - Add @tanstack/react-db, @tanstack/query-db-collection, and related packages - Implement disputes, orders, and settlements collections with Zod validation - Create useLiveQuery hooks for reactive data filtering and searching - Update components to use client-side collections instead of server functions
1 parent 8bdcb89 commit 200e184

40 files changed

+813
-549
lines changed

examples/ts-react-search/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414
"@tanstack/ai": "workspace:*",
1515
"@tanstack/ai-openai": "workspace:*",
1616
"@tanstack/ai-react": "workspace:*",
17+
"@tanstack/query-db-collection": "^1.0.6",
18+
"@tanstack/react-db": "^0.1.55",
1719
"@tanstack/react-devtools": "^0.8.2",
20+
"@tanstack/react-query": "^5.90.12",
1821
"@tanstack/react-router": "^1.139.7",
1922
"@tanstack/react-router-devtools": "^1.139.7",
2023
"@tanstack/react-router-ssr-query": "^1.139.7",
2124
"@tanstack/react-start": "^1.139.8",
2225
"@tanstack/router-plugin": "^1.139.7",
26+
"@tanstack/zod-adapter": "^1.140.1",
2327
"class-variance-authority": "^0.7.1",
2428
"clsx": "^2.1.1",
2529
"lucide-react": "^0.555.0",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Label } from '@/components/ui/label'
2+
import {
3+
Select,
4+
SelectContent,
5+
SelectItem,
6+
SelectTrigger,
7+
SelectValue,
8+
} from '@/components/ui/select'
9+
import { ALL_OPTION } from '@/constants'
10+
11+
type FilterSelectProps = {
12+
id: string
13+
label: string
14+
value: string
15+
onChange: (value: string) => void
16+
options: Array<[string, string]>
17+
}
18+
19+
function FilterSelect({
20+
id,
21+
label,
22+
value,
23+
onChange,
24+
options,
25+
}: FilterSelectProps) {
26+
return (
27+
<div className="flex min-w-0 flex-col gap-2">
28+
<Label htmlFor={id}>{label}</Label>
29+
<Select name="status" value={value || ''} onValueChange={onChange}>
30+
<SelectTrigger id={id}>
31+
<SelectValue />
32+
</SelectTrigger>
33+
<SelectContent>
34+
<SelectItem value={ALL_OPTION}>All</SelectItem>
35+
{options.map(([key, label]) => (
36+
<SelectItem key={key} value={key}>
37+
{label}
38+
</SelectItem>
39+
))}
40+
</SelectContent>
41+
</Select>
42+
</div>
43+
)
44+
}
45+
46+
export default FilterSelect
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { LoaderCircleIcon } from 'lucide-react'
2+
3+
function Spinner() {
4+
return <LoaderCircleIcon className="animate-spin size-4 m-auto" />
5+
}
6+
7+
export default Spinner
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const ALL_OPTION = 'ALL'

examples/ts-react-search/src/features/disputes/DisputesFilters.tsx

Lines changed: 35 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,17 @@
22

33
import { useState } from 'react'
44
import { useNavigate } from '@tanstack/react-router'
5-
import { DISPUTE_REASON_MAP, DISPUTE_STATUS_MAP } from './constants'
5+
import {
6+
DISPUTE_REASON_MAP,
7+
DISPUTE_STATUS_MAP,
8+
disputesSearchSchema,
9+
} from './constants'
10+
import type { FormEvent } from 'react'
611
import type { DisputesSearch } from './types'
7-
import { Label } from '@/components/ui/label'
812
import { DatePicker } from '@/components/ui/date-picker'
913
import { Button } from '@/components/ui/button'
10-
import {
11-
Select,
12-
SelectContent,
13-
SelectItem,
14-
SelectTrigger,
15-
SelectValue,
16-
} from '@/components/ui/select'
14+
import { ALL_OPTION } from '@/constants'
15+
import FilterSelect from '@/components/FilterSelect'
1716

1817
type DisputesFiltersProps = {
1918
search: DisputesSearch
@@ -23,87 +22,58 @@ function DisputesFilters({ search }: DisputesFiltersProps) {
2322
const navigate = useNavigate()
2423

2524
const [pendingStatus, setPendingStatus] = useState<string>(
26-
search.status || 'ALL',
25+
search.status || ALL_OPTION,
2726
)
2827
const [pendingReason, setPendingReason] = useState<string>(
29-
search.reason || 'ALL',
28+
search.reason || ALL_OPTION,
3029
)
3130
const [pendingFrom, setPendingFrom] = useState<string | undefined>(
3231
search.from,
3332
)
3433
const [pendingTo, setPendingTo] = useState<string | undefined>(search.to)
3534

36-
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
35+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
3736
event.preventDefault()
3837

39-
navigate({
38+
await navigate({
4039
to: '/disputes',
41-
search: {
42-
status: pendingStatus === 'ALL' ? undefined : pendingStatus,
43-
reason: pendingReason === 'ALL' ? undefined : pendingReason,
40+
search: disputesSearchSchema.parse({
41+
status: pendingStatus === ALL_OPTION ? undefined : pendingStatus,
42+
reason: pendingReason === ALL_OPTION ? undefined : pendingReason,
4443
from: pendingFrom === '' ? undefined : pendingFrom,
4544
to: pendingTo === '' ? undefined : pendingTo,
46-
},
45+
}),
4746
})
4847
}
4948

50-
function handleClear() {
51-
setPendingStatus('ALL')
52-
setPendingReason('ALL')
49+
async function handleClear() {
50+
setPendingStatus(ALL_OPTION)
51+
setPendingReason(ALL_OPTION)
5352
setPendingFrom(undefined)
5453
setPendingTo(undefined)
55-
navigate({ to: '/disputes' })
54+
55+
await navigate({ to: '/disputes', search: disputesSearchSchema.parse({}) })
5656
}
5757

5858
return (
5959
<form
6060
className="grid gap-4 lg:grid-cols-[repeat(4,minmax(0,1fr))_auto] px-6"
6161
onSubmit={handleSubmit}
6262
>
63-
<div className="flex min-w-0 flex-col gap-2">
64-
<Label htmlFor="status">Status</Label>
65-
<Select
66-
name="status"
67-
value={pendingStatus || ''}
68-
onValueChange={setPendingStatus}
69-
>
70-
<SelectTrigger id="status">
71-
<SelectValue />
72-
</SelectTrigger>
73-
<SelectContent>
74-
<SelectItem value="ALL">All</SelectItem>
75-
{Object.entries(DISPUTE_STATUS_MAP).map(
76-
([statusKey, statusLabel]) => (
77-
<SelectItem key={statusKey} value={statusKey}>
78-
{statusLabel}
79-
</SelectItem>
80-
),
81-
)}
82-
</SelectContent>
83-
</Select>
84-
</div>
85-
<div className="flex min-w-0 flex-col gap-2">
86-
<Label htmlFor="reason">Reason</Label>
87-
<Select
88-
name="reason"
89-
value={pendingReason || ''}
90-
onValueChange={setPendingReason}
91-
>
92-
<SelectTrigger id="reason">
93-
<SelectValue />
94-
</SelectTrigger>
95-
<SelectContent>
96-
<SelectItem value="ALL">All</SelectItem>
97-
{Object.entries(DISPUTE_REASON_MAP).map(
98-
([reasonKey, reasonLabel]) => (
99-
<SelectItem key={reasonKey} value={reasonKey}>
100-
{reasonLabel}
101-
</SelectItem>
102-
),
103-
)}
104-
</SelectContent>
105-
</Select>
106-
</div>
63+
<FilterSelect
64+
id="status"
65+
label="Status"
66+
value={pendingStatus}
67+
onChange={setPendingStatus}
68+
options={Object.entries(DISPUTE_STATUS_MAP)}
69+
/>
70+
<FilterSelect
71+
id="resason"
72+
label="Reason"
73+
value={pendingReason}
74+
onChange={setPendingReason}
75+
options={Object.entries(DISPUTE_REASON_MAP)}
76+
/>
10777
<DatePicker
10878
label="From"
10979
value={pendingFrom}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
3+
import useDisputesQuery from './useDisputesQuery'
4+
import DisputesTable from './DisputesTable'
5+
import disputesCollection from './disputesCollection'
6+
import type { DisputesSearch } from './types'
7+
import TableSummary from '@/components/TableSummary'
8+
9+
type DisputesManagerProps = {
10+
search: DisputesSearch
11+
}
12+
13+
function DisputesManager({ search }: DisputesManagerProps) {
14+
const { data: disputes } = useDisputesQuery(search)
15+
16+
return (
17+
<>
18+
<DisputesTable disputes={disputes} />
19+
<TableSummary
20+
totalCount={disputesCollection.toArray.length}
21+
resultCount={disputes.length}
22+
/>
23+
</>
24+
)
25+
}
26+
27+
export default DisputesManager
Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,50 @@
11
import { z } from 'zod'
2+
import { fallback } from '@tanstack/zod-adapter'
3+
import type { DisputeReason, DisputeStatus } from './types'
24

3-
export const DISPUTE_STATUS_MAP = {
5+
export const DISPUTE_STATUSES = [
6+
'LOST',
7+
'RESPONSE_REQUIRED',
8+
'UNDER_REVIEW',
9+
'WON',
10+
] as const
11+
12+
export const DISPUTE_STATUS_MAP: Record<DisputeStatus, string> = {
413
LOST: 'Lost',
514
RESPONSE_REQUIRED: 'Response required',
615
UNDER_REVIEW: 'Under review',
716
WON: 'Won',
8-
} as const
17+
}
18+
19+
export const DISPUTE_REASONS = [
20+
'FAULTY_GOODS',
21+
'GOODS_NOT_RECEIVED',
22+
'HIGH_RISK_ORDER',
23+
'INCORRECT_INVOICE',
24+
'RETURN',
25+
'UNAUTHORIZED_PURCHASE',
26+
] as const
927

10-
export const DISPUTE_REASON_MAP = {
28+
export const DISPUTE_REASON_MAP: Record<DisputeReason, string> = {
1129
FAULTY_GOODS: 'Faulty goods',
1230
GOODS_NOT_RECEIVED: 'Goods not received',
1331
HIGH_RISK_ORDER: 'High risk order',
1432
INCORRECT_INVOICE: 'Incorrect invoice',
1533
RETURN: 'Return',
1634
UNAUTHORIZED_PURCHASE: 'Unauthorized purchase',
17-
} as const
35+
}
36+
37+
export const disputeSchema = z.object({
38+
id: z.string(),
39+
status: z.enum(DISPUTE_STATUSES),
40+
reason: z.enum(DISPUTE_REASONS),
41+
from: z.iso.datetime(),
42+
to: z.iso.datetime(),
43+
})
1844

1945
export const disputesSearchSchema = z.object({
20-
status: z.enum(Object.keys(DISPUTE_STATUS_MAP)).optional(),
21-
reason: z.enum(Object.keys(DISPUTE_REASON_MAP)).optional(),
22-
from: z.string().optional(),
23-
to: z.string().optional(),
46+
status: fallback(z.enum(DISPUTE_STATUSES).optional(), undefined),
47+
reason: fallback(z.enum(DISPUTE_REASONS).optional(), undefined),
48+
from: fallback(z.string().optional(), undefined),
49+
to: fallback(z.string().optional(), undefined),
2450
})

examples/ts-react-search/src/features/disputes/data.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,73 @@
1-
import { DISPUTE_REASON_MAP, DISPUTE_STATUS_MAP } from './constants'
21
import type { Dispute } from './types'
32

43
export const DISPUTES: Array<Dispute> = [
54
{
65
id: 'dis_2001',
7-
status: DISPUTE_STATUS_MAP.LOST,
8-
reason: DISPUTE_REASON_MAP.RETURN,
6+
status: 'LOST',
7+
reason: 'RETURN',
98
from: '2025-01-10T09:00:00Z',
109
to: '2025-01-15T18:00:00Z',
1110
},
1211
{
1312
id: 'dis_2002',
14-
status: DISPUTE_STATUS_MAP.WON,
15-
reason: DISPUTE_REASON_MAP.INCORRECT_INVOICE,
13+
status: 'WON',
14+
reason: 'INCORRECT_INVOICE',
1615
from: '2025-02-05T10:30:00Z',
1716
to: '2025-02-12T14:00:00Z',
1817
},
1918
{
2019
id: 'dis_2003',
21-
status: DISPUTE_STATUS_MAP.RESPONSE_REQUIRED,
22-
reason: DISPUTE_REASON_MAP.HIGH_RISK_ORDER,
20+
status: 'RESPONSE_REQUIRED',
21+
reason: 'HIGH_RISK_ORDER',
2322
from: '2025-03-18T08:15:00Z',
2423
to: '2025-03-25T17:00:00Z',
2524
},
2625
{
2726
id: 'dis_2004',
28-
status: DISPUTE_STATUS_MAP.UNDER_REVIEW,
29-
reason: DISPUTE_REASON_MAP.GOODS_NOT_RECEIVED,
27+
status: 'UNDER_REVIEW',
28+
reason: 'GOODS_NOT_RECEIVED',
3029
from: '2025-04-12T11:00:00Z',
3130
to: '2025-04-18T16:30:00Z',
3231
},
3332
{
3433
id: 'dis_2005',
35-
status: DISPUTE_STATUS_MAP.UNDER_REVIEW,
36-
reason: DISPUTE_REASON_MAP.UNAUTHORIZED_PURCHASE,
34+
status: 'UNDER_REVIEW',
35+
reason: 'UNAUTHORIZED_PURCHASE',
3736
from: '2025-05-08T09:45:00Z',
3837
to: '2025-05-15T15:00:00Z',
3938
},
4039
{
4140
id: 'dis_2006',
42-
status: DISPUTE_STATUS_MAP.LOST,
43-
reason: DISPUTE_REASON_MAP.FAULTY_GOODS,
41+
status: 'LOST',
42+
reason: 'FAULTY_GOODS',
4443
from: '2025-06-02T13:20:00Z',
4544
to: '2025-06-08T10:00:00Z',
4645
},
4746
{
4847
id: 'dis_2007',
49-
status: DISPUTE_STATUS_MAP.RESPONSE_REQUIRED,
50-
reason: DISPUTE_REASON_MAP.UNAUTHORIZED_PURCHASE,
48+
status: 'RESPONSE_REQUIRED',
49+
reason: 'UNAUTHORIZED_PURCHASE',
5150
from: '2025-07-20T14:50:00Z',
5251
to: '2025-07-28T09:30:00Z',
5352
},
5453
{
5554
id: 'dis_2008',
56-
status: DISPUTE_STATUS_MAP.UNDER_REVIEW,
57-
reason: DISPUTE_REASON_MAP.RETURN,
55+
status: 'UNDER_REVIEW',
56+
reason: 'RETURN',
5857
from: '2025-08-14T08:00:00Z',
5958
to: '2025-08-22T17:30:00Z',
6059
},
6160
{
6261
id: 'dis_2009',
63-
status: DISPUTE_STATUS_MAP.WON,
64-
reason: DISPUTE_REASON_MAP.GOODS_NOT_RECEIVED,
62+
status: 'WON',
63+
reason: 'GOODS_NOT_RECEIVED',
6564
from: '2025-09-09T10:10:00Z',
6665
to: '2025-09-15T16:00:00Z',
6766
},
6867
{
6968
id: 'dis_2010',
70-
status: DISPUTE_STATUS_MAP.UNDER_REVIEW,
71-
reason: DISPUTE_REASON_MAP.HIGH_RISK_ORDER,
69+
status: 'UNDER_REVIEW',
70+
reason: 'HIGH_RISK_ORDER',
7271
from: '2025-10-01T15:00:00Z',
7372
to: '2025-10-10T11:00:00Z',
7473
},

0 commit comments

Comments
 (0)