From 82f3d1ce1d4e41259c19066b524fed2f86c421cf Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 8 Jul 2026 08:56:15 +0200 Subject: [PATCH 1/4] ref(forms): Migrate admin user edit off JsonForm to scraps form --- static/app/router/routes.tsx | 2 + static/app/views/admin/adminUserEdit.tsx | 383 ++++++++++++++--------- static/app/views/admin/adminUsers.tsx | 192 ++++++++---- 3 files changed, 383 insertions(+), 194 deletions(-) diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index fd66c268e96e..9745258bad36 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -2596,6 +2596,7 @@ function buildRoutes(): RouteObject[] { }, { path: 'users/', + name: t('Users'), children: [ { index: true, @@ -2603,6 +2604,7 @@ function buildRoutes(): RouteObject[] { }, { path: ':id', + name: t('Details'), component: make(() => import('sentry/views/admin/adminUserEdit')), }, ], diff --git a/static/app/views/admin/adminUserEdit.tsx b/static/app/views/admin/adminUserEdit.tsx index f026f7947910..b0c4781d52e4 100644 --- a/static/app/views/admin/adminUserEdit.tsx +++ b/static/app/views/admin/adminUserEdit.tsx @@ -1,83 +1,58 @@ import {Fragment, useState} from 'react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {z} from 'zod'; +import {Alert} from '@sentry/scraps/alert'; import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, useScrapsForm} from '@sentry/scraps/form'; +import {Flex} from '@sentry/scraps/layout'; import {useModal} from '@sentry/scraps/modal'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {RadioGroup} from 'sentry/components/forms/controls/radioGroup'; -import {Form} from 'sentry/components/forms/form'; -import JsonForm from 'sentry/components/forms/jsonForm'; -import {FormModel} from 'sentry/components/forms/model'; -import type {JsonFormObject} from 'sentry/components/forms/types'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; import type {User} from 'sentry/types/user'; +import type {ApiResponse} from 'sentry/utils/api/apiFetch'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {setApiQueryData, useApiQuery} from 'sentry/utils/queryClient'; -import {useApi} from 'sentry/utils/useApi'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useParams} from 'sentry/utils/useParams'; +import {BreadcrumbTitle} from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle'; -const userEditForm: JsonFormObject = { - title: 'User details', - fields: [ - { - name: 'name', - type: 'string', - required: true, - label: t('Name'), - }, - { - name: 'username', - type: 'string', - required: true, - label: t('Username'), - help: t('The username is the unique id of the user in the system'), - }, - { - name: 'email', - type: 'string', - required: true, - label: t('Email'), - help: t('The users primary email address'), - }, - { - name: 'isActive', - type: 'boolean', - required: false, - label: t('Active'), - help: t( - 'Designates whether this user should be treated as active. Unselect this instead of deleting accounts.' - ), - }, - { - name: 'isStaff', - type: 'boolean', - required: false, - label: t('Admin'), - help: t('Designates whether this user can perform administrative functions.'), - }, - { - name: 'isSuperuser', - type: 'boolean', - required: false, - label: t('Superuser'), - help: t( - 'Designates whether this user has all permissions without explicitly assigning them.' - ), - }, - ], -}; +function userDetailsQueryOptions(userId: string) { + return apiOptions.as()('/users/$userId/', { + path: {userId}, + staleTime: 0, + }); +} -const REMOVE_BUTTON_LABEL = { - disable: t('Disable User'), - delete: t('Permanently Delete User'), -}; +const schema = z.object({ + name: z.string().trim().min(1, t('Name is required')), + username: z.string().trim().min(1, t('Username is required')), + email: z.string().email(t('A valid email is required')), + isActive: z.boolean(), + isStaff: z.boolean(), + isSuperuser: z.boolean(), +}); + +// The `/users/$userId/` PUT endpoint accepts these editable fields. +type UserUpdatePayload = z.infer; + +function toFormValues(user: User): UserUpdatePayload { + return { + name: user.name, + username: user.username, + email: user.email, + isActive: user.isActive, + isStaff: user.isStaff, + isSuperuser: user.isSuperuser, + }; +} type DeleteType = 'disable' | 'delete'; @@ -86,7 +61,7 @@ type RemoveModalProps = ModalRenderProps & { user: User; }; -function RemoveUserModal({user, onRemove, closeModal}: RemoveModalProps) { +function RemoveUserModal({user, onRemove, closeModal, Footer}: RemoveModalProps) { const [deleteType, setDeleteType] = useState('disable'); const handleRemove = () => { @@ -105,122 +80,248 @@ function RemoveUserModal({user, onRemove, closeModal}: RemoveModalProps) { ['delete', t('Permanently remove the user and their data.')], ]} /> - - - - +
+ + + + +
); } -function AdminUserEdit() { +function AdminUserEditForm({ + user, + userEndpoint, +}: { + user: User; + userEndpoint: ReturnType; +}) { const {openModal} = useModal(); - - const theme = useTheme(); - const {id} = useParams<{id: string}>(); - const userEndpoint = getApiUrl('/users/$userId/', {path: {userId: id}}); - const [formModel] = useState(() => new FormModel()); - const api = useApi({persistInFlight: true}); const navigate = useNavigate(); const queryClient = useQueryClient(); - const { - data: user, - isPending, - isError, - refetch, - } = useApiQuery([userEndpoint], { - staleTime: 0, + const updateMutation = useMutation({ + mutationFn: (data: UserUpdatePayload) => + fetchMutation({url: userEndpoint, method: 'PUT', data}), + onSuccess: response => { + queryClient.setQueryData(userDetailsQueryOptions(user.id).queryKey, prev => ({ + json: response, + headers: prev?.headers ?? {}, + })); + addSuccessMessage(t('User account updated.')); + }, + onError: error => { + const detail = + error instanceof RequestError ? error.responseJSON?.detail : undefined; + addErrorMessage( + typeof detail === 'string' ? detail : t('Failed to update user account.') + ); + }, }); const deleteMutation = useMutation({ - mutationFn: () => { - return api.requestPromise(userEndpoint, { + mutationFn: () => + fetchMutation({ + url: userEndpoint, method: 'DELETE', data: {hardDelete: true, organizations: []}, - }); - }, + }), onSuccess: () => { - addSuccessMessage(t("%s's account has been deleted.", user?.email)); + addSuccessMessage(t("%s's account has been deleted.", user.email)); navigate('/manage/users/', {replace: true}); }, + onError: () => { + addErrorMessage(t('Failed to delete user account.')); + }, }); const deactivateMutation = useMutation({ - mutationFn: () => { - return api.requestPromise(userEndpoint, { + mutationFn: () => + fetchMutation({ + url: userEndpoint, method: 'PUT', data: {isActive: false}, - }); - }, + }), onSuccess: response => { - setApiQueryData(queryClient, [userEndpoint], response); - formModel.setInitialData(response); + queryClient.setQueryData(userDetailsQueryOptions(user.id).queryKey, prev => ({ + json: response, + headers: prev?.headers ?? {}, + })); + form.reset(toFormValues(response)); addSuccessMessage(t("%s's account has been deactivated.", response.email)); }, + onError: () => { + addErrorMessage(t('Failed to deactivate user account.')); + }, }); - const removeUser = (actionType: DeleteType) => - actionType === 'delete' ? deleteMutation.mutate() : deactivateMutation.mutate(); + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: toFormValues(user), + validators: {onDynamic: schema}, + onSubmit: ({value}) => + updateMutation + .mutateAsync(value) + .then(() => form.reset(value)) + .catch(() => {}), + }); - if (isPending) { - return ; - } + return ( + + + + + + {field => ( + + + + )} + - if (isError) { - return ; - } + + {field => ( + + + + )} + - if (!user) { - return null; - } + + {field => ( + + + + )} + - const openDeleteModal = () => - openModal(opts => ); + + {field => ( + + + + )} + - return ( - -

{t('Users')}

-

{t('Editing user: %s', user.email)}

-
{ - addErrorMessage(err?.responseJSON?.detail); - }} - onSubmitSuccess={response => { - setApiQueryData(queryClient, [userEndpoint], response); - addSuccessMessage(t('User account updated.')); - }} - extraButton={ + + {field => ( + + + + )} + + + + {field => ( + + + + )} + + + + - } - > - - + {t('Save Changes')} +
+
); } -const ModalFooter = styled('div')` - display: grid; - grid-auto-flow: column; - gap: ${p => p.theme.space.md}; - justify-content: end; - padding: 20px 30px; - margin: 20px -30px -30px; - border-top: 1px solid ${p => p.theme.tokens.border.primary}; -`; +function AdminUserEdit() { + const {id} = useParams<{id: string}>(); + const userEndpoint = getApiUrl('/users/$userId/', {path: {userId: id}}); + const queryClient = useQueryClient(); + + const { + data: user, + isPending, + isError, + error, + refetch, + } = useQuery({ + ...userDetailsQueryOptions(id), + // The user may have already been loaded by the users list, so seed it from + // that cache to render instantly and avoid a loading state. + initialData: () => { + const listQueries = queryClient.getQueriesData>({ + queryKey: [getApiUrl('/users/')], + }); + for (const [, data] of listQueries) { + const found = data?.json.find(candidate => candidate.id === id); + if (found) { + return {json: found, headers: {}}; + } + } + return; + }, + }); + + if (isPending) { + return ; + } + + const notFound = error instanceof RequestError && error.status === 404; + + if (isError && !notFound) { + return ; + } + + if (notFound || !user) { + return ( + + + {t('The user you were looking for was not found.')} + + + ); + } + + return ; +} export default AdminUserEdit; diff --git a/static/app/views/admin/adminUsers.tsx b/static/app/views/admin/adminUsers.tsx index 23df83ae7584..40d94ca3357b 100644 --- a/static/app/views/admin/adminUsers.tsx +++ b/static/app/views/admin/adminUsers.tsx @@ -1,67 +1,153 @@ +import {Fragment, useMemo} from 'react'; +import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; +import debounce from 'lodash/debounce'; import moment from 'moment-timezone'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Pagination} from '@sentry/scraps/pagination'; +import {Text} from '@sentry/scraps/text'; -import {ResultGrid} from 'sentry/components/resultGrid'; +import {LoadingError} from 'sentry/components/loadingError'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {SearchBar} from 'sentry/components/searchBar'; +import {SimpleTable} from 'sentry/components/tables/simpleTable'; +import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; import {t} from 'sentry/locale'; +import type {User} from 'sentry/types/user'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; +import {decodeScalar} from 'sentry/utils/queryString'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; -type Row = { - dateJoined: string; - email: string; - id: string; - lastLogin: string; - username: string; -}; +type Status = 'active' | 'disabled'; -const getRow = (row: Row) => [ - - - {row.username} - -
- {row.email !== row.username && {row.email}} - , - - {moment(row.dateJoined).format('ll')} - , - - {moment(row.lastLogin).format('ll')} - , +const STATUS_OPTIONS: Array<{label: string; value: Status}> = [ + {value: 'active', label: t('Active')}, + {value: 'disabled', label: t('Disabled')}, ]; export default function AdminUsers() { - const columns = [ - User, - - Joined - , - - Last Login - , - ]; + const location = useLocation(); + const navigate = useNavigate(); + + const query = decodeScalar(location.query.query, ''); + const status = STATUS_OPTIONS.find( + option => option.value === decodeScalar(location.query.status) + )?.value; + + const {data, isPending, isError, refetch} = useQuery({ + ...apiOptions.as()('/users/', { + query: { + query, + status, + sortBy: 'date', + cursor: decodeScalar(location.query.cursor), + per_page: 50, + }, + staleTime: 0, + }), + select: selectJsonWithHeaders, + }); + + const users = data?.json; + const pageLinks = data?.headers.Link; + + const debouncedSearch = useMemo( + () => + debounce( + (searchQuery: string) => + navigate( + {query: {...location.query, query: searchQuery, cursor: undefined}}, + {replace: true} + ), + DEFAULT_DEBOUNCE_DURATION + ), + [location.query, navigate] + ); return ( -
-

{t('Users')}

- -
+ + + + {containerProps => ( + + )} + + ( + + {STATUS_OPTIONS.find(option => option.value === status)?.label ?? t('Any')} + + )} + value={status} + options={STATUS_OPTIONS} + onChange={option => + navigate({ + query: { + ...location.query, + status: option?.value || undefined, + cursor: undefined, + }, + }) + } + /> + + + {isError ? ( + + ) : isPending ? ( + + ) : ( + + + {t('User')} + {t('Joined')} + {t('Last Login')} + + {users?.length ? ( + users.map(user => ( + + + + + {user.username} + + {user.email !== user.username && ( + + {user.email} + + )} + + + + {moment(user.dateJoined).format('ll')} + + + {moment(user.lastLogin).format('ll')} + + + )) + ) : ( + {t('No users found.')} + )} + + )} + + {pageLinks && } + ); } + +const UsersTable = styled(SimpleTable)` + grid-template-columns: minmax(0, 1fr) max-content max-content; +`; From cd9bd142db027a3a70f50aecbbb22a0a48800887 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Fri, 17 Jul 2026 15:52:29 +0200 Subject: [PATCH 2/4] fix(forms): Fix stale admin user edit form when switching users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed the detail query from the users list cache via placeholderData instead of initialData. Because placeholderData isn't written to the cache and keeps the query pending, the detail fetch always runs and the form only mounts once authoritative data has loaded — so switching users while the view stays mounted remounts the form with fresh values instead of showing the previous user's data. Move BreadcrumbTitle to the parent so it shows the placeholder name while loading and the detail name once resolved. Keep the detail setQueryData on mutation success so the breadcrumb updates without a refetch; the list uses staleTime: 0 and refreshes on its own. --- static/app/views/admin/adminUserEdit.tsx | 53 +++++++++++++----------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/static/app/views/admin/adminUserEdit.tsx b/static/app/views/admin/adminUserEdit.tsx index b0c4781d52e4..2a05b28c2426 100644 --- a/static/app/views/admin/adminUserEdit.tsx +++ b/static/app/views/admin/adminUserEdit.tsx @@ -107,6 +107,9 @@ function AdminUserEditForm({ mutationFn: (data: UserUpdatePayload) => fetchMutation({url: userEndpoint, method: 'PUT', data}), onSuccess: response => { + // Update the detail cache so the breadcrumb reflects the new name without + // waiting for a refetch. The users list uses staleTime: 0, so it refreshes + // on its own when navigated back to. queryClient.setQueryData(userDetailsQueryOptions(user.id).queryKey, prev => ({ json: response, headers: prev?.headers ?? {}, @@ -171,7 +174,6 @@ function AdminUserEditForm({ return ( - @@ -279,15 +281,18 @@ function AdminUserEdit() { const { data: user, - isPending, + isLoading, + isPlaceholderData, isError, error, refetch, } = useQuery({ ...userDetailsQueryOptions(id), - // The user may have already been loaded by the users list, so seed it from - // that cache to render instantly and avoid a loading state. - initialData: () => { + // Seed from the users list cache as a temporary fallback so the breadcrumb + // has a name while the detail request loads. Unlike initialData, this isn't + // written to the cache, so the detail fetch always runs and the form only + // renders once authoritative data has loaded. + placeholderData: () => { const listQueries = queryClient.getQueriesData>({ queryKey: [getApiUrl('/users/')], }); @@ -301,27 +306,27 @@ function AdminUserEdit() { }, }); - if (isPending) { - return ; - } - const notFound = error instanceof RequestError && error.status === 404; - if (isError && !notFound) { - return ; - } - - if (notFound || !user) { - return ( - - - {t('The user you were looking for was not found.')} - - - ); - } - - return ; + return ( + + {/* Fall back to the placeholder user's name until the detail loads. */} + {user && } + {isLoading || isPlaceholderData ? ( + + ) : isError && !notFound ? ( + + ) : notFound || !user ? ( + + + {t('The user you were looking for was not found.')} + + + ) : ( + + )} + + ); } export default AdminUserEdit; From 65cd7447909cac7be627042c1cab4cb379fdee76 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 08:15:39 +0200 Subject: [PATCH 3/4] fix(forms): Reset admin user form from server response on save The update path reset the form from the submitted client payload while the cache was updated from the PUT response, so the form baseline could diverge from cached user data when the API normalizes fields. Reset from the response instead, matching the deactivate path. --- static/app/views/admin/adminUserEdit.tsx | 32 ++++++------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/static/app/views/admin/adminUserEdit.tsx b/static/app/views/admin/adminUserEdit.tsx index 2a05b28c2426..641d1b3a297b 100644 --- a/static/app/views/admin/adminUserEdit.tsx +++ b/static/app/views/admin/adminUserEdit.tsx @@ -107,13 +107,11 @@ function AdminUserEditForm({ mutationFn: (data: UserUpdatePayload) => fetchMutation({url: userEndpoint, method: 'PUT', data}), onSuccess: response => { - // Update the detail cache so the breadcrumb reflects the new name without - // waiting for a refetch. The users list uses staleTime: 0, so it refreshes - // on its own when navigated back to. queryClient.setQueryData(userDetailsQueryOptions(user.id).queryKey, prev => ({ json: response, headers: prev?.headers ?? {}, })); + form.reset(toFormValues(response)); addSuccessMessage(t('User account updated.')); }, onError: error => { @@ -165,11 +163,7 @@ function AdminUserEditForm({ ...defaultFormOptions, defaultValues: toFormValues(user), validators: {onDynamic: schema}, - onSubmit: ({value}) => - updateMutation - .mutateAsync(value) - .then(() => form.reset(value)) - .catch(() => {}), + onSubmit: ({value}) => updateMutation.mutateAsync(value).catch(() => {}), }); return ( @@ -288,21 +282,12 @@ function AdminUserEdit() { refetch, } = useQuery({ ...userDetailsQueryOptions(id), - // Seed from the users list cache as a temporary fallback so the breadcrumb - // has a name while the detail request loads. Unlike initialData, this isn't - // written to the cache, so the detail fetch always runs and the form only - // renders once authoritative data has loaded. placeholderData: () => { - const listQueries = queryClient.getQueriesData>({ - queryKey: [getApiUrl('/users/')], - }); - for (const [, data] of listQueries) { - const found = data?.json.find(candidate => candidate.id === id); - if (found) { - return {json: found, headers: {}}; - } - } - return; + const found = queryClient + .getQueriesData>({queryKey: [getApiUrl('/users/')]}) + .flatMap(([, data]) => data?.json ?? []) + .find(candidate => candidate.id === id); + return found ? {json: found, headers: {}} : undefined; }, }); @@ -310,8 +295,7 @@ function AdminUserEdit() { return ( - {/* Fall back to the placeholder user's name until the detail loads. */} - {user && } + {isLoading || isPlaceholderData ? ( ) : isError && !notFound ? ( From 316393aebccf06542951d15e46594a22d1ea489c Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 20 Jul 2026 09:19:27 +0200 Subject: [PATCH 4/4] fix(forms): Fix stale query overwriting URL state in admin user search The migrated admin user search debounced navigation by recreating a lodash debounce whenever location.query changed, without cancelling pending timers. A leaked timer would fire with a stale location.query snapshot and spread it over the URL, clobbering newer state such as the status filter or pagination cursor set by a competing navigation. Replace the manual debounce with nuqs useQueryStates: it writes only the query, status, and cursor keys (no read-modify-write spread of the whole query), debounces the URL update via limitUrlUpdates, and updates state optimistically so typing stays responsive. The status filter keeps its push history entry while search continues to replace. --- static/app/views/admin/adminUsers.tsx | 57 +++++++++++---------------- 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/static/app/views/admin/adminUsers.tsx b/static/app/views/admin/adminUsers.tsx index 40d94ca3357b..65eda165970a 100644 --- a/static/app/views/admin/adminUsers.tsx +++ b/static/app/views/admin/adminUsers.tsx @@ -1,8 +1,8 @@ -import {Fragment, useMemo} from 'react'; +import {Fragment} from 'react'; import styled from '@emotion/styled'; import {useQuery} from '@tanstack/react-query'; -import debounce from 'lodash/debounce'; import moment from 'moment-timezone'; +import {debounce, parseAsString, parseAsStringLiteral, useQueryStates} from 'nuqs'; import {CompactSelect} from '@sentry/scraps/compactSelect'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; @@ -19,9 +19,6 @@ import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; import {t} from 'sentry/locale'; import type {User} from 'sentry/types/user'; import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; -import {decodeScalar} from 'sentry/utils/queryString'; -import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; type Status = 'active' | 'disabled'; @@ -30,22 +27,22 @@ const STATUS_OPTIONS: Array<{label: string; value: Status}> = [ {value: 'disabled', label: t('Disabled')}, ]; -export default function AdminUsers() { - const location = useLocation(); - const navigate = useNavigate(); +const STATUS_VALUES = STATUS_OPTIONS.map(option => option.value); - const query = decodeScalar(location.query.query, ''); - const status = STATUS_OPTIONS.find( - option => option.value === decodeScalar(location.query.status) - )?.value; +export default function AdminUsers() { + const [{query, status, cursor}, setSearchParams] = useQueryStates({ + query: parseAsString.withDefault(''), + status: parseAsStringLiteral(STATUS_VALUES), + cursor: parseAsString, + }); const {data, isPending, isError, refetch} = useQuery({ ...apiOptions.as()('/users/', { query: { query, - status, + status: status ?? undefined, sortBy: 'date', - cursor: decodeScalar(location.query.cursor), + cursor: cursor ?? undefined, per_page: 50, }, staleTime: 0, @@ -56,18 +53,11 @@ export default function AdminUsers() { const users = data?.json; const pageLinks = data?.headers.Link; - const debouncedSearch = useMemo( - () => - debounce( - (searchQuery: string) => - navigate( - {query: {...location.query, query: searchQuery, cursor: undefined}}, - {replace: true} - ), - DEFAULT_DEBOUNCE_DURATION - ), - [location.query, navigate] - ); + const onSearch = (searchQuery: string) => + setSearchParams( + {query: searchQuery, cursor: null}, + {limitUrlUpdates: debounce(DEFAULT_DEBOUNCE_DURATION), history: 'replace'} + ); return ( @@ -77,7 +67,7 @@ export default function AdminUsers() { )} @@ -89,16 +79,13 @@ export default function AdminUsers() { {STATUS_OPTIONS.find(option => option.value === status)?.label ?? t('Any')} )} - value={status} + value={status ?? undefined} options={STATUS_OPTIONS} onChange={option => - navigate({ - query: { - ...location.query, - status: option?.value || undefined, - cursor: undefined, - }, - }) + setSearchParams( + {status: option?.value ?? null, cursor: null}, + {history: 'push'} + ) } />