From 6ca94ee3f18d61fe406bc8c065bb52a3e73c851c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 12 May 2026 23:34:24 +0200 Subject: [PATCH] fix(compiler): migrate 6 list pages to useQuery (set-state-in-effect) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the useState + useEffect + apiFetch pattern with TanStack Query in six admin list pages — same pattern, mechanical refactor: - admin/tags/tag-list - admin/ports/port-list - admin/roles/role-list - admin/users/user-list - admin/document-templates/template-list - admin/webhooks/page - dashboard/timezone-drift-banner (also: detected-tz reads via useSyncExternalStore so render stays pure) Side benefits: list refetches now share a query cache across tabs (via @tanstack/query-broadcast-client-experimental that was wired up earlier this branch), so when admin A edits a role in one tab, admin B's tab sees the updated row without a manual reload. set-state-in-effect warnings: 51 → 45. Verified: tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[portSlug]/admin/webhooks/page.tsx | 26 +++---- .../document-templates/template-list.tsx | 26 +++---- src/components/admin/ports/port-list.tsx | 25 +++---- src/components/admin/roles/role-list.tsx | 45 +++++-------- src/components/admin/tags/tag-list.tsx | 45 +++++-------- src/components/admin/users/user-list.tsx | 67 +++++++++---------- .../dashboard/timezone-drift-banner.tsx | 61 +++++++++-------- 7 files changed, 126 insertions(+), 169 deletions(-) diff --git a/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx b/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx index af979869..8bd3223d 100644 --- a/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { PageHeader } from '@/components/shared/page-header'; @@ -30,9 +31,10 @@ interface Webhook { createdAt: string; } +const WEBHOOKS_QUERY_KEY = ['admin', 'webhooks'] as const; + export default function WebhooksPage() { - const [webhooks, setWebhooks] = useState([]); - const [loading, setLoading] = useState(true); + const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -44,20 +46,12 @@ export default function WebhooksPage() { masked: string; } | null>(null); - const loadWebhooks = useCallback(async () => { - try { - const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks'); - setWebhooks(result.data); - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to load webhooks'); - } finally { - setLoading(false); - } - }, []); + const { data: webhooks = [], isLoading: loading } = useQuery({ + queryKey: WEBHOOKS_QUERY_KEY, + queryFn: () => apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks').then((r) => r.data), + }); - useEffect(() => { - void loadWebhooks(); - }, [loadWebhooks]); + const loadWebhooks = () => queryClient.invalidateQueries({ queryKey: WEBHOOKS_QUERY_KEY }); async function handleDelete() { if (!deleteTarget) return; diff --git a/src/components/admin/document-templates/template-list.tsx b/src/components/admin/document-templates/template-list.tsx index caf68b45..585fa875 100644 --- a/src/components/admin/document-templates/template-list.tsx +++ b/src/components/admin/document-templates/template-list.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Pencil, Trash2, History, FileText } from 'lucide-react'; import { type ColumnDef } from '@tanstack/react-table'; @@ -41,27 +42,22 @@ const TYPE_LABELS: Record = { custom: 'Custom', }; +const TEMPLATES_QUERY_KEY = ['admin', 'document-templates'] as const; + export function TemplateList() { - const [templates, setTemplates] = useState([]); - const [loading, setLoading] = useState(true); + const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); const [historyTemplate, setHistoryTemplate] = useState(null); const [historyOpen, setHistoryOpen] = useState(false); - const fetchTemplates = useCallback(async () => { - setLoading(true); - try { - const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates'); - setTemplates(res.data); - } finally { - setLoading(false); - } - }, []); + const { data: templates = [], isLoading: loading } = useQuery({ + queryKey: TEMPLATES_QUERY_KEY, + queryFn: () => + apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates').then((r) => r.data), + }); - useEffect(() => { - void fetchTemplates(); - }, [fetchTemplates]); + const fetchTemplates = () => queryClient.invalidateQueries({ queryKey: TEMPLATES_QUERY_KEY }); function handleNewTemplate() { setEditingTemplate(null); diff --git a/src/components/admin/ports/port-list.tsx b/src/components/admin/ports/port-list.tsx index d48aea35..1bd29cdc 100644 --- a/src/components/admin/ports/port-list.tsx +++ b/src/components/admin/ports/port-list.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { type ColumnDef } from '@tanstack/react-table'; import { Pencil, Plus } from 'lucide-react'; @@ -24,25 +25,19 @@ interface PortRow { createdAt: string; } +const PORTS_QUERY_KEY = ['admin', 'ports'] as const; + export function PortList() { - const [ports, setPorts] = useState([]); - const [loading, setLoading] = useState(true); + const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editingPort, setEditingPort] = useState(null); - const fetchPorts = useCallback(async () => { - setLoading(true); - try { - const res = await apiFetch<{ data: PortRow[] }>('/api/v1/admin/ports'); - setPorts(res.data); - } finally { - setLoading(false); - } - }, []); + const { data: ports = [], isLoading: loading } = useQuery({ + queryKey: PORTS_QUERY_KEY, + queryFn: () => apiFetch<{ data: PortRow[] }>('/api/v1/admin/ports').then((r) => r.data), + }); - useEffect(() => { - void fetchPorts(); - }, [fetchPorts]); + const fetchPorts = () => queryClient.invalidateQueries({ queryKey: PORTS_QUERY_KEY }); function handleNewPort() { setEditingPort(null); diff --git a/src/components/admin/roles/role-list.tsx b/src/components/admin/roles/role-list.tsx index 7df4c433..0ea0ba13 100644 --- a/src/components/admin/roles/role-list.tsx +++ b/src/components/admin/roles/role-list.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type ColumnDef } from '@tanstack/react-table'; import { Pencil, Trash2, Plus, Lock } from 'lucide-react'; @@ -31,27 +32,25 @@ interface Role { createdAt: string; } +const ROLES_QUERY_KEY = ['admin', 'roles'] as const; + export function RoleList() { - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(true); + const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editingRole, setEditingRole] = useState(null); - const [deletingId, setDeletingId] = useState(null); const [viewingPermissions, setViewingPermissions] = useState(null); - const fetchRoles = useCallback(async () => { - setLoading(true); - try { - const res = await apiFetch<{ data: Role[] }>('/api/v1/admin/roles'); - setRoles(res.data); - } finally { - setLoading(false); - } - }, []); + const { data: roles = [], isLoading: loading } = useQuery({ + queryKey: ROLES_QUERY_KEY, + queryFn: () => apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((r) => r.data), + }); - useEffect(() => { - void fetchRoles(); - }, [fetchRoles]); + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY }), + }); + + const fetchRoles = () => queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY }); function handleNewRole() { setEditingRole(null); @@ -63,16 +62,6 @@ export function RoleList() { setFormOpen(true); } - async function handleDeleteRole(id: string) { - setDeletingId(id); - try { - await apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' }); - await fetchRoles(); - } finally { - setDeletingId(null); - } - } - function countPermissions(perms: Record>): string { let granted = 0; let total = 0; @@ -155,8 +144,8 @@ export function RoleList() { title="Delete Role" description={`Delete "${row.original.name}"? Users assigned to this role must be reassigned first.`} confirmLabel="Delete" - onConfirm={() => handleDeleteRole(row.original.id)} - loading={deletingId === row.original.id} + onConfirm={() => deleteMutation.mutate(row.original.id)} + loading={deleteMutation.isPending && deleteMutation.variables === row.original.id} /> )} diff --git a/src/components/admin/tags/tag-list.tsx b/src/components/admin/tags/tag-list.tsx index 25968ce8..5d48e978 100644 --- a/src/components/admin/tags/tag-list.tsx +++ b/src/components/admin/tags/tag-list.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type ColumnDef } from '@tanstack/react-table'; import { Pencil, Trash2, Plus } from 'lucide-react'; @@ -19,26 +20,24 @@ interface Tag { createdAt: string; } +const TAGS_QUERY_KEY = ['admin', 'tags'] as const; + export function TagList() { - const [tags, setTags] = useState([]); - const [loading, setLoading] = useState(true); + const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editingTag, setEditingTag] = useState(null); - const [deletingId, setDeletingId] = useState(null); - const fetchTags = useCallback(async () => { - setLoading(true); - try { - const res = await apiFetch<{ data: Tag[] }>('/api/v1/tags'); - setTags(res.data); - } finally { - setLoading(false); - } - }, []); + const { data: tags = [], isLoading: loading } = useQuery({ + queryKey: TAGS_QUERY_KEY, + queryFn: () => apiFetch<{ data: Tag[] }>('/api/v1/tags').then((r) => r.data), + }); - useEffect(() => { - void fetchTags(); - }, [fetchTags]); + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiFetch(`/api/v1/tags/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }), + }); + + const fetchTags = () => queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }); function handleNewTag() { setEditingTag(null); @@ -50,16 +49,6 @@ export function TagList() { setFormOpen(true); } - async function handleDeleteTag(id: string) { - setDeletingId(id); - try { - await apiFetch(`/api/v1/tags/${id}`, { method: 'DELETE' }); - await fetchTags(); - } finally { - setDeletingId(null); - } - } - const columns: ColumnDef[] = [ { accessorKey: 'name', @@ -111,8 +100,8 @@ export function TagList() { title="Delete Tag" description={`Are you sure you want to delete "${row.original.name}"? This action cannot be undone.`} confirmLabel="Delete" - onConfirm={() => handleDeleteTag(row.original.id)} - loading={deletingId === row.original.id} + onConfirm={() => deleteMutation.mutate(row.original.id)} + loading={deleteMutation.isPending && deleteMutation.variables === row.original.id} /> ), diff --git a/src/components/admin/users/user-list.tsx b/src/components/admin/users/user-list.tsx index 14a6998d..f1332f79 100644 --- a/src/components/admin/users/user-list.tsx +++ b/src/components/admin/users/user-list.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { type ColumnDef } from '@tanstack/react-table'; import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff, Power, PowerOff } from 'lucide-react'; @@ -27,27 +28,36 @@ interface UserRow { assignedAt: string; } +const USERS_QUERY_KEY = ['admin', 'users'] as const; + export function UserList() { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); + const queryClient = useQueryClient(); const [formOpen, setFormOpen] = useState(false); const [editingUser, setEditingUser] = useState(null); - const [deletingId, setDeletingId] = useState(null); - const [togglingId, setTogglingId] = useState(null); - const fetchUsers = useCallback(async () => { - setLoading(true); - try { - const res = await apiFetch<{ data: UserRow[] }>('/api/v1/admin/users'); - setUsers(res.data); - } finally { - setLoading(false); - } - }, []); + const { data: users = [], isLoading: loading } = useQuery({ + queryKey: USERS_QUERY_KEY, + queryFn: () => apiFetch<{ data: UserRow[] }>('/api/v1/admin/users').then((r) => r.data), + }); - useEffect(() => { - void fetchUsers(); - }, [fetchUsers]); + const fetchUsers = () => queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEY }); + + const removeMutation = useMutation({ + mutationFn: (userId: string) => apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }), + onSuccess: () => fetchUsers(), + }); + + const toggleMutation = useMutation({ + mutationFn: (user: UserRow) => + apiFetch(`/api/v1/admin/users/${user.userId}`, { + method: 'PATCH', + body: { isActive: !user.isActive }, + }), + onSuccess: () => fetchUsers(), + }); + + const deletingId = removeMutation.isPending ? removeMutation.variables : null; + const togglingId = toggleMutation.isPending ? (toggleMutation.variables?.userId ?? null) : null; function handleNewUser() { setEditingUser(null); @@ -59,27 +69,12 @@ export function UserList() { setFormOpen(true); } - async function handleRemoveUser(userId: string) { - setDeletingId(userId); - try { - await apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }); - await fetchUsers(); - } finally { - setDeletingId(null); - } + function handleRemoveUser(userId: string) { + removeMutation.mutate(userId); } - async function handleToggleActive(user: UserRow) { - setTogglingId(user.userId); - try { - await apiFetch(`/api/v1/admin/users/${user.userId}`, { - method: 'PATCH', - body: { isActive: !user.isActive }, - }); - await fetchUsers(); - } finally { - setTogglingId(null); - } + function handleToggleActive(user: UserRow) { + toggleMutation.mutate(user); } const columns: ColumnDef[] = [ diff --git a/src/components/dashboard/timezone-drift-banner.tsx b/src/components/dashboard/timezone-drift-banner.tsx index d57ce282..53976565 100644 --- a/src/components/dashboard/timezone-drift-banner.tsx +++ b/src/components/dashboard/timezone-drift-banner.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useSyncExternalStore } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Clock, X } from 'lucide-react'; import { toast } from 'sonner'; @@ -35,37 +35,37 @@ interface MeResponse { * Dismissal is sticky per browser via localStorage so the rep isn't nagged * once they've decided. Clearing storage or signing in elsewhere re-asks. */ +function getDetectedTimezone(): string | null { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || null; + } catch { + return null; + } +} + +function getInitialDismissed(): boolean { + try { + return window.localStorage.getItem(DISMISS_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +// useSyncExternalStore lets us read window.matchMedia-style external state +// without bouncing through useEffect → setState. +const detectedSubscribe = () => () => {}; + export function TimezoneDriftBanner() { const queryClient = useQueryClient(); - const [detected, setDetected] = useState(null); - const [stored, setStored] = useState(null); - const [profileLoaded, setProfileLoaded] = useState(false); - const [dismissed, setDismissed] = useState(false); + const detected = useSyncExternalStore(detectedSubscribe, getDetectedTimezone, () => null); + const [dismissed, setDismissed] = useState(getInitialDismissed); - // Read on mount: browser's resolved timezone (mirrors the OS setting) + - // the user's stored preference + any prior dismissal flag. All three - // are stable across the lifetime of the dashboard view; the banner - // makes a single comparison and either renders or doesn't. - useEffect(() => { - try { - setDetected(Intl.DateTimeFormat().resolvedOptions().timeZone || null); - } catch { - setDetected(null); - } - try { - const flag = window.localStorage.getItem(DISMISS_STORAGE_KEY); - if (flag === 'true') setDismissed(true); - } catch { - // Private mode or quota — proceed without dismissal memory. - } - void apiFetch('/api/v1/me') - .then((res) => { - const tz = res.data.profile?.preferences?.timezone ?? res.data.profile?.timezone ?? null; - setStored(tz); - }) - .catch(() => setStored(null)) - .finally(() => setProfileLoaded(true)); - }, []); + const { data: profile, isSuccess: profileLoaded } = useQuery({ + queryKey: ['me'], + queryFn: () => apiFetch('/api/v1/me'), + }); + const stored = + profile?.data.profile?.preferences?.timezone ?? profile?.data.profile?.timezone ?? null; const mutation = useMutation({ mutationFn: async (newTz: string) => { @@ -77,7 +77,6 @@ export function TimezoneDriftBanner() { onSuccess: () => { toast.success(`Timezone updated to ${formatTimezoneLabel(detected ?? '')}.`); queryClient.invalidateQueries({ queryKey: ['me'] }); - setStored(detected); }, onError: (err) => toastError(err), });