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:

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
├── updatedAt

Step 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 values
enum 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:migrate

Regenerate the Prisma client:

pnpm --filter @kit/database prisma:generate

Verify with Prisma Studio:

pnpm --filter @kit/database prisma:studio

You 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 format

Step 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 validation
export 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 org
async 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 org
async 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 voteCount update 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:

  • hasVoted check: 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 where object with spread syntax. This pattern makes it easy to add filters conditionally.
  • Flexible sorting: A switch statement maps sort keys to Prisma's orderBy object. The default is createdAt: '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 organization
const 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>
);
}
TeamPulse Board Detail
TeamPulse Create Feedback Dialog
TeamPulse Feedback Table

Checkpoint: Verify It Works

Test each feature, paying attention to vote toggle behavior and filter combinations:

  • [ ] Migration applied - feedback_item and feedback_vote tables 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 healthcheck

Viewing 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}
/>
</>
);
}
TeamPulse Feedback Item Sheet

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.

TeamPulse Edit Feedback Dialog

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 &quot;{feedbackTitle}&quot;? 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 revalidatePath calls to invalidate the cache of a specific page (or revalidateTag for 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 45
const queryClient = useQueryClient();

At line 73 in the onSubmit function, let's add the following code to invalidate the cached item:

// line 73
await 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 66
const queryClient = useQueryClient();
// line 87
queryClient.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 35
const queryClient = useQueryClient();
// line 51
await 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