diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx index 80663c9322187..c3914d202adf7 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.test.tsx @@ -16,16 +16,20 @@ * along with this program. If not, see . */ -import { render, screen, userEvent } from 'design/utils/testing'; +import { act, render, screen, userEvent } from 'design/utils/testing'; import Logger, { NullService } from 'teleterm/logger'; import { VnetContextProvider } from 'teleterm/ui/Vnet'; import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider'; import { MockAppContext } from 'teleterm/ui/fixtures/mocks'; - import { makeRootCluster } from 'teleterm/services/tshd/testHelpers'; +import { + DocumentTshNode, + Workspace, +} from 'teleterm/ui/services/workspacesService'; -import { Workspace } from 'teleterm/ui/services/workspacesService'; +import { routing } from 'teleterm/ui/uri'; +import { unique } from 'teleterm/ui/utils'; import { Connections } from './Connections'; @@ -109,7 +113,7 @@ describe('opening a connection', () => { serverId: 'foo', login: 'alice', title: 'alice@foo', - uri: '/docs/123', + uri: routing.getDocUri({ docId: unique() }), }; appContext.workspacesService.setState(draft => { draft.workspaces[cluster.uri] = { @@ -137,3 +141,138 @@ describe('opening a connection', () => { expect(workspace.location).toEqual(doc.uri); }); }); + +test('adding a new conn while the list is open puts the new conn at the top', async () => { + const user = userEvent.setup(); + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + appContext.clustersService.setState(draft => { + draft.clusters.set(cluster.uri, cluster); + }); + const doc: DocumentTshNode = { + kind: 'doc.terminal_tsh_node', + origin: 'search_bar', + rootClusterId: cluster.name, + leafClusterId: undefined, + serverUri: `${cluster.uri}/servers/foo`, + serverId: 'foo', + login: 'alice', + title: 'alice@foo', + uri: routing.getDocUri({ docId: unique() }), + status: 'connected', + }; + appContext.workspacesService.setState(draft => { + draft.workspaces[cluster.uri] = { + documents: [doc], + location: undefined, + } as Workspace; + }); + + render( + + + + + + ); + + await user.click(screen.getByTitle(/Open Connections/)); + expect(screen.getByText(doc.title)).toBeInTheDocument(); + + const newDoc: DocumentTshNode = { + kind: 'doc.terminal_tsh_node', + origin: 'search_bar', + rootClusterId: cluster.name, + leafClusterId: undefined, + serverUri: `${cluster.uri}/servers/bar`, + serverId: 'bar', + login: 'alice', + title: 'alice@bar', + uri: routing.getDocUri({ docId: unique() }), + status: 'connected', + }; + + act(() => { + appContext.workspacesService.setState(draft => { + draft.workspaces[cluster.uri].documents.push(newDoc); + }); + }); + + const oldConn = screen.getByText(doc.title); + const newConn = screen.getByText(newDoc.title); + // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition + expect( + newConn.compareDocumentPosition(oldConn) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); +}); + +test('disconnecting a conn does not update its position in the list', async () => { + const user = userEvent.setup(); + const appContext = new MockAppContext(); + const cluster = makeRootCluster(); + appContext.clustersService.setState(draft => { + draft.clusters.set(cluster.uri, cluster); + }); + const doc: DocumentTshNode = { + kind: 'doc.terminal_tsh_node', + origin: 'search_bar', + rootClusterId: cluster.name, + leafClusterId: undefined, + serverUri: `${cluster.uri}/servers/foo`, + serverId: 'foo', + login: 'alice', + title: 'alice@foo', + uri: routing.getDocUri({ docId: unique() }), + status: 'connected', + }; + const docToBeClosed: DocumentTshNode = { + kind: 'doc.terminal_tsh_node', + origin: 'search_bar', + rootClusterId: cluster.name, + leafClusterId: undefined, + serverUri: `${cluster.uri}/servers/bar`, + serverId: 'bar', + login: 'alice', + title: 'alice@bar', + uri: routing.getDocUri({ docId: unique() }), + status: 'connected', + }; + appContext.workspacesService.setState(draft => { + draft.workspaces[cluster.uri] = { + documents: [docToBeClosed, doc], + location: undefined, + } as Workspace; + }); + + render( + + + + + + ); + + await user.click(screen.getByTitle(/Open Connections/)); + const conn = () => screen.getByText(doc.title); + const connToBeClosed = () => screen.getByText(docToBeClosed.title); + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition + expect( + connToBeClosed().compareDocumentPosition(conn()) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + const disconnectButton = screen.getByTitle( + `Disconnect ${docToBeClosed.title}` + ); + await user.click(disconnectButton); + + expect( + await screen.findByTitle(`Remove ${docToBeClosed.title}`) + ).toBeInTheDocument(); + + expect( + connToBeClosed().compareDocumentPosition(conn()) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); +}); diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx index deb74aee37475..d55b23eba449e 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/Connections.tsx @@ -19,33 +19,27 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import Popover from 'design/Popover'; import { Box, StepSlider } from 'design'; -import { StepComponentProps } from 'design/StepSlider'; import { useKeyboardShortcuts } from 'teleterm/ui/services/keyboardShortcuts'; -import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; import { VnetSliderStep, useVnetContext } from 'teleterm/ui/Vnet'; +import { useAppContext } from 'teleterm/ui/appContextProvider'; -import { useConnections } from './useConnections'; import { ConnectionsIcon } from './ConnectionsIcon/ConnectionsIcon'; -import { ConnectionsFilterableList } from './ConnectionsFilterableList/ConnectionsFilterableList'; +import { ConnectionsSliderStep } from './ConnectionsSliderStep'; export function Connections() { + const { connectionTracker } = useAppContext(); + connectionTracker.useState(); const iconRef = useRef(); const [isPopoverOpened, setIsPopoverOpened] = useState(false); - const connections = useConnections(); const { status: vnetStatus } = useVnetContext(); const isAnyConnectionActive = - connections.isAnyConnectionActive || vnetStatus === 'running'; + connectionTracker.getConnections().some(c => c.connected) || + vnetStatus === 'running'; const togglePopover = useCallback(() => { - setIsPopoverOpened(wasOpened => { - const isOpened = !wasOpened; - if (isOpened) { - connections.updateSorting(); - } - return isOpened; - }); - }, [setIsPopoverOpened, connections.updateSorting]); + setIsPopoverOpened(wasOpened => !wasOpened); + }, []); useKeyboardShortcuts( useMemo( @@ -56,9 +50,8 @@ export function Connections() { ) ); - function activateItem(id: string): void { + function closeConnectionList(): void { setIsPopoverOpened(false); - connections.activateItem(id); } // TODO(ravicious): Investigate the problem with height getting temporarily reduced when switching @@ -69,22 +62,6 @@ export function Connections() { // // We aim to replace the sliding animation with an expanding animation before the release, so it // might not be worth the effort. - const sliderSteps = [ - (props: StepComponentProps) => ( - - - - - - ), - VnetSliderStep, - ]; return ( <> @@ -104,9 +81,16 @@ export function Connections() { padding (so 24px on both sides) and ConnectionsFilterableList had 300px of width. */} - + ); } + +const stepSliderFlows = { default: [ConnectionsSliderStep, VnetSliderStep] }; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx index bcacab2ebae5b..00ce1cf6402cc 100644 --- a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsFilterableList/ConnectionItem.tsx @@ -32,25 +32,25 @@ export function ConnectionItem(props: { index: number; item: ExtendedTrackedConnection; showClusterName: boolean; - onActivate(): void; - onRemove(): void; - onDisconnect(): void; + activate(): void; + remove(): void; + disconnect(): void; }) { const offline = !props.item.connected; const { isActive, scrollIntoViewIfActive } = useKeyboardArrowsNavigation({ index: props.index, - onRun: props.onActivate, + onRun: props.activate, }); const actionIcons = { disconnect: { - title: 'Disconnect', - action: props.onDisconnect, + title: `Disconnect ${props.item.title}`, + action: props.disconnect, Icon: Unlink, }, remove: { - title: 'Remove', - action: props.onRemove, + title: `Remove ${props.item.title}`, + action: props.remove, Icon: Trash, }, }; @@ -64,7 +64,7 @@ export function ConnectionItem(props: { return ( props.onActivateItem(item.id)} - onRemove={() => props.onRemoveItem(item.id)} - onDisconnect={() => props.onDisconnectItem(item.id)} + activate={() => props.activateItem(item.id)} + remove={() => props.removeItem(item.id)} + disconnect={() => props.disconnectItem(item.id)} /> ) } diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsSliderStep.tsx b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsSliderStep.tsx new file mode 100644 index 0000000000000..3314b211098e3 --- /dev/null +++ b/web/packages/teleterm/src/ui/TopBar/Connections/ConnectionsSliderStep.tsx @@ -0,0 +1,88 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { useState } from 'react'; +import { Box } from 'design'; +import { StepComponentProps } from 'design/StepSlider'; + +import { useAppContext } from 'teleterm/ui/appContextProvider'; +import { KeyboardArrowsNavigation } from 'teleterm/ui/components/KeyboardArrowsNavigation'; + +import { ConnectionsFilterableList } from './ConnectionsFilterableList/ConnectionsFilterableList'; + +export const ConnectionsSliderStep = ( + props: StepComponentProps & { closeConnectionList: () => void } +) => { + const { connectionTracker } = useAppContext(); + connectionTracker.useState(); + + const items = connectionTracker.getConnections(); + const [sortedIds, setSortedIds] = useState(null); + + // Sorting needs to be updated only once when the component gets first rendered. This is so that + // when new connections are added while the list is open, the sorting is _not_ updated and new + // items end up at the top of the list. + // + // This also keeps the list stable when you e.g. disconnect the first connection in the list. + // Instead of the item jumping around, it stays as the top until the user closes and then opens + // the list again. + if (sortedIds === null) { + const sorted = items + .slice() + // New connections are pushed to the list in `connectionTracker`, so we have to reverse them + // to get the newest items on the top + .reverse() + // Connected items first. + .sort((a, b) => (a.connected === b.connected ? 0 : a.connected ? -1 : 1)) + .map(a => a.id); + setSortedIds(sorted); + return null; + } + + const sortedItems = + // It is possible that new connections are added when the menu is open. + // They will have -1 index and appear on the top. + // Items are sorted by insertion order, meaning that if I add A then B, + // then close both, open A and close it, it's going to appear after B + // even though it was used more recently than B. + items + .slice() + .sort((a, b) => sortedIds.indexOf(a.id) - sortedIds.indexOf(b.id)); + + const removeItem = connectionTracker.removeItem.bind(connectionTracker); + const disconnectItem = + connectionTracker.disconnectItem.bind(connectionTracker); + const activateItem = (id: string) => { + props.closeConnectionList(); + connectionTracker.activateItem(id, { origin: 'connection_list' }); + }; + + return ( + + + + + + ); +}; diff --git a/web/packages/teleterm/src/ui/TopBar/Connections/useConnections.ts b/web/packages/teleterm/src/ui/TopBar/Connections/useConnections.ts deleted file mode 100644 index 7cbcd4bc32f39..0000000000000 --- a/web/packages/teleterm/src/ui/TopBar/Connections/useConnections.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { useCallback, useState } from 'react'; - -import { useAppContext } from 'teleterm/ui/appContextProvider'; -import { ExtendedTrackedConnection } from 'teleterm/ui/services/connectionTracker'; - -export function useConnections() { - const { connectionTracker } = useAppContext(); - - connectionTracker.useState(); - - const items = connectionTracker.getConnections(); - const [sortedIds, setSortedIds] = useState([]); - - const getSortedItems = () => { - const findIndexInSorted = (item: ExtendedTrackedConnection) => - sortedIds.indexOf(item.id); - // It is possible that new connections are added when the menu is open - // they will have -1 index and appear on the top. - // Items are sorted by insertion order, meaning that if I add A then B - // then close both, open A and close it, it's going to appear after B - // even though it was used more recently than B - return [...items].sort( - (a, b) => findIndexInSorted(a) - findIndexInSorted(b) - ); - }; - - const serializedItems = items.map(i => `${i.id}${i.connected}`).join(','); - const updateSorting = useCallback(() => { - const sorted = [...items] - // new connections are pushed to the list in `connectionTracker`, - // so we have to reverse them to get the newest items on the top - .reverse() - // connected first - .sort((a, b) => (a.connected === b.connected ? 0 : a.connected ? -1 : 1)) - .map(a => a.id); - - setSortedIds(sorted); - }, [setSortedIds, serializedItems]); - - return { - isAnyConnectionActive: items.some(c => c.connected), - removeItem: (id: string) => connectionTracker.removeItem(id), - activateItem: (id: string) => - connectionTracker.activateItem(id, { origin: 'connection_list' }), - disconnectItem: (id: string) => connectionTracker.disconnectItem(id), - updateSorting, - items: getSortedItems(), - }; -}