Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
38 changes: 38 additions & 0 deletions console/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 10 additions & 4 deletions console/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -9,9 +12,12 @@ export default function App(props: RouterProps) {
const router = useMemo(() => createRouter(props), [props])

return (
<PreferencesProvider>
<RouterProvider router={router} />
<Toaster />
</PreferencesProvider>
<QueryClientProvider client={queryClient}>
<PreferencesProvider>
<RouterProvider router={router} />
<Toaster />
</PreferencesProvider>
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
)
}
24 changes: 24 additions & 0 deletions console/src/lib/query-client.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
})
23 changes: 23 additions & 0 deletions console/src/lib/query-keys.ts
Original file line number Diff line number Diff line change
@@ -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<SearchParams>) =>
["users", projectId, "list", params ?? {}] as const,
detail: (projectId: UUID, userId: UUID) => ["users", projectId, "detail", userId] as const,
},
} as const
62 changes: 37 additions & 25 deletions console/src/views/users/Users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"), [])
Expand Down Expand Up @@ -124,27 +124,31 @@ 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
const totalPages = Math.ceil(total / limit)
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() }]
Expand All @@ -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}`)
}
Expand Down
Loading