From 55a0c4fe239f415b54784270365ee0a3b2b312e2 Mon Sep 17 00:00:00 2001 From: Tania Sanz Date: Sun, 24 May 2026 21:18:55 +0200 Subject: [PATCH 1/4] feat: tanstack table view with sorting, faceted filter, and bulk delete for templates Adopt @tanstack/react-table as the table framework and add a card/table view switcher to the templates list, structured to shadcn data-table conventions but built on the existing @plunk/ui primitives. Backend - Add a shared list-sort helper (apps/api/src/utils/listSort.ts) exposing a ListSort type + parseListSort() that validates ?sort=name|createdAt| updatedAt&dir=asc|desc against allow-lists, silently falling back to the existing createdAt desc default. Thread it through GET /templates, GET /workflows and GET /campaigns and their service list() methods into Prisma orderBy. Existing call shapes stay backward-compatible. - Add POST /templates/bulk-update (TemplateSchemas.bulkUpdate) accepting { ids: string[] (1..1000), delete?: boolean }. Bulk delete runs the ownership/project-scope check (404 on a foreign id), the workflow-step reference check (409, mirroring single delete), and deleteMany inside one prisma.$transaction so a partial delete is impossible. The schema is kept open-ended for future bulk modes. Covers it with TemplateService tests. Frontend (generic, reusable shared components under components/data-table) - DataTable: presentation-only tanstack renderer on @plunk/ui Table, owns per-th aria-sort. - DataTableColumnHeader: sortable header (asc -> desc -> unsorted) with chevron icons and an optional in-header filter slot. - DataTableFacetedFilter: Excel-style per-column dropdown (Popover+Command) for fixed-value columns. - DataTableViewOptions: column-visibility "Columns" selector. - DataTableViewSwitcher: card/table icon toggle. - BulkActionBar: selection action bar. - hooks: useColumnVisibility (localStorage), usePersistentState (view), useShiftClickSelection (anchor+range selection). Templates page - Card view is the default and is unchanged from before (same search + Type filter pills + card grid). Table view adds header sort, a Type faceted filter in the column header (no pills, no sort-by dropdown), a localStorage-persisted column-visibility menu (Name + Actions locked), and a bulk-delete bar with shift-click range selection. Sorting and the Type facet both feed the SWR query string so the server stays authoritative (manualSorting). Selection clears on page/search/filter changes and after a successful delete. View and column choices persist in localStorage (plunk:templates:view, plunk:templates:columns). --- apps/api/src/controllers/Campaigns.ts | 10 + apps/api/src/controllers/Templates.ts | 41 +- apps/api/src/controllers/Workflows.ts | 10 +- apps/api/src/services/CampaignService.ts | 6 +- apps/api/src/services/TemplateService.ts | 84 +++- apps/api/src/services/WorkflowService.ts | 4 +- .../__tests__/TemplateService.test.ts | 82 ++++ apps/api/src/utils/listSort.ts | 35 ++ apps/web/package.json | 1 + .../components/data-table/BulkActionBar.tsx | 60 +++ .../src/components/data-table/DataTable.tsx | 97 +++++ .../data-table/DataTableColumnHeader.tsx | 71 +++ .../data-table/DataTableFacetedFilter.tsx | 131 ++++++ .../data-table/DataTableViewOptions.tsx | 67 +++ .../data-table/DataTableViewSwitcher.tsx | 48 ++ apps/web/src/components/data-table/index.ts | 6 + apps/web/src/lib/hooks/useColumnVisibility.ts | 47 ++ apps/web/src/lib/hooks/usePersistentState.ts | 51 +++ .../src/lib/hooks/useShiftClickSelection.ts | 65 +++ apps/web/src/pages/templates/index.tsx | 412 ++++++++++++++++-- packages/shared/src/schemas/index.ts | 12 + yarn.lock | 20 + 22 files changed, 1326 insertions(+), 34 deletions(-) create mode 100644 apps/api/src/utils/listSort.ts create mode 100644 apps/web/src/components/data-table/BulkActionBar.tsx create mode 100644 apps/web/src/components/data-table/DataTable.tsx create mode 100644 apps/web/src/components/data-table/DataTableColumnHeader.tsx create mode 100644 apps/web/src/components/data-table/DataTableFacetedFilter.tsx create mode 100644 apps/web/src/components/data-table/DataTableViewOptions.tsx create mode 100644 apps/web/src/components/data-table/DataTableViewSwitcher.tsx create mode 100644 apps/web/src/components/data-table/index.ts create mode 100644 apps/web/src/lib/hooks/useColumnVisibility.ts create mode 100644 apps/web/src/lib/hooks/usePersistentState.ts create mode 100644 apps/web/src/lib/hooks/useShiftClickSelection.ts diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index d2033d0c..8ee87802 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -8,6 +8,7 @@ import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {CampaignService} from '../services/CampaignService.js'; import {DomainService} from '../services/DomainService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; +import {parseListSort} from '../utils/listSort.js'; @Controller('campaigns') export class Campaigns { @@ -57,6 +58,13 @@ export class Campaigns { /** * Get all campaigns for a project * GET /campaigns + * + * Query params: + * - page, pageSize: pagination + * - status: filter by CampaignStatus + * - search: filter by name/subject/from + * - sort: name | createdAt | updatedAt (default: createdAt) + * - dir: asc | desc (default: desc) */ @Get('') @Middleware([requireAuth, requireEmailVerified]) @@ -67,6 +75,7 @@ export class Campaigns { const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined; const page = parseInt(req.query.page as string) || 1; const pageSize = parseInt(req.query.pageSize as string) || 20; + const sort = parseListSort(req.query.sort, req.query.dir, {field: 'createdAt', direction: 'desc'}); // Validate status if provided if (status && !Object.values(CampaignStatus).includes(status)) { @@ -78,6 +87,7 @@ export class Campaigns { search, page, pageSize, + sort, }); return res.json(result); diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index 26a9cf3e..20d83a0e 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -1,16 +1,25 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {TemplateType} from '@plunk/db'; +import {TemplateSchemas} from '@plunk/shared'; import type {NextFunction, Request, Response} from 'express'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {TemplateService} from '../services/TemplateService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; +import {parseListSort} from '../utils/listSort.js'; @Controller('templates') export class Templates { /** * GET /templates * List all templates for the authenticated project + * + * Query params: + * - page, pageSize: pagination + * - search: filter by name/description/subject + * - type: filter by TemplateType + * - sort: name | createdAt | updatedAt (default: createdAt) + * - dir: asc | desc (default: desc) */ @Get('') @Middleware([requireAuth, requireEmailVerified]) @@ -21,8 +30,9 @@ export class Templates { const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const search = req.query.search as string | undefined; const type = req.query.type as TemplateType | undefined; + const sort = parseListSort(req.query.sort, req.query.dir, {field: 'createdAt', direction: 'desc'}); - const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type); + const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type, sort); return res.status(200).json(result); } @@ -146,6 +156,35 @@ export class Templates { return res.status(204).send(); } + /** + * POST /templates/bulk-update + * Apply a bulk operation to multiple templates at once. + * + * Currently supports `{ids: string[], delete: true}` for bulk delete. + * The schema is intentionally open-ended so tag-related fields + * (addTags / removeTags) can stack on the same endpoint once a + * `Template.tags` column exists. + * + * Atomicity: the underlying service wraps the ownership check, the + * workflow-step-reference check, and the delete in a single Prisma + * transaction, so a partial bulk delete is not possible. + */ + @Post('bulk-update') + @Middleware([requireAuth, requireEmailVerified]) + @CatchAsync + public async bulkUpdate(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth; + + // Let Zod throw on invalid input so the global error handler in app.ts + // formats it into the standard {success:false, error:{message, ...}} + // envelope, matching every other endpoint in this controller. + const data = TemplateSchemas.bulkUpdate.parse(req.body); + + const result = await TemplateService.bulkUpdate(auth.projectId!, data); + + return res.status(200).json(result); + } + /** * POST /templates/:id/duplicate * Duplicate a template diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index 87e29168..8747b334 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -5,12 +5,19 @@ import signale from 'signale'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {WorkflowService} from '../services/WorkflowService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; +import {parseListSort} from '../utils/listSort.js'; @Controller('workflows') export class Workflows { /** * GET /workflows * List all workflows for the authenticated project + * + * Query params: + * - page, pageSize: pagination + * - search: filter by name/description + * - sort: name | createdAt | updatedAt (default: createdAt) + * - dir: asc | desc (default: desc) */ @Get('') @Middleware([requireAuth, requireEmailVerified]) @@ -20,8 +27,9 @@ export class Workflows { const page = parseInt(req.query.page as string) || 1; const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const search = req.query.search as string | undefined; + const sort = parseListSort(req.query.sort, req.query.dir, {field: 'createdAt', direction: 'desc'}); - const result = await WorkflowService.list(auth.projectId!, page, pageSize, search); + const result = await WorkflowService.list(auth.projectId!, page, pageSize, search, sort); return res.status(200).json(result); } diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 06a30c36..b5c5e88c 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -6,6 +6,7 @@ import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; +import type {ListSort} from '../utils/listSort.js'; import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; import {BillingLimitService} from './BillingLimitService.js'; @@ -189,9 +190,10 @@ export class CampaignService { search?: string; page?: number; pageSize?: number; + sort?: ListSort; } = {}, ): Promise> { - const {status, search, page = 1, pageSize = 20} = options; + const {status, search, page = 1, pageSize = 20, sort = {field: 'createdAt', direction: 'desc'}} = options; const skip = (page - 1) * pageSize; const where: Prisma.CampaignWhereInput = { @@ -214,7 +216,7 @@ export class CampaignService { include: { segment: true, }, - orderBy: {createdAt: 'desc'}, + orderBy: {[sort.field]: sort.direction} as Prisma.CampaignOrderByWithRelationInput, skip, take: pageSize, }), diff --git a/apps/api/src/services/TemplateService.ts b/apps/api/src/services/TemplateService.ts index 5c7af7f1..8b46117b 100644 --- a/apps/api/src/services/TemplateService.ts +++ b/apps/api/src/services/TemplateService.ts @@ -4,6 +4,7 @@ import type {PaginatedResponse} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; +import type {ListSort} from '../utils/listSort.js'; import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; export class TemplateService { @@ -16,6 +17,7 @@ export class TemplateService { pageSize = 20, search?: string, type?: Template['type'], + sort: ListSort = {field: 'createdAt', direction: 'desc'}, ): Promise> { const skip = (page - 1) * pageSize; @@ -38,7 +40,7 @@ export class TemplateService { where, skip, take: pageSize, - orderBy: {createdAt: 'desc'}, + orderBy: {[sort.field]: sort.direction} as Prisma.TemplateOrderByWithRelationInput, }), prisma.template.count({where}), ]); @@ -159,6 +161,86 @@ export class TemplateService { }); } + /** + * Apply a bulk operation to multiple templates at once. + * + * The payload is intentionally open-ended (a single endpoint) so future + * tag-related fields (`addTags` / `removeTags`) can stack on the same + * operation once a `Template.tags` column exists. For now the only + * supported mode is `delete: true` (bulk delete). + * + * Atomicity: every selected template must belong to the requesting project + * AND none of them may currently be referenced by a workflow step. Both + * checks plus the `deleteMany` are folded into a single Prisma transaction, + * so a partial bulk delete is impossible — either every selected template is + * removed, or the whole operation rolls back. + * + * - 404 if any id is missing from this project (foreign / cross-project id). + * - 409 if any selected template is still referenced by a workflow step + * (mirrors the single-template `delete()` guard above). + */ + public static async bulkUpdate( + projectId: string, + options: {ids: string[]; delete?: boolean}, + ): Promise<{deleted?: number; updated?: number}> { + const {ids, delete: shouldDelete} = options; + + // Dedup defensively — the schema permits the same id twice and we don't + // want duplicates inflating the ownership/row counts below. + const uniqueIds = Array.from(new Set(ids)); + + if (uniqueIds.length === 0) { + return {updated: 0}; + } + + if (shouldDelete) { + return prisma.$transaction(async tx => { + // 1. Ownership / project-scope check. Cross-project leaks are the main + // thing this endpoint must defend against. + const owned = await tx.template.findMany({ + where: {id: {in: uniqueIds}, projectId}, + select: {id: true}, + }); + + if (owned.length !== uniqueIds.length) { + throw new HttpException(404, 'One or more templates not found in this project'); + } + + // 2. Reject the whole bulk delete if ANY selected template is still + // referenced by a workflow step. Mirrors the single delete() + // behavior — no partial wipes. + const referenced = await tx.workflowStep.findMany({ + where: { + templateId: {in: uniqueIds}, + workflow: {projectId}, + }, + select: {templateId: true}, + distinct: ['templateId'], + }); + + if (referenced.length > 0) { + const count = referenced.length; + throw new HttpException( + 409, + `Cannot delete: ${count} of the selected template${ + count === 1 ? ' is' : 's are' + } currently used in workflow steps. Remove from workflows first.`, + ); + } + + const result = await tx.template.deleteMany({ + where: {id: {in: uniqueIds}, projectId}, + }); + + return {deleted: result.count}; + }); + } + + // No-op shape for forward-compat: when tag add/remove modes ship they'll + // branch off here. Returning {updated: 0} keeps the response shape stable. + return {updated: 0}; + } + /** * Duplicate a template */ diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 407ddeeb..a184f595 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -6,6 +6,7 @@ import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; +import type {ListSort} from '../utils/listSort.js'; import {ContactService} from './ContactService.js'; import {EventService} from './EventService.js'; @@ -21,6 +22,7 @@ export class WorkflowService { page = 1, pageSize = 20, search?: string, + sort: ListSort = {field: 'createdAt', direction: 'desc'}, ): Promise> { const skip = (page - 1) * pageSize; @@ -41,7 +43,7 @@ export class WorkflowService { where, skip, take: pageSize, - orderBy: {createdAt: 'desc'}, + orderBy: {[sort.field]: sort.direction} as Prisma.WorkflowOrderByWithRelationInput, include: { _count: { select: { diff --git a/apps/api/src/services/__tests__/TemplateService.test.ts b/apps/api/src/services/__tests__/TemplateService.test.ts index b472a1ca..f39a7f81 100644 --- a/apps/api/src/services/__tests__/TemplateService.test.ts +++ b/apps/api/src/services/__tests__/TemplateService.test.ts @@ -396,6 +396,88 @@ describe('TemplateService', () => { }); }); + describe('bulkUpdate (delete)', () => { + it('should bulk-delete multiple templates in this project', async () => { + const a = await factories.createTemplate({projectId}); + const b = await factories.createTemplate({projectId}); + const c = await factories.createTemplate({projectId}); + + const result = await TemplateService.bulkUpdate(projectId, { + ids: [a.id, b.id, c.id], + delete: true, + }); + + expect(result.deleted).toBe(3); + expect(await prisma.template.count({where: {projectId}})).toBe(0); + }); + + it('should dedup repeated ids so the deleted count is not inflated', async () => { + const a = await factories.createTemplate({projectId}); + + const result = await TemplateService.bulkUpdate(projectId, { + ids: [a.id, a.id, a.id], + delete: true, + }); + + expect(result.deleted).toBe(1); + expect(await prisma.template.findUnique({where: {id: a.id}})).toBeNull(); + }); + + it('should throw 404 when any id does not exist', async () => { + const a = await factories.createTemplate({projectId}); + + await expect( + TemplateService.bulkUpdate(projectId, {ids: [a.id, 'non-existent-id'], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Nothing deleted — the whole operation rolled back. + expect(await prisma.template.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + + it('should throw 404 (and delete nothing) when an id belongs to another project', async () => { + const {project: otherProject} = await factories.createUserWithProject(); + const mine = await factories.createTemplate({projectId}); + const foreign = await factories.createTemplate({projectId: otherProject.id}); + + await expect( + TemplateService.bulkUpdate(projectId, {ids: [mine.id, foreign.id], delete: true}), + ).rejects.toThrow(/not found in this project/i); + + // Both survive — atomic rollback, no cross-project leak. + expect(await prisma.template.findUnique({where: {id: mine.id}})).not.toBeNull(); + expect(await prisma.template.findUnique({where: {id: foreign.id}})).not.toBeNull(); + }); + + it('should throw 409 (and delete nothing) when any selected template is used in a workflow step', async () => { + const used = await factories.createTemplate({projectId}); + const free = await factories.createTemplate({projectId}); + const workflow = await factories.createWorkflow({projectId}); + await factories.createWorkflowStep({ + workflowId: workflow.id, + templateId: used.id, + type: 'SEND_EMAIL', + }); + + await expect( + TemplateService.bulkUpdate(projectId, {ids: [used.id, free.id], delete: true}), + ).rejects.toThrow(/currently used in workflow steps/i); + + // Partial delete must be impossible — the un-referenced template must survive too. + expect(await prisma.template.findUnique({where: {id: used.id}})).not.toBeNull(); + expect(await prisma.template.findUnique({where: {id: free.id}})).not.toBeNull(); + }); + + it('should return {updated: 0} when delete flag is omitted (forward-compat no-op)', async () => { + const a = await factories.createTemplate({projectId}); + + const result = await TemplateService.bulkUpdate(projectId, {ids: [a.id]}); + + expect(result).toEqual({updated: 0}); + // No-op: the template is untouched. + expect(await prisma.template.findUnique({where: {id: a.id}})).not.toBeNull(); + }); + }); + describe('duplicate', () => { it('should duplicate a template with (Copy) suffix', async () => { const original = await factories.createTemplate({ diff --git a/apps/api/src/utils/listSort.ts b/apps/api/src/utils/listSort.ts new file mode 100644 index 00000000..ef25a184 --- /dev/null +++ b/apps/api/src/utils/listSort.ts @@ -0,0 +1,35 @@ +/** + * Shared sort-query parsing for list endpoints. + * + * Accepts `?sort=name|createdAt|updatedAt&dir=asc|desc` on list endpoints and + * returns a `{field, direction}` shape that maps directly onto a Prisma + * `orderBy` clause. Invalid values silently fall back to the supplied default + * so existing callers keep their current behavior. + */ + +export type ListSortField = 'name' | 'createdAt' | 'updatedAt'; +export type ListSortDirection = 'asc' | 'desc'; + +export interface ListSort { + field: ListSortField; + direction: ListSortDirection; +} + +const ALLOWED_FIELDS = new Set(['name', 'createdAt', 'updatedAt']); +const ALLOWED_DIRECTIONS = new Set(['asc', 'desc']); + +/** + * Parse `sort` and `dir` query-string values into a typed sort descriptor. + * Unknown or missing values fall back to `defaultSort`. + */ +export function parseListSort(sortRaw: unknown, dirRaw: unknown, defaultSort: ListSort): ListSort { + const sort = + typeof sortRaw === 'string' && ALLOWED_FIELDS.has(sortRaw as ListSortField) + ? (sortRaw as ListSortField) + : defaultSort.field; + const dir = + typeof dirRaw === 'string' && ALLOWED_DIRECTIONS.has(dirRaw as ListSortDirection) + ? (dirRaw as ListSortDirection) + : defaultSort.direction; + return {field: sort, direction: dir}; +} diff --git a/apps/web/package.json b/apps/web/package.json index 133ab059..ad1d0d08 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@plunk/db": "*", "@plunk/shared": "*", "@plunk/ui": "*", + "@tanstack/react-table": "^8.21.3", "@tiptap/core": "^3.11.0", "@tiptap/extension-color": "^3.11.0", "@tiptap/extension-image": "^3.11.0", diff --git a/apps/web/src/components/data-table/BulkActionBar.tsx b/apps/web/src/components/data-table/BulkActionBar.tsx new file mode 100644 index 00000000..a2f57911 --- /dev/null +++ b/apps/web/src/components/data-table/BulkActionBar.tsx @@ -0,0 +1,60 @@ +import {Button} from '@plunk/ui'; +import {X} from 'lucide-react'; +import type {ReactNode} from 'react'; + +interface BulkActionBarProps { + /** Number of currently-selected rows. The bar renders only when this is > 0. */ + selectedCount: number; + /** Singular noun for the selected item type (e.g. "template"). Pluralized with a trailing 's'. */ + itemNoun: string; + /** Clears the row selection state. */ + onClear: () => void; + /** + * Action slot — render the Buttons that belong to the bar here. Kept as + * `children` (rather than a fixed `actions` prop) so future bulk operations + * (e.g. tag add/remove, a separate PR) can be spliced in next to the delete + * button without touching this component. + */ + children?: ReactNode; +} + +/** + * Slim selection-driven action bar that sits above a table when one or more + * rows are selected. This PR wires it for the templates table with a single + * "Delete selected" action; the `children` slot keeps it open for later bulk + * operations. Generic and reusable across list tables. + * + * Visually a neutral pill above the table — not sticky, so it scrolls with the + * page, matching the rest of the dashboard's affordances and keeping tab order + * predictable. + */ +export function BulkActionBar({selectedCount, itemNoun, onClear, children}: BulkActionBarProps) { + if (selectedCount <= 0) return null; + + const noun = selectedCount === 1 ? itemNoun : `${itemNoun}s`; + + return ( +
+
+ + + {selectedCount} {noun} selected + +
+ {children ?
{children}
: null} +
+ ); +} diff --git a/apps/web/src/components/data-table/DataTable.tsx b/apps/web/src/components/data-table/DataTable.tsx new file mode 100644 index 00000000..bdb7b55f --- /dev/null +++ b/apps/web/src/components/data-table/DataTable.tsx @@ -0,0 +1,97 @@ +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@plunk/ui'; +import {flexRender, type Table as TanstackTable} from '@tanstack/react-table'; + +/** + * Per-column presentation hints read off `columnDef.meta`. Optional — a column + * without them just gets the default cell padding/alignment. + */ +export interface DataTableColumnMeta { + /** Display label, used by the column-visibility menu. */ + label?: string; + /** Extra className applied to this column's ``. */ + headClassName?: string; + /** Extra className applied to this column's `` cells. */ + cellClassName?: string; +} + +interface DataTableProps { + table: TanstackTable; + /** + * Optional empty-state node rendered (spanning all columns) when the table + * has zero rows. Callers usually short-circuit before rendering the table at + * all, but this keeps the component self-sufficient. + */ + emptyState?: React.ReactNode; +} + +/** + * Generic, presentation-only table built on tanstack-react-table + Plunk's + * `@plunk/ui` Table primitives, following the shadcn `DataTable` convention. + * + * The table instance (and therefore all sorting / selection / visibility state) + * is owned by the caller and passed in, so this component stays reusable for + * any list page — workflows and campaigns can adopt it with just a column + * definition. Sorting indicators live in the column headers + * (`DataTableColumnHeader`); this component owns the `aria-sort` attribute on + * each `` so it always mirrors the real sort state. + */ +export function DataTable({table, emptyState}: DataTableProps) { + const rows = table.getRowModel().rows; + const leafColumnCount = table.getVisibleLeafColumns().length; + + return ( + + + {table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map(header => { + const meta = header.column.columnDef.meta as DataTableColumnMeta | undefined; + const sorted = header.column.getIsSorted(); + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {rows.length === 0 ? ( + + + {emptyState ?? 'No results.'} + + + ) : ( + rows.map(row => ( + + {row.getVisibleCells().map(cell => { + const meta = cell.column.columnDef.meta as DataTableColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + )) + )} + +
+ ); +} diff --git a/apps/web/src/components/data-table/DataTableColumnHeader.tsx b/apps/web/src/components/data-table/DataTableColumnHeader.tsx new file mode 100644 index 00000000..b0b8f213 --- /dev/null +++ b/apps/web/src/components/data-table/DataTableColumnHeader.tsx @@ -0,0 +1,71 @@ +import type {Column} from '@tanstack/react-table'; +import {ChevronDown, ChevronUp, ChevronsUpDown} from 'lucide-react'; +import type {ReactNode} from 'react'; + +interface DataTableColumnHeaderProps { + column: Column; + children: ReactNode; + /** Align the header content (defaults to `left`). */ + align?: 'left' | 'right' | 'center'; + /** + * Optional slot rendered next to the sort control — used for the per-column + * faceted filter (`DataTableFacetedFilter`). Table-view-only. + */ + filter?: ReactNode; +} + +const alignClass = (align: 'left' | 'right' | 'center') => + align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : 'justify-start'; + +/** + * Sortable column header for the tanstack-driven `DataTable`, following the + * shadcn data-table convention but adapted to Plunk's primitives. Clicking the + * label cycles asc → desc → unsorted with chevron indicators; the surrounding + * `` is responsible for the `aria-sort` attribute (see `DataTable`). + * + * An optional `filter` slot lets fixed-value columns (e.g. Type) render a + * faceted dropdown right in the header — the table-view-only Excel-style filter. + */ +export function DataTableColumnHeader({ + column, + children, + align = 'left', + filter, +}: DataTableColumnHeaderProps) { + const canSort = column.getCanSort(); + const sorted = column.getIsSorted(); + + const label = canSort ? ( + + ) : ( + {children} + ); + + return ( + + {label} + {filter} + + ); +} diff --git a/apps/web/src/components/data-table/DataTableFacetedFilter.tsx b/apps/web/src/components/data-table/DataTableFacetedFilter.tsx new file mode 100644 index 00000000..41de2f49 --- /dev/null +++ b/apps/web/src/components/data-table/DataTableFacetedFilter.tsx @@ -0,0 +1,131 @@ +import { + Badge, + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, + CommandSeparator, + Popover, + PopoverContent, + PopoverTrigger, +} from '@plunk/ui'; +import {Check, ListFilter} from 'lucide-react'; +import type {ComponentType, ReactNode} from 'react'; + +export interface FacetedFilterOption { + /** Stable value sent back through `onChange` (e.g. a `TemplateType` enum member). */ + value: string; + /** Human label shown in the dropdown. */ + label: ReactNode; + /** Optional leading icon. */ + icon?: ComponentType<{className?: string}>; +} + +interface DataTableFacetedFilterProps { + /** Title shown in the dropdown header and the trigger's accessible name. */ + title: string; + options: FacetedFilterOption[]; + /** Currently-selected values (controlled). The component itself owns no state. */ + selected: string[]; + /** Called with the full next selection whenever the user toggles an option or clears. */ + onChange: (next: string[]) => void; + /** + * When false, only a single option can be selected at a time (a fresh pick + * replaces the prior one). Defaults to true (multi-select). Templates use + * single-select for Type. + */ + multiple?: boolean; +} + +/** + * Excel-style faceted filter rendered inside a column header (shadcn data-table + * convention), adapted to Plunk's `@plunk/ui` Popover + Command primitives. + * + * It is intentionally controlled and value-agnostic: the parent decides what a + * selection means (the templates page maps it straight onto the `?type=` query + * param so the server stays authoritative). Only meant for fixed-value columns — + * free text stays in the global search bar. + */ +export function DataTableFacetedFilter({ + title, + options, + selected, + onChange, + multiple = true, +}: DataTableFacetedFilterProps) { + const selectedSet = new Set(selected); + + const toggle = (value: string) => { + if (selectedSet.has(value)) { + onChange(selected.filter(v => v !== value)); + return; + } + onChange(multiple ? [...selected, value] : [value]); + }; + + return ( + + + + + + + + No options. + + {options.map(option => { + const isSelected = selectedSet.has(option.value); + const Icon = option.icon; + return ( + toggle(option.value)} className="cursor-pointer"> + + + {Icon ? + ); + })} + + {selectedSet.size > 0 && ( + <> + + + onChange([])} + className="cursor-pointer justify-center text-center text-sm text-neutral-600" + > + Clear filter + + + + )} + + + + + ); +} diff --git a/apps/web/src/components/data-table/DataTableViewOptions.tsx b/apps/web/src/components/data-table/DataTableViewOptions.tsx new file mode 100644 index 00000000..b09d4892 --- /dev/null +++ b/apps/web/src/components/data-table/DataTableViewOptions.tsx @@ -0,0 +1,67 @@ +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@plunk/ui'; +import type {Table} from '@tanstack/react-table'; +import {Columns3} from 'lucide-react'; + +interface DataTableViewOptionsProps { + table: Table; + /** + * Column IDs pinned visible (their checkbox is disabled). A column declared + * with `enableHiding: false` is treated as locked even if not listed here. + */ + lockedColumnIds?: ReadonlyArray; +} + +/** + * "Columns" selector (shadcn `DataTableViewOptions` convention) built on + * Plunk's `@plunk/ui` DropdownMenu. Lists every leaf column with a checkbox, + * honouring each column's `enableHiding` flag and the optional `lockedColumnIds` + * (Name + Actions stay locked-visible on the templates table). Reads each + * column's display label from `columnDef.meta.label`, falling back to its id. + */ +export function DataTableViewOptions({table, lockedColumnIds = []}: DataTableViewOptionsProps) { + const lockedSet = new Set(lockedColumnIds); + const columns = table.getAllLeafColumns(); + + return ( + + + + + + Visible columns + + {columns.map(column => { + const id = column.id; + const locked = lockedSet.has(id) || !column.getCanHide(); + const label = (column.columnDef.meta as {label?: string} | undefined)?.label ?? id; + return ( + { + if (locked) return; + column.toggleVisibility(!!value); + }} + onSelect={e => e.preventDefault()} + className="capitalize" + > + {label} + + ); + })} + + + ); +} diff --git a/apps/web/src/components/data-table/DataTableViewSwitcher.tsx b/apps/web/src/components/data-table/DataTableViewSwitcher.tsx new file mode 100644 index 00000000..5d0382f6 --- /dev/null +++ b/apps/web/src/components/data-table/DataTableViewSwitcher.tsx @@ -0,0 +1,48 @@ +import {Button} from '@plunk/ui'; +import {LayoutGrid, List} from 'lucide-react'; + +export type DataTableView = 'card' | 'table'; + +export const isDataTableView = (value: string): value is DataTableView => value === 'card' || value === 'table'; + +interface DataTableViewSwitcherProps { + view: DataTableView; + onChange: (next: DataTableView) => void; +} + +/** + * Card / table view toggle (icon segmented control) built on Plunk's Button. + * Generic and stateless — the parent owns the value (persisted via + * `usePersistentState`) so any list page can drop this in. Card view is the + * default everywhere; this only switches the rendering, never the data. + */ +export function DataTableViewSwitcher({view, onChange}: DataTableViewSwitcherProps) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/data-table/index.ts b/apps/web/src/components/data-table/index.ts new file mode 100644 index 00000000..ee0845b9 --- /dev/null +++ b/apps/web/src/components/data-table/index.ts @@ -0,0 +1,6 @@ +export {BulkActionBar} from './BulkActionBar'; +export {DataTable, type DataTableColumnMeta} from './DataTable'; +export {DataTableColumnHeader} from './DataTableColumnHeader'; +export {DataTableFacetedFilter, type FacetedFilterOption} from './DataTableFacetedFilter'; +export {DataTableViewOptions} from './DataTableViewOptions'; +export {DataTableViewSwitcher, isDataTableView, type DataTableView} from './DataTableViewSwitcher'; diff --git a/apps/web/src/lib/hooks/useColumnVisibility.ts b/apps/web/src/lib/hooks/useColumnVisibility.ts new file mode 100644 index 00000000..24dfa41c --- /dev/null +++ b/apps/web/src/lib/hooks/useColumnVisibility.ts @@ -0,0 +1,47 @@ +import {useEffect, useState} from 'react'; +import type {VisibilityState} from '@tanstack/react-table'; + +/** + * Persist a tanstack `columnVisibility` state to localStorage under `storageKey`. + * + * Only string-keyed boolean entries that exist in `defaults` are merged, so a + * malformed or stale stored value can't leak unknown keys into the tanstack + * state. SSR-safe: reads happen inside an effect and the initial state is always + * `defaults` (so server and first client render match). + */ +export function useColumnVisibility(storageKey: string, defaults: VisibilityState) { + const [columnVisibility, setColumnVisibility] = useState(defaults); + + // Hydrate from localStorage on first mount (client only). + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object') return; + const sanitized: VisibilityState = {...defaults}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value === 'boolean' && key in defaults) { + sanitized[key] = value; + } + } + setColumnVisibility(sanitized); + } catch { + // Ignore malformed JSON — fall back to defaults. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + // Persist whenever it changes. + useEffect(() => { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(storageKey, JSON.stringify(columnVisibility)); + } catch { + // Ignore quota / private-mode errors. + } + }, [storageKey, columnVisibility]); + + return [columnVisibility, setColumnVisibility] as const; +} diff --git a/apps/web/src/lib/hooks/usePersistentState.ts b/apps/web/src/lib/hooks/usePersistentState.ts new file mode 100644 index 00000000..e73e4d48 --- /dev/null +++ b/apps/web/src/lib/hooks/usePersistentState.ts @@ -0,0 +1,51 @@ +import {useCallback, useEffect, useState} from 'react'; + +/** + * A tiny `useState` wrapper that persists a small, validated value in + * localStorage under `storageKey`. + * + * Used by the card/table view switcher (`plunk:templates:view`) but kept + * generic so other list pages (workflows, campaigns) can reuse the same + * switcher with their own key. SSR-safe: the initial render always returns + * `defaultValue` and hydration happens inside an effect, so server and first + * client render agree. + * + * `validate` guards against stale / malformed stored values — only a value it + * returns `true` for is adopted from storage. + */ +export function usePersistentState( + storageKey: string, + defaultValue: T, + validate: (value: string) => value is T, +): readonly [T, (next: T) => void] { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = window.localStorage.getItem(storageKey); + if (stored !== null && validate(stored)) { + setValue(stored); + } + } catch { + // Ignore read errors (private mode etc.) — keep the default. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey]); + + const set = useCallback( + (next: T) => { + setValue(next); + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(storageKey, next); + } catch { + // Ignore quota / private-mode errors. + } + } + }, + [storageKey], + ); + + return [value, set] as const; +} diff --git a/apps/web/src/lib/hooks/useShiftClickSelection.ts b/apps/web/src/lib/hooks/useShiftClickSelection.ts new file mode 100644 index 00000000..4ba1894d --- /dev/null +++ b/apps/web/src/lib/hooks/useShiftClickSelection.ts @@ -0,0 +1,65 @@ +import {useCallback, useRef} from 'react'; +import type {Row, RowSelectionState, Table} from '@tanstack/react-table'; + +interface ShiftClickHandlers { + /** + * Attach to the selection control's `onClick` to capture the shift-key state + * of the click. Pair with `onCheckedChange` below. + */ + onClick: (event: React.MouseEvent) => void; + /** + * Attach to the selection control's `onCheckedChange`. On a plain click this + * toggles the single row; when the preceding click had `shiftKey` pressed AND + * a prior anchor row exists, every row between the anchor and this row is set + * to the new checked state. + */ + onCheckedChange: (row: Row, value: boolean | 'indeterminate') => void; +} + +/** + * Range-selection helper for tanstack tables driven by a checkbox cell. + * + * Tracks the most-recently-clicked row as an anchor and, on a shift-click, + * selects/deselects every row between the anchor and the current row using + * `table.setRowSelection`. + * + * The anchor updates on every click (shift or not) so a user can chain + * selections naturally: click row 1, shift-click row 10 (selects 1–10), + * then shift-click row 5 (the new anchor is row 10, so rows 5–10 are set to + * the checked state of row 5's click). + */ +export function useShiftClickSelection(table: Table): ShiftClickHandlers { + const shiftHeldRef = useRef(false); + const anchorRowIdRef = useRef(null); + + const onClick = useCallback((event: React.MouseEvent) => { + shiftHeldRef.current = event.shiftKey; + }, []); + + const onCheckedChange = useCallback( + (row: Row, value: boolean | 'indeterminate') => { + const nextSelected = value === true; + const rows = table.getRowModel().rows; + const currentIndex = rows.findIndex(r => r.id === row.id); + const anchorIndex = anchorRowIdRef.current ? rows.findIndex(r => r.id === anchorRowIdRef.current) : -1; + + if (shiftHeldRef.current && anchorIndex !== -1 && currentIndex !== -1 && anchorIndex !== currentIndex) { + const [start, end] = anchorIndex < currentIndex ? [anchorIndex, currentIndex] : [currentIndex, anchorIndex]; + const updates: RowSelectionState = {}; + for (let i = start; i <= end; i++) { + const r = rows[i]; + if (r) updates[r.id] = nextSelected; + } + table.setRowSelection(prev => ({...prev, ...updates})); + } else { + row.toggleSelected(nextSelected); + } + + anchorRowIdRef.current = row.id; + shiftHeldRef.current = false; + }, + [table], + ); + + return {onClick, onCheckedChange}; +} diff --git a/apps/web/src/pages/templates/index.tsx b/apps/web/src/pages/templates/index.tsx index 62654dbe..9d2dc505 100644 --- a/apps/web/src/pages/templates/index.tsx +++ b/apps/web/src/pages/templates/index.tsx @@ -3,34 +3,95 @@ import { Button, Card, CardContent, + Checkbox, ConfirmDialog, + EmptyState, IconSpinner, Input, } from '@plunk/ui'; import type {Template} from '@plunk/db'; +import {TemplateSchemas} from '@plunk/shared'; import type {PaginatedResponse} from '@plunk/types'; -import {EmptyState} from '@plunk/ui'; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, + type RowSelectionState, + type SortingState, + type VisibilityState, +} from '@tanstack/react-table'; import {DashboardLayout} from '../../components/DashboardLayout'; +import { + BulkActionBar, + DataTable, + DataTableColumnHeader, + DataTableFacetedFilter, + DataTableViewOptions, + DataTableViewSwitcher, + isDataTableView, + type DataTableColumnMeta, + type DataTableView, +} from '../../components/data-table'; import {network} from '../../lib/network'; import {formatRelativeTime} from '../../lib/dateUtils'; +import {useColumnVisibility} from '../../lib/hooks/useColumnVisibility'; +import {usePersistentState} from '../../lib/hooks/usePersistentState'; +import {useShiftClickSelection} from '../../lib/hooks/useShiftClickSelection'; import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2, X} from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; -import {useEffect, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; import dayjs from 'dayjs'; +type TypeFilter = 'ALL' | 'TRANSACTIONAL' | 'MARKETING' | 'HEADLESS'; + +const VIEW_STORAGE_KEY = 'plunk:templates:view'; +const COLUMNS_STORAGE_KEY = 'plunk:templates:columns'; + +// Name + Actions are locked-visible (see lockedColumnIds below). `select` is +// also locked. Everything starts visible. +const DEFAULT_COLUMN_VISIBILITY: VisibilityState = { + select: true, + name: true, + type: true, + subject: true, + updatedAt: true, + actions: true, +}; + +// Fixed-value options for the Type column's faceted filter (table view) and the +// existing card-view pill row. Single source of truth for both. +const TYPE_OPTIONS = ['MARKETING', 'TRANSACTIONAL', 'HEADLESS'] as const; + export default function TemplatesPage() { const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [searchInput, setSearchInput] = useState(''); - const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING' | 'HEADLESS'>('ALL'); + const [typeFilter, setTypeFilter] = useState('ALL'); + const [view, setView] = usePersistentState(VIEW_STORAGE_KEY, 'card', isDataTableView); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [templateToDelete, setTemplateToDelete] = useState(null); + const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false); + const [bulkDeleteStatus, setBulkDeleteStatus] = useState<'idle' | 'loading'>('idle'); + + // Tanstack table state. + const [sorting, setSorting] = useState([]); + const [columnVisibility, setColumnVisibility] = useColumnVisibility(COLUMNS_STORAGE_KEY, DEFAULT_COLUMN_VISIBILITY); + // Row-selection state drives the BulkActionBar above the table. + const [rowSelection, setRowSelection] = useState({}); + + // Build the sort query string from tanstack state. The backend is + // authoritative (`?sort=&dir=asc|desc`); without those params it falls + // back to its default order. manualSorting is on, so the client only mirrors. + const sortParam = sorting[0]?.id ?? ''; + const dirParam = sorting[0] ? (sorting[0].desc ? 'desc' : 'asc') : ''; const {data, mutate, isLoading} = useSWR>( - `/templates?page=${page}&pageSize=20${search ? `&search=${search}` : ''}${typeFilter !== 'ALL' ? `&type=${typeFilter}` : ''}`, + `/templates?page=${page}&pageSize=20${search ? `&search=${search}` : ''}${ + typeFilter !== 'ALL' ? `&type=${typeFilter}` : '' + }${sortParam ? `&sort=${sortParam}&dir=${dirParam}` : ''}`, {revalidateOnFocus: false}, ); @@ -42,6 +103,14 @@ export default function TemplatesPage() { return () => clearTimeout(timer); }, [searchInput]); + // Clear row selection whenever the visible data set changes (page, search, + // type filter). Selections only make sense for currently-visible rows — + // keeping a stale selection across pagination would let the user bulk-delete + // templates they can no longer see. + useEffect(() => { + setRowSelection({}); + }, [page, search, typeFilter]); + const handleDelete = async () => { if (!templateToDelete) return; @@ -66,6 +135,203 @@ export default function TemplatesPage() { } }; + const selectedIds = useMemo(() => Object.keys(rowSelection).filter(id => rowSelection[id]), [rowSelection]); + + const handleBulkDelete = async () => { + if (selectedIds.length === 0) return; + setBulkDeleteStatus('loading'); + try { + const result = await network.fetch<{deleted?: number}, typeof TemplateSchemas.bulkUpdate>( + 'POST', + '/templates/bulk-update', + { + ids: selectedIds, + delete: true, + }, + ); + const count = result?.deleted ?? selectedIds.length; + toast.success(`${count} template${count === 1 ? '' : 's'} deleted`); + setRowSelection({}); + void mutate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete templates'); + } finally { + // ConfirmDialog closes itself after onConfirm resolves. + setBulkDeleteStatus('idle'); + } + }; + + const columns = useMemo>>( + () => [ + { + id: 'select', + enableSorting: false, + enableHiding: false, // Selection column is locked-visible. + meta: {label: 'Select', headClassName: 'w-10', cellClassName: 'w-10'} satisfies DataTableColumnMeta, + header: ({table}) => ( + table.toggleAllPageRowsSelected(!!value)} + /> + ), + cell: ({row}) => ( + { + e.stopPropagation(); + shiftSelect.onClick(e); + }} + onCheckedChange={value => shiftSelect.onCheckedChange(row, value)} + /> + ), + }, + { + id: 'name', + accessorKey: 'name', + enableHiding: false, // Name column is locked-visible. + meta: {label: 'Name'} satisfies DataTableColumnMeta, + header: ({column}) => Name, + cell: ({row}) => ( + + {row.original.name} + + ), + }, + { + id: 'type', + accessorKey: 'type', + enableSorting: false, // Type is faceted-filtered, not sorted. + meta: {label: 'Type'} satisfies DataTableColumnMeta, + header: ({column}) => ( + ({value: t, label: t.toLowerCase()}))} + selected={typeFilter === 'ALL' ? [] : [typeFilter]} + onChange={next => { + setTypeFilter((next[0] as TypeFilter) ?? 'ALL'); + setPage(1); + }} + /> + } + > + Type + + ), + cell: ({row}) => ( + + {row.original.type.toLowerCase()} + + ), + }, + { + id: 'subject', + accessorKey: 'subject', + enableSorting: false, // No backend sort field for subject. + meta: {label: 'Subject', cellClassName: 'max-w-xs'} satisfies DataTableColumnMeta, + header: ({column}) => Subject, + cell: ({row}) => ( +

+ {row.original.subject} +

+ ), + }, + { + id: 'updatedAt', + accessorKey: 'updatedAt', + // ISO-string values sort ascending on first click by default; flip so + // the first click on "Updated" surfaces the most recently edited rows. + sortDescFirst: true, + meta: {label: 'Updated'} satisfies DataTableColumnMeta, + header: ({column}) => Updated, + cell: ({row}) => ( +
+ {formatRelativeTime(row.original.updatedAt)} +
+ {dayjs(row.original.updatedAt).format('DD MMMM YYYY, hh:mm')} +
+
+ ), + }, + { + id: 'actions', + enableSorting: false, + enableHiding: false, // Actions column is locked-visible. + meta: {label: 'Actions', headClassName: 'text-right', cellClassName: 'text-right'} satisfies DataTableColumnMeta, + header: () => Actions, + cell: ({row}) => ( +
+ + + +
+ ), + }, + ], + // Re-creating columns on every render is cheap and avoids stale-closure bugs + // for the typeFilter-driven facet and delete/duplicate handlers. + // eslint-disable-next-line react-hooks/exhaustive-deps + [typeFilter], + ); + + const table = useReactTable