diff --git a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx index a4e6f661b69b6..98367d01db9ac 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx @@ -51,7 +51,7 @@ import { CrossClusterResourceSearchResult } from '../useSearch'; import { useActionAttempts } from './useActionAttempts'; import { getParameterPicker } from './pickers'; -import { ResultList, NonInteractiveItem } from './ResultList'; +import { ResultList, NonInteractiveItem, IconAndContent } from './ResultList'; import { PickerContainer } from './PickerContainer'; export function ActionPicker(props: { input: ReactElement }) { @@ -374,30 +374,9 @@ type SearchResultItem = { getOptionalClusterName: (uri: uri.ResourceUri) => string; }; -function Item( - props: React.PropsWithChildren<{ - Icon: React.ComponentType<{ - color: string; - fontSize: string; - lineHeight: string; - }>; - iconColor: string; - }> -) { - return ( - - {/* lineHeight of the icon needs to match the line height of the first row of props.children */} - - - {props.children} - - - ); -} - function ClusterFilterItem(props: SearchResultItem) { return ( - + Search only in{' '} @@ -407,7 +386,7 @@ function ClusterFilterItem(props: SearchResultItem) { /> - + ); } @@ -428,7 +407,7 @@ function ResourceTypeFilterItem( props: SearchResultItem ) { return ( - @@ -441,7 +420,7 @@ function ResourceTypeFilterItem( /> - + ); } @@ -453,7 +432,7 @@ export function ServerItem(props: SearchResultItem) { ); return ( - + ) { )} - + ); } @@ -527,7 +506,7 @@ export function DatabaseItem(props: SearchResultItem) { ); return ( - + ) { ) : ( {$resourceFields} )} - + ); } @@ -566,7 +545,7 @@ export function KubeItem(props: SearchResultItem) { const { searchResult } = props; return ( - + ) { - + ); } @@ -613,10 +592,10 @@ export function NoResultsItem(props: { return ( - + No matching results found. {expiredCertsCopy && {expiredCertsCopy}} - + ); } @@ -660,7 +639,7 @@ export function ResourceSearchErrorsItem(props: { return ( - + Some of the search results are incomplete. @@ -687,7 +666,7 @@ export function ResourceSearchErrorsItem(props: { Show details - + ); } diff --git a/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx b/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx index b37154e1916fe..e40c2b26b31c0 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/ParameterPicker.tsx @@ -21,11 +21,13 @@ import { mapAttempt, useAsync, } from 'shared/hooks/useAsync'; +import { Text } from 'design'; +import * as icons from 'design/Icon'; import { useSearchContext } from '../SearchContext'; import { ParametrizedAction } from '../actions'; -import { ResultList } from './ResultList'; +import { IconAndContent, NonInteractiveItem, ResultList } from './ResultList'; import { actionPicker } from './pickers'; import { PickerContainer } from './PickerContainer'; @@ -42,14 +44,21 @@ export function ParameterPicker(props: ParameterPickerProps) { resetInput, addWindowEventListener, } = useSearchContext(); - const [suggestionsAttempt, fetch] = useAsync( + const [suggestionsAttempt, getSuggestions] = useAsync( props.action.parameter.getSuggestions ); const inputSuggestionAttempt = makeSuccessAttempt(inputValue && [inputValue]); + const $suggestionsError = + suggestionsAttempt.status === 'error' ? ( + + ) : null; useEffect(() => { - fetch(); - }, [props.action]); + getSuggestions(); + // We want to get suggestions only once on mount. + // useAsync already handles cleanup and calling the hook twice. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const attempt = mapAttempt(suggestionsAttempt, suggestions => suggestions.filter( @@ -80,6 +89,7 @@ export function ParameterPicker(props: ParameterPickerProps) { {props.input} attempts={[inputSuggestionAttempt, attempt]} + ExtraTopComponent={$suggestionsError} onPick={onPick} onBack={onBack} addWindowEventListener={addWindowEventListener} @@ -93,3 +103,14 @@ export function ParameterPicker(props: ParameterPickerProps) { ); } + +export const SuggestionsError = ({ statusText }: { statusText: string }) => ( + + + + Could not fetch suggestions. Type in the desired value to continue. + + {statusText} + + +); diff --git a/web/packages/teleterm/src/ui/Search/pickers/ResultList.tsx b/web/packages/teleterm/src/ui/Search/pickers/ResultList.tsx index a9e0de2049977..bcc2a097f6edf 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/ResultList.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/ResultList.tsx @@ -20,7 +20,9 @@ import React, { useMemo, useRef, useState, + useCallback, } from 'react'; +import { Flex } from 'design'; import styled, { css } from 'styled-components'; import { Attempt } from 'shared/hooks/useAsync'; @@ -55,6 +57,13 @@ export function ResultList(props: ResultListProps) { } = props; const activeItemRef = useRef(); const [activeItemIndex, setActiveItemIndex] = useState(0); + const pickAndResetActiveItem = useCallback( + (item: T) => { + onPick(item); + setActiveItemIndex(0); + }, + [onPick] + ); const items = useMemo(() => { return attempts.map(a => a.data || []).flat(); @@ -83,7 +92,7 @@ export function ResultList(props: ResultListProps) { const item = items[activeItemIndex]; if (item) { - onPick(item); + pickAndResetActiveItem(item); } break; } @@ -110,7 +119,13 @@ export function ResultList(props: ResultListProps) { capture: true, }); return cleanup; - }, [items, onPick, onBack, activeItemIndex, addWindowEventListener]); + }, [ + items, + pickAndResetActiveItem, + onBack, + activeItemIndex, + addWindowEventListener, + ]); return ( <> @@ -131,7 +146,7 @@ export function ResultList(props: ResultListProps) { role="menuitem" active={isActive} key={key} - onClick={() => props.onPick(r)} + onClick={() => pickAndResetActiveItem(r)} > {Component} @@ -173,6 +188,30 @@ const InteractiveItem = styled(NonInteractiveItem)` }} `; +/** + * IconAndContent is supposed to be used within InteractiveItem & NonInteractiveItem. + */ +export function IconAndContent( + props: React.PropsWithChildren<{ + Icon: React.ComponentType<{ + color: string; + fontSize: string; + lineHeight: string; + }>; + iconColor: string; + }> +) { + return ( + + {/* lineHeight of the icon needs to match the line height of the first row of props.children */} + + + {props.children} + + + ); +} + function getNext(selectedIndex = 0, max = 0) { let index = selectedIndex % max; if (index < 0) { diff --git a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.story.tsx b/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx similarity index 96% rename from web/packages/teleterm/src/ui/Search/pickers/ActionPicker.story.tsx rename to web/packages/teleterm/src/ui/Search/pickers/results.story.tsx index 0fbb64231092f..6513f3a94f169 100644 --- a/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.story.tsx +++ b/web/packages/teleterm/src/ui/Search/pickers/results.story.tsx @@ -37,19 +37,20 @@ import { ResourceSearchErrorsItem, TypeToSearchItem, } from './ActionPicker'; +import { SuggestionsError } from './ParameterPicker'; import { ResultList } from './ResultList'; import type * as uri from 'teleterm/ui/uri'; export default { - title: 'Teleterm/Search/ActionPicker', + title: 'Teleterm/Search', }; 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 = (props: { maxWidth: string }) => { +export const Results = (props: { maxWidth: string }) => { const { maxWidth = '600px' } = props; return ( @@ -92,8 +93,8 @@ export const Items = (props: { maxWidth: string }) => { ); }; -export const ItemsNarrow = () => { - return ; +export const ResultsNarrow = () => { + return ; }; const SearchResultItems = () => { @@ -397,6 +398,11 @@ const AuxiliaryItems = () => ( ), ]} /> +