Connects: SearchBar improvements (#24190)

* Add stories for longer resource and cluster names

* Extract `PickerContainer`, improve styling of pickers and input

* Extract `FilterButton` component to avoid repeating the same code

* Add a message about excluded clusters

* Show a hint message when input is empty

* Show cluster filters only when there is more than one cluster

* Make search bar input responsive

* Review fixes

* Add a story for no results state

* Fix missing margin when input wraps

* Add license header

* Render `NoResultsItem` and `TypeToSearchItem` as extra items above regular items

* Use `calc` to calculate padding

* Fix comment

* Show TypeToSearchItem only after filter actions attempt finishes

* Run filter search synchronously

---------

Co-authored-by: Rafał Cieślak <rafal.cieslak@goteleport.com>
This commit is contained in:
Grzegorz Zdunek 2023-04-11 15:20:54 +02:00 committed by GitHub
parent 54c7bc82fd
commit 5641b22f52
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 423 additions and 227 deletions

View file

@ -33,7 +33,10 @@ it('does not display empty results copy after selecting two filters', () => {
draft.rootClusterUri = '/clusters/foo';
});
const mockAttempts = [makeSuccessAttempt([])];
const mockAttempts = {
filterActionsAttempt: makeSuccessAttempt([]),
resourceActionsAttempt: makeSuccessAttempt([]),
};
jest
.spyOn(useSearchAttempts, 'useSearchAttempts')
.mockImplementation(() => mockAttempts);
@ -72,25 +75,16 @@ it('does display empty results copy after providing search query for which there
draft.rootClusterUri = '/clusters/foo';
});
const mockAttempts = [makeSuccessAttempt([])];
const mockAttempts = {
filterActionsAttempt: makeSuccessAttempt([]),
resourceActionsAttempt: makeSuccessAttempt([]),
};
jest
.spyOn(useSearchAttempts, 'useSearchAttempts')
.mockImplementation(() => mockAttempts);
jest.spyOn(SearchContext, 'useSearchContext').mockImplementation(() => ({
inputValue: 'foo',
filters: [],
setFilter: () => {},
removeFilter: () => {},
opened: true,
open: () => {},
close: () => {},
closeAndResetInput: () => {},
resetInput: () => {},
changeActivePicker: () => {},
onInputValueChange: () => {},
activePicker: pickers.actionPicker,
inputRef: undefined,
}));
jest
.spyOn(SearchContext, 'useSearchContext')
.mockImplementation(() => mockedSearchContext);
render(
<MockAppContextProvider appContext={appContext}>
@ -99,5 +93,63 @@ it('does display empty results copy after providing search query for which there
);
const results = screen.getByRole('menu');
expect(results).toHaveTextContent('No matching results found');
expect(results).toHaveTextContent('No matching results found.');
});
it('does display empty results copy and excluded clusters after providing search query for which there is no results', () => {
const appContext = new MockAppContext();
jest
.spyOn(appContext.clustersService, 'getRootClusters')
.mockImplementation(() => [
{
uri: '/clusters/teleport-12-ent.asteroid.earth',
name: 'teleport-12-ent.asteroid.earth',
connected: false,
leaf: false,
proxyHost: 'test:3030',
authClusterId: '73c4746b-d956-4f16-9848-4e3469f70762',
},
]);
appContext.workspacesService.setState(draft => {
draft.rootClusterUri = '/clusters/foo';
});
const mockAttempts = {
filterActionsAttempt: makeSuccessAttempt([]),
resourceActionsAttempt: makeSuccessAttempt([]),
};
jest
.spyOn(useSearchAttempts, 'useSearchAttempts')
.mockImplementation(() => mockAttempts);
jest
.spyOn(SearchContext, 'useSearchContext')
.mockImplementation(() => mockedSearchContext);
render(
<MockAppContextProvider appContext={appContext}>
<SearchBarConnected />
</MockAppContextProvider>
);
const results = screen.getByRole('menu');
expect(results).toHaveTextContent('No matching results found.');
expect(results).toHaveTextContent(
'The cluster teleport-12-ent.asteroid.earth was excluded from the search because you are not logged in to it.'
);
});
const mockedSearchContext = {
inputValue: 'foo',
filters: [],
setFilter: () => {},
removeFilter: () => {},
opened: true,
open: () => {},
close: () => {},
closeAndResetInput: () => {},
resetInput: () => {},
changeActivePicker: () => {},
onInputValueChange: () => {},
activePicker: pickers.actionPicker,
inputRef: undefined,
};

View file

@ -17,7 +17,6 @@
import React, { useRef, useEffect } from 'react';
import styled from 'styled-components';
import { Box, Flex } from 'design';
import { space, width, color, height } from 'styled-system';
import {
SearchContextProvider,
@ -105,9 +104,15 @@ function SearchBar() {
flex-shrink: 1;
min-width: calc(${props => props.theme.space[7]}px * 2);
height: 100%;
background: ${props => props.theme.colors.levels.surface};
background: ${props => props.theme.colors.levels.sunkenSecondary};
border: 1px ${props => props.theme.colors.action.disabledBackground}
solid;
border-radius: ${props => props.theme.radii[2]}px;
&:hover {
color: ${props => props.theme.colors.levels.contrast};
background: ${props => props.theme.colors.levels.surface};
}
`}
justifyContent="center"
ref={containerRef}
@ -131,33 +136,23 @@ function SearchBar() {
);
}
const Input = styled.input(props => {
const { theme } = props;
return {
height: '100%',
background: theme.colors.levels.sunkenSecondary,
boxSizing: 'border-box',
color: theme.colors.text.primary,
width: '100%',
outline: 'none',
border: 'none',
padding: `${theme.space[1]}px ${theme.space[2]}px`,
'&:hover, &:focus': {
color: theme.colors.text.contrast,
background: theme.colors.levels.surface,
const Input = styled.input`
height: 38px;
width: 100%;
min-width: calc(${props => props.theme.space[9]}px * 2);
background: inherit;
color: inherit;
box-sizing: border-box;
outline: none;
border: none;
font-size: 14px;
border-radius: ${props => props.theme.radii[2]}px;
padding-inline: ${props => props.theme.space[2]}px;
opacity: 1,
},
'::placeholder': {
color: theme.colors.text.secondary,
},
...space(props),
...width(props),
...height(props),
...color(props),
};
});
::placeholder {
color: ${props => props.theme.colors.text.secondary};
}
`;
const Shortcut = styled(Box).attrs({ p: 1 })`
position: absolute;
@ -168,5 +163,5 @@ const Shortcut = styled(Box).attrs({ p: 1 })`
background-color: ${({ theme }) => theme.colors.levels.surface};
line-height: 12px;
font-size: 12px;
border-radius: 2px;
border-radius: ${props => props.theme.radii[2]}px;
`;

View file

@ -28,7 +28,7 @@ import {
makeLabelsList,
} from '../searchResultTestHelpers';
import { ComponentMap } from './ActionPicker';
import { ComponentMap, NoResultsItem, TypeToSearchItem } from './ActionPicker';
import { ResultList } from './ResultList';
import type * as uri from 'teleterm/ui/uri';
@ -38,6 +38,8 @@ export default {
};
const clusterUri: uri.ClusterUri = '/clusters/teleport-local';
const longClusterUri: uri.ClusterUri =
'/clusters/teleport-very-long-cluster-name-with-uuid-2f96e498-88ec-442f-a25b-569fa915041c';
export const Items = () => {
return (
@ -45,6 +47,9 @@ export const Items = () => {
css={`
position: relative;
max-width: 600px;
display: flex;
flex-direction: column;
row-gap: 8px;
> * {
max-height: unset;
@ -61,6 +66,9 @@ export const ItemsNarrow = () => {
css={`
position: relative;
max-width: 300px;
display: flex;
flex-direction: column;
row-gap: 8px;
> * {
max-height: unset;
@ -122,6 +130,20 @@ const List = () => {
}),
}),
}),
makeResourceResult({
kind: 'server',
resource: makeServer({
hostname:
'super-long-server-name-with-uuid-2f96e498-88ec-442f-a25b-569fa915041c',
uri: `${longClusterUri}/servers/super-long-desc`,
labelsList: makeLabelsList({
internal: '10.0.0.175',
service: 'ansible',
external: '32.192.113.93',
arch: 'aarch64',
}),
}),
}),
makeResourceResult({
kind: 'database',
resource: makeDatabase({
@ -194,6 +216,23 @@ const List = () => {
}),
}),
}),
makeResourceResult({
kind: 'database',
resource: makeDatabase({
name: 'super-long-server-db-with-uuid-2f96e498-88ec-442f-a25b-569fa915041c',
uri: `${longClusterUri}/dbs/super-long-desc`,
labelsList: makeLabelsList({
'aws/Environment': 'demo-13-biz',
'aws/Accounting': 'dev-ops',
'aws/Name': 'db-bastion-4-13biz',
engine: '🐘',
'aws/Owner': 'foobar',
'aws/Service': 'teleport-db',
env: 'dev',
'teleport.dev/origin': 'config-file',
}),
}),
}),
makeResourceResult({
kind: 'kube',
resource: makeKube({
@ -219,6 +258,18 @@ const List = () => {
}),
}),
}),
makeResourceResult({
kind: 'kube',
resource: makeKube({
name: 'super-long-kube-name-with-uuid-2f96e498-88ec-442f-a25b-569fa915041c',
uri: `/clusters/teleport-very-long-cluster-name-with-uuid-2f96e498-88ec-442f-a25b-569fa915041c/kubes/super-long-desc`,
labelsList: makeLabelsList({
'im-just-a-smol': 'kube',
kube: 'kubersson',
with: 'little-to-no-labels',
}),
}),
}),
{
kind: 'resource-type-filter',
resource: 'kubes',
@ -238,30 +289,58 @@ const List = () => {
nameMatch: '',
score: 0,
},
{
kind: 'cluster-filter',
resource: {
name: 'teleport-very-long-cluster-name-with-uuid-2f96e498-88ec-442f-a25b-569fa915041c',
uri: longClusterUri,
authClusterId: '',
connected: true,
leaf: false,
proxyHost: 'teleport-local.dev:3090',
},
nameMatch: '',
score: 0,
},
];
const attempt = makeSuccessAttempt(searchResults);
return (
<ResultList<SearchResult>
attempts={[attempt]}
onPick={() => {}}
onBack={() => {}}
render={searchResult => {
const Component = ComponentMap[searchResult.kind];
<>
<ResultList<SearchResult>
attempts={[attempt]}
onPick={() => {}}
onBack={() => {}}
render={searchResult => {
const Component = ComponentMap[searchResult.kind];
return {
key:
searchResult.kind !== 'resource-type-filter'
? searchResult.resource.uri
: searchResult.resource,
Component: (
<Component
searchResult={searchResult}
getClusterName={routing.parseClusterName}
/>
),
};
}}
/>
return {
key:
searchResult.kind !== 'resource-type-filter'
? searchResult.resource.uri
: searchResult.resource,
Component: (
<Component
searchResult={searchResult}
getClusterName={routing.parseClusterName}
/>
),
};
}}
/>
<NoResultsItem
clusters={[
{
uri: clusterUri,
name: 'teleport-12-ent.asteroid.earth',
connected: false,
leaf: false,
proxyHost: 'test:3030',
authClusterId: '73c4746b-d956-4f16-9848-4e3469f70762',
},
]}
/>
<TypeToSearchItem />
</>
);
};

View file

@ -19,6 +19,7 @@ import styled from 'styled-components';
import { Box, ButtonPrimary, Flex, Label as DesignLabel, Text } from 'design';
import * as icons from 'design/Icon';
import { Highlight } from 'shared/components/Highlight';
import { hasFinished } from 'shared/hooks/useAsync';
import { useAppContext } from 'teleterm/ui/appContextProvider';
import {
@ -39,7 +40,8 @@ import { useSearchContext } from '../SearchContext';
import { useSearchAttempts } from './useSearchAttempts';
import { getParameterPicker } from './pickers';
import { ResultList, EmptyListCopy } from './ResultList';
import { ResultList, NonInteractiveItem } from './ResultList';
import { PickerContainer } from './PickerContainer';
export function ActionPicker(props: { input: ReactElement }) {
const ctx = useAppContext();
@ -55,7 +57,7 @@ export function ActionPicker(props: { input: ReactElement }) {
filters,
removeFilter,
} = useSearchContext();
const attempts = useSearchAttempts();
const { filterActionsAttempt, resourceActionsAttempt } = useSearchAttempts();
const totalCountOfClusters = clustersService.getClusters().length;
const getClusterName = useCallback(
@ -95,51 +97,24 @@ export function ActionPicker(props: { input: ReactElement }) {
[changeActivePicker, closeAndResetInput, resetInput]
);
// If the input is empty, we don't want to say "No matching results found" if the user is yet to
// type anything. This can happen e.g. after selecting two filters.
const NoResultsComponent =
inputValue.length > 0 ? (
<EmptyListCopy>
<Text>No matching results found.</Text>
</EmptyListCopy>
) : null;
const filterButtons = filters.map(s => {
if (s.filter === 'resource-type') {
return (
<ButtonPrimary
m={1}
mr={0}
px={2}
size="small"
<FilterButton
key="resource-type"
text={s.resourceType}
onClick={() => removeFilter(s)}
>
{s.resourceType}
</ButtonPrimary>
/>
);
}
if (s.filter === 'cluster') {
const clusterName = getClusterName(s.clusterUri);
return (
<ButtonPrimary
m={1}
mr={0}
px={2}
size="small"
title={clusterName}
css={`
max-width: 130px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
`}
<FilterButton
key="cluster"
text={clusterName}
onClick={() => removeFilter(s)}
>
{clusterName}
</ButtonPrimary>
/>
);
}
});
@ -151,12 +126,32 @@ export function ActionPicker(props: { input: ReactElement }) {
}
}
let ExtraComponent = null;
// The order of attempts is important. Filter actions should be displayed before resource actions.
const attempts = [filterActionsAttempt, resourceActionsAttempt];
const attemptsHaveFinishedWithoutActions = attempts.every(
a => hasFinished(a) && a.data.length === 0
);
const noRemainingFilters =
filterActionsAttempt.status === 'success' &&
filterActionsAttempt.data.length === 0;
if (inputValue && attemptsHaveFinishedWithoutActions) {
ExtraComponent = (
<NoResultsItem clusters={clustersService.getRootClusters()} />
);
}
if (!inputValue && noRemainingFilters) {
ExtraComponent = <TypeToSearchItem />;
}
return (
<>
<Flex flex={1} onKeyDown={handleKeyDown}>
<PickerContainer>
<InputWrapper onKeyDown={handleKeyDown}>
{filterButtons}
{props.input}
</Flex>
</InputWrapper>
<ResultList<SearchAction>
attempts={attempts}
onPick={onPick}
@ -176,12 +171,29 @@ export function ActionPicker(props: { input: ReactElement }) {
),
};
}}
NoResultsComponent={NoResultsComponent}
ExtraComponent={ExtraComponent}
/>
</>
</PickerContainer>
);
}
export const InputWrapper = styled(Flex).attrs({ px: 2 })`
row-gap: ${props => props.theme.space[2]}px;
column-gap: ${props => props.theme.space[2]}px;
align-items: center;
flex-wrap: wrap;
// account for border
padding-block: calc(${props => props.theme.space[2]}px - 1px);
// input height without border
min-height: 38px;
& > input {
height: unset;
padding-inline: 0;
flex: 1;
}
`;
export const ComponentMap: Record<
SearchResult['kind'],
React.FC<SearchResultItem<SearchResult>>
@ -399,6 +411,44 @@ export function KubeItem(props: SearchResultItem<SearchResultKube>) {
);
}
export function NoResultsItem(props: { clusters: tsh.Cluster[] }) {
const excludedClustersCopy = getExcludedClustersCopy(props.clusters);
return (
<NonInteractiveItem>
<Item Icon={icons.Info} iconColor="text.primary">
<Text typography="body1">No matching results found.</Text>
{excludedClustersCopy && (
<Text typography="body1" color="text.primary">
{excludedClustersCopy}
</Text>
)}
</Item>
</NonInteractiveItem>
);
}
export function TypeToSearchItem() {
return (
<NonInteractiveItem>
<Text typography="body1" color="text.primary">
Type something to search.
</Text>
</NonInteractiveItem>
);
}
function getExcludedClustersCopy(allClusters: tsh.Cluster[]): string {
const excludedClusters = allClusters.filter(c => !c.connected);
const excludedClustersString = excludedClusters.map(c => c.name).join(', ');
if (excludedClusters.length === 0) {
return '';
}
if (excludedClusters.length === 1) {
return `The cluster ${excludedClustersString} was excluded from the search because you are not logged in to it.`;
}
return `Clusters ${excludedClustersString} were excluded from the search because you are not logged in to them.`;
}
function Labels(
props: React.PropsWithChildren<{
searchResult: ResourceSearchResult;
@ -499,3 +549,25 @@ function HighlightField(props: {
/>
);
}
function FilterButton(props: { text: string; onClick(): void }) {
return (
<ButtonPrimary
px={2}
size="small"
title={props.text}
onClick={props.onClick}
>
<span
css={`
max-width: calc(${props => props.theme.space[9]}px * 2);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
`}
>
{props.text}
</span>
</ButtonPrimary>
);
}

View file

@ -27,6 +27,7 @@ import { ParametrizedAction } from '../actions';
import { ResultList } from './ResultList';
import { actionPicker } from './pickers';
import { PickerContainer } from './PickerContainer';
interface ParameterPickerProps {
action: ParametrizedAction;
@ -70,7 +71,7 @@ export function ParameterPicker(props: ParameterPickerProps) {
}, [changeActivePicker]);
return (
<>
<PickerContainer>
{props.input}
<ResultList<string>
attempts={[inputSuggestionAttempt, attempt]}
@ -83,6 +84,6 @@ export function ParameterPicker(props: ParameterPickerProps) {
),
})}
/>
</>
</PickerContainer>
);
}

View file

@ -0,0 +1,38 @@
/**
Copyright 2023 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import styled from 'styled-components';
export const PickerContainer = styled.div`
display: flex;
flex-direction: column;
position: absolute;
box-sizing: border-box;
z-index: 1000;
font-size: 12px;
color: ${props => props.theme.colors.text.contrast};
background: ${props => props.theme.colors.levels.surface};
box-shadow: 8px 8px 18px rgb(0, 0, 0, 0.56);
border-radius: ${props => props.theme.radii[2]}px;
border: 1px solid ${props => props.theme.colors.action.hover};
text-shadow: none;
// Prevents inner items from covering the border on rounded corners.
overflow: hidden;
// Account for border.
width: calc(100% + 2px);
margin-top: -1px;
`;

View file

@ -24,7 +24,6 @@ import React, {
import styled from 'styled-components';
import { Attempt } from 'shared/hooks/useAsync';
import { Box } from 'design';
import LinearProgress from 'teleterm/ui/components/LinearProgress';
@ -36,22 +35,18 @@ type ResultListProps<T> = {
*/
attempts: Attempt<T[]>[];
/**
* NoResultsComponent is the element that's going to be rendered instead of the list if the
* attempt has successfully finished but there's no results to show.
* ExtraComponent is the element that is rendered above the items.
*/
NoResultsComponent?: ReactElement;
ExtraComponent?: ReactElement;
onPick(item: T): void;
onBack(): void;
render(item: T): { Component: ReactElement; key: string };
};
export function ResultList<T>(props: ResultListProps<T>) {
const { attempts, NoResultsComponent, onPick, onBack } = props;
const { attempts, ExtraComponent, onPick, onBack } = props;
const activeItemRef = useRef<HTMLDivElement>();
const [activeItemIndex, setActiveItemIndex] = useState(0);
const shouldShowNoResultsCopy =
NoResultsComponent &&
attempts.every(a => a.status === 'success' && a.data.length === 0);
const items = useMemo(() => {
return attempts.map(a => a.data || []).flat();
@ -108,55 +103,43 @@ export function ResultList<T>(props: ResultListProps<T>) {
}, [items, onPick, onBack, activeItemIndex]);
return (
<StyledGlobalSearchResults role="menu">
{attempts.some(a => a.status === 'processing') && (
<div
style={{
position: 'absolute',
top: 0,
height: '1px',
left: 0,
right: 0,
}}
>
<>
<Separator>
{attempts.some(a => a.status === 'processing') && (
<LinearProgress transparentBackground={true} />
</div>
)}
{items.map((r, index) => {
const isActive = index === activeItemIndex;
const { Component, key } = props.render(r);
)}
</Separator>
<Overflow role="menu">
{ExtraComponent}
{items.map((r, index) => {
const isActive = index === activeItemIndex;
const { Component, key } = props.render(r);
return (
<StyledItem
ref={isActive ? activeItemRef : null}
role="menuitem"
$active={isActive}
key={key}
onClick={() => props.onPick(r)}
>
{Component}
</StyledItem>
);
})}
{shouldShowNoResultsCopy && NoResultsComponent}
</StyledGlobalSearchResults>
return (
<InteractiveItem
ref={isActive ? activeItemRef : null}
role="menuitem"
$active={isActive}
key={key}
onClick={() => props.onPick(r)}
>
{Component}
</InteractiveItem>
);
})}
</Overflow>
</>
);
}
const StyledItem = styled.div`
&:hover,
&:focus {
cursor: pointer;
background: ${props => props.theme.colors.levels.elevated};
}
export const NonInteractiveItem = styled.div`
& mark {
color: inherit;
background-color: ${props => props.theme.colors.brand.accent};
}
:not(:last-of-type) {
border-bottom: 2px solid
border-bottom: 1px solid
${props => props.theme.colors.levels.surfaceSecondary};
}
@ -168,15 +151,11 @@ const StyledItem = styled.div`
: props.theme.colors.levels.surface};
`;
export const EmptyListCopy = styled(Box)`
width: 100%;
height: 100%;
padding: ${props => props.theme.space[2]}px;
line-height: 1.5em;
ul {
margin: 0;
padding-inline-start: 2em;
const InteractiveItem = styled(NonInteractiveItem)`
&:hover,
&:focus {
cursor: pointer;
background: ${props => props.theme.colors.levels.elevated};
}
`;
@ -188,26 +167,17 @@ function getNext(selectedIndex = 0, max = 0) {
return index;
}
const StyledGlobalSearchResults = styled.div(({ theme }) => {
return {
boxShadow: '8px 8px 18px rgb(0 0 0)',
color: theme.colors.text.contrast,
background: theme.colors.levels.surface,
boxSizing: 'border-box',
// Account for border.
width: 'calc(100% + 2px)',
// Careful, this is hardcoded based on the input height.
marginTop: '38px',
display: 'block',
position: 'absolute',
border: '1px solid ' + theme.colors.action.hover,
fontSize: '12px',
listStyle: 'none outside none',
textShadow: 'none',
zIndex: '1000',
maxHeight: '350px',
overflow: 'auto',
// Hardcoded to height of the shortest item.
minHeight: '42px',
};
});
const Separator = styled.div`
position: relative;
background: ${props => props.theme.colors.action.hover};
height: 1px;
`;
const Overflow = styled.div`
overflow: auto;
height: 100%;
list-style: none outside none;
max-height: 350px;
// Hardcoded to height of the shortest item.
min-height: 40px;
`;

View file

@ -14,14 +14,13 @@
* limitations under the License.
*/
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { makeEmptyAttempt, mapAttempt, useAsync } from 'shared/hooks/useAsync';
makeEmptyAttempt,
makeSuccessAttempt,
mapAttempt,
useAsync,
} from 'shared/hooks/useAsync';
import { debounce } from 'shared/utils/highbar';
import {
@ -37,25 +36,15 @@ import { useSearchContext } from 'teleterm/ui/Search/SearchContext';
export function useSearchAttempts() {
const searchLogger = useRef(new Logger('search'));
const ctx = useAppContext();
// Both states are used by mapToActions.
ctx.workspacesService.useState();
ctx.clustersService.useState();
const searchContext = useSearchContext();
const { inputValue, filters } = searchContext;
const [resourceSearchAttempt, runResourceSearch, setResourceSearchAttempt] =
useAsync(useResourceSearch());
const [filterSearchAttempt, runFilterSearch, setFilterSearchAttempt] =
useAsync(useFilterSearch());
const runResourceSearchDebounced = useDebounce(runResourceSearch, 200);
// Both states are used by mapToActions.
ctx.workspacesService.useState();
ctx.clustersService.useState();
const resetAttempts = useCallback(() => {
setResourceSearchAttempt(makeEmptyAttempt());
setFilterSearchAttempt(makeEmptyAttempt());
}, [setResourceSearchAttempt, setFilterSearchAttempt]);
const resourceActionsAttempt = useMemo(
() =>
mapAttempt(resourceSearchAttempt, ({ results, search }) => {
@ -67,35 +56,34 @@ export function useSearchAttempts() {
[ctx, resourceSearchAttempt, searchContext]
);
const filterActionsAttempt = useMemo(
() =>
mapAttempt(filterSearchAttempt, ({ results }) =>
// TODO(gzdunek): filters are sorted inline, should be done here to align with resource search
mapToActions(ctx, searchContext, results)
),
[ctx, filterSearchAttempt, searchContext]
);
const runFilterSearch = useFilterSearch();
const filterActionsAttempt = useMemo(() => {
// TODO(gzdunek): filters are sorted inline, should be done here to align with resource search
const filterSearchResults = runFilterSearch(inputValue, filters);
const filterActions = mapToActions(ctx, searchContext, filterSearchResults);
return makeSuccessAttempt(filterActions);
}, [runFilterSearch, inputValue, filters, ctx, searchContext]);
useEffect(() => {
// Reset both attempts as soon as the input changes. If we didn't do that, then the resource
// search attempt would only get updated on debounce. This could lead to the following scenario:
// Reset the resource search attempt as soon as the input changes. If we didn't do that, then
// the attempt would only get updated on debounce. This could lead to the following scenario:
//
// 1. You type in `foo`, wait for the results to show up.
// 2. You clear the input and quickly type in `bar`.
// 3. Now you see the stale results for `foo`, because the debounce didn't kick in yet.
resetAttempts();
setResourceSearchAttempt(makeEmptyAttempt());
runFilterSearch(inputValue, filters);
runResourceSearchDebounced(inputValue, filters);
}, [
inputValue,
filters,
resetAttempts,
setResourceSearchAttempt,
runFilterSearch,
runResourceSearchDebounced,
]);
return [filterActionsAttempt, resourceActionsAttempt];
return { filterActionsAttempt, resourceActionsAttempt };
}
function useDebounce<Args extends unknown[], ReturnValue>(

View file

@ -101,10 +101,7 @@ export function useFilterSearch() {
workspacesService.useState();
return useCallback(
async (
search: string,
restrictions: SearchFilter[]
): Promise<{ results: FilterSearchResult[]; search: string }> => {
(search: string, restrictions: SearchFilter[]): FilterSearchResult[] => {
const getClusters = () => {
let clusters = clustersService.getClusters();
if (search) {
@ -114,6 +111,10 @@ export function useFilterSearch() {
.includes(search.toLocaleLowerCase())
);
}
// Cluster filter should not be visible if there is only one cluster
if (clusters.length === 1) {
return [];
}
return clusters.map(cluster => {
let score = getLengthScore(search, cluster.name);
if (
@ -168,7 +169,7 @@ export function useFilterSearch() {
return b.score - a.score;
});
return { results, search };
return results;
},
[clustersService, workspacesService]
);