diff --git a/packages/shared/components/MenuLogin/MenuLogin.test.tsx b/packages/shared/components/MenuLogin/MenuLogin.test.tsx new file mode 100644 index 000000000..5d42c7898 --- /dev/null +++ b/packages/shared/components/MenuLogin/MenuLogin.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { render, fireEvent, waitFor } from 'design/utils/testing'; +import { MenuLogin } from './MenuLogin'; + +test('does not accept an empty value when required is set to true', async () => { + const onSelect = jest.fn(); + const { findByText, findByPlaceholderText } = render( + []} + onSelect={() => onSelect()} + /> + ); + + fireEvent.click(await findByText('CONNECT')); + await waitFor(async () => + fireEvent.keyPress(await findByPlaceholderText('MenuLogin input'), { + key: 'Enter', + keyCode: 13, + }) + ); + + expect(onSelect).toHaveBeenCalledTimes(0); +}); + +test('accepts an empty value when required is set to false', async () => { + const onSelect = jest.fn(); + const { findByText, findByPlaceholderText } = render( + []} + onSelect={() => onSelect()} + /> + ); + + fireEvent.click(await findByText('CONNECT')); + await waitFor(async () => + fireEvent.keyPress(await findByPlaceholderText('MenuLogin input'), { + key: 'Enter', + keyCode: 13, + }) + ); + + expect(onSelect).toHaveBeenCalledTimes(1); +}); diff --git a/packages/shared/components/MenuLogin/MenuLogin.tsx b/packages/shared/components/MenuLogin/MenuLogin.tsx index 0ccd63967..6ed26911f 100644 --- a/packages/shared/components/MenuLogin/MenuLogin.tsx +++ b/packages/shared/components/MenuLogin/MenuLogin.tsx @@ -26,7 +26,7 @@ import { useAsync, Attempt } from 'shared/hooks/useAsync'; export const MenuLogin = React.forwardRef( (props, ref) => { - const { onSelect, anchorOrigin, transformOrigin } = props; + const { onSelect, anchorOrigin, transformOrigin, required = true } = props; const anchorRef = useRef(); const [isOpen, setIsOpen] = useState(false); const [getLoginItemsAttempt, runGetLoginItems] = useAsync(() => @@ -51,7 +51,7 @@ export const MenuLogin = React.forwardRef( onSelect(e, login); }; const onKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && e.currentTarget.value) { + if (e.key === 'Enter' && (!required || e.currentTarget.value)) { onClose(); onSelect(e, e.currentTarget.value); } diff --git a/packages/shared/components/MenuLogin/types.ts b/packages/shared/components/MenuLogin/types.ts index 6a83ca5f5..6f98471a8 100644 --- a/packages/shared/components/MenuLogin/types.ts +++ b/packages/shared/components/MenuLogin/types.ts @@ -25,6 +25,7 @@ export type MenuLoginProps = { anchorOrigin?: any; transformOrigin?: any; placeholder?: string; + required?: boolean; }; export type MenuLoginHandle = { diff --git a/packages/teleterm/src/services/tshd/createClient.ts b/packages/teleterm/src/services/tshd/createClient.ts index d4d737493..8fa16919c 100644 --- a/packages/teleterm/src/services/tshd/createClient.ts +++ b/packages/teleterm/src/services/tshd/createClient.ts @@ -215,7 +215,8 @@ export default function createClient(addr: string) { const req = new api.CreateGatewayRequest() .setTargetUri(params.targetUri) .setTargetUser(params.user) - .setLocalPort(params.port); + .setLocalPort(params.port) + .setTargetSubresourceName(params.subresource_name); return new Promise((resolve, reject) => { tshd.createGateway(req, (err, response) => { if (err) { diff --git a/packages/teleterm/src/services/tshd/types.ts b/packages/teleterm/src/services/tshd/types.ts index 910d76c4e..f2bc294d5 100644 --- a/packages/teleterm/src/services/tshd/types.ts +++ b/packages/teleterm/src/services/tshd/types.ts @@ -74,4 +74,5 @@ export type CreateGatewayParams = { targetUri: string; port?: string; user?: string; + subresource_name?: string; }; diff --git a/packages/teleterm/src/services/tshd/v1/gateway_pb.d.ts b/packages/teleterm/src/services/tshd/v1/gateway_pb.d.ts index d9a2f919c..0476575eb 100644 --- a/packages/teleterm/src/services/tshd/v1/gateway_pb.d.ts +++ b/packages/teleterm/src/services/tshd/v1/gateway_pb.d.ts @@ -31,6 +31,9 @@ export class Gateway extends jspb.Message { getCliCommand(): string; setCliCommand(value: string): Gateway; + getTargetSubresourceName(): string; + setTargetSubresourceName(value: string): Gateway; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): Gateway.AsObject; @@ -52,5 +55,6 @@ export namespace Gateway { localPort: string, protocol: string, cliCommand: string, + targetSubresourceName: string, } } diff --git a/packages/teleterm/src/services/tshd/v1/gateway_pb.js b/packages/teleterm/src/services/tshd/v1/gateway_pb.js index d8cdbc84c..004daf858 100644 --- a/packages/teleterm/src/services/tshd/v1/gateway_pb.js +++ b/packages/teleterm/src/services/tshd/v1/gateway_pb.js @@ -73,7 +73,8 @@ proto.teleport.terminal.v1.Gateway.toObject = function(includeInstance, msg) { localAddress: jspb.Message.getFieldWithDefault(msg, 5, ""), localPort: jspb.Message.getFieldWithDefault(msg, 6, ""), protocol: jspb.Message.getFieldWithDefault(msg, 7, ""), - cliCommand: jspb.Message.getFieldWithDefault(msg, 8, "") + cliCommand: jspb.Message.getFieldWithDefault(msg, 8, ""), + targetSubresourceName: jspb.Message.getFieldWithDefault(msg, 9, "") }; if (includeInstance) { @@ -142,6 +143,10 @@ proto.teleport.terminal.v1.Gateway.deserializeBinaryFromReader = function(msg, r var value = /** @type {string} */ (reader.readString()); msg.setCliCommand(value); break; + case 9: + var value = /** @type {string} */ (reader.readString()); + msg.setTargetSubresourceName(value); + break; default: reader.skipField(); break; @@ -227,6 +232,13 @@ proto.teleport.terminal.v1.Gateway.serializeBinaryToWriter = function(message, w f ); } + f = message.getTargetSubresourceName(); + if (f.length > 0) { + writer.writeString( + 9, + f + ); + } }; @@ -374,4 +386,22 @@ proto.teleport.terminal.v1.Gateway.prototype.setCliCommand = function(value) { }; +/** + * optional string target_subresource_name = 9; + * @return {string} + */ +proto.teleport.terminal.v1.Gateway.prototype.getTargetSubresourceName = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 9, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.terminal.v1.Gateway} returns this + */ +proto.teleport.terminal.v1.Gateway.prototype.setTargetSubresourceName = function(value) { + return jspb.Message.setProto3StringField(this, 9, value); +}; + + goog.object.extend(exports, proto.teleport.terminal.v1); diff --git a/packages/teleterm/src/services/tshd/v1/service_pb.d.ts b/packages/teleterm/src/services/tshd/v1/service_pb.d.ts index 393d64cb0..a3dbe0892 100644 --- a/packages/teleterm/src/services/tshd/v1/service_pb.d.ts +++ b/packages/teleterm/src/services/tshd/v1/service_pb.d.ts @@ -402,6 +402,9 @@ export class CreateGatewayRequest extends jspb.Message { getLocalPort(): string; setLocalPort(value: string): CreateGatewayRequest; + getTargetSubresourceName(): string; + setTargetSubresourceName(value: string): CreateGatewayRequest; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): CreateGatewayRequest.AsObject; @@ -418,6 +421,7 @@ export namespace CreateGatewayRequest { targetUri: string, targetUser: string, localPort: string, + targetSubresourceName: string, } } diff --git a/packages/teleterm/src/services/tshd/v1/service_pb.js b/packages/teleterm/src/services/tshd/v1/service_pb.js index 1053a0ab8..2c069ebbb 100644 --- a/packages/teleterm/src/services/tshd/v1/service_pb.js +++ b/packages/teleterm/src/services/tshd/v1/service_pb.js @@ -2990,7 +2990,8 @@ proto.teleport.terminal.v1.CreateGatewayRequest.toObject = function(includeInsta var f, obj = { targetUri: jspb.Message.getFieldWithDefault(msg, 1, ""), targetUser: jspb.Message.getFieldWithDefault(msg, 2, ""), - localPort: jspb.Message.getFieldWithDefault(msg, 3, "") + localPort: jspb.Message.getFieldWithDefault(msg, 3, ""), + targetSubresourceName: jspb.Message.getFieldWithDefault(msg, 4, "") }; if (includeInstance) { @@ -3039,6 +3040,10 @@ proto.teleport.terminal.v1.CreateGatewayRequest.deserializeBinaryFromReader = fu var value = /** @type {string} */ (reader.readString()); msg.setLocalPort(value); break; + case 4: + var value = /** @type {string} */ (reader.readString()); + msg.setTargetSubresourceName(value); + break; default: reader.skipField(); break; @@ -3089,6 +3094,13 @@ proto.teleport.terminal.v1.CreateGatewayRequest.serializeBinaryToWriter = functi f ); } + f = message.getTargetSubresourceName(); + if (f.length > 0) { + writer.writeString( + 4, + f + ); + } }; @@ -3146,6 +3158,24 @@ proto.teleport.terminal.v1.CreateGatewayRequest.prototype.setLocalPort = functio }; +/** + * optional string target_subresource_name = 4; + * @return {string} + */ +proto.teleport.terminal.v1.CreateGatewayRequest.prototype.getTargetSubresourceName = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "")); +}; + + +/** + * @param {string} value + * @return {!proto.teleport.terminal.v1.CreateGatewayRequest} returns this + */ +proto.teleport.terminal.v1.CreateGatewayRequest.prototype.setTargetSubresourceName = function(value) { + return jspb.Message.setProto3StringField(this, 4, value); +}; + + /** * List of repeated fields within this message type. diff --git a/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx b/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx index 6e53c95cc..9c1f6b4c9 100644 --- a/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx +++ b/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/Databases.tsx @@ -14,13 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { useRef, useState } from 'react'; +import styled from 'styled-components'; import { useDatabases, State } from './useDatabases'; import { Table } from 'teleterm/ui/components/Table'; import { Cell } from 'design/DataTable'; import { renderLabelCell } from '../renderLabelCell'; import { Danger } from 'design/Alert'; -import { MenuLogin } from 'shared/components/MenuLogin'; +import { MenuLogin, MenuLoginHandle } from 'shared/components/MenuLogin'; import { MenuLoginTheme } from '../MenuLoginTheme'; import { useAppContext } from 'teleterm/ui/appContextProvider'; import { ClustersService } from 'teleterm/ui/services/clusters'; @@ -55,7 +56,9 @@ function DatabaseList(props: State) { render: db => ( props.connect(db.uri, user)} + onConnect={(dbUser, dbName) => + props.connect(db.uri, dbUser, dbName) + } /> ), }, @@ -72,33 +75,66 @@ function ConnectButton({ onConnect, }: { dbUri: string; - onConnect: (user: string) => void; + onConnect: (dbUser: string, dbName: string) => void; }) { const { clustersService, notificationsService } = useAppContext(); + const dbNameMenuLoginRef = useRef(); + const [dbUser, setDbUser] = useState(); return ( - - getDatabaseUsers(dbUri, clustersService, notificationsService) - } - onSelect={(_, user) => onConnect(user)} - transformOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - anchorOrigin={{ - vertical: 'center', - horizontal: 'right', - }} - /> + + {/* The db name MenuLogin will be overlayed by the db username MenuLogin, which the user + should interact with first. */} + []} + onSelect={(_, dbName) => onConnect(dbUser, dbName)} + transformOrigin={transformOrigin} + anchorOrigin={anchorOrigin} + /> + + getDatabaseUsers(dbUri, clustersService, notificationsService) + } + onSelect={(_, user) => { + setDbUser(user); + dbNameMenuLoginRef.current.open(); + }} + transformOrigin={transformOrigin} + anchorOrigin={anchorOrigin} + /> + ); } +const transformOrigin = { + vertical: 'top', + horizontal: 'right', +}; +const anchorOrigin = { + vertical: 'center', + horizontal: 'right', +}; + +const OverlayGrid = styled.div` + display: inline-grid; + + & > button { + grid-area: 1 / 1; + } + + & button:first-child { + visibility: hidden; + } +`; + async function getDatabaseUsers( dbUri: string, clustersService: ClustersService, diff --git a/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts b/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts index f8fa04ced..13f44a397 100644 --- a/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts +++ b/packages/teleterm/src/ui/DocumentCluster/ClusterResources/Databases/useDatabases.ts @@ -24,7 +24,7 @@ export function useDatabases() { const dbs = clusterContext.getDbs(); const syncStatus = clusterContext.getSyncStatus().dbs; - function connect(dbUri: string, user: string): void { + function connect(dbUri: string, dbUser: string, dbName: string): void { const db = appContext.clustersService.findDb(dbUri); const rootClusterUri = routing.ensureRootClusterUri(db.uri); const documentsService = @@ -33,9 +33,10 @@ export function useDatabases() { const doc = documentsService.createGatewayDocument({ // Not passing the `gatewayUri` field here, as at this point the gateway doesn't exist yet. // `port` is not passed as well, we'll let the tsh daemon pick a random one. - title: db.name, targetUri: db.uri, - targetUser: user, + targetName: db.name, + targetUser: dbUser, + targetSubresourceName: dbName, }); documentsService.add(doc); documentsService.open(doc.uri); diff --git a/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts b/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts index d7e4c1b91..82474601e 100644 --- a/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts +++ b/packages/teleterm/src/ui/DocumentGateway/useDocumentGateway.ts @@ -33,6 +33,7 @@ export default function useGateway(doc: types.DocumentGateway) { targetUri: doc.targetUri, port: doc.port, user: doc.targetUser, + subresource_name: doc.targetSubresourceName, }); workspaceDocumentsService.update(doc.uri, { diff --git a/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx b/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx index d82284323..2e63adb66 100644 --- a/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx +++ b/packages/teleterm/src/ui/TabHost/useTabShortcuts.test.tsx @@ -38,6 +38,7 @@ function getMockDocuments(): Document[] { title: 'Test 4', gatewayUri: '', targetUri: '', + targetName: 'foobar', targetUser: 'foo', }, { @@ -46,6 +47,7 @@ function getMockDocuments(): Document[] { title: 'Test 5', gatewayUri: '', targetUri: '', + targetName: 'foobar', targetUser: 'bar', }, { diff --git a/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx b/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx index d1a3e025e..c0d234158 100644 --- a/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx +++ b/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx @@ -24,6 +24,7 @@ export function ExpanderConnections() { connected: true, kind: 'connection.gateway', title: 'graves', + targetName: 'graves', id: '68b6a281', targetUri: 'brock', port: '22', diff --git a/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index c6514df8e..feae9ade7 100644 --- a/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -48,15 +48,24 @@ export function ConnectionItem(props: ConnectionItemProps) { height: unset; `} > - + - +
{props.item.title} @@ -72,13 +80,11 @@ export function ConnectionItem(props: ConnectionItemProps) { {props.item.clusterName} - +
{ kind: 'doc.gateway', gatewayUri: '', targetUri: '', + targetName: '', targetUser: 'foo', }; @@ -129,6 +130,7 @@ test('only gateway documents should be returned', () => { title: 'gw', gatewayUri: '', targetUri: '', + targetName: '', targetUser: 'foo', }; diff --git a/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts b/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts index fc712e575..fb99f8f3c 100644 --- a/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts +++ b/packages/teleterm/src/ui/services/workspacesService/documentsService/documentsService.ts @@ -88,17 +88,39 @@ export class DocumentsService { }; } + /** + * If title is not present in opts, createGatewayDocument will create one based on opts. + */ createGatewayDocument(opts: CreateGatewayDocumentOpts): DocumentGateway { - const { targetUri, title, targetUser, port, gatewayUri } = opts; + const { + targetUri, + targetUser, + targetName, + targetSubresourceName, + port, + gatewayUri, + } = opts; const uri = routing.getDocUri({ docId: unique() }); + let title = opts.title; + + if (!title) { + title = `${targetUser}@${targetName}`; + + if (targetSubresourceName) { + title += `/${targetSubresourceName}`; + } + } + return { uri, kind: 'doc.gateway', targetUri, targetUser, + targetName, + targetSubresourceName, gatewayUri, title, - port + port, }; } diff --git a/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts b/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts index d6c407a4d..626b28c8d 100644 --- a/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts +++ b/packages/teleterm/src/ui/services/workspacesService/documentsService/types.ts @@ -56,6 +56,8 @@ export interface DocumentGateway extends DocumentBase { gatewayUri?: string; targetUri: string; targetUser: string; + targetName: string; + targetSubresourceName?: string; port?: string; } @@ -84,8 +86,10 @@ export type Document = export type CreateGatewayDocumentOpts = { gatewayUri?: string; targetUri: string; + targetName: string; targetUser: string; - title: string; + targetSubresourceName?: string; + title?: string; port?: string; };