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

Connect: Add SearchBar #23980

Merged
merged 1 commit into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export function mapAttempt<A, B>(
export function mapSuccessfulAttempt<A, B>(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think mapAttempt is fine as mapping a non-success attempt doesn't make sense – there's no data to map if the attempt wasn't successful. If we ever need to map the error, we can add something like mapError. Though I wish JS had Go or Haskell-like package scoping so that we don't have to slap "attempt" into the function name to make it more clear where it comes from.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to the search bar in any way? If not, I would move it to a separate PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is related! Before the search bar was introduced, you could open a new terminal tab by pressing Cmd + K, typing any command you want and then pressing Enter.

With the addition of the search bar, there's no way at the moment to open a local terminal tab from the search bar. So we added a new item to the More Options menu to the left of the profile selector. The shortcut is displayed there to the right of the action. This way the user can still open a new terminal tab using the keyboard only.

Since the plan is to backport the search bar to v12.3, I say we can just keep those commits in this PR.

.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