commit
This commit is contained in:
24
src/features/react-query-demo/api/queries.ts
Normal file
24
src/features/react-query-demo/api/queries.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
99
src/features/react-query-demo/components/pokemon-info.tsx
Normal file
99
src/features/react-query-demo/components/pokemon-info.tsx
Normal 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 · 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 · Prefetched on server, hydrated on client
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
46
src/features/react-query-demo/info-content.ts
Normal file
46
src/features/react-query-demo/info-content.ts
Normal 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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
27
src/features/users/api/mutations.ts
Normal file
27
src/features/users/api/mutations.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
17
src/features/users/api/queries.ts
Normal file
17
src/features/users/api/queries.ts
Normal 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)
|
||||
});
|
||||
47
src/features/users/api/service.ts
Normal file
47
src/features/users/api/service.ts
Normal 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);
|
||||
}
|
||||
28
src/features/users/api/types.ts
Normal file
28
src/features/users/api/types.ts
Normal 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;
|
||||
};
|
||||
189
src/features/users/components/user-form-sheet.tsx
Normal file
189
src/features/users/components/user-form-sheet.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
src/features/users/components/user-listing.tsx
Normal file
31
src/features/users/components/user-listing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/features/users/components/users-table/cell-action.tsx
Normal file
66
src/features/users/components/users-table/cell-action.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
72
src/features/users/components/users-table/columns.tsx
Normal file
72
src/features/users/components/users-table/columns.tsx
Normal 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} />
|
||||
}
|
||||
];
|
||||
61
src/features/users/components/users-table/index.tsx
Normal file
61
src/features/users/components/users-table/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/features/users/components/users-table/options.tsx
Normal file
8
src/features/users/components/users-table/options.tsx
Normal 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' }
|
||||
];
|
||||
41
src/features/users/info-content.ts
Normal file
41
src/features/users/info-content.ts
Normal 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: []
|
||||
}
|
||||
]
|
||||
};
|
||||
12
src/features/users/schemas/user.ts
Normal file
12
src/features/users/schemas/user.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user