This commit is contained in:
phaichayon
2026-04-16 14:06:59 +07:00
parent 1e8d6a9b19
commit 4702150af1
50 changed files with 3815 additions and 3 deletions

View File

@@ -0,0 +1,24 @@
import { queryOptions } from '@tanstack/react-query';
export type Pokemon = {
id: number;
name: string;
sprites: {
front_shiny: string;
front_default: string;
};
types: { type: { name: string } }[];
stats: { base_stat: number; stat: { name: string } }[];
height: number;
weight: number;
};
export const pokemonOptions = (id: number = 25) =>
queryOptions({
queryKey: ['pokemon', id],
queryFn: async (): Promise<Pokemon> => {
const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
if (!response.ok) throw new Error('Failed to fetch pokemon');
return response.json();
}
});

View File

@@ -0,0 +1,99 @@
'use client';
import { useState } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { pokemonOptions } from '../api/queries';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter
} from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
const POKEMON_IDS = [25, 1, 4, 7, 6, 150, 133, 39, 143, 94];
export function PokemonInfo() {
const [pokemonId, setPokemonId] = useState(25);
const { data } = useSuspenseQuery(pokemonOptions(pokemonId));
return (
<div className='space-y-6'>
{/* Pokemon selector */}
<Card>
<CardHeader>
<CardTitle>Pick a Pokemon</CardTitle>
<CardDescription>
Each selection triggers <code>useSuspenseQuery</code> cached results are instant, new
fetches show the Suspense fallback.
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{POKEMON_IDS.map((id) => (
<Button
key={id}
variant={pokemonId === id ? 'default' : 'outline'}
size='sm'
onClick={() => setPokemonId(id)}
>
#{id}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Pokemon card */}
<Card>
<CardHeader>
<div className='flex items-center gap-3'>
<CardTitle className='capitalize'>{data.name}</CardTitle>
<div className='flex gap-1'>
{data.types.map(({ type }) => (
<Badge key={type.name} variant='secondary'>
{type.name}
</Badge>
))}
</div>
</div>
<CardDescription>
Height: {data.height / 10}m &middot; Weight: {data.weight / 10}kg
</CardDescription>
</CardHeader>
<CardContent>
<div className='flex flex-col items-center gap-6 sm:flex-row'>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.sprites.front_shiny}
alt={data.name}
width={160}
height={160}
className='bg-muted/50 rounded-lg'
/>
<div className='flex-1 space-y-3'>
{data.stats.map((s) => (
<div key={s.stat.name} className='space-y-1'>
<div className='flex justify-between text-sm'>
<span className='text-muted-foreground capitalize'>{s.stat.name}</span>
<span className='font-medium'>{s.base_stat}</span>
</div>
<Progress value={Math.min(s.base_stat, 150) / 1.5} />
</div>
))}
</div>
</div>
</CardContent>
<CardFooter>
<p className='text-muted-foreground text-xs'>
Data from PokeAPI &middot; Prefetched on server, hydrated on client
</p>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Card, CardHeader, CardContent, CardFooter } from '@/components/ui/card';
export function PokemonSkeleton() {
return (
<div className='animate-pulse space-y-6'>
{/* Selector card skeleton */}
<Card>
<CardHeader>
<div className='bg-muted h-6 w-40 rounded' />
<div className='bg-muted mt-2 h-4 w-72 rounded' />
</CardHeader>
<CardContent>
<div className='flex flex-wrap gap-2'>
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className='bg-muted h-9 w-14 rounded-md' />
))}
</div>
</CardContent>
</Card>
{/* Pokemon card skeleton */}
<Card>
<CardHeader>
<div className='flex items-center gap-3'>
<div className='bg-muted h-7 w-32 rounded' />
<div className='bg-muted h-5 w-16 rounded-full' />
</div>
<div className='bg-muted mt-2 h-4 w-48 rounded' />
</CardHeader>
<CardContent>
<div className='flex flex-col items-center gap-6 sm:flex-row'>
<div className='bg-muted size-40 rounded-lg' />
<div className='w-full flex-1 space-y-3'>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className='space-y-1'>
<div className='flex justify-between'>
<div className='bg-muted h-4 w-24 rounded' />
<div className='bg-muted h-4 w-8 rounded' />
</div>
<div className='bg-muted h-2 w-full rounded-full' />
</div>
))}
</div>
</div>
</CardContent>
<CardFooter>
<div className='bg-muted h-3 w-64 rounded' />
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { InfobarContent } from '@/components/ui/infobar';
export const reactQueryInfoContent: InfobarContent = {
title: 'React Query Pattern',
sections: [
{
title: 'Server Prefetch',
description:
'Data is prefetched on the server using getQueryClient().prefetchQuery(). The dehydrated state is passed to HydrationBoundary so the client starts with cached data — no loading spinners on first load.',
links: [
{
title: 'TanStack Query SSR Docs',
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr'
}
]
},
{
title: 'Query Options',
description:
'Query keys and fetch functions are defined in a shared queryOptions() object. This is reused across server prefetch and client hooks, keeping them in sync.',
links: [
{
title: 'queryOptions API',
url: 'https://tanstack.com/query/latest/docs/framework/react/reference/queryOptions'
}
]
},
{
title: 'Suspense Query',
description:
'The client uses useSuspenseQuery() which integrates with React Suspense. Combined with server prefetch, data is available immediately — Suspense only shows the fallback on subsequent navigations if the cache is stale.',
links: []
},
{
title: 'Optimistic Mutations',
description:
'Mutations use onMutate to optimistically update the cache before the request completes. On error, the previous state is rolled back. On settle, the query is invalidated to refetch fresh data.',
links: [
{
title: 'Optimistic Updates Guide',
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates'
}
]
}
]
};

View File

@@ -0,0 +1,27 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { createUser, updateUser, deleteUser } from './service';
import { userKeys } from './queries';
import type { UserMutationPayload } from './types';
export const createUserMutation = mutationOptions({
mutationFn: (data: UserMutationPayload) => createUser(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: userKeys.all });
}
});
export const updateUserMutation = mutationOptions({
mutationFn: ({ id, values }: { id: number; values: UserMutationPayload }) =>
updateUser(id, values),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: userKeys.all });
}
});
export const deleteUserMutation = mutationOptions({
mutationFn: (id: number) => deleteUser(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: userKeys.all });
}
});

View File

@@ -0,0 +1,17 @@
import { queryOptions } from '@tanstack/react-query';
import { getUsers } from './service';
import type { User, UserFilters } from './types';
export type { User };
export const userKeys = {
all: ['users'] as const,
list: (filters: UserFilters) => [...userKeys.all, 'list', filters] as const,
detail: (id: number) => [...userKeys.all, 'detail', id] as const
};
export const usersQueryOptions = (filters: UserFilters) =>
queryOptions({
queryKey: userKeys.list(filters),
queryFn: () => getUsers(filters)
});

View File

@@ -0,0 +1,47 @@
// ============================================================
// User Service — Data Access Layer
// ============================================================
// This is the ONLY file you modify when connecting to your backend.
// Queries (queries.ts) and components import from here — they never change.
//
// Pick your pattern and replace the function bodies below:
//
// 1. Server Actions + ORM (Prisma / Drizzle / Supabase)
// → Add 'use server' at the top of this file
// → Call your ORM directly in each function
//
// 2. Route Handlers + ORM
// → import { apiClient } from '@/lib/api-client'
// → return apiClient<UsersResponse>('/users?...')
// → Replace mock calls in route handlers (src/app/api/users/) with ORM
//
// 3. BFF — Route Handlers proxy to external backend (Laravel, Go, etc.)
// → import { apiClient } from '@/lib/api-client'
// → return apiClient<UsersResponse>('/users?...')
// → Route handlers proxy requests to your external backend service
//
// 4. Direct external API (frontend-only, no Next.js backend)
// → const res = await fetch('https://your-api.com/users?...')
// → return res.json()
//
// Current: Mock (in-memory fake data for demo/prototyping)
// ============================================================
import { fakeUsers } from '@/constants/mock-api-users';
import type { UserFilters, UsersResponse, UserMutationPayload } from './types';
export async function getUsers(filters: UserFilters): Promise<UsersResponse> {
return fakeUsers.getUsers(filters);
}
export async function createUser(data: UserMutationPayload) {
return fakeUsers.createUser(data);
}
export async function updateUser(id: number, data: UserMutationPayload) {
return fakeUsers.updateUser(id, data);
}
export async function deleteUser(id: number) {
return fakeUsers.deleteUser(id);
}

View File

@@ -0,0 +1,28 @@
export type { User } from '@/constants/mock-api-users';
export type UserFilters = {
page?: number;
limit?: number;
roles?: string;
search?: string;
sort?: string;
};
export type UsersResponse = {
success: boolean;
time: string;
message: string;
total_users: number;
offset: number;
limit: number;
users: import('@/constants/mock-api-users').User[];
};
export type UserMutationPayload = {
first_name: string;
last_name: string;
email: string;
phone: string;
role: string;
status: string;
};

View File

@@ -0,0 +1,189 @@
'use client';
import { useState } from 'react';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle
} from '@/components/ui/sheet';
import { Icons } from '@/components/icons';
import { useMutation } from '@tanstack/react-query';
import { createUserMutation, updateUserMutation } from '../api/mutations';
import type { User } from '../api/types';
import { toast } from 'sonner';
import * as z from 'zod';
import { userSchema, type UserFormValues } from '../schemas/user';
import { ROLE_OPTIONS } from './users-table/options';
const STATUS_OPTIONS = [
{ value: 'Active', label: 'Active' },
{ value: 'Inactive', label: 'Inactive' },
{ value: 'Invited', label: 'Invited' }
];
interface UserFormSheetProps {
user?: User;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UserFormSheet({ user, open, onOpenChange }: UserFormSheetProps) {
const isEdit = !!user;
const createMutation = useMutation({
...createUserMutation,
onSuccess: () => {
toast.success('User created successfully');
onOpenChange(false);
form.reset();
},
onError: () => toast.error('Failed to create user')
});
const updateMutation = useMutation({
...updateUserMutation,
onSuccess: () => {
toast.success('User updated successfully');
onOpenChange(false);
},
onError: () => toast.error('Failed to update user')
});
const form = useAppForm({
defaultValues: {
first_name: user?.first_name ?? '',
last_name: user?.last_name ?? '',
email: user?.email ?? '',
phone: user?.phone ?? '',
role: user?.role ?? '',
status: user?.status ?? 'Active'
} as UserFormValues,
validators: {
onSubmit: userSchema
},
onSubmit: async ({ value }) => {
if (isEdit) {
await updateMutation.mutateAsync({ id: user.id, values: value });
} else {
await createMutation.mutateAsync(value);
}
}
});
const { FormTextField, FormSelectField } = useFormFields<UserFormValues>();
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit User' : 'New User'}</SheetTitle>
<SheetDescription>
{isEdit
? 'Update the user details below.'
: 'Fill in the details to create a new user.'}
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='user-form-sheet' className='space-y-4'>
<div className='grid grid-cols-2 gap-4'>
<FormTextField
name='first_name'
label='First Name'
required
placeholder='John'
validators={{
onBlur: z.string().min(2, 'First name must be at least 2 characters')
}}
/>
<FormTextField
name='last_name'
label='Last Name'
required
placeholder='Doe'
validators={{
onBlur: z.string().min(2, 'Last name must be at least 2 characters')
}}
/>
</div>
<FormTextField
name='email'
label='Email'
required
type='email'
placeholder='john@example.com'
validators={{
onBlur: z.string().email('Please enter a valid email')
}}
/>
<FormTextField
name='phone'
label='Phone'
required
type='tel'
placeholder='(555) 123-4567'
validators={{
onBlur: z.string().min(1, 'Phone number is required')
}}
/>
<FormSelectField
name='role'
label='Role'
required
options={ROLE_OPTIONS}
placeholder='Select role'
validators={{
onBlur: z.string().min(1, 'Please select a role')
}}
/>
<FormSelectField
name='status'
label='Status'
required
options={STATUS_OPTIONS}
placeholder='Select status'
validators={{
onBlur: z.string().min(1, 'Please select a status')
}}
/>
</form.Form>
</form.AppForm>
</div>
<SheetFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' form='user-form-sheet' isLoading={isPending}>
<Icons.check /> {isEdit ? 'Update User' : 'Create User'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export function UserFormSheetTrigger() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> Add User
</Button>
<UserFormSheet open={open} onOpenChange={setOpen} />
</>
);
}

View File

@@ -0,0 +1,31 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { usersQueryOptions } from '../api/queries';
import { UsersTable } from './users-table';
export default function UserListingPage() {
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const roles = searchParamsCache.get('role');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(roles && { roles }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(usersQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UsersTable />
</HydrationBoundary>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { AlertModal } from '@/components/modal/alert-modal';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { deleteUserMutation } from '../../api/mutations';
import type { User } from '../../api/types';
import { Icons } from '@/components/icons';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';
import { UserFormSheet } from '../user-form-sheet';
interface CellActionProps {
data: User;
}
export function CellAction({ data }: CellActionProps) {
const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const deleteMutation = useMutation({
...deleteUserMutation,
onSuccess: () => {
toast.success('User deleted successfully');
setDeleteOpen(false);
},
onError: () => {
toast.error('Failed to delete user');
}
});
return (
<>
<AlertModal
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<UserFormSheet user={data} open={editOpen} onOpenChange={setEditOpen} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='h-8 w-8 p-0'>
<span className='sr-only'>Open menu</span>
<Icons.ellipsis className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setEditOpen(true)}>
<Icons.edit className='mr-2 h-4 w-4' /> Update
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import type { User } from '../../api/types';
import { Column, ColumnDef } from '@tanstack/react-table';
import { Icons } from '@/components/icons';
import { CellAction } from './cell-action';
import { ROLE_OPTIONS } from './options';
export const columns: ColumnDef<User>[] = [
{
id: 'name',
accessorFn: (row) => `${row.first_name} ${row.last_name}`,
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Name' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<span className='font-medium'>
{row.original.first_name} {row.original.last_name}
</span>
<span className='text-muted-foreground text-xs'>{row.original.email}</span>
</div>
),
meta: {
label: 'Name',
placeholder: 'Search users...',
variant: 'text' as const,
icon: Icons.text
},
enableColumnFilter: true
},
{
accessorKey: 'phone',
header: 'PHONE'
},
{
id: 'role',
accessorKey: 'role',
enableSorting: false,
header: ({ column }: { column: Column<User, unknown> }) => (
<DataTableColumnHeader column={column} title='Role' />
),
cell: ({ cell }) => {
return (
<Badge variant='outline' className='capitalize'>
{cell.getValue<User['role']>()}
</Badge>
);
},
enableColumnFilter: true,
meta: {
label: 'roles',
variant: 'multiSelect' as const,
options: ROLE_OPTIONS
}
},
{
accessorKey: 'status',
header: 'STATUS',
cell: ({ cell }) => {
const status = cell.getValue<User['status']>();
const variant =
status === 'Active' ? 'default' : status === 'Inactive' ? 'secondary' : 'outline';
return <Badge variant={variant}>{status}</Badge>;
}
},
{
id: 'actions',
cell: ({ row }) => <CellAction data={row.original} />
}
];

View File

@@ -0,0 +1,61 @@
'use client';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { getSortingStateParser } from '@/lib/parsers';
import { usersQueryOptions } from '../../api/queries';
import { columns } from './columns';
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
export function UsersTable() {
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
role: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.role && { roles: params.role }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(usersQueryOptions(filters));
const pageCount = Math.ceil(data.total_users / params.perPage);
const { table } = useDataTable({
data: data.users,
columns,
pageCount,
shallow: true,
debounceMs: 500,
initialState: {
columnPinning: { right: ['actions'] }
}
});
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
</DataTable>
);
}
export function UsersTableSkeleton() {
return (
<div className='flex flex-1 animate-pulse flex-col gap-4'>
<div className='bg-muted h-10 w-full rounded' />
<div className='bg-muted h-96 w-full rounded-lg' />
<div className='bg-muted h-10 w-full rounded' />
</div>
);
}

View File

@@ -0,0 +1,8 @@
export const ROLE_OPTIONS = [
{ value: 'Developer', label: 'Developer' },
{ value: 'Designer', label: 'Designer' },
{ value: 'Manager', label: 'Manager' },
{ value: 'QA', label: 'QA' },
{ value: 'DevOps', label: 'DevOps' },
{ value: 'Product Owner', label: 'Product Owner' }
];

View File

@@ -0,0 +1,41 @@
import type { InfobarContent } from '@/components/ui/infobar';
export const usersInfoContent: InfobarContent = {
title: 'Users — React Query + nuqs Pattern',
sections: [
{
title: 'Overview',
description:
'This page demonstrates client-side data fetching with React Query combined with nuqs URL search params — as an alternative to the Products page which uses server-side RSC fetching. Both patterns use the same DataTable, useDataTable hook, and nuqs URL state.',
links: [
{
title: 'TanStack Query SSR Docs',
url: 'https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr'
}
]
},
{
title: 'Server Prefetch + Client Hydration',
description:
'The server component reads search params via searchParamsCache, builds filters, and calls queryClient.prefetchQuery(). The dehydrated state is passed to HydrationBoundary so the client starts with cached data. The client component reads the same search params via useQueryState and calls useSuspenseQuery with matching filters.',
links: []
},
{
title: 'URL State with nuqs',
description:
'Pagination, search, and role filters are synced to the URL via nuqs. The useDataTable hook manages the TanStack Table state and debounces filter changes before updating the URL. When the URL changes, React Query automatically refetches because the query key includes the filters.',
links: [
{
title: 'nuqs Documentation',
url: 'https://nuqs.47ng.com'
}
]
},
{
title: 'Products vs Users Pattern',
description:
'Products: searchParams → RSC fetch → pass data as props to client table. Users: searchParams → server prefetch → HydrationBoundary → client useSuspenseQuery. The Users pattern enables background refetching, cache sharing across components, and optimistic mutations.',
links: []
}
]
};

View File

@@ -0,0 +1,12 @@
import * as z from 'zod';
export const userSchema = z.object({
first_name: z.string().min(2, 'First name must be at least 2 characters'),
last_name: z.string().min(2, 'Last name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
phone: z.string().min(1, 'Phone number is required'),
role: z.string().min(1, 'Please select a role'),
status: z.string().min(1, 'Please select a status')
});
export type UserFormValues = z.infer<typeof userSchema>;