diff --git a/console/package.json b/console/package.json index 8edeb892..247ce6cb 100644 --- a/console/package.json +++ b/console/package.json @@ -43,6 +43,7 @@ "@rehookify/datepicker": "^3.2.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-hotkeys": "^0.4.1", + "@tanstack/react-query": "^5.101.2", "@tanstack/react-table": "^8.21.3", "@textea/json-viewer": "^4.0.1", "@tiptap/core": "3.20.1", @@ -142,6 +143,7 @@ "@eslint/eslintrc": "^3", "@eslint/js": "^9.38.0", "@react-router/dev": "^7.13.0", + "@tanstack/react-query-devtools": "^5.101.2", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml index f0e901a3..a35441ec 100644 --- a/console/pnpm-lock.yaml +++ b/console/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@tanstack/react-hotkeys': specifier: ^0.4.1 version: 0.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@18.3.1) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -336,6 +339,9 @@ importers: '@react-router/dev': specifier: ^7.13.0 version: 7.13.0(@react-router/serve@7.13.0(react-router@7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(typescript@5.9.3))(@types/node@22.19.9)(babel-plugin-macros@3.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-router@7.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.9)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)) + '@tanstack/react-query-devtools': + specifier: ^5.101.2 + version: 5.101.2(@tanstack/react-query@5.101.2(react@18.3.1))(react@18.3.1) '@testing-library/jest-dom': specifier: ^5.17.0 version: 5.17.0 @@ -2201,6 +2207,12 @@ packages: resolution: {integrity: sha512-EGHqcdKP2jzy0dEkahA3ABtEXohMqPlU3Ac04sBQjgesJqr9xWuesJotOfWPh3P68kQQg8krNAtFTydIN3+WSw==} engines: {node: '>=18'} + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/query-devtools@5.101.2': + resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==} + '@tanstack/react-hotkeys@0.4.1': resolution: {integrity: sha512-hFh/kKQODn4kSytfIsEE/Vf1AaAb+NAFi4lx+OB49NmKY5z/BNH1/uEdYlVgOEvnDm4QrCISIMBOVpMgK5QNQg==} engines: {node: '>=18'} @@ -2208,6 +2220,17 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-query-devtools@5.101.2': + resolution: {integrity: sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==} + peerDependencies: + '@tanstack/react-query': ^5.101.2 + react: ^18 || ^19 + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-store@0.9.2': resolution: {integrity: sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ==} peerDependencies: @@ -7296,6 +7319,10 @@ snapshots: dependencies: '@tanstack/store': 0.9.2 + '@tanstack/query-core@5.101.2': {} + + '@tanstack/query-devtools@5.101.2': {} + '@tanstack/react-hotkeys@0.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/hotkeys': 0.4.1 @@ -7303,6 +7330,17 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-query-devtools@5.101.2(@tanstack/react-query@5.101.2(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-devtools': 5.101.2 + '@tanstack/react-query': 5.101.2(react@18.3.1) + react: 18.3.1 + + '@tanstack/react-query@5.101.2(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 18.3.1 + '@tanstack/react-store@0.9.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/store': 0.9.2 diff --git a/console/src/App.tsx b/console/src/App.tsx index 442bc7c1..0e50d258 100644 --- a/console/src/App.tsx +++ b/console/src/App.tsx @@ -1,6 +1,9 @@ import { useMemo } from "react" import { RouterProvider } from "react-router" +import { QueryClientProvider } from "@tanstack/react-query" +import { ReactQueryDevtools } from "@tanstack/react-query-devtools" import { PreferencesProvider } from "./contexts/PreferencesContext" +import { queryClient } from "./lib/query-client" import type { RouterProps } from "./views/router" import { createRouter } from "./views/router" import { Toaster } from "@/components/ui/sonner" @@ -9,9 +12,12 @@ export default function App(props: RouterProps) { const router = useMemo(() => createRouter(props), [props]) return ( - - - - + + + + + + {import.meta.env.DEV && } + ) } diff --git a/console/src/lib/query-client.ts b/console/src/lib/query-client.ts new file mode 100644 index 00000000..2d3157c4 --- /dev/null +++ b/console/src/lib/query-client.ts @@ -0,0 +1,24 @@ +import { QueryClient } from "@tanstack/react-query" + +/** + * The shared QueryClient for the console. + * + * Defaults are tuned for an authenticated dashboard where most data is + * project-scoped and changes from other tabs/sessions are rare: + * + * - `staleTime` of 30s keeps navigation snappy (cached data is shown + * instantly) while still revalidating in the background often enough that + * the UI never feels stale. + * - `retry: 1` avoids hammering the API on genuine 4xx errors while still + * tolerating a single transient blip. + * - `refetchOnWindowFocus` is left on (the default) so returning to a tab + * pulls fresh data without a manual reload. + */ +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + }, + }, +}) diff --git a/console/src/lib/query-keys.ts b/console/src/lib/query-keys.ts new file mode 100644 index 00000000..b4f05bfa --- /dev/null +++ b/console/src/lib/query-keys.ts @@ -0,0 +1,23 @@ +import type { SearchParams } from "@/types" +import type { UUID } from "@/types/common" + +/** + * Centralised TanStack Query key factory. + * + * Keeping every key in one place gives us a single source of truth for cache + * invalidation: a mutation can invalidate `queryKeys.users.list(projectId)` to + * refresh every paginated/filtered users query, or + * `queryKeys.users.all(projectId)` to refresh lists *and* detail views at once. + * + * Convention: keys are hierarchical, from least to most specific — + * [entity] → [entity, projectId] → [entity, projectId, "list", params] + * so a prefix match (how `invalidateQueries` works) cascades naturally. + */ +export const queryKeys = { + users: { + all: (projectId: UUID) => ["users", projectId] as const, + list: (projectId: UUID, params?: Partial) => + ["users", projectId, "list", params ?? {}] as const, + detail: (projectId: UUID, userId: UUID) => ["users", projectId, "detail", userId] as const, + }, +} as const diff --git a/console/src/views/users/Users.tsx b/console/src/views/users/Users.tsx index 592ef2f1..af19f46c 100644 --- a/console/src/views/users/Users.tsx +++ b/console/src/views/users/Users.tsx @@ -18,7 +18,8 @@ import { import { UserImportDialog } from "@/components/ui/user-import-dialog" import { NIL } from "uuid" import { useRoute } from "@/hooks/use-route" -import { useResolver } from "../../hooks" +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { queryKeys } from "@/lib/query-keys" import { formatDate, cn } from "../../utils" import { getRandomColor } from "@/lib/colors" import { getUserDisplayName, getUserInitials, getPrimaryExternalId } from "@/lib/name" @@ -94,7 +95,6 @@ export default function Users() { const [searchQuery, setSearchQuery] = useState("") const [debouncedQuery, setDebouncedQuery] = useState("") const [isCreateOpen, setIsCreateOpen] = useState(false) - const [isCreating, setIsCreating] = useState(false) const [isBulkImportOpen, setIsBulkImportOpen] = useState(false) const [isNewUserTimezoneOpen, setIsNewUserTimezoneOpen] = useState(false) const timezones = useMemo(() => Intl.supportedValuesOf("timeZone"), []) @@ -124,15 +124,22 @@ export default function Users() { }, 300) }, []) - const [result, , reload] = useResolver( - useCallback(async () => { - return await api.users.search(projectId, { - limit, - offset: (page - 1) * limit, - search: debouncedQuery || undefined, - }) - }, [projectId, debouncedQuery, page]), - ) + const searchParams = { + limit, + offset: (page - 1) * limit, + search: debouncedQuery || undefined, + } + const { data: result } = useQuery({ + queryKey: queryKeys.users.list(projectId, searchParams), + queryFn: () => api.users.search(projectId, searchParams), + // Keep the previous page's rows visible while the next page loads, so + // paging and typing in search don't flash an empty table. + placeholderData: keepPreviousData, + }) + + const queryClient = useQueryClient() + const invalidateUsers = () => + queryClient.invalidateQueries({ queryKey: queryKeys.users.all(projectId) }) const users = result?.results const total = result?.total ?? 0 @@ -140,11 +147,8 @@ export default function Users() { const hasNextPage = page < totalPages const hasPrevPage = page > 1 - const createUser = async (data: CreateUserFormValues) => { - if (!data.external_id?.trim() && !data.email) return - - setIsCreating(true) - try { + const createUserMutation = useMutation({ + mutationFn: async (data: CreateUserFormValues) => { const newUser: User = { identifier: data.external_id?.trim() ? [{ source: "default", external_id: data.external_id.trim() }] @@ -156,21 +160,29 @@ export default function Users() { data: newUserData, } as User - await api.users.create(projectId, newUser) - await reload() + return await api.users.create(projectId, newUser) + }, + onSuccess: async () => { + await invalidateUsers() setIsCreateOpen(false) form.reset() setNewUserData({}) - } finally { - setIsCreating(false) - } - } + }, + }) + const isCreating = createUserMutation.isPending - const handleImportUsers = async (file: File) => { - await api.users.addImport(projectId, file) - await reload() + const createUser = (data: CreateUserFormValues) => { + if (!data.external_id?.trim() && !data.email) return + createUserMutation.mutate(data) } + const importUsersMutation = useMutation({ + mutationFn: (file: File) => api.users.addImport(projectId, file), + onSuccess: () => invalidateUsers(), + }) + + const handleImportUsers = (file: File) => importUsersMutation.mutateAsync(file) + const handleRowClick = (user: User) => { route(`users/${user.id}`) }