Feedback Items with Voting and Filtering
Build feedback items with a voting system, status workflow, and filtering using Prisma ORM relations, PostgreSQL enums, and URL-based query parameters.
This module builds the core feature that makes TeamPulse useful: feedback items. Users submit bugs, feature requests, and ideas, then vote on what matters most. Your team triages items through a status workflow - from "New" to "Done" - giving everyone visibility into progress.
You'll learn: parent-child relationships in Prisma, PostgreSQL enums for type-safe values, vote toggling with transactions, and URL-based filtering.
What you'll accomplish:
- Design feedback items and votes schemas with proper relationships
- Build CRUD operations with multi-tenant security
- Implement voting with toggle logic and atomic transactions
- Create a status workflow (New → Planned → In Progress → Done)
- Add filtering and sorting via URL search parameters
Technologies used:
- Prisma ORM - Database queries with relations
- next-safe-action - Type-safe server actions
- React Hook Form - Form state management
- Zod - Schema validation
Prerequisites: Complete Module 3: Data Model & First Feature first. You need the boards feature working.
What We're Building
Each feedback item belongs to a board and tracks a bug report, feature request, or idea. Users vote on items to surface what matters most, and the team moves items through status stages.
The feedback_votes table creates a many-to-many relationship between users and feedback items. We store voteCount directly on the feedback item - this denormalization avoids counting votes on every page load.
feedback_item feedback_vote├── id ├── id├── boardId (FK) ├── feedbackItemId (FK)├── authorId (FK) ├── userId (FK)├── title ├── createdAt├── description├── type (bug/feature/idea)├── status (new/planned/in_progress/done/closed)├── voteCount├── createdAt├── updatedAtStep 1: Design the Schema
This schema uses PostgreSQL enums via Prisma's enum keyword. Unlike string columns, enums are validated at the database level - you can't insert an invalid value. Prisma generates TypeScript types from these enums, providing type safety from database to UI.
Note: If using MySQL or SQLite, enum support differs. Check the Prisma documentation for your database.
Create the Feedback Items Table
The feedback items table references boards and users. Indexes on status, type, and voteCount optimize filtering and sorting.
packages/database/src/prisma/schema.prisma
// Enums for type-safe status and type valuesenum FeedbackType { bug feature idea}enum FeedbackStatus { new planned in_progress done closed}model FeedbackItem { id String @id boardId String @map("board_id") authorId String? @map("author_id") title String description String? type FeedbackType @default(idea) status FeedbackStatus @default(new) voteCount Int @default(0) @map("vote_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") board Board @relation(fields: [boardId], references: [id], onDelete: Cascade) author User? @relation(fields: [authorId], references: [id], onDelete: Cascade) votes FeedbackVote[] @@index([boardId], name: "feedback_items_board_id_idx") @@index([authorId], name: "feedback_items_author_id_idx") @@index([status], name: "feedback_items_status_idx") @@index([type], name: "feedback_items_type_idx") @@index([voteCount], name: "feedback_items_vote_count_idx") @@map("feedback_items")}Create the Votes Table
The votes table tracks which users voted on which items. The unique constraint on (feedbackItemId, userId) prevents double-voting at the database level - even if a race condition occurs, the database rejects duplicates.
packages/database/src/prisma/schema.prisma
model FeedbackVote { id String @id feedbackItemId String @map("feedback_item_id") userId String @map("user_id") createdAt DateTime @default(now()) @map("created_at") feedbackItem FeedbackItem @relation(fields: [feedbackItemId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Prevent double-voting @@unique([feedbackItemId, userId], name: "feedback_votes_unique") @@index([feedbackItemId], name: "feedback_votes_feedback_item_id_idx") @@index([userId], name: "feedback_votes_user_id_idx") @@map("feedback_votes")}We now add the relations on the Board model:
packages/database/src/prisma/schema.prisma
model Board { id String @id organizationId String @map("organization_id") name String description String? slug String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) feedbackItems FeedbackItem[] @@index([organizationId], name: "boards_organization_id_idx") @@index([slug], name: "boards_slug_idx") @@map("boards")}Run the Migration
Create and apply the migration:
pnpm --filter @kit/database prisma:migrateRegenerate the Prisma client:
pnpm --filter @kit/database prisma:generateVerify with Prisma Studio:
pnpm --filter @kit/database prisma:studioYou should see the feedback_items and feedback_votes tables with all columns.
Formatting the Schema with Prisma
Tip: Prisma can format your schema and fix linting issues automatically. You can run the following command to format your schema and fix linting issues:
pnpm --filter @kit/database exec prisma formatStep 2: Create the Validation Schema
Each action has different input requirements, so we define multiple schemas.
apps/web/lib/feedback/feedback.schema.ts
import { z } from 'zod';// Enum values for validationexport const feedbackTypes = ['bug', 'feature', 'idea'] as const;export const feedbackStatuses = [ 'new', 'planned', 'in_progress', 'done', 'closed',] as const;export const createFeedbackSchema = z.object({ boardId: z.string().min(1, 'Board ID is required'), title: z .string() .min(5, 'Title must be at least 5 characters') .max(200, 'Title must be less than 200 characters') .transform((val) => val.trim()), description: z .string() .max(5000, 'Description must be less than 5000 characters') .optional() .transform((val) => val?.trim()), type: z.enum(feedbackTypes).default('idea'),});export const updateFeedbackSchema = z.object({ id: z.string().min(1, 'Feedback ID is required'), title: z .string() .min(5, 'Title must be at least 5 characters') .max(200, 'Title must be less than 200 characters') .transform((val) => val.trim()), description: z .string() .max(5000, 'Description must be less than 5000 characters') .optional() .transform((val) => val?.trim()), type: z.enum(feedbackTypes),});export const changeStatusSchema = z.object({ id: z.string().min(1, 'Feedback ID is required'), status: z.enum(feedbackStatuses),});export const deleteFeedbackSchema = z.object({ id: z.string().min(1, 'Feedback ID is required'),});export const voteFeedbackSchema = z.object({ feedbackItemId: z.string().min(1, 'Feedback ID is required'),});export type CreateFeedbackInput = z.output<typeof createFeedbackSchema>;export type UpdateFeedbackInput = z.output<typeof updateFeedbackSchema>;export type ChangeStatusInput = z.output<typeof changeStatusSchema>;export type VoteFeedbackInput = z.output<typeof voteFeedbackSchema>;Step 3: Create the Server Actions
Two helper functions - verifyBoardAccess and verifyFeedbackAccess - encapsulate multi-tenant security checks. The voting action toggles votes: if the user has voted, clicking removes it. Both operations use a transaction to keep the vote record and voteCount in sync.
apps/web/lib/feedback/feedback-server-actions.ts
'use server';import { revalidatePath } from 'next/cache';import { authenticatedActionClient } from '@kit/action-middleware';import { getActiveOrganizationId } from '@kit/better-auth/context';import { db } from '@kit/database';import { getLogger } from '@kit/shared/logger';import { generateId } from '@kit/shared/utils';import { changeStatusSchema, createFeedbackSchema, deleteFeedbackSchema, updateFeedbackSchema, voteFeedbackSchema,} from './feedback.schema';// Helper to verify board belongs to orgasync function verifyBoardAccess(boardId: string, organizationId: string) { const board = await db.board.findFirst({ where: { id: boardId, organizationId, }, select: { id: true }, }); return !!board;}// Helper to verify feedback belongs to board in orgasync function verifyFeedbackAccess( feedbackId: string, organizationId: string,) { const feedback = await db.feedbackItem.findFirst({ where: { id: feedbackId, board: { organizationId, }, }, select: { id: true, authorId: true, boardId: true, }, }); return feedback;}export const createFeedbackAction = authenticatedActionClient .inputSchema(createFeedbackSchema) .action(async ({ parsedInput: data, ctx }) => { const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } // Verify board belongs to org const hasAccess = await verifyBoardAccess(data.boardId, organizationId); if (!hasAccess) { throw new Error('Board not found'); } const logger = (await getLogger()).child({ name: 'create-feedback', userId, boardId: data.boardId, title: data.title, }); logger.info('Creating feedback item...'); const feedback = await db.feedbackItem.create({ data: { id: generateId(), boardId: data.boardId, authorId: userId, title: data.title, description: data.description ?? null, type: data.type, status: 'new', voteCount: 0, }, }); logger.info( { feedbackId: feedback.id }, 'Feedback item created successfully', ); revalidatePath(`/boards/${data.boardId}`, 'layout'); return { feedback }; });export const updateFeedbackAction = authenticatedActionClient .inputSchema(updateFeedbackSchema) .action(async ({ parsedInput: data, ctx }) => { const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } const existing = await verifyFeedbackAccess(data.id, organizationId); if (!existing) { throw new Error('Feedback not found'); } // Only author can edit (admins handled in Module 5) if (existing.authorId !== userId) { throw new Error('Only the author can edit this feedback'); } const logger = (await getLogger()).child({ name: 'update-feedback', userId, feedbackId: data.id, }); logger.info('Updating feedback item...'); const feedback = await db.feedbackItem.update({ where: { id: data.id }, data: { title: data.title, description: data.description ?? null, type: data.type, }, }); logger.info('Feedback item updated successfully'); revalidatePath(`/boards/${existing.boardId}`, 'layout'); return { feedback }; });export const changeStatusAction = authenticatedActionClient .inputSchema(changeStatusSchema) .action(async ({ parsedInput: data, ctx }) => { const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } const existing = await verifyFeedbackAccess(data.id, organizationId); if (!existing) { throw new Error('Feedback not found'); } const logger = (await getLogger()).child({ name: 'change-status', userId, feedbackId: data.id, status: data.status, }); // TODO: Check admin/owner role (implemented in Module 5) // For now, any org member can change status logger.info('Changing feedback status...'); const feedback = await db.feedbackItem.update({ where: { id: data.id }, data: { status: data.status }, }); logger.info('Feedback status changed successfully'); revalidatePath(`/boards/${existing.boardId}`, 'layout'); return { feedback }; });export const deleteFeedbackAction = authenticatedActionClient .inputSchema(deleteFeedbackSchema) .action(async ({ parsedInput: data, ctx }) => { const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } const existing = await verifyFeedbackAccess(data.id, organizationId); if (!existing) { throw new Error('Feedback not found'); } const logger = (await getLogger()).child({ name: 'delete-feedback', userId, feedbackId: data.id, }); // Only author can delete (admins handled in Module 5) if (existing.authorId !== userId) { throw new Error('Only the author can delete this feedback'); } logger.info('Deleting feedback item...'); await db.feedbackItem.delete({ where: { id: data.id }, }); logger.info('Feedback item deleted successfully'); revalidatePath(`/boards/${existing.boardId}`, 'layout'); return { success: true }; });export const voteFeedbackAction = authenticatedActionClient .inputSchema(voteFeedbackSchema) .action(async ({ parsedInput: data, ctx }) => { const userId = ctx.user.id; const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } const existing = await verifyFeedbackAccess( data.feedbackItemId, organizationId, ); if (!existing) { throw new Error('Feedback not found'); } const logger = (await getLogger()).child({ name: 'vote-feedback', userId, feedbackItemId: data.feedbackItemId, }); // Check if user already voted const existingVote = await db.feedbackVote.findFirst({ where: { feedbackItemId: data.feedbackItemId, userId, }, }); if (existingVote) { // Remove vote (toggle off) logger.info('User already voted. Removing vote...'); await db.$transaction([ db.feedbackVote.delete({ where: { id: existingVote.id }, }), db.feedbackItem.update({ where: { id: data.feedbackItemId }, data: { voteCount: { decrement: 1 } }, }), ]); logger.info('Vote removed successfully'); revalidatePath(`/boards/${existing.boardId}`, 'layout'); return { voted: false }; } else { // Add vote (toggle on) logger.info('Adding vote...'); await db.$transaction([ db.feedbackVote.create({ data: { id: generateId(), feedbackItemId: data.feedbackItemId, userId, }, }), db.feedbackItem.update({ where: { id: data.feedbackItemId }, data: { voteCount: { increment: 1 } }, }), ]); logger.info('Vote added successfully'); revalidatePath(`/boards/${existing.boardId}`, 'layout'); return { voted: true }; } });Key patterns:
- Access verification helpers: Check that resources belong to the current organization
- Vote toggle logic: One action handles both upvote and remove - simplifies the UI
- Transactional consistency: Vote insert/delete and
voteCountupdate happen atomically - Path revalidation: Each mutation refreshes the server component cache
Step 4: Create the Data Loader
The feedback loader supports filtering, sorting, and includes a subquery to check if the current user has voted on each item. The hasVoted field lets the UI highlight upvoted items.
apps/web/lib/feedback/feedback-page.loader.ts
import 'server-only';import { cache } from 'react';import { getActiveOrganizationId, getSession } from '@kit/better-auth/context';import { db } from '@kit/database';async function requireOrgAuth() { const session = await getSession(); if (!session?.user?.id) { throw new Error('Not authenticated'); } const organizationId = await getActiveOrganizationId(); if (!organizationId) { throw new Error('No active organization'); } return { userId: session.user.id, organizationId };}export type FeedbackFilters = { type?: 'bug' | 'feature' | 'idea'; status?: 'new' | 'planned' | 'in_progress' | 'done' | 'closed'; sortBy?: 'newest' | 'votes' | 'updated'; page?: number; pageSize?: number; search?: string;};export const loadFeedbackItems = cache( async (boardId: string, filters?: FeedbackFilters) => { const auth = await requireOrgAuth(); const pageIndex = filters?.page ?? 1; const pageSize = filters?.pageSize ?? 10; const skip = (pageIndex - 1) * pageSize; // Build orderBy clause let orderBy: Record<string, 'asc' | 'desc'>; switch (filters?.sortBy) { case 'votes': orderBy = { voteCount: 'desc' }; break; case 'updated': orderBy = { updatedAt: 'desc' }; break; default: orderBy = { createdAt: 'desc' }; } // Build where clause with org check const where = { boardId, board: { organizationId: auth.organizationId }, ...(filters?.type && { type: filters.type }), ...(filters?.status && { status: filters.status }), ...(filters?.search && { title: { contains: filters.search, mode: 'insensitive' as const }, }), }; const [total, items] = await Promise.all([ db.feedbackItem.count({ where }), db.feedbackItem.findMany({ where, select: { id: true, title: true, description: true, type: true, status: true, voteCount: true, createdAt: true, updatedAt: true, author: { select: { id: true, name: true, image: true }, }, votes: { where: { userId: auth.userId }, select: { id: true }, }, }, orderBy, skip, take: pageSize, }), ]); return { items: items.map((item) => ({ id: item.id, title: item.title, description: item.description, type: item.type, status: item.status, voteCount: item.voteCount, createdAt: item.createdAt, updatedAt: item.updatedAt, author: item.author ?? { id: 'anonymous-' + item.createdAt.toISOString(), name: 'Anonymous', image: null, }, hasVoted: item.votes.length > 0, })), total, pageIndex, pageSize, pageCount: Math.ceil(total / pageSize), }; },);export const loadFeedbackItem = cache(async (feedbackId: string) => { const auth = await requireOrgAuth(); const item = await db.feedbackItem.findFirst({ where: { id: feedbackId, board: { organizationId: auth.organizationId }, }, select: { id: true, boardId: true, title: true, description: true, type: true, status: true, voteCount: true, createdAt: true, updatedAt: true, author: { select: { id: true, name: true, image: true }, }, votes: { where: { userId: auth.userId }, select: { id: true }, }, }, }); if (!item) { return null; } return { id: item.id, boardId: item.boardId, title: item.title, description: item.description, type: item.type, status: item.status, voteCount: item.voteCount, createdAt: item.createdAt, updatedAt: item.updatedAt, author: item.author ?? { id: 'anonymous-' + item.createdAt.toISOString(), name: 'Anonymous', image: null, }, hasVoted: item.votes.length > 0, };});export type FeedbackItem = NonNullable< Awaited<ReturnType<typeof loadFeedbackItem>>>;export type FeedbackItems = Awaited<ReturnType<typeof loadFeedbackItems>>;Key patterns:
hasVotedcheck: We include the votes relation filtered by the current user. If the votes array is non-empty, the user has voted.- Dynamic filtering: Conditions are built into a
whereobject with spread syntax. This pattern makes it easy to add filters conditionally. - Flexible sorting: A switch statement maps sort keys to Prisma's
orderByobject. The default iscreatedAt: 'desc'(newest first). cache()deduplication: React's cache wrapper ensures that if multiple components call the loader with the same arguments, the database is queried only once per request.- Multi-tenancy filtering: The loader filters the data by the current organization ID. This is a common pattern in multi-tenant applications to ensure that users only see data that belongs to them.
The author relation is optional (author: User?), so Prisma includes feedback items even if the author doesn't exist. This is important because we allow anonymous submissions for public boards.
Multi-tenancy Pattern
In a multi-tenant application, data isolation is critical. Every query that touches tenant data must filter by organizationId. Forgetting this filter is a security vulnerability - users could see other organizations' data:
// BAD - no organization filter!const results = await db.feedbackItem.findMany();Always include the organization filter in your queries:
// GOOD - filtered by organizationconst results = await db.feedbackItem.findMany({ where: { board: { organizationId: auth.organizationId }, },});This ensures that queries only return data belonging to the current organization.
For more advanced permission checks, we will be discussing it in the next sections, so don't worry about it for now.
Step 5: Create the Feedback Table Component
We want to display the feedback items in a table - paginated and sortable. This is an extremely common pattern in web applications, therefore we cannot not cover it in this course.
We will reuse the DataTable component from the @kit/ui/enhanced-data-table package. This is a wrapper around the TanStack Table component that provides a lot of features out of the box, such as pagination, sorting, filtering, and more.
Since the loader already takes care of returning the data in the correct format, we have a lot of the hard work done for us. All this component needs to do is to pass the data to the DataTable component.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/feedback-data-table.tsx
'use client';import { useCallback, useMemo, useState, useTransition } from 'react';import type { FeedbackItems } from '@lib/feedback/feedback-page.loader';import { voteFeedbackAction } from '@lib/feedback/feedback-server-actions';import { formatDistanceToNow } from 'date-fns';import { Bug, ChevronDown, ChevronUp, Lightbulb, Sparkles } from 'lucide-react';import { Badge } from '@kit/ui/badge';import { Button } from '@kit/ui/button';import { ColumnDef, DataTable } from '@kit/ui/enhanced-data-table';import { ProfileAvatar } from '@kit/ui/profile-avatar';import { toast } from '@kit/ui/sonner';import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from '@kit/ui/tooltip';import { FeedbackDetailSheet } from './feedback-detail-sheet';type FeedbackItemRow = FeedbackItems['items'][number];interface FeedbackDataTableProps { data: FeedbackItemRow[]; total: number; pageIndex: number; pageSize: number; pageCount: number;}const typeConfig = { bug: { icon: Bug, label: 'Bug', variant: 'destructive' as const, }, feature: { icon: Sparkles, label: 'Feature', variant: 'default' as const, }, idea: { icon: Lightbulb, label: 'Idea', variant: 'warning' as const, },};const statusConfig = { new: { label: 'New', variant: 'link' as const, }, planned: { label: 'Planned', variant: 'default' as const, }, in_progress: { label: 'In Progress', variant: 'warning' as const, }, done: { label: 'Done', variant: 'success' as const, }, closed: { label: 'Closed', variant: 'secondary' as const, },};function VoteButton({ feedbackItemId, voteCount, hasVoted,}: { feedbackItemId: string; voteCount: number; hasVoted: boolean;}) { const [isPending, startTransition] = useTransition(); const handleVote = (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); startTransition(async () => { const result = await voteFeedbackAction({ feedbackItemId }); if (result?.serverError || result.validationErrors) { toast.error(result.serverError); } }); }; return ( <TooltipProvider> <Tooltip> <TooltipTrigger render={ <Button variant={hasVoted ? 'default' : 'outline'} size="sm" className="flex gap-x-0.5 px-2" onClick={handleVote} disabled={isPending} data-testid="vote-button" > <span className="text-xs">{voteCount}</span> {hasVoted ? ( <ChevronDown className="size-4" /> ) : ( <ChevronUp className="size-4" /> )} </Button> } /> <TooltipContent> {hasVoted ? 'Remove vote' : 'Upvote this feedback'} </TooltipContent> </Tooltip> </TooltipProvider> );}export function FeedbackDataTable({ data, pageIndex, pageSize, pageCount,}: FeedbackDataTableProps) { const [selectedFeedbackId, setSelectedFeedbackId] = useState<string | null>( null, ); const handleRowClick = useCallback( ({ row }: { row: { original: FeedbackItemRow } }) => { setSelectedFeedbackId(row.original.id); }, [], ); const handleSheetOpenChange = useCallback((open: boolean) => { if (!open) { setSelectedFeedbackId(null); } }, []); const columns = useMemo<ColumnDef<FeedbackItemRow>[]>( () => [ { accessorKey: 'author', header: 'Author', size: 150, enableSorting: false, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const { author } = row.original; return ( <div className="flex items-center justify-start gap-2.5"> <ProfileAvatar pictureUrl={author.image} displayName={author.name} className="m-0! size-8!" /> <span className="text-muted-foreground truncate text-sm"> {author.name} </span> </div> ); }, }, { id: 'votes', header: 'Votes', size: 80, cell: ({ row }: { row: { original: FeedbackItemRow } }) => ( <VoteButton feedbackItemId={row.original.id} voteCount={row.original.voteCount} hasVoted={row.original.hasVoted} /> ), }, { accessorKey: 'title', header: 'Title', size: 250, enableSorting: false, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const { type, title, description } = row.original; const config = typeConfig[type]; const Icon = config.icon; return ( <div className="flex flex-col gap-0"> <div className="flex items-center gap-2"> <Icon className="text-muted-foreground h-4 w-4 shrink-0" /> <span className="text-sm">{title}</span> </div> {description && ( <p className="text-muted-foreground line-clamp-1 text-xs"> {description} </p> )} </div> ); }, }, { accessorKey: 'type', header: 'Type', enableSorting: false, size: 80, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const type = row.original.type; const config = typeConfig[type]; return <Badge variant={config.variant}>{config.label}</Badge>; }, }, { accessorKey: 'status', header: 'Status', size: 80, enableSorting: false, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const status = row.original.status; const config = statusConfig[status]; return <Badge variant={config.variant}>{config.label}</Badge>; }, }, { accessorKey: 'createdAt', header: 'Created', size: 120, cell: ({ row }: { row: { original: FeedbackItemRow } }) => ( <span className="text-muted-foreground text-xs"> {formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true, })} </span> ), }, ], [], ); return ( <> <DataTable data={data} columns={columns} pageIndex={pageIndex - 1} pageSize={pageSize} pageCount={pageCount} getRowId={(row) => row.id} onClick={handleRowClick} /> <FeedbackDetailSheet feedbackId={selectedFeedbackId} onOpenChange={handleSheetOpenChange} /> </> );}Step 6: Create the Feedback Form
The feedback form introduces a new UI element: a Select dropdown for choosing the feedback type.
The form structure follows the same pattern as boards - React Hook Form with Zod validation and next-safe-action for submission.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/create-feedback-form.tsx
'use client';import { zodResolver } from '@hookform/resolvers/zod';import { useAction } from 'next-safe-action/hooks';import { useForm } from 'react-hook-form';import { Button } from '@kit/ui/button';import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { Input } from '@kit/ui/input';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@kit/ui/select';import { toast } from '@kit/ui/sonner';import { Textarea } from '@kit/ui/textarea';import { createFeedbackAction } from '@lib/feedback/feedback-server-actions';import { type CreateFeedbackInput, createFeedbackSchema, feedbackTypes,} from '@lib/feedback/feedback.schema';interface CreateFeedbackFormProps { boardId: string; onSuccess?: () => void;}export function CreateFeedbackForm({ boardId, onSuccess,}: CreateFeedbackFormProps) { const { executeAsync, status } = useAction(createFeedbackAction); const form = useForm({ resolver: zodResolver(createFeedbackSchema), defaultValues: { boardId, title: '', description: '', type: 'idea', }, }); const isPending = status === 'executing'; const onSubmit = async (data: CreateFeedbackInput) => { const result = await executeAsync(data); if (result?.serverError) { toast.error(result.serverError); return; } if (result?.validationErrors) { toast.error('Please check your input'); return; } toast.success('Feedback submitted successfully'); form.reset(); onSuccess?.(); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField name="type" render={({ field }) => ( <FormItem> <FormLabel>Type</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} disabled={isPending} > <FormControl render={ <SelectTrigger data-testid="feedback-type-select"> <SelectValue> {(value) => value || 'Choose Type'} </SelectValue> </SelectTrigger> } /> <SelectContent> {feedbackTypes.map((type) => ( <SelectItem key={type} value={type}> {type.charAt(0).toUpperCase() + type.slice(1)} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField name="title" render={({ field }) => ( <FormItem> <FormLabel>Title</FormLabel> <FormControl render={ <Input placeholder="Brief summary of your feedback" disabled={isPending} data-testid="feedback-title-input" {...field} /> } /> <FormMessage /> </FormItem> )} /> <FormField name="description" render={({ field }) => ( <FormItem> <FormLabel>Description (optional)</FormLabel> <FormControl render={ <Textarea placeholder="Provide more details..." disabled={isPending} rows={4} data-testid="feedback-description-input" {...field} /> } /> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={isPending} data-testid="submit-feedback-button" > {isPending ? 'Submitting...' : 'Submit Feedback'} </Button> </form> </Form> );}Step 7: Create the Feedback Dialog
The dialog receives boardId as a prop and passes it to the form.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/create-feedback-dialog.tsx
'use client';import { useState } from 'react';import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,} from '@kit/ui/dialog';import { CreateFeedbackForm } from './create-feedback-form';interface CreateFeedbackDialogProps { boardId: string;}export function CreateFeedbackDialog({ boardId, children,}: React.PropsWithChildren<CreateFeedbackDialogProps>) { const [open, setOpen] = useState(false); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger data-testid="create-feedback-button" render={children as React.ReactElement} /> <DialogContent> <DialogHeader> <DialogTitle>Submit Feedback</DialogTitle> <DialogDescription> Share a bug report, feature request, or idea. </DialogDescription> </DialogHeader> <CreateFeedbackForm boardId={boardId} onSuccess={() => setOpen(false)} /> </DialogContent> </Dialog> );}Step 8: Create the Filter Component
URL-based filtering stores filter state in search parameters. Users can bookmark filtered views, share links, and the browser's back button works correctly.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/feedback-filters.tsx
'use client';import { useRouter, useSearchParams } from 'next/navigation';import { feedbackStatuses, feedbackTypes } from '@lib/feedback/feedback.schema';import { Button } from '@kit/ui/button';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@kit/ui/select';export function FeedbackFilters() { const router = useRouter(); const searchParams = useSearchParams(); const currentType = searchParams.get('type') ?? 'all'; const currentStatus = searchParams.get('status') ?? 'all'; const currentSort = searchParams.get('sort') ?? 'newest'; const updateFilter = (key: string, value: string) => { const params = new URLSearchParams(searchParams); if (value === 'all') { params.delete(key); } else { params.set(key, value); } router.push(`?${params.toString()}`); }; const clearFilters = () => { router.push('?'); }; const hasFilters = currentType !== 'all' || currentStatus !== 'all' || currentSort !== 'newest'; return ( <div className="flex flex-wrap items-center gap-2"> <Select value={currentType} onValueChange={(value) => value && updateFilter('type', value)} > <SelectTrigger className="h-8 w-[130px]" data-testid="filter-type"> <SelectValue>{(value) => value || 'Select Type'}</SelectValue> </SelectTrigger> <SelectContent> <SelectItem value="all">All Types</SelectItem> {feedbackTypes.map((type) => ( <SelectItem key={type} value={type}> {type.charAt(0).toUpperCase() + type.slice(1)} </SelectItem> ))} </SelectContent> </Select> <Select value={currentStatus} onValueChange={(value) => value && updateFilter('status', value)} > <SelectTrigger className="h-8 w-[140px]" data-testid="filter-status"> <SelectValue>{(value) => value || 'Select Status'}</SelectValue> </SelectTrigger> <SelectContent> <SelectItem value="all">All Statuses</SelectItem> {feedbackStatuses.map((status) => ( <SelectItem key={status} value={status}> {status .replace('_', ' ') .replace(/\b\w/g, (l) => l.toUpperCase())} </SelectItem> ))} </SelectContent> </Select> <Select value={currentSort} onValueChange={(value) => value && updateFilter('sort', value)} > <SelectTrigger className="h-8 w-[130px]" data-testid="filter-sort"> <SelectValue>{(value) => value || 'Sort By'}</SelectValue> </SelectTrigger> <SelectContent> <SelectItem value="newest">Newest</SelectItem> <SelectItem value="votes">Most Votes</SelectItem> <SelectItem value="updated">Recently Updated</SelectItem> </SelectContent> </Select> {hasFilters && ( <Button variant="ghost" size="sm" onClick={clearFilters}> Clear </Button> )} </div> );}Step 9: Create the Status Dropdown
The status dropdown lets team members move feedback through the workflow. For now, any organization member can change status - we'll add role-based restrictions in Module 5.
The dropdown shows all available statuses and disables the currently selected one.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/status-dropdown.tsx
'use client';import { useAction } from 'next-safe-action/hooks';import { changeStatusAction } from '@lib/feedback/feedback-server-actions';import { feedbackStatuses } from '@lib/feedback/feedback.schema';import { useQueryClient } from '@tanstack/react-query';import { Button } from '@kit/ui/button';import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,} from '@kit/ui/dropdown-menu';import { toast } from '@kit/ui/sonner';const statusLabels: Record<string, string> = { new: 'New', planned: 'Planned', in_progress: 'In Progress', done: 'Done', closed: 'Closed',};interface StatusDropdownProps { feedbackId: string; currentStatus: string;}export function StatusDropdown({ feedbackId, currentStatus,}: StatusDropdownProps) { const queryClient = useQueryClient(); const { executeAsync, status } = useAction(changeStatusAction); const isPending = status === 'executing'; const handleChange = async (newStatus: string) => { const result = await executeAsync({ id: feedbackId, status: newStatus as (typeof feedbackStatuses)[number], }); if (result?.serverError || result.validationErrors) { toast.error(result.serverError); return; } await queryClient.invalidateQueries({ queryKey: ['feedback-item', feedbackId], }); toast.success(`Status changed to ${statusLabels[newStatus]}`); }; return ( <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="outline" size="sm" disabled={isPending} data-testid="status-dropdown" > {statusLabels[currentStatus]} </Button> } /> <DropdownMenuContent> {feedbackStatuses.map((status) => ( <DropdownMenuItem key={status} onClick={() => handleChange(status)} disabled={status === currentStatus} > {statusLabels[status]} </DropdownMenuItem> ))} </DropdownMenuContent> </DropdownMenu> );}Step 10: Update the Board Detail Page
Now integrate everything into the board detail page. This update adds the filter controls, the feedback list, and the create dialog.
The page reads filter values from searchParams and passes them to the loader - when users change filters, Next.js re-renders the page with fresh data.
apps/web/app/[locale]/(internal)/boards/[boardId]/page.tsx
import type { Metadata } from 'next';import { notFound } from 'next/navigation';import { loadFeedbackItems } from '@lib/feedback/feedback-page.loader';import { Plus } from 'lucide-react';import { loadBoard } from '@lib/boards/boards-page.loader';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { Button } from '@kit/ui/button';import { EmptyState, EmptyStateButton, EmptyStateHeading, EmptyStateText,} from '@kit/ui/empty-state';import { PageBody, PageHeader } from '@kit/ui/page';import { DeleteBoardDialog } from '../_components/delete-board-dialog';import { EditBoardDialog } from '../_components/edit-board-dialog';import { CreateFeedbackDialog } from './_components/create-feedback-dialog';import { FeedbackDataTable } from './_components/feedback-data-table';import { FeedbackFilters } from './_components/feedback-filters';interface FeedbackFilters { page?: number; search?: string; type?: 'bug' | 'feature' | 'idea'; status?: 'new' | 'planned' | 'in_progress' | 'done' | 'closed'; sort?: string;}interface BoardPageProps { params: Promise<{ boardId: string }>; searchParams: Promise<FeedbackFilters>;}export async function generateMetadata({ params,}: BoardPageProps): Promise<Metadata> { const { boardId } = await params; const board = await loadBoard(boardId); return { title: board?.name ?? 'Board Not Found', };}export default async function BoardPage({ params, searchParams,}: BoardPageProps) { const { boardId } = await params; const filters = await searchParams; const [ board, { items: feedbackItems, total, pageIndex, pageSize, pageCount }, ] = await Promise.all([ loadBoard(boardId), loadFeedbackItems(boardId, filters), ]); if (!board) { notFound(); } return ( <PageBody> <PageHeader> <div className="flex-1"> <AppBreadcrumbs values={{ boards: 'Boards', [board.id]: board.name, }} /> </div> <div className="flex gap-2"> <EditBoardDialog board={board} /> <DeleteBoardDialog boardId={board.id} boardName={board.name} /> </div> </PageHeader> <div className="flex flex-1 flex-col space-y-4 pb-4"> <div className="flex items-center justify-between"> <FeedbackFilters /> <CreateFeedbackDialog boardId={boardId}> <Button size="sm"> <Plus className="mr-2 h-4 w-4" /> Add Feedback </Button> </CreateFeedbackDialog> </div> {feedbackItems.length === 0 ? ( <EmptyState> <EmptyStateHeading>No feedback yet</EmptyStateHeading> <EmptyStateText> Be the first to submit feedback for this board. </EmptyStateText> <CreateFeedbackDialog boardId={boardId}> <EmptyStateButton> <Plus className="mr-2 h-4 w-4" /> Create Feedback </EmptyStateButton> </CreateFeedbackDialog> </EmptyState> ) : ( <FeedbackDataTable data={feedbackItems} total={total} pageIndex={pageIndex} pageSize={pageSize} pageCount={pageCount} /> )} </div> </PageBody> );}


Checkpoint: Verify It Works
Test each feature, paying attention to vote toggle behavior and filter combinations:
- [ ] Migration applied -
feedback_itemandfeedback_votetables exist - [ ] Navigate to a board detail page
- [ ] Click "Add Feedback" and submit feedback with different types
- [ ] Verify feedback appears in the list
- [ ] Click the vote button - vote count increases
- [ ] Click again - vote is removed (toggle)
- [ ] Use filters to filter by type and status
- [ ] Use sort to change ordering
Run quality checks:
pnpm healthcheckViewing Feedback Item Details
Display feedback details in a sheet dialog using React Query for client-side fetching. Use React Query when data isn't needed during initial render - only after user interaction.
Creating the API Route Handler
The route handler reuses loadFeedbackItem and returns appropriate HTTP status codes.
apps/web/app/api/feedback/[id]/route.ts
import { NextResponse } from 'next/server';import { loadFeedbackItem } from '@lib/feedback/feedback-page.loader';interface RouteParams { params: Promise<{ id: string }>;}export async function GET(_request: Request, { params }: RouteParams) { try { const { id } = await params; const feedbackItem = await loadFeedbackItem(id); if (!feedbackItem) { return NextResponse.json({ error: 'Feedback not found' }, { status: 404 }); } return NextResponse.json(feedbackItem); } catch (error) { // Handle auth errors (thrown as standard Errors from the loader) if (error instanceof Error && error.message.includes('authenticated')) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } console.error('Failed to load feedback item:', error); return NextResponse.json( { error: 'Internal server error' }, { status: 500 }, ); }}Creating the Fetch Hook
This hook wraps React Query's useQuery. The enabled option ensures the query only runs with a valid ID. A 30-second staleTime serves cached data without refetching.
apps/web/app/[locale]/(internal)/boards/[boardId]/_lib/use-fetch-feedback-item.tsx
'use client';import { useQuery } from '@tanstack/react-query';import type { FeedbackItem } from '@lib/feedback/feedback-page.loader';async function fetchFeedbackItem(id: string): Promise<FeedbackItem> { const response = await fetch(`/api/feedback/${id}`); if (!response.ok) { throw new Error('Failed to fetch feedback item'); } return response.json() as Promise<FeedbackItem>;}export function useFetchFeedbackItem(feedbackId: string | null) { return useQuery({ queryKey: ['feedback-item', feedbackId], queryFn: () => fetchFeedbackItem(feedbackId!), enabled: !!feedbackId, staleTime: 30 * 1000, });}Creating the Sheet Component
The Sheet slides in from the right, displaying the selected feedback without navigating away from the table. Skeleton placeholders show during loading.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/feedback-detail-sheet.tsx
'use client';import { useState } from 'react';import { useFetchFeedbackItem } from '../_lib/use-fetch-feedback-item';import { format } from 'date-fns';import { Bug, Lightbulb, Pencil, Sparkles, Trash2 } from 'lucide-react';import { Badge } from '@kit/ui/badge';import { Button } from '@kit/ui/button';import { ProfileAvatar } from '@kit/ui/profile-avatar';import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from '@kit/ui/sheet';import { Skeleton } from '@kit/ui/skeleton';import { DeleteFeedbackDialog } from './delete-feedback-dialog';import { EditFeedbackDialog } from './edit-feedback-dialog';import { StatusDropdown } from './status-dropdown';interface FeedbackDetailSheetProps { feedbackId: string | null; onOpenChange: (open: boolean) => void;}const typeConfig = { bug: { icon: Bug, label: 'Bug', variant: 'destructive' as const, }, feature: { icon: Sparkles, label: 'Feature', variant: 'default' as const, }, idea: { icon: Lightbulb, label: 'Idea', variant: 'warning' as const, },};function LoadingSkeleton() { return ( <div className="space-y-6 pt-4"> <div className="space-y-2"> <Skeleton className="h-4 w-20" /> <Skeleton className="h-6 w-full" /> </div> <div className="space-y-2"> <Skeleton className="h-4 w-20" /> <Skeleton className="h-20 w-full" /> </div> <div className="flex gap-4"> <div className="space-y-2"> <Skeleton className="h-4 w-16" /> <Skeleton className="h-6 w-20" /> </div> <div className="space-y-2"> <Skeleton className="h-4 w-16" /> <Skeleton className="h-6 w-20" /> </div> </div> </div> );}function FeedbackDetailContent({ feedbackId, onClose,}: { feedbackId: string; onClose: () => void;}) { const [editDialogOpen, setEditDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { data: feedbackItem, isLoading } = useFetchFeedbackItem(feedbackId); const type = feedbackItem?.type ?? 'idea'; const config = typeConfig[type]; const Icon = config.icon; return ( <> <SheetHeader className="pr-8"> <SheetTitle className="flex items-center gap-2"> {isLoading ? ( <Skeleton className="h-6 w-48" /> ) : ( <> <Icon className="text-muted-foreground h-5 w-5 shrink-0" /> <span className="line-clamp-2">{feedbackItem?.title}</span> </> )} </SheetTitle> <SheetDescription> {isLoading ? ( <Skeleton className="h-4 w-32" /> ) : feedbackItem ? ( <span> Submitted {format(new Date(feedbackItem.createdAt), 'PPP')} </span> ) : null} </SheetDescription> </SheetHeader> {feedbackItem && ( <> <EditFeedbackDialog feedbackItem={feedbackItem} open={editDialogOpen} onOpenChange={setEditDialogOpen} /> <DeleteFeedbackDialog feedbackId={feedbackItem.id} feedbackTitle={feedbackItem.title} open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} onSuccess={onClose} /> </> )} {isLoading ? ( <LoadingSkeleton /> ) : feedbackItem ? ( <div className="space-y-6 pt-4"> <div className="flex items-center gap-3"> <ProfileAvatar pictureUrl={feedbackItem.author.image} displayName={feedbackItem.author.name} className="m-0! size-10" /> <div className="flex flex-col"> <span className="text-sm font-medium"> {feedbackItem.author.name} </span> <span className="text-muted-foreground text-xs">Author</span> </div> </div> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Description </label> <p className="text-sm whitespace-pre-wrap"> {feedbackItem.description || 'No description provided.'} </p> </div> <div className="flex flex-wrap gap-4"> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Type </label> <div> <Badge variant={config.variant}>{config.label}</Badge> </div> </div> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Status </label> <div> <StatusDropdown feedbackId={feedbackItem.id} currentStatus={feedbackItem.status} /> </div> </div> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Votes </label> <div> <Badge variant="secondary">{feedbackItem.voteCount}</Badge> </div> </div> </div> <div className="flex gap-2 border-t pt-4"> <Button variant="outline" size="sm" onClick={() => setEditDialogOpen(true)} data-testid="edit-feedback-button" > <Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit </Button> <Button variant="destructive" size="sm" onClick={() => setDeleteDialogOpen(true)} data-testid="delete-feedback-button" > <Trash2 className="mr-1.5 h-3.5 w-3.5" /> Delete </Button> </div> <div className="text-muted-foreground text-xs"> Last updated {format(new Date(feedbackItem.updatedAt), 'PPpp')} </div> </div> ) : ( <div className="text-muted-foreground py-8 text-center text-sm"> Feedback not found </div> )} </> );}export function FeedbackDetailSheet({ feedbackId, onOpenChange,}: FeedbackDetailSheetProps) { return ( <Sheet open={!!feedbackId} onOpenChange={onOpenChange}> <SheetContent className="sm:max-w-lg"> {feedbackId && ( <FeedbackDetailContent feedbackId={feedbackId} onClose={() => onOpenChange(false)} /> )} </SheetContent> </Sheet> );}Opening the Sheet component when the user clicks on a feedback row
Finally, we need to wire up our data table to open the sheet when a user clicks on a row. We'll update the FeedbackDataTable component to track the selected feedback ID and pass it to our sheet component.
The key changes are: we add a selectedFeedbackId state to track which feedback item is selected, a handleRowClick callback that captures the clicked row's ID, and a handleSheetOpenChange callback that clears the selection when the sheet closes. The DataTable component's onClick prop triggers our row click handler, and we render the FeedbackDetailSheet alongside the table.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/feedback-data-table.tsx
'use client';import { useCallback, useMemo, useState, useTransition } from 'react';import type { FeedbackItems } from '@lib/feedback/feedback-page.loader';import { formatDistanceToNow } from 'date-fns';import { Bug, ChevronDown, ChevronUp, Lightbulb, Sparkles } from 'lucide-react';import { voteFeedbackAction } from '@lib/feedback/feedback-server-actions';import { Badge } from '@kit/ui/badge';import { Button } from '@kit/ui/button';import { ColumnDef, DataTable } from '@kit/ui/enhanced-data-table';import { ProfileAvatar } from '@kit/ui/profile-avatar';import { toast } from '@kit/ui/sonner';import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from '@kit/ui/tooltip';import { FeedbackDetailSheet } from './feedback-detail-sheet';type FeedbackItemRow = FeedbackItems['items'][number];interface FeedbackDataTableProps { data: FeedbackItemRow[]; total: number; pageIndex: number; pageSize: number; pageCount: number;}const typeConfig = { bug: { icon: Bug, label: 'Bug', variant: 'destructive' as const, }, feature: { icon: Sparkles, label: 'Feature', variant: 'default' as const, }, idea: { icon: Lightbulb, label: 'Idea', variant: 'warning' as const, },};const statusConfig = { new: { label: 'New', variant: 'outline' as const, }, planned: { label: 'Planned', variant: 'default' as const, }, in_progress: { label: 'In Progress', variant: 'warning' as const, }, done: { label: 'Done', variant: 'success' as const, }, closed: { label: 'Closed', variant: 'secondary' as const, },};function VoteButton({ feedbackItemId, voteCount, hasVoted,}: { feedbackItemId: string; voteCount: number; hasVoted: boolean;}) { const [isPending, startTransition] = useTransition(); const handleVote = (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); startTransition(async () => { const result = await voteFeedbackAction({ feedbackItemId }); if (result?.serverError || result.validationErrors) { toast.error(result.serverError); } }); }; return ( <TooltipProvider> <Tooltip> <TooltipTrigger render={<Button variant={hasVoted ? 'default' : 'outline'} size="sm" className="flex gap-x-0.5 px-2" onClick={handleVote} disabled={isPending} data-testid="vote-button" > <span className="text-xs">{voteCount}</span> {hasVoted ? ( <ChevronDown className="size-4" /> ) : ( <ChevronUp className="size-4" /> )} </Button>}> </TooltipTrigger> <TooltipContent> {hasVoted ? 'Remove vote' : 'Upvote this feedback'} </TooltipContent> </Tooltip> </TooltipProvider> );}export function FeedbackDataTable({ data, pageIndex, pageSize, pageCount,}: FeedbackDataTableProps) { const [selectedFeedbackId, setSelectedFeedbackId] = useState<string | null>( null, ); const handleRowClick = useCallback( ({ row }: { row: { original: FeedbackItemRow } }) => { setSelectedFeedbackId(row.original.id); }, [], ); const handleSheetOpenChange = useCallback((open: boolean) => { if (!open) { setSelectedFeedbackId(null); } }, []); const columns = useMemo<ColumnDef<FeedbackItemRow>[]>( () => [ { accessorKey: 'author', header: 'Author', size: 150, enableSorting: false, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const { author } = row.original; return ( <div className="flex items-center justify-start gap-2.5"> <ProfileAvatar pictureUrl={author.image} displayName={author.name} className="m-0! size-8!" /> <span className="text-muted-foreground truncate text-sm"> {author.name} </span> </div> ); }, }, { id: 'votes', header: 'Votes', size: 80, cell: ({ row }: { row: { original: FeedbackItemRow } }) => ( <VoteButton feedbackItemId={row.original.id} voteCount={row.original.voteCount} hasVoted={row.original.hasVoted} /> ), }, { accessorKey: 'title', header: 'Title', size: 250, enableSorting: false, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const { type, title, description } = row.original; const config = typeConfig[type]; const Icon = config.icon; return ( <div className="flex flex-col gap-0"> <div className="flex items-center gap-2"> <Icon className="text-muted-foreground h-4 w-4 shrink-0" /> <span className="text-sm">{title}</span> </div> {description && ( <p className="text-muted-foreground line-clamp-1 text-xs"> {description} </p> )} </div> ); }, }, { accessorKey: 'type', header: 'Type', enableSorting: false, size: 80, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const type = row.original.type; const config = typeConfig[type]; return <Badge variant={config.variant}>{config.label}</Badge>; }, }, { accessorKey: 'status', header: 'Status', size: 80, enableSorting: false, cell: ({ row }: { row: { original: FeedbackItemRow } }) => { const status = row.original.status; const config = statusConfig[status]; return <Badge variant={config.variant}>{config.label}</Badge>; }, }, { accessorKey: 'createdAt', header: 'Created', size: 120, cell: ({ row }: { row: { original: FeedbackItemRow } }) => ( <span className="text-muted-foreground text-xs"> {formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true, })} </span> ), }, ], [], ); return ( <> <DataTable data={data} columns={columns} pageIndex={pageIndex - 1} pageSize={pageSize} pageCount={pageCount} getRowId={(row) => row.id} onClick={handleRowClick} /> <FeedbackDetailSheet feedbackId={selectedFeedbackId} onOpenChange={handleSheetOpenChange} /> </> );}
Editing a Feedback Item
Now let's allow users to edit their feedback submissions. We'll create an edit form that pre-populates with the existing feedback data and submits changes through a server action.
Creating a Dialog component to edit feedback item details
The edit form follows the same pattern we used for creating feedback: react-hook-form for form state management, Zod for validation, and next-safe-action for type-safe server action execution. The form is pre-populated with the feedback item's current values using defaultValues.
We use the useAction hook from next-safe-action to execute our updateFeedbackAction. The hook provides an executeAsync function and a status field we can use to show loading states and disable the form during submission. On success, we display a toast notification and call the optional onSuccess callback to close the dialog.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/edit-feedback-form.tsx
'use client';import { useAction } from 'next-safe-action/hooks';import type { FeedbackItem } from '@lib/feedback/feedback-page.loader';import { zodResolver } from '@hookform/resolvers/zod';import { useForm } from 'react-hook-form';import { updateFeedbackAction } from '@lib/feedback/feedback-server-actions';import { type UpdateFeedbackInput, feedbackTypes, updateFeedbackSchema,} from '@lib/feedback/feedback.schema';import { Button } from '@kit/ui/button';import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage,} from '@kit/ui/form';import { Input } from '@kit/ui/input';import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@kit/ui/select';import { toast } from '@kit/ui/sonner';import { Textarea } from '@kit/ui/textarea';interface EditFeedbackFormProps { feedbackItem: FeedbackItem; onSuccess?: () => void;}export function EditFeedbackForm({ feedbackItem, onSuccess,}: EditFeedbackFormProps) { const { executeAsync, status } = useAction(updateFeedbackAction); const form = useForm({ resolver: zodResolver(updateFeedbackSchema), defaultValues: { id: feedbackItem.id, title: feedbackItem.title, description: feedbackItem.description ?? '', type: feedbackItem.type, }, }); const isPending = status === 'executing'; const onSubmit = async (data: UpdateFeedbackInput) => { const result = await executeAsync(data); if (result?.serverError) { toast.error(result.serverError); return; } if (result?.validationErrors) { toast.error('Please check your input'); return; } toast.success('Feedback updated successfully'); onSuccess?.(); }; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField name="type" render={({ field }) => ( <FormItem> <FormLabel>Type</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value} disabled={isPending} > <FormControl render={ <SelectTrigger data-testid="edit-feedback-type-select"> <SelectValue> {(value) => value || 'Select type'} </SelectValue> </SelectTrigger> } /> <SelectContent> {feedbackTypes.map((type) => ( <SelectItem key={type} value={type}> {type.charAt(0).toUpperCase() + type.slice(1)} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField name="title" render={({ field }) => ( <FormItem> <FormLabel>Title</FormLabel> <FormControl render={ <Input placeholder="Brief summary of your feedback" disabled={isPending} data-testid="edit-feedback-title-input" {...field} /> } /> <FormMessage /> </FormItem> )} /> <FormField name="description" render={({ field }) => ( <FormItem> <FormLabel>Description (optional)</FormLabel> <FormControl render={ <Textarea placeholder="Provide more details..." disabled={isPending} rows={4} data-testid="edit-feedback-description-input" {...field} /> } /> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={isPending} data-testid="save-feedback-button" > {isPending ? 'Saving...' : 'Save Changes'} </Button> </form> </Form> );}Now, we want to create a dialog component to edit feedback item details.
This dialog will be used to edit the feedback item details when the user clicks on the "Edit" button in the feedback item details sheet.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/edit-feedback-dialog.tsx
'use client';import type { FeedbackItem } from '@lib/feedback/feedback-page.loader';import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,} from '@kit/ui/dialog';import { EditFeedbackForm } from './edit-feedback-form';interface EditFeedbackDialogProps { feedbackItem: FeedbackItem; open: boolean; onOpenChange: (open: boolean) => void;}export function EditFeedbackDialog({ feedbackItem, open, onOpenChange,}: EditFeedbackDialogProps) { return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent> <DialogHeader> <DialogTitle>Edit Feedback</DialogTitle> <DialogDescription> Update your feedback details below. </DialogDescription> </DialogHeader> <EditFeedbackForm feedbackItem={feedbackItem} onSuccess={() => onOpenChange(false)} /> </DialogContent> </Dialog> );}Opening the Dialog component when the user clicks on the "Edit" button
Let's update the FeedbackDetailSheet component to open the EditFeedbackDialog when the user clicks on the "Edit" button in the feedback item details sheet:
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/feedback-detail-sheet.tsx
import { EditFeedbackDialog } from './edit-feedback-dialog';{feedbackItem && ( <EditFeedbackDialog feedbackItem={feedbackItem} open={editDialogOpen} onOpenChange={setEditDialogOpen} />)}In the above code, we import the EditFeedbackDialog component and render it conditionally when the feedbackItem is not null and the editDialogOpen state is true.
For the full source code, please wait until the end of this module, as we will also add a button to delete the feedback item.

Try it out and verify that the feedback item details are updated correctly.
Deleting a Feedback Item
Finally, let's implement the ability to delete feedback items. Since deletion is a destructive action that cannot be undone, we'll use an AlertDialog component to ask for confirmation before proceeding.
Creating an Alert Dialog component to delete feedback item details
The AlertDialog component provides a modal with a clear warning message and requires explicit user confirmation. We display the feedback title in the confirmation message so users know exactly which item they're about to delete.
The delete form uses the same useAction pattern, but with a twist: we wrap the action in a toast.promise call to show loading, success, and error states automatically. On successful deletion, we close the dialog and call onSuccess to refresh the parent component's data.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/delete-feedback-dialog.tsx
'use client';import { useAction } from 'next-safe-action/hooks';import { deleteFeedbackAction } from '@lib/feedback/feedback-server-actions';import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,} from '@kit/ui/alert-dialog';import { toast } from '@kit/ui/sonner';interface DeleteFeedbackDialogProps { feedbackId: string; feedbackTitle: string; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void;}export function DeleteFeedbackDialog({ feedbackId, feedbackTitle, open, onOpenChange, onSuccess,}: DeleteFeedbackDialogProps) { return ( <AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Delete Feedback</AlertDialogTitle> <AlertDialogDescription> Are you sure you want to delete "{feedbackTitle}"? This action cannot be undone. </AlertDialogDescription> </AlertDialogHeader> <DeleteFeedbackForm feedbackId={feedbackId} onSuccess={() => { onOpenChange(false); onSuccess?.(); }} /> </AlertDialogContent> </AlertDialog> );}function DeleteFeedbackForm({ feedbackId, onSuccess,}: { feedbackId: string; onSuccess: () => void;}) { const { executeAsync, status } = useAction(deleteFeedbackAction); const isPending = status === 'executing'; const handleDelete = async () => { const promise = executeAsync({ id: feedbackId }).then((result) => { if (result?.serverError || result?.validationErrors) { throw new Error(result.serverError); } return result; }); await toast .promise(promise, { loading: 'Deleting feedback...', success: 'Feedback deleted successfully', error: 'Failed to delete feedback', }) .unwrap(); onSuccess(); }; return ( <AlertDialogFooter> <AlertDialogCancel disabled={isPending}>Cancel</AlertDialogCancel> <AlertDialogAction onClick={handleDelete} disabled={isPending} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" data-testid="confirm-delete-feedback-button" > {isPending ? 'Deleting...' : 'Delete Feedback'} </AlertDialogAction> </AlertDialogFooter> );}Opening the Alert Dialog component when the user clicks on the "Delete" button
Now let's update the FeedbackDetailSheet component to include both the edit and delete functionality. We'll add state variables to control each dialog's visibility and render action buttons that trigger them.
The updated component adds editDialogOpen and deleteDialogOpen state variables, along with Edit and Delete buttons in the sheet header. When the delete action succeeds, we also close the sheet itself since the feedback item no longer exists. Here's the complete updated component:
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/feedback-detail-sheet.tsx
'use client';import { useState } from 'react';import { format } from 'date-fns';import { Bug, Lightbulb, Pencil, Sparkles, Trash2 } from 'lucide-react';import { Badge } from '@kit/ui/badge';import { Button } from '@kit/ui/button';import { ProfileAvatar } from '@kit/ui/profile-avatar';import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from '@kit/ui/sheet';import { Skeleton } from '@kit/ui/skeleton';import { useFetchFeedbackItem } from '../_lib/use-fetch-feedback-item';import { DeleteFeedbackDialog } from './delete-feedback-dialog';import { EditFeedbackDialog } from './edit-feedback-dialog';import { StatusDropdown } from './status-dropdown';interface FeedbackDetailSheetProps { feedbackId: string | null; onOpenChange: (open: boolean) => void;}const typeConfig = { bug: { icon: Bug, label: 'Bug', variant: 'destructive' as const, }, feature: { icon: Sparkles, label: 'Feature', variant: 'default' as const, }, idea: { icon: Lightbulb, label: 'Idea', variant: 'warning' as const, },};function LoadingSkeleton() { return ( <div className="space-y-6 pt-4"> <div className="space-y-2"> <Skeleton className="h-4 w-20" /> <Skeleton className="h-6 w-full" /> </div> <div className="space-y-2"> <Skeleton className="h-4 w-20" /> <Skeleton className="h-20 w-full" /> </div> <div className="flex gap-4"> <div className="space-y-2"> <Skeleton className="h-4 w-16" /> <Skeleton className="h-6 w-20" /> </div> <div className="space-y-2"> <Skeleton className="h-4 w-16" /> <Skeleton className="h-6 w-20" /> </div> </div> </div> );}function FeedbackDetailContent({ feedbackId, onClose,}: { feedbackId: string; onClose: () => void;}) { const [editDialogOpen, setEditDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { data: feedbackItem, isLoading } = useFetchFeedbackItem(feedbackId); const type = feedbackItem?.type ?? 'idea'; const config = typeConfig[type]; const Icon = config.icon; return ( <> <SheetHeader className="pr-8"> <SheetTitle className="flex items-center gap-2"> {isLoading ? ( <Skeleton className="h-6 w-48" /> ) : ( <> <Icon className="text-muted-foreground h-5 w-5 shrink-0" /> <span className="line-clamp-2">{feedbackItem?.title}</span> </> )} </SheetTitle> <SheetDescription> {isLoading ? ( <Skeleton className="h-4 w-32" /> ) : feedbackItem ? ( <span> Submitted {format(new Date(feedbackItem.createdAt), 'PPP')} </span> ) : null} </SheetDescription> </SheetHeader> {feedbackItem && ( <> <EditFeedbackDialog feedbackItem={feedbackItem} open={editDialogOpen} onOpenChange={setEditDialogOpen} /> <DeleteFeedbackDialog feedbackId={feedbackItem.id} feedbackTitle={feedbackItem.title} open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen} onSuccess={onClose} /> </> )} {isLoading ? ( <LoadingSkeleton /> ) : feedbackItem ? ( <div className="space-y-6 pt-4"> <div className="flex items-center gap-3"> <ProfileAvatar pictureUrl={feedbackItem.author.image} displayName={feedbackItem.author.name} className="m-0! size-10" /> <div className="flex flex-col"> <span className="text-sm font-medium"> {feedbackItem.author.name} </span> <span className="text-muted-foreground text-xs">Author</span> </div> </div> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Description </label> <p className="text-sm whitespace-pre-wrap"> {feedbackItem.description || 'No description provided.'} </p> </div> <div className="flex flex-wrap gap-4"> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Type </label> <div> <Badge variant={config.variant}>{config.label}</Badge> </div> </div> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Status </label> <div> <StatusDropdown feedbackId={feedbackItem.id} currentStatus={feedbackItem.status} /> </div> </div> <div className="space-y-2"> <label className="text-muted-foreground text-sm font-medium"> Votes </label> <div> <Badge variant="secondary">{feedbackItem.voteCount}</Badge> </div> </div> </div> <div className="flex gap-2 border-t pt-4"> <Button variant="outline" size="sm" onClick={() => setEditDialogOpen(true)} data-testid="edit-feedback-button" > <Pencil className="mr-1.5 h-3.5 w-3.5" /> Edit </Button> <Button variant="destructive" size="sm" onClick={() => setDeleteDialogOpen(true)} data-testid="delete-feedback-button" > <Trash2 className="mr-1.5 h-3.5 w-3.5" /> Delete </Button> </div> <div className="text-muted-foreground text-xs"> Last updated {format(new Date(feedbackItem.updatedAt), 'PPpp')} </div> </div> ) : ( <div className="text-muted-foreground py-8 text-center text-sm"> Feedback not found </div> )} </> );}export function FeedbackDetailSheet({ feedbackId, onOpenChange,}: FeedbackDetailSheetProps) { return ( <Sheet open={!!feedbackId} onOpenChange={onOpenChange}> <SheetContent className="sm:max-w-lg"> {feedbackId && ( <FeedbackDetailContent feedbackId={feedbackId} onClose={() => onOpenChange(false)} /> )} </SheetContent> </Sheet> );}Cache Invalidation
If you tested the application, you will notice a small issue when updating a feedback item details. Because we fetch data from React Query, the data is not updated when using Server Actions; this is one of the gotchas about using two data fetching patterns that share different caching invalidation strategies:
- React Server Components rely on specific
revalidatePathcalls to invalidate the cache of a specific page (orrevalidateTagfor a specific tag). - React Query relies on its queryKeys or manual invalidation
In this case, the data of an individual feedback item is not updated when using Server Actions because the data is fetched from React Query. Therefore, we want to update the code to invalidate the cached item when the feedback item is updated:
Edit Feedback Form
First, we import the useQueryClient from @tanstack/react-query.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/edit-feedback-form.tsx
import { useQueryClient } from '@tanstack/react-query';When the form is submitted, we invalidate the cached item by calling the invalidateQueries method with the queryKey of the feedback item.
We need to instantiate the useQueryClient in the component at line 45:
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/edit-feedback-form.tsx
// line 45const queryClient = useQueryClient();At line 73 in the onSubmit function, let's add the following code to invalidate the cached item:
// line 73await queryClient.invalidateQueries({ queryKey: ['feedback-item', feedbackItem.id],});Delete Feedback Form
When the delete form is submitted, we invalidate the cached item by calling the invalidateQueries method with the queryKey of the feedback item.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/delete-feedback-dialog.tsx
import { useQueryClient } from '@tanstack/react-query';// line 66const queryClient = useQueryClient();// line 87queryClient.removeQueries({ queryKey: ['feedback-item', feedbackId],});Updating the Status of the feedback item
When the status of the feedback item is updated, we invalidate the cached item by calling the invalidateQueries method with the queryKey of the feedback item.
apps/web/app/[locale]/(internal)/boards/[boardId]/_components/status-dropdown.tsx
import { useQueryClient } from '@tanstack/react-query';// line 35const queryClient = useQueryClient();// line 51await queryClient.invalidateQueries({ queryKey: ['feedback-item', feedbackId],});Module Complete
You now have:
- [x] Feedback items and votes schemas with proper relationships
- [x] CRUD operations with multi-tenant security checks
- [x] Vote toggle functionality using atomic transactions
- [x] Status workflow with dropdown controls
- [x] URL-based filtering by type and status
- [x] Sorting options for newest, most voted, and recently updated
- [x] Viewing a feedback item details
- [x] Editing a feedback item
- [x] Deleting a feedback item
Next: In Module 5: Public Boards, you'll create public-facing pages where external users can view boards and submit feedback without signing in - a common pattern for product feedback tools.
Learn More
- Database Documentation
- Prisma Transactions - Atomic operations
- Prisma Operators - Filtering and sorting
- Next.js Search Params - URL-based filtering