Skip to content

Commit

Permalink
Connect: Add SearchBar (#23980)
Browse files Browse the repository at this point in the history
This commit adds an MVP of the search bar to Connect. Currently it's
behind a feature flag (`feature.searchBar`) but we'll enable it by
default before the release. The plan was to merge the code ASAP,
potentially even straight to v12 when we have the chance, which didn't
exactly pan out but there's no harm to having this feature flag for now.

On top of that, this commit adds a new shortcut to open the search bar
(this replaces the current shortcut to open the command bar) and a
shortcut to open a new terminal tab.

The search works by essentially making a `ListResources` request for
each supported resource type to every cluster the user is logged in to.
We repurposed the old command palette UI for that but rewritten it to
use React context and hooks rather than a class and a store. This
allowed us to be a little bit more flexible as the old approach required
every picker to conform to the same interface, both in terms of UI and
code.

This implementation has two main pickers so far:

* `ActionPicker` which is the main one. It searches for resources but at
  the moment it also supports applying filters. In the future, we plan
  to add more actions to it such as "Open a new tab" or "Install tsh".
* `ParameterPicker` is activated when you pick an action from the
  `ActionPicker` that requires an additional parameter. Think choosing
  an SSH server or a db – you need to provide an SSH login or a db user
  for those item. In those situations, `ActionPicker` will switch to
  `ParameterPicker` and let you pick a relevant item from the list.

Everything is contained within `web/packages/teleterm/src/ui/Search`.
Arguably, `useSearch` could be refactored a little bit to maybe make its
structure a little more clear as it handles both the resource search and
the filter search. However, at the moment we're not totally sure how the
search bar will evolve, so we want to leave any bigger refactors for
later. We added a couple of basic tests for regressions that happened so
far. We also have stories for the items from the action picker.

Error handling will be added in an upcoming PR. Docs updates will be
done in a separate PR as well.

Co-authored-by: Rafał Cieślak <rafal.cieslak@goteleport.com>
  • Loading branch information
gzdunek and ravicious authored Apr 4, 2023
1 parent 057ff52 commit d229a53
Show file tree
Hide file tree
Showing 50 changed files with 3,036 additions and 294 deletions.
4 changes: 2 additions & 2 deletions web/packages/design/src/Icon/Icon.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ limitations under the License.

import React from 'react';
import styled from 'styled-components';
import { space, fontSize, width, color } from 'styled-system';
import { space, fontSize, width, color, lineHeight } from 'styled-system';
import '../assets/icomoon/style.css';

const Icon = styled.span`
display: inline-block;
transition: color 0.3s;
${space} ${width} ${color} ${fontSize}
${space} ${width} ${color} ${fontSize} ${lineHeight}
`;

Icon.displayName = `Icon`;
Expand Down
79 changes: 79 additions & 0 deletions web/packages/shared/components/Highlight/Highlight.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* 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 React from 'react';
import styled from 'styled-components';
import { Flex, Text } from 'design';

import { Highlight } from './Highlight';

export default {
title: 'Shared/Highlight',
};

export const Story = () => {
const keywords = [
'Aliquam',
'olor',
// Overlapping matches: 'lor' and 'rem' both match 'lorem', so the whole word should get
// highlighted.
'lor',
'rem',
// https://www.contentful.com/blog/unicode-javascript-and-the-emoji-family/
// Unfortunately, the library we use for highlighting seems to match only the first emoji of
// such group, e.g. searching for the emoji of a son won't match a group in which the son is
// present.
'👩',
'👨‍👨‍👦‍👦',
'🥑',
];
return (
<Flex
flexDirection="column"
gap={6}
css={`
max-width: 60ch;
`}
>
<Flex flexDirection="column" gap={2}>
<Text>
Highlighting <code>{keywords.join(', ')}</code> in the below text:
</Text>
<Text>
<Highlight text={loremIpsum} keywords={keywords} />
</Text>
</Flex>

<Flex flexDirection="column" gap={2}>
<Text>Custom highlighting</Text>
<Text>
<CustomHighlight>
<Highlight text={loremIpsum} keywords={keywords} />
</CustomHighlight>
</Text>
</Flex>
</Flex>
);
};

const loremIpsum =
'Lorem ipsum 👩‍👩‍👧‍👦 dolor sit amet, 👨‍👨‍👦‍👦 consectetur adipiscing elit. 🥑 Aliquam vel augue varius, venenatis velit sit amet, aliquam arcu. Morbi dictum mattis ultrices. Nullam ut porta ipsum, porta ornare nibh. Vivamus magna felis, semper sed enim sit amet, varius rhoncus leo. Aenean ornare convallis sem ut accumsan.';

const CustomHighlight = styled.div`
mark {
background-color: magenta;
}
`;
50 changes: 50 additions & 0 deletions web/packages/shared/components/Highlight/Highlight.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* 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 React from 'react';
import { findAll } from 'highlight-words-core';

/**
* Highlight wraps the keywords found in the text in <mark> tags.
*
* It is a simplified version of the component provided by the react-highlight-words package.
* It can be extended with the features provided by highlight-words-core (e.g. case sensitivity).
*
* It doesn't handle Unicode super well because highlight-words-core uses a regex with the i flag
* underneath. This means that the component will not always ignore differences in case, for example
* when matching a string with the Turkish İ.
*/
export function Highlight(props: { text: string; keywords: string[] }) {
const chunks = findAll({
textToHighlight: props.text,
searchWords: props.keywords,
});

return (
<>
{chunks.map((chunk, index) => {
const { end, highlight, start } = chunk;
const chunkText = props.text.substr(start, end - start);

if (highlight) {
return <mark key={index}>{chunkText}</mark>;
} else {
return chunkText;
}
})}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@
* limitations under the License.
*/

export { NavigationMenu } from './NavigationMenu';
export { Highlight } from './Highlight';
20 changes: 20 additions & 0 deletions web/packages/shared/hooks/useAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,23 @@ export function makeErrorAttempt<T>(statusText: string): Attempt<T> {
statusText,
};
}

/**
* mapAttempt maps attempt data but only if the attempt is successful.
*/
export function mapAttempt<A, B>(
attempt: Attempt<A>,
mapFunction: (attemptData: A) => B
): Attempt<B> {
if (attempt.status !== 'success') {
return {
...attempt,
data: null,
};
}

return {
...attempt,
data: mapFunction(attempt.data),
};
}
1 change: 1 addition & 0 deletions web/packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"create-react-class": "^15.6.3",
"cross-env": "5.0.5",
"date-fns": "^2.28.0",
"highlight-words-core": "^1.2.2",
"react": "^16.8.4",
"react-day-picker": "7.3.2",
"react-dom": "^16.8.4",
Expand Down
15 changes: 14 additions & 1 deletion web/packages/teleterm/src/services/config/appConfigSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ export const createAppConfigSchema = (platform: Platform) => {
.boolean()
.default(false)
.describe('Enables collecting of anonymous usage data.'),
'feature.searchBar': z
.boolean()
.default(false)
.describe('Replaces the command bar with the new search bar'),
'keymap.tab1': shortcutSchema
.default(defaultKeymap['tab1'])
.describe(getShortcutDesc('open tab 1')),
Expand Down Expand Up @@ -68,6 +72,9 @@ export const createAppConfigSchema = (platform: Platform) => {
'keymap.newTab': shortcutSchema
.default(defaultKeymap['newTab'])
.describe(getShortcutDesc('open a new tab')),
'keymap.newTerminalTab': shortcutSchema
.default(defaultKeymap['newTerminalTab'])
.describe(getShortcutDesc('open a new terminal tab')),
'keymap.previousTab': shortcutSchema
.default(defaultKeymap['previousTab'])
.describe(getShortcutDesc('go to the previous tab')),
Expand Down Expand Up @@ -118,14 +125,17 @@ export type KeyboardShortcutAction =
| 'tab9'
| 'closeTab'
| 'newTab'
| 'newTerminalTab'
| 'previousTab'
| 'nextTab'
| 'openCommandBar'
| 'openConnections'
| 'openClusters'
| 'openProfiles';

const getDefaultKeymap = (platform: Platform) => {
const getDefaultKeymap = (
platform: Platform
): Record<KeyboardShortcutAction, string> => {
switch (platform) {
case 'win32':
return {
Expand All @@ -140,6 +150,7 @@ const getDefaultKeymap = (platform: Platform) => {
tab9: 'Ctrl+9',
closeTab: 'Ctrl+W',
newTab: 'Ctrl+T',
newTerminalTab: 'Ctrl+Shift+T',
previousTab: 'Ctrl+Shift+Tab',
nextTab: 'Ctrl+Tab',
openCommandBar: 'Ctrl+K',
Expand All @@ -160,6 +171,7 @@ const getDefaultKeymap = (platform: Platform) => {
tab9: 'Alt+9',
closeTab: 'Ctrl+W',
newTab: 'Ctrl+T',
newTerminalTab: 'Ctrl+Shift+T',
previousTab: 'Ctrl+Shift+Tab',
nextTab: 'Ctrl+Tab',
openCommandBar: 'Ctrl+K',
Expand All @@ -180,6 +192,7 @@ const getDefaultKeymap = (platform: Platform) => {
tab9: 'Command+9',
closeTab: 'Command+W',
newTab: 'Command+T',
newTerminalTab: 'Shift+Command+T',
previousTab: 'Control+Shift+Tab',
nextTab: 'Control+Tab',
openCommandBar: 'Command+K',
Expand Down
30 changes: 21 additions & 9 deletions web/packages/teleterm/src/services/tshd/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,20 +61,24 @@ export default function createClient(
async getKubes({
clusterUri,
search,
sort = { fieldName: 'name', dir: 'ASC' },
sort,
query,
searchAsRoles,
startKey,
limit,
}: types.ServerSideParams) {
}: types.GetResourcesParams) {
const req = new api.GetKubesRequest()
.setClusterUri(clusterUri)
.setSearchAsRoles(searchAsRoles)
.setStartKey(startKey)
.setSortBy(`${sort.fieldName}:${sort.dir.toLowerCase()}`)
.setSearch(search)
.setQuery(query)
.setLimit(limit);

if (sort) {
req.setSortBy(`${sort.fieldName}:${sort.dir.toLowerCase()}`);
}

return new Promise<types.GetKubesResponse>((resolve, reject) => {
tshd.getKubes(req, (err, response) => {
if (err) {
Expand Down Expand Up @@ -128,20 +132,24 @@ export default function createClient(
async getDatabases({
clusterUri,
search,
sort = { fieldName: 'name', dir: 'ASC' },
sort,
query,
searchAsRoles,
startKey,
limit,
}: types.ServerSideParams) {
}: types.GetResourcesParams) {
const req = new api.GetDatabasesRequest()
.setClusterUri(clusterUri)
.setSearchAsRoles(searchAsRoles)
.setStartKey(startKey)
.setSortBy(`${sort.fieldName}:${sort.dir.toLowerCase()}`)
.setSearch(search)
.setQuery(query)
.setLimit(limit);

if (sort) {
req.setSortBy(`${sort.fieldName}:${sort.dir.toLowerCase()}`);
}

return new Promise<types.GetDatabasesResponse>((resolve, reject) => {
tshd.getDatabases(req, (err, response) => {
if (err) {
Expand Down Expand Up @@ -198,19 +206,23 @@ export default function createClient(
clusterUri,
search,
query,
sort = { fieldName: 'hostname', dir: 'ASC' },
sort,
searchAsRoles,
startKey,
limit,
}: types.ServerSideParams) {
}: types.GetResourcesParams) {
const req = new api.GetServersRequest()
.setClusterUri(clusterUri)
.setSearchAsRoles(searchAsRoles)
.setStartKey(startKey)
.setSortBy(`${sort.fieldName}:${sort.dir.toLowerCase()}`)
.setSearch(search)
.setQuery(query)
.setLimit(limit);

if (sort) {
req.setSortBy(`${sort.fieldName}:${sort.dir.toLowerCase()}`);
}

return new Promise<types.GetServersResponse>((resolve, reject) => {
tshd.getServers(req, (err, response) => {
if (err) {
Expand Down
8 changes: 4 additions & 4 deletions web/packages/teleterm/src/services/tshd/fixtures/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
LoginPasswordlessParams,
LoginSsoParams,
ReviewAccessRequestParams,
ServerSideParams,
GetResourcesParams,
TshAbortController,
TshAbortSignal,
TshClient,
Expand All @@ -39,13 +39,13 @@ import {
export class MockTshClient implements TshClient {
listRootClusters: () => Promise<Cluster[]>;
listLeafClusters: (clusterUri: string) => Promise<Cluster[]>;
getKubes: (params: ServerSideParams) => Promise<GetKubesResponse>;
getDatabases: (params: ServerSideParams) => Promise<GetDatabasesResponse>;
getKubes: (params: GetResourcesParams) => Promise<GetKubesResponse>;
getDatabases: (params: GetResourcesParams) => Promise<GetDatabasesResponse>;
listDatabaseUsers: (dbUri: string) => Promise<string[]>;
getRequestableRoles: (
params: GetRequestableRolesParams
) => Promise<GetRequestableRolesResponse>;
getServers: (params: ServerSideParams) => Promise<GetServersResponse>;
getServers: (params: GetResourcesParams) => Promise<GetServersResponse>;
assumeRole: (
clusterUri: string,
requestIds: string[],
Expand Down
Loading

0 comments on commit d229a53

Please sign in to comment.