From d474bf5dceb0283591a219caafcb3d49bfc8b21e Mon Sep 17 00:00:00 2001 From: Okorie Chigozie Jehoshaphat Date: Thu, 18 Jun 2026 08:02:52 +0100 Subject: [PATCH] Add optimistic updates for admin mutations #22 --- app/admin/members/page.tsx | 66 +++++++++++++++++--- app/admin/policies/page.tsx | 61 ++++++++++++++++--- lib/api/live.ts | 19 ++++-- lib/api/optimistic.ts | 50 ++++++++++++++++ next-env.d.ts | 1 - package.json | 3 +- test/optimistic-updates.test.ts | 103 ++++++++++++++++++++++++++++++++ test/tsconfig.json | 17 ++++++ tsconfig.json | 31 ++++++++-- 9 files changed, 322 insertions(+), 29 deletions(-) create mode 100644 lib/api/optimistic.ts create mode 100644 test/optimistic-updates.test.ts create mode 100644 test/tsconfig.json diff --git a/app/admin/members/page.tsx b/app/admin/members/page.tsx index 6d1ac4c..23e0f13 100644 --- a/app/admin/members/page.tsx +++ b/app/admin/members/page.tsx @@ -6,11 +6,22 @@ import { getApi, type MemberRow, type Role } from '@/lib/api' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' import { useState } from 'react' import { AdminGuard } from '@/components/admin-guard' import { useSiweAuth } from '@/lib/wallet/providers' import { AuthError } from '@/lib/api/live' import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/components/ui/api-states' +import { applyOptimisticRole } from '@/lib/api/optimistic' + +type AssignRoleInput = { + address: string + role: Role +} + +type AssignRoleRollback = { + previousMembers?: MemberRow[] +} function SessionExpiredBanner() { const { signIn, isSigningIn } = useSiweAuth() @@ -49,6 +60,9 @@ export default function MembersPage() { const [addr, setAddr] = useState('') const [role, setRole] = useState('member') + const [pendingAssignment, setPendingAssignment] = useState(null) + const [successMessage, setSuccessMessage] = useState('') + const [rollbackMessage, setRollbackMessage] = useState('') const { mutate, @@ -56,19 +70,40 @@ export default function MembersPage() { isError: mutateError, error: mutateErrorValue, reset: resetMutation - } = useMutation({ - mutationFn: () => getApi(address, authSession?.token).assignRole(addr, role), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['members'] }) + } = useMutation({ + mutationFn: (input) => + getApi(address, authSession?.token).assignRole(input.address, input.role), + onMutate: async (input) => { + await qc.cancelQueries({ queryKey: ['members'] }) + const previousMembers = qc.getQueryData(['members']) + + setPendingAssignment(input) + setSuccessMessage('') + setRollbackMessage('') setSessionExpired(false) + + qc.setQueryData(['members'], (currentMembers) => + applyOptimisticRole(currentMembers, input.address, input.role), + ) + + return { previousMembers } + }, + onSuccess: (_data, input) => { + setSuccessMessage(`Role "${input.role}" saved for ${input.address}.`) setAddr('') resetMutation() }, - onError: (err: unknown) => { + onError: (err: unknown, _input, context) => { + qc.setQueryData(['members'], context?.previousMembers) + setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`) if (err instanceof AuthError) { setSessionExpired(true) } }, + onSettled: () => { + setPendingAssignment(null) + qc.invalidateQueries({ queryKey: ['members'] }) + }, }) return ( @@ -100,17 +135,27 @@ export default function MembersPage() { + {successMessage && ( +
+ {successMessage} +
+ )} + {rollbackMessage && ( +
+ {rollbackMessage} +
+ )} {mutateError && ( mutate()} + onRetry={() => mutate({ address: addr, role })} /> )} @@ -137,8 +182,11 @@ export default function MembersPage() { className="flex items-center justify-between border rounded-md p-2" >
{m.address}
-
- Tier: {m.tier} • Roles: {m.roles.join(', ')} +
+ Tier: {m.tier} • Roles: {m.roles.join(', ')} + {pendingAssignment?.address.toLowerCase() === m.address.toLowerCase() && ( + Saving + )}
))} diff --git a/app/admin/policies/page.tsx b/app/admin/policies/page.tsx index 10d35c7..97cd7ed 100644 --- a/app/admin/policies/page.tsx +++ b/app/admin/policies/page.tsx @@ -5,11 +5,17 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getApi, type AccessPolicy } from '@/lib/api' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { AdminGuard } from '@/components/admin-guard' import { useSiweAuth } from '@/lib/wallet/providers' import { AuthError } from '@/lib/api/live' import { useState } from 'react' import { LoadingState, ErrorState, EmptyState, safeErrorMessage } from '@/components/ui/api-states' +import { applyOptimisticPolicy } from '@/lib/api/optimistic' + +type PolicyRollback = { + previousPolicies?: AccessPolicy[] +} function SessionExpiredBanner() { const { signIn, isSigningIn } = useSiweAuth() @@ -39,6 +45,9 @@ export default function PoliciesPage() { const { authSession } = useSiweAuth() const qc = useQueryClient() const [sessionExpired, setSessionExpired] = useState(false) + const [pendingPolicyId, setPendingPolicyId] = useState(null) + const [successMessage, setSuccessMessage] = useState('') + const [rollbackMessage, setRollbackMessage] = useState('') const { data: policies, isLoading, isError, error, refetch } = useQuery({ queryKey: ['policies'], @@ -48,23 +57,42 @@ export default function PoliciesPage() { const { mutate, - isPending, isError: mutateError, error: mutateErrorValue, reset: resetMutation - } = useMutation({ + } = useMutation({ mutationFn: (p: AccessPolicy) => getApi(address, authSession?.token).updatePolicy(p), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['policies'] }) + onMutate: async (policy) => { + await qc.cancelQueries({ queryKey: ['policies'] }) + const previousPolicies = qc.getQueryData(['policies']) + + setPendingPolicyId(policy.resourceId) + setSuccessMessage('') + setRollbackMessage('') setSessionExpired(false) + + qc.setQueryData(['policies'], (currentPolicies) => + applyOptimisticPolicy(currentPolicies, policy), + ) + + return { previousPolicies } + }, + onSuccess: (_data, policy) => { + setSuccessMessage(`Policy saved for ${policy.resourceId}.`) resetMutation() }, - onError: (err: unknown) => { + onError: (err: unknown, _policy, context) => { + qc.setQueryData(['policies'], context?.previousPolicies) + setRollbackMessage(`Change reverted: ${safeErrorMessage(err)}`) if (err instanceof AuthError) { setSessionExpired(true) } }, + onSettled: () => { + setPendingPolicyId(null) + qc.invalidateQueries({ queryKey: ['policies'] }) + }, }) return ( @@ -90,13 +118,18 @@ export default function PoliciesPage() { ) : ( policies.map((p) => (
-
{p.resourceId}
+
+ {p.resourceId} + {pendingPolicyId === p.resourceId && ( + Saving + )} +