mirror of
https://github.com/gravitational/teleport
synced 2024-10-19 08:43:58 +00:00
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:
parent
54c7bc82fd
commit
5641b22f52
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
`;
|
|
@ -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;
|
||||
`;
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue