Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v14] Prepare SearchBar for the integration with unified resources #34543

Merged
merged 10 commits into from Nov 17, 2023
2 changes: 1 addition & 1 deletion web/packages/teleterm/src/ui/Search/SearchBar.test.tsx
Expand Up @@ -63,7 +63,7 @@ it('does not display empty results copy after selecting two filters', () => {
...getMockedSearchContext(),
filters: [
{ filter: 'cluster', clusterUri: '/clusters/foo' },
{ filter: 'resource-type', resourceType: 'servers' },
{ filter: 'resource-type', resourceType: 'node' },
],
inputValue: '',
}));
Expand Down
7 changes: 0 additions & 7 deletions web/packages/teleterm/src/ui/Search/SearchContext.tsx
Expand Up @@ -64,13 +64,6 @@ export const SearchContextProvider: FC = props => {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [activePicker, setActivePicker] = useState(actionPicker);
// TODO(ravicious): Consider using another data structure for search filters as we know that we
// always have only two specific filters: one for clusters and one for resource type.
//
// This could probably be represented by an object instead plus an array for letting the user
// provide those filters in any order they want. The array would be used in the UI that renders
// the filters while code that uses the search filters, such as ResourcesService.searchResources,
// could operate on the object instead.
const [filters, setFilters] = useState<SearchFilter[]>([]);

function changeActivePicker(picker: SearchPicker): void {
Expand Down
Expand Up @@ -203,7 +203,7 @@ describe('getActionPickerStatus', () => {
];
const status = getActionPickerStatus({
inputValue: '',
filters: [{ filter: 'resource-type', resourceType: 'servers' }],
filters: [{ filter: 'resource-type', resourceType: 'node' }],
filterActionsAttempt: makeSuccessAttempt([]),
allClusters: [],
actionAttempts: [makeSuccessAttempt([])],
Expand Down
71 changes: 48 additions & 23 deletions web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx
Expand Up @@ -16,15 +16,9 @@

import React, { ReactElement, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import {
Box,
ButtonBorder,
ButtonPrimary,
Flex,
Label as DesignLabel,
Text,
} from 'design';
import { Box, ButtonBorder, Flex, Label as DesignLabel, Text } from 'design';
import * as icons from 'design/Icon';
import { Cross as CloseIcon } from 'design/Icon';
import { Highlight } from 'shared/components/Highlight';
import { Attempt, hasFinished } from 'shared/hooks/useAsync';

Expand All @@ -40,6 +34,7 @@ import {
SearchResultCluster,
SearchResultResourceType,
SearchFilter,
ResourceTypeFilter,
} from 'teleterm/ui/Search/searchResult';
import * as tsh from 'teleterm/services/tshd/types';
import * as uri from 'teleterm/ui/uri';
Expand Down Expand Up @@ -125,8 +120,8 @@ export function ActionPicker(props: { input: ReactElement }) {
if (s.filter === 'resource-type') {
return (
<FilterButton
key="resource-type"
text={s.resourceType}
key={`resource-type-${s.resourceType}`}
text={resourceTypeToPrettyName[s.resourceType]}
onClick={() => removeFilter(s)}
/>
);
Expand Down Expand Up @@ -497,9 +492,9 @@ const resourceIcons: Record<
lineHeight: string;
}>
> = {
kubes: icons.Kubernetes,
servers: icons.Server,
databases: icons.Database,
kube_cluster: icons.Kubernetes,
node: icons.Server,
db: icons.Database,
};

function ResourceTypeFilterItem(
Expand All @@ -511,10 +506,10 @@ function ResourceTypeFilterItem(
iconColor="text.slightlyMuted"
>
<Text typography="body1">
Search only for{' '}
Search for{' '}
<strong>
<Highlight
text={props.searchResult.resource}
text={resourceTypeToPrettyName[props.searchResult.resource]}
keywords={[props.searchResult.nameMatch]}
/>
</strong>
Expand Down Expand Up @@ -873,22 +868,52 @@ function HighlightField(props: {

function FilterButton(props: { text: string; onClick(): void }) {
return (
<ButtonPrimary
px={2}
<Flex
justifyContent="center"
alignItems="center"
css={`
color: ${props => props.theme.colors.buttons.text};
background: ${props => props.theme.colors.spotBackground[1]};
border-radius: ${props => props.theme.radii[2]}px;
`}
px="6px"
size="small"
title={props.text}
onClick={props.onClick}
>
<CloseIcon
color="buttons.text"
mr={1}
mt="1px"
title="Remove filter"
onClick={props.onClick}
css={`
cursor: pointer;
border-radius: ${props => props.theme.radii[1]}px;

:hover {
background: ${props => props.theme.colors.spotBackground[1]};
}

> svg {
height: 13px;
width: 13px;
}
`}
/>
<span
title={props.text}
css={`
max-width: calc(${props => props.theme.space[9]}px * 2);
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
cursor: default;
`}
>
{props.text}
</span>
</ButtonPrimary>
</Flex>
);
}

const resourceTypeToPrettyName: Record<ResourceTypeFilter, string> = {
db: 'databases',
node: 'servers',
kube_cluster: 'kubes',
};
15 changes: 10 additions & 5 deletions web/packages/teleterm/src/ui/Search/pickers/PickerContainer.ts
Expand Up @@ -19,22 +19,27 @@ import styled from 'styled-components';
export const PickerContainer = styled.div`
display: flex;
flex-direction: column;
position: absolute;
position: fixed;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
z-index: 1000;
font-size: 12px;
color: ${props => props.theme.colors.text.main};
background: ${props => props.theme.colors.levels.elevated};
box-shadow: ${props => props.theme.boxShadow[1]};
border-radius: ${props => props.theme.radii[2]}px;
// we have to use a border of the same width as in SearchBar when it is closed to keep
// the layout from shifting when switching between open and closed state
border: 1px solid ${props => props.theme.colors.levels.elevated};
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;

// These values are adjusted so that the cluster selector and search input
// are minimally covered by the picker.
max-width: 660px;
width: 76%;
`;
Expand Up @@ -290,7 +290,7 @@ const SearchResultItems = () => {
}),
{
kind: 'resource-type-filter',
resource: 'kubes',
resource: 'kube_cluster',
nameMatch: '',
score: 0,
},
Expand Down
22 changes: 20 additions & 2 deletions web/packages/teleterm/src/ui/Search/searchResult.ts
Expand Up @@ -18,6 +18,7 @@ import type { ClusterUri } from 'teleterm/ui/uri';
import type { Cluster } from 'teleterm/services/tshd/types';

import type * as resourcesServiceTypes from 'teleterm/ui/services/resources';
import type { SharedUnifiedResource } from 'shared/components/UnifiedResources';

type ResourceSearchResultBase<
Result extends resourcesServiceTypes.SearchResult
Expand All @@ -27,6 +28,11 @@ type ResourceSearchResultBase<
score: number;
};

export type ResourceTypeFilter = Extract<
SharedUnifiedResource['resource']['kind'],
'node' | 'kube_cluster' | 'db'
>;

export type SearchResultServer =
ResourceSearchResultBase<resourcesServiceTypes.SearchResultServer>;
export type SearchResultDatabase =
Expand All @@ -41,7 +47,7 @@ export type SearchResultCluster = {
};
export type SearchResultResourceType = {
kind: 'resource-type-filter';
resource: 'kubes' | 'servers' | 'databases';
resource: ResourceTypeFilter;
nameMatch: string;
score: number;
};
Expand Down Expand Up @@ -103,7 +109,7 @@ export const searchableFields: {

export interface ResourceTypeSearchFilter {
filter: 'resource-type';
resourceType: 'kubes' | 'servers' | 'databases';
resourceType: ResourceTypeFilter;
}

export interface ClusterSearchFilter {
Expand All @@ -112,3 +118,15 @@ export interface ClusterSearchFilter {
}

export type SearchFilter = ResourceTypeSearchFilter | ClusterSearchFilter;

export function isResourceTypeSearchFilter(
searchFilter: SearchFilter
): searchFilter is ResourceTypeSearchFilter {
return searchFilter.filter === 'resource-type';
}

export function isClusterSearchFilter(
searchFilter: SearchFilter
): searchFilter is ClusterSearchFilter {
return searchFilter.filter === 'cluster';
}
4 changes: 2 additions & 2 deletions web/packages/teleterm/src/ui/Search/useSearch.test.tsx
Expand Up @@ -156,7 +156,7 @@ describe('useResourceSearch', () => {
expect(appContext.resourcesService.searchResources).toHaveBeenCalledWith({
clusterUri: cluster.uri,
search: 'foo',
filter: undefined,
filters: [],
limit: 100,
});
expect(appContext.resourcesService.searchResources).toHaveBeenCalledTimes(
Expand Down Expand Up @@ -187,7 +187,7 @@ describe('useResourceSearch', () => {
expect(appContext.resourcesService.searchResources).toHaveBeenCalledWith({
clusterUri: cluster.uri,
search: '',
filter: undefined,
filters: [],
limit: 5,
});
expect(appContext.resourcesService.searchResources).toHaveBeenCalledTimes(
Expand Down
47 changes: 24 additions & 23 deletions web/packages/teleterm/src/ui/Search/useSearch.ts
Expand Up @@ -20,8 +20,8 @@ import { assertUnreachable } from 'teleterm/ui/utils';
import { useAppContext } from 'teleterm/ui/appContextProvider';

import {
ClusterSearchFilter,
ResourceTypeSearchFilter,
isResourceTypeSearchFilter,
isClusterSearchFilter,
SearchFilter,
LabelMatch,
mainResourceField,
Expand All @@ -30,6 +30,7 @@ import {
searchableFields,
ResourceSearchResult,
FilterSearchResult,
ResourceTypeFilter,
} from './searchResult';

import type * as resourcesServiceTypes from 'teleterm/ui/services/resources';
Expand All @@ -40,6 +41,12 @@ export type CrossClusterResourceSearchResult = {
search: string;
};

const SUPPORTED_RESOURCE_TYPES: ResourceTypeFilter[] = [
'node',
'db',
'kube_cluster',
];

/**
* useResourceSearch returns a function which searches for the given list of space-separated keywords across
* all root and leaf clusters that the user is currently logged in to.
Expand Down Expand Up @@ -87,12 +94,10 @@ export function useResourceSearch() {
}
}

const clusterSearchFilter = filters.find(
s => s.filter === 'cluster'
) as ClusterSearchFilter;
const resourceTypeSearchFilter = filters.find(
s => s.filter === 'resource-type'
) as ResourceTypeSearchFilter;
const clusterSearchFilter = filters.find(isClusterSearchFilter);
const resourceTypeSearchFilters = filters.filter(
isResourceTypeSearchFilter
);

const connectedClusters = clustersService
.getClusters()
Expand All @@ -111,7 +116,7 @@ export function useResourceSearch() {
resourcesService.searchResources({
clusterUri: cluster.uri,
search,
filter: resourceTypeSearchFilter,
filters: resourceTypeSearchFilters.map(f => f.resourceType),
limit,
})
)
Expand Down Expand Up @@ -180,11 +185,15 @@ export function useFilterSearch() {
});
};
const getResourceType = () => {
let resourceTypes = [
'servers' as const,
'databases' as const,
'kubes' as const,
];
let resourceTypes = SUPPORTED_RESOURCE_TYPES.filter(resourceType => {
const isFilterForResourceTypeAdded = filters.some(searchFilter => {
return (
searchFilter.filter === 'resource-type' &&
searchFilter.resourceType === resourceType
);
});
return !isFilterForResourceTypeAdded;
});
if (search) {
resourceTypes = resourceTypes.filter(resourceType =>
resourceType.toLowerCase().includes(search.toLowerCase())
Expand All @@ -199,22 +208,14 @@ export function useFilterSearch() {
};

const shouldReturnClusters = !filters.some(r => r.filter === 'cluster');
const shouldReturnResourceTypes = !filters.some(
r => r.filter === 'resource-type'
);

const results = [
shouldReturnResourceTypes && getResourceType(),
shouldReturnClusters && getClusters(),
]
return [getResourceType(), shouldReturnClusters && getClusters()]
.filter(Boolean)
.flat()
.sort((a, b) => {
// Highest score first.
return b.score - a.score;
});

return results;
},
[clustersService, workspacesService]
);
Expand Down