From 7c3404d2b4d2c283e4588d4d7986281869ee052c Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Thu, 6 Aug 2026 16:05:40 -0700 Subject: [PATCH 1/2] feat(admin): Improve admin search dropdowns Replace the hand-rolled debounce search with typed React Query and the core Select component. Results now use fzf ordering, region-aware organization queries, and useful user details instead of the old bare rows. Co-Authored-By: Codex --- static/app/utils/api/knownGetsentryApiUrls.ts | 1 + .../components/debounceSearch.spec.tsx | 38 +++ static/gsAdmin/components/debounceSearch.tsx | 315 +++++++++--------- static/gsAdmin/views/home.tsx | 218 +++++++----- 4 files changed, 332 insertions(+), 240 deletions(-) create mode 100644 static/gsAdmin/components/debounceSearch.spec.tsx diff --git a/static/app/utils/api/knownGetsentryApiUrls.ts b/static/app/utils/api/knownGetsentryApiUrls.ts index 6ca3a65c1881..225d0b8fe144 100644 --- a/static/app/utils/api/knownGetsentryApiUrls.ts +++ b/static/app/utils/api/knownGetsentryApiUrls.ts @@ -7,6 +7,7 @@ export type KnownGetsentryApiUrls = | '/_admin/cells/$region/admin-invoices/$invoiceId/' + | '/_admin/cells/$region/customers/' | '/_admin/cells/$region/invoice-comparison/' | '/audit-logs/' | '/beacons/' diff --git a/static/gsAdmin/components/debounceSearch.spec.tsx b/static/gsAdmin/components/debounceSearch.spec.tsx new file mode 100644 index 000000000000..79dd29022da0 --- /dev/null +++ b/static/gsAdmin/components/debounceSearch.spec.tsx @@ -0,0 +1,38 @@ +import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; + +import {DebounceSearch} from 'admin/components/debounceSearch'; + +type Result = { + id: string; + name: string; +}; + +describe('DebounceSearch', () => { + it('queries and selects a result', async () => { + const result: Result = {id: '1', name: 'Alice'}; + const onSelectResult = jest.fn(); + render( + item.id} + getResultSearchTerms={item => [item.name]} + onSelectResult={onSelectResult} + queryOptions={query => ({ + queryKey: ['admin-search-test', query], + queryFn: () => Promise.resolve([result]), + staleTime: Infinity, + })} + renderResult={item => item.name} + /> + ); + const user = userEvent.setup(); + const input = screen.getByRole('textbox', {name: 'Users'}); + + await user.type(input, 'ali'); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + + await user.click(screen.getByText('Alice')); + expect(onSelectResult).toHaveBeenCalledWith(result); + }); +}); diff --git a/static/gsAdmin/components/debounceSearch.tsx b/static/gsAdmin/components/debounceSearch.tsx index 912a5523892a..5d9e11dbc6a7 100644 --- a/static/gsAdmin/components/debounceSearch.tsx +++ b/static/gsAdmin/components/debounceSearch.tsx @@ -1,179 +1,164 @@ -import type {ReactElement} from 'react'; -import {useCallback, useEffect, useRef, useState} from 'react'; -import styled from '@emotion/styled'; -import debounce from 'lodash/debounce'; +import type {ReactNode} from 'react'; +import {useId, useState} from 'react'; +import {useTheme} from '@emotion/react'; +import type {QueryKey, UseQueryOptions} from '@tanstack/react-query'; +import {useQuery} from '@tanstack/react-query'; -import {Container} from '@sentry/scraps/layout'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Select, type SelectValue} from '@sentry/scraps/select'; +import {Text} from '@sentry/scraps/text'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {SearchBar} from 'sentry/components/searchBar'; -import {useApi} from 'sentry/utils/useApi'; -import {useKeyPress} from 'sentry/utils/useKeyPress'; +import {fzf} from 'sentry/utils/search/fzf'; +import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; -type Props = { - onSelectResult: (value: string) => void; - path: string; - placeholder: string; - suggestionContent: (suggestion: any) => ReactElement; - createSuggestionPath?: (suggestion: any) => string; - host?: string; - onSearch?: (value: string) => void; - queryParam?: string; -}; - -export function DebounceSearch({ - createSuggestionPath, - onSearch, - onSelectResult, - host, - path, - placeholder, - queryParam = '', - suggestionContent, -}: Props) { - const [error, setError] = useState(''); - const [loading, setLoading] = useState(false); - const [query, setQuery] = useState(''); - const [queryResults, setQueryResults] = useState([]); - const [showResults, setShowResults] = useState(false); - const [node, setNode] = useState(); - const setKeyHandlers = useCallback((nodeRef: HTMLDivElement | null) => { - setNode(nodeRef); - }, []); - const downPress = useKeyPress('ArrowDown', node); - const upPress = useKeyPress('ArrowUp', node); - const enterPress = useKeyPress('Enter', node); - const escapePress = useKeyPress('Escape', node); - - const [cursor, setCursor] = useState(0); - - const api = useApi(); +const SEARCH_DEBOUNCE_MS = 300; - const debouncedSearch = useRef( - debounce(async (searchHost, searchPath, value) => { - // Avoid slow-fetch race conditions - api.clear(); - setError(''); - setQueryResults([]); - - if (value) { - try { - const queryParams = { - query: [queryParam, value].filter(Boolean).join(':'), - per_page: 10, - }; - const results = await api.requestPromise(searchPath, { - method: 'GET', - host: searchHost, - data: queryParams, - }); - setQueryResults(results); - } catch (err) { - setError((err as Error).message); - } - } - setLoading(false); - value ? setShowResults(true) : setShowResults(false); - }, 300) - ).current; - - const onChange = useCallback( - (value: string) => { - value ? setLoading(true) : setLoading(false); - value ? setShowResults(true) : setShowResults(false); - setQuery(value); - debouncedSearch(host, path, value); - }, - [host, path, debouncedSearch] - ); +type SearchOption = SelectValue & + ({kind: 'query'; query: string} | {kind: 'result'; result: TResult}); - useEffect(() => { - if (queryResults.length && downPress) { - setCursor(prevState => - prevState < queryResults.length ? prevState + 1 : prevState - ); - } - }, [downPress, queryResults.length]); +function getBestMatchScore(searchTerms: readonly string[], query: string) { + const normalizedQuery = query.toLowerCase(); + let bestScore = Number.NEGATIVE_INFINITY; - useEffect(() => { - if (queryResults.length && upPress) { - setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState)); + for (const searchTerm of searchTerms) { + const match = fzf(searchTerm, normalizedQuery, false); + if (match.end !== -1) { + bestScore = Math.max(bestScore, match.score); } - }, [upPress, queryResults.length]); + } - useEffect(() => { - if (enterPress && cursor === 0) { - if (onSearch) { - onSearch(query); - } else { - onChange(query); - } - } else if (enterPress && cursor <= queryResults.length) { - const item = queryResults[cursor - 1]!; - onSelectResult(item); - } - }, [cursor, enterPress, onChange, onSearch, onSelectResult, query, queryResults]); + return bestScore; +} - useEffect(() => { - api.clear(); - setCursor(0); - setError(''); - setLoading(false); - setQueryResults([]); - setShowResults(false); - }, [escapePress, debouncedSearch, api, host, path]); +type Props = { + getResultKey: (result: TResult) => string; + getResultSearchTerms: (result: TResult) => readonly string[]; + label: string; + onSelectResult: (result: TResult) => void; + queryOptions: ( + query: string + ) => UseQueryOptions; + renderResult: (result: TResult) => ReactNode; + isExactMatch?: (result: TResult, query: string) => boolean; + onSearch?: (query: string) => void; + placeholder?: string; +}; - const renderSuggestion = (item: any, idx: number) => { - return ( - - - {suggestionContent(item)} - - - ); - }; +export function DebounceSearch({ + getResultKey, + getResultSearchTerms, + isExactMatch, + label, + onSearch, + onSelectResult, + placeholder = label, + queryOptions, + renderResult, +}: Props) { + const inputId = useId(); + const theme = useTheme(); + const [inputValue, setInputValue] = useState(''); + const normalizedInput = inputValue.trim(); + const debouncedQuery = useDebouncedValue(normalizedInput, SEARCH_DEBOUNCE_MS); + + const options = queryOptions(debouncedQuery); + const query = useQuery({ + ...options, + enabled: + normalizedInput.length > 0 && + debouncedQuery.length > 0 && + options.enabled !== false, + }); + + const hasSettledInput = normalizedInput === debouncedQuery; + const resultOptions: Array> = hasSettledInput + ? (query.data ?? []) + .map(result => { + const searchTerms = getResultSearchTerms(result); + return { + isExactMatch: isExactMatch?.(result, debouncedQuery) ?? false, + result, + score: getBestMatchScore(searchTerms, debouncedQuery), + searchTerms, + }; + }) + .toSorted( + (a, b) => Number(b.isExactMatch) - Number(a.isExactMatch) || b.score - a.score + ) + .map(({result, searchTerms}) => ({ + kind: 'result', + result, + value: `result:${getResultKey(result)}`, + label: renderResult(result), + textValue: searchTerms.join(' '), + })) + : []; + const selectOptions = onSearch + ? [ + { + kind: 'query', + query: normalizedInput, + value: `query:${normalizedInput}`, + label: `Search ${label.toLowerCase()} for "${normalizedInput}"`, + } satisfies SearchOption, + ...resultOptions, + ] + : resultOptions; + const isLoading = normalizedInput.length > 0 && (!hasSettledInput || query.isFetching); return ( -
-
- -
- - {loading && } - {!loading && showResults && queryResults.map(renderSuggestion)} - {!loading && showResults && !queryResults.length && No results found} - - {error && {error}} -
+ + + {label} + + > + inputId={inputId} + inputValue={inputValue} + isLoading={isLoading} + isSearchable + openMenuOnClick={false} + options={selectOptions} + placeholder={placeholder} + styles={{ + control: provided => ({ + ...provided, + backgroundColor: theme.tokens.background.primary, + }), + }} + value={null} + components={{ + DropdownIndicator: null, + LoadingMessage: () => ( + + + Loading results… + + + ), + }} + filterOption={null} + noOptionsMessage={() => + query.isError ? 'Unable to load results' : 'No results found' + } + onInputChange={(value, action) => { + if (action.action !== 'input-change') { + return; + } + setInputValue(value); + }} + onChange={option => { + if (option.kind === 'query') { + onSearch?.(option.query); + } else { + onSelectResult(option.result); + } + }} + /> + {hasSettledInput && query.error && ( + + {query.error.message} + + )} + ); } - -const Card = styled('div')<{highlight?: boolean}>` - background: ${p => - p.highlight ? p.theme.colors.gray100 : p.theme.tokens.background.primary}; - color: ${p => - p.highlight - ? p.theme.tokens.interactive.link.accent.active - : p.theme.tokens.content.primary}; - box-shadow: ${p => p.theme.shadow.medium}; - padding: ${p => p.theme.space.xl}; -`; -const Error = styled('div')` - color: red; -`; -const SuggestionCard = styled(Card)` - &:hover { - color: ${p => p.theme.tokens.interactive.link.accent.active}; - background: ${p => p.theme.colors.gray100}; - cursor: pointer; - } -`; diff --git a/static/gsAdmin/views/home.tsx b/static/gsAdmin/views/home.tsx index d7cb7dcfd569..b59cd998297f 100644 --- a/static/gsAdmin/views/home.tsx +++ b/static/gsAdmin/views/home.tsx @@ -1,19 +1,96 @@ import {useState} from 'react'; import styled from '@emotion/styled'; +import {skipToken} from '@tanstack/react-query'; +import {UserAvatar} from '@sentry/scraps/avatar'; +import {Badge} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Flex, Container} from '@sentry/scraps/layout'; +import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Text} from '@sentry/scraps/text'; -import {UserBadge} from 'sentry/components/idBadge/userBadge'; -import {Truncate} from 'sentry/components/truncate'; +import type {OrganizationSummary} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; +import type {User} from 'sentry/types/user'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; import {getCells} from 'sentry/utils/cells'; import {useNavigate} from 'sentry/utils/useNavigate'; import {DebounceSearch} from 'admin/components/debounceSearch'; import {Overview} from 'admin/views/overview'; +type OrganizationSearchResult = Pick; + +type ProjectSearchResult = Pick & { + organization: Pick; +}; + +function renderOrganizationResult(organization: OrganizationSearchResult) { + return ( + + + {organization.slug} + {' '} + ( + + {organization.name} + + ) + + ); +} + +function renderUserResult(user: User) { + const displayName = user.name || user.username || user.email; + const identifiers = [...new Set([user.email, user.username])].filter( + identifier => identifier && identifier !== displayName + ); + + return ( + + + + + + {displayName} + + + {user.isSuperuser ? ( + Superuser + ) : user.isStaff ? ( + Staff + ) : null} + {user.isSuspended ? ( + Suspended + ) : user.isActive ? null : ( + Inactive + )} + + + + {[...identifiers, `ID ${user.id}`].join(' · ')} + + + + ); +} + +function renderProjectResult(project: ProjectSearchResult) { + return ( + + + {project.organization.slug} + + : {project.slug} (id:{' '} + + {project.id} + + ) + + ); +} + export function HomePage() { const navigate = useNavigate(); const cells = getCells(); @@ -21,9 +98,8 @@ export function HomePage() { const [localityUrl, setLocalityUrl] = useState(cells[0]!.locality_url); const selectedCell = cells.find(cell => cell.locality_url === localityUrl); - const buildOrgPath = (org: any) => `/_admin/customers/${org.slug}/`; - const orgSelect = (org: any) => { - navigate(buildOrgPath(org)); + const orgSelect = (organization: OrganizationSearchResult) => { + navigate(`/_admin/customers/${organization.slug}/`); }; const orgSubmit = (query: string) => { navigate({ @@ -34,9 +110,8 @@ export function HomePage() { }, }); }; - const buildUserPath = (user: any) => `/_admin/users/${user.id}/`; - const userSelect = (user: any) => { - navigate(buildUserPath(user)); + const userSelect = (user: User) => { + navigate(`/_admin/users/${user.id}/`); }; const userSubmit = (query: string) => { navigate({ @@ -46,35 +121,8 @@ export function HomePage() { }, }); }; - const buildProjPath = (proj: any) => - `/_admin/customers/${proj.organization.slug}/projects/${proj.slug}/`; - const projSelect = (proj: any) => { - navigate(buildProjPath(proj)); - }; - - const renderOrgSuggestion = (org: any) => { - return ( -
- {org.slug} ({org.name}) -
- ); - }; - const renderUserSuggestion = (user: any) => { - return ( - } - /> - ); - }; - const renderProjSuggestion = (proj: any) => { - return ( -
- {proj.organization.slug}: {proj.slug} (id:{' '} - {proj.id}) -
- ); + const projSelect = (project: ProjectSearchResult) => { + navigate(`/_admin/customers/${project.organization.slug}/projects/${project.slug}/`); }; if (oldSplash) { @@ -108,19 +156,23 @@ export function HomePage() { All actions are logged and audited -
- - Users - + user.id} + getResultSearchTerms={user => [user.username, user.email, user.name]} onSelectResult={userSelect} onSearch={userSubmit} - suggestionContent={renderUserSuggestion} - placeholder="Query users" - createSuggestionPath={buildUserPath} + queryOptions={query => + apiOptions.as()('/users/', { + query: {query, per_page: 10}, + staleTime: 30_000, + }) + } + renderResult={renderUserResult} /> -
+ ( @@ -136,33 +188,53 @@ export function HomePage() { }} /> - - Organizations + + organization.id} + getResultSearchTerms={organization => [organization.slug, organization.name]} + isExactMatch={(organization, query) => + organization.slug.toLowerCase() === query.toLowerCase() + } + onSelectResult={orgSelect} + onSearch={orgSubmit} + queryOptions={query => + apiOptions.as()( + '/_admin/cells/$region/customers/', + { + path: selectedCell ? {region: selectedCell.name} : skipToken, + query: {query, per_page: 50, sortBy: 'members'}, + host: localityUrl, + staleTime: 30_000, + } + ) + } + renderResult={renderOrganizationResult} + /> - - - Projects (by ID) + + project.id} + getResultSearchTerms={project => [ + project.id, + project.slug, + project.organization.slug, + ]} + onSelectResult={projSelect} + queryOptions={query => + apiOptions.as()('/projects/', { + query: {query: `id:${query}`, per_page: 10, show: 'all'}, + host: localityUrl, + staleTime: 30_000, + }) + } + renderResult={renderProjectResult} + /> - @@ -182,10 +254,6 @@ const HeaderTitle = styled('h3')` color: ${p => p.theme.tokens.content.primary}; `; -const SecondaryText = styled('span')` - color: ${p => p.theme.tokens.content.secondary}; -`; - const Warning = styled('div')` color: red; font-size: large; From 11909532fd74e558b28469c633e3ce6d74a75367 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Fri, 7 Aug 2026 12:08:05 -0700 Subject: [PATCH 2/2] fix(admin): Polish search combobox behavior Don't offer empty search actions, and let existing queries reopen on click or focus. Rename the component around its actual UI role while we're here. Co-Authored-By: Codex --- ....spec.tsx => adminSearchCombobox.spec.tsx} | 6 +-- ...unceSearch.tsx => adminSearchCombobox.tsx} | 46 +++++++++---------- static/gsAdmin/views/home.tsx | 8 ++-- 3 files changed, 30 insertions(+), 30 deletions(-) rename static/gsAdmin/components/{debounceSearch.spec.tsx => adminSearchCombobox.spec.tsx} (88%) rename static/gsAdmin/components/{debounceSearch.tsx => adminSearchCombobox.tsx} (79%) diff --git a/static/gsAdmin/components/debounceSearch.spec.tsx b/static/gsAdmin/components/adminSearchCombobox.spec.tsx similarity index 88% rename from static/gsAdmin/components/debounceSearch.spec.tsx rename to static/gsAdmin/components/adminSearchCombobox.spec.tsx index 79dd29022da0..d4ed4d5057a4 100644 --- a/static/gsAdmin/components/debounceSearch.spec.tsx +++ b/static/gsAdmin/components/adminSearchCombobox.spec.tsx @@ -1,18 +1,18 @@ import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; -import {DebounceSearch} from 'admin/components/debounceSearch'; +import {AdminSearchCombobox} from 'admin/components/adminSearchCombobox'; type Result = { id: string; name: string; }; -describe('DebounceSearch', () => { +describe('AdminSearchCombobox', () => { it('queries and selects a result', async () => { const result: Result = {id: '1', name: 'Alice'}; const onSelectResult = jest.fn(); render( - item.id} diff --git a/static/gsAdmin/components/debounceSearch.tsx b/static/gsAdmin/components/adminSearchCombobox.tsx similarity index 79% rename from static/gsAdmin/components/debounceSearch.tsx rename to static/gsAdmin/components/adminSearchCombobox.tsx index 5d9e11dbc6a7..6aa039920fcf 100644 --- a/static/gsAdmin/components/debounceSearch.tsx +++ b/static/gsAdmin/components/adminSearchCombobox.tsx @@ -13,7 +13,7 @@ import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; const SEARCH_DEBOUNCE_MS = 300; -type SearchOption = SelectValue & +type AdminSearchComboboxOption = SelectValue & ({kind: 'query'; query: string} | {kind: 'result'; result: TResult}); function getBestMatchScore(searchTerms: readonly string[], query: string) { @@ -30,7 +30,7 @@ function getBestMatchScore(searchTerms: readonly string[], query: string) { return bestScore; } -type Props = { +type AdminSearchComboboxProps = { getResultKey: (result: TResult) => string; getResultSearchTerms: (result: TResult) => readonly string[]; label: string; @@ -44,7 +44,7 @@ type Props = { placeholder?: string; }; -export function DebounceSearch({ +export function AdminSearchCombobox({ getResultKey, getResultSearchTerms, isExactMatch, @@ -54,24 +54,22 @@ export function DebounceSearch( placeholder = label, queryOptions, renderResult, -}: Props) { +}: AdminSearchComboboxProps) { const inputId = useId(); const theme = useTheme(); const [inputValue, setInputValue] = useState(''); const normalizedInput = inputValue.trim(); + const hasInput = normalizedInput.length > 0; const debouncedQuery = useDebouncedValue(normalizedInput, SEARCH_DEBOUNCE_MS); const options = queryOptions(debouncedQuery); const query = useQuery({ ...options, - enabled: - normalizedInput.length > 0 && - debouncedQuery.length > 0 && - options.enabled !== false, + enabled: hasInput && debouncedQuery.length > 0 && options.enabled !== false, }); const hasSettledInput = normalizedInput === debouncedQuery; - const resultOptions: Array> = hasSettledInput + const resultOptions: Array> = hasSettledInput ? (query.data ?? []) .map(result => { const searchTerms = getResultSearchTerms(result); @@ -93,30 +91,32 @@ export function DebounceSearch( textValue: searchTerms.join(' '), })) : []; - const selectOptions = onSearch - ? [ - { - kind: 'query', - query: normalizedInput, - value: `query:${normalizedInput}`, - label: `Search ${label.toLowerCase()} for "${normalizedInput}"`, - } satisfies SearchOption, - ...resultOptions, - ] - : resultOptions; - const isLoading = normalizedInput.length > 0 && (!hasSettledInput || query.isFetching); + const selectOptions = + onSearch && hasInput + ? [ + { + kind: 'query', + query: normalizedInput, + value: `query:${normalizedInput}`, + label: `Search ${label.toLowerCase()} for "${normalizedInput}"`, + } satisfies AdminSearchComboboxOption, + ...resultOptions, + ] + : resultOptions; + const isLoading = hasInput && (!hasSettledInput || query.isFetching); return ( {label} - > + > inputId={inputId} inputValue={inputValue} isLoading={isLoading} isSearchable - openMenuOnClick={false} + openMenuOnClick={hasInput} + openMenuOnFocus={hasInput} options={selectOptions} placeholder={placeholder} styles={{ diff --git a/static/gsAdmin/views/home.tsx b/static/gsAdmin/views/home.tsx index b59cd998297f..e9a2c929904b 100644 --- a/static/gsAdmin/views/home.tsx +++ b/static/gsAdmin/views/home.tsx @@ -17,7 +17,7 @@ import {apiOptions} from 'sentry/utils/api/apiOptions'; import {getCells} from 'sentry/utils/cells'; import {useNavigate} from 'sentry/utils/useNavigate'; -import {DebounceSearch} from 'admin/components/debounceSearch'; +import {AdminSearchCombobox} from 'admin/components/adminSearchCombobox'; import {Overview} from 'admin/views/overview'; type OrganizationSearchResult = Pick; @@ -157,7 +157,7 @@ export function HomePage() { - user.id} @@ -189,7 +189,7 @@ export function HomePage() { /> - organization.id} @@ -215,7 +215,7 @@ export function HomePage() { - project.id}