Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import React from 'react';
import { useRef, useEffect } from 'react';
import { Flex, Label, Text } from 'design';

import styled from 'styled-components';
Expand All @@ -31,15 +31,21 @@ interface ClusterItemProps {
}

export function ClusterItem(props: ClusterItemProps) {
const { isActive } = useKeyboardArrowsNavigation({
const { isActive, scrollIntoViewIfActive } = useKeyboardArrowsNavigation({
index: props.index,
onRun: props.onSelect,
});
const ref = useRef<HTMLElement>();

const clusterName = props.item.name;

useEffect(() => {
scrollIntoViewIfActive(ref.current);
}, [scrollIntoViewIfActive]);

return (
<StyledListItem
ref={ref}
onClick={props.onSelect}
isActive={isActive}
isSelected={props.isSelected}
Expand Down
163 changes: 124 additions & 39 deletions web/packages/teleterm/src/ui/TopBar/Connections/Connections.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,60 +14,145 @@
* limitations under the License.
*/

import React from 'react';
import { useLayoutEffect } from 'react';
import { Flex, Text } from 'design';

import AppContextProvider from 'teleterm/ui/appContextProvider';
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
import { ExtendedTrackedConnection } from 'teleterm/ui/services/connectionTracker';

import { Connections } from './Connections';

export default {
title: 'Teleterm/TopBar/Connections',
decorators: [
Story => {
useOpenConnections();

return <Story />;
},
],
};

export function ExpanderConnections() {
export function Story() {
const appContext = new MockAppContext();
appContext.connectionTracker.getConnections = () => {
return [
{
connected: true,
kind: 'connection.server',
title: 'ansible',
id: 'e9c4fbc2',
serverUri: '/clusters/foo/servers/ansible',
login: 'casey',
clusterName: 'teleport.example.sh',
},
{
connected: true,
kind: 'connection.gateway',
title: 'postgres',
targetName: 'postgres',
id: '68b6a281',
targetUri: '/clusters/foo/dbs/brock',
port: '22',
gatewayUri: '/gateways/empty',
clusterName: 'teleport.example.sh',
},
{
connected: false,
kind: 'connection.server',
title: 'ansible-staging',
id: '949651ed',
serverUri: '/clusters/foo/servers/ansible-staging',
login: 'casey',
clusterName: 'teleport.example.sh',
},
];
};
appContext.connectionTracker.activateItem = async () => {};
appContext.connectionTracker.disconnectItem = async () => {};
appContext.connectionTracker.removeItem = async () => {};
appContext.connectionTracker.useState = () => null;
prepareAppContext(appContext);

return (
<AppContextProvider value={appContext}>
<Connections />
</AppContextProvider>
);
}

export function WithScroll() {
const appContext = new MockAppContext();
prepareAppContext(appContext);
appContext.connectionTracker.getConnections = () => [
{
connected: false,
kind: 'connection.server' as const,
title: 'last-item',
id: 'last-item',
serverUri: '/clusters/foo/servers/last-item',
login: 'item',
clusterName: 'teleport.example.sh',
},
...Array(10)
.fill(undefined)
.flatMap((_, index) => makeConnections(index)),
];

return (
<Flex
flexDirection="row"
justifyContent="space-between"
gap={3}
minWidth="500px"
maxWidth="600px"
>
<AppContextProvider value={appContext}>
<Connections />
</AppContextProvider>
<Text
css={`
max-width: 20ch;
`}
>
Manipulate window height to simulate how the list behaves in Connect.
</Text>
</Flex>
);
}

export function Empty() {
const appContext = new MockAppContext({ platform: 'win32' });

return (
<AppContextProvider value={appContext}>
<Connections />
</AppContextProvider>
);
}

const makeConnections = (index = 0): ExtendedTrackedConnection[] => {
const suffix = index === 0 ? '' : `-${index}`;

return [
{
connected: true,
kind: 'connection.server' as const,
title: 'ansible' + suffix,
id: 'e9c4fbc2' + suffix,
serverUri: `/clusters/foo/servers/ansible${suffix}`,
login: 'casey',
clusterName: 'teleport.example.sh',
},
{
connected: true,
kind: 'connection.gateway' as const,
title: 'postgres' + suffix,
targetName: 'postgres',
id: '68b6a281' + suffix,
targetUri: `/clusters/foo/dbs/brock${suffix}`,
port: '22',
gatewayUri: '/gateways/empty',
clusterName: 'teleport.example.sh',
},
{
connected: false,
kind: 'connection.server' as const,
title: 'ansible-staging' + suffix,
id: '949651ed' + suffix,
serverUri: `/clusters/foo/servers/ansible-staging${suffix}`,
login: 'casey',
clusterName: 'teleport.example.sh',
},
];
};

const prepareAppContext = (appContext: MockAppContext) => {
appContext.connectionTracker.getConnections = () => makeConnections();
appContext.connectionTracker.activateItem = async () => {};
appContext.connectionTracker.disconnectItem = async () => {};
appContext.connectionTracker.removeItem = async () => {};
appContext.connectionTracker.useState = () => null;
};

const useOpenConnections = () => {
useLayoutEffect(() => {
const areConnectionsOpen = !!document.querySelector(
'input[role=searchbox]'
);

if (areConnectionsOpen) {
return;
}

const button = document.querySelector(
'button[title~="connections"i]'
) as HTMLButtonElement;

button?.click();
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import React from 'react';
import { useEffect, useRef } from 'react';
import { ButtonIcon, Flex, Text } from 'design';
import { Trash, Unlink } from 'design/Icon';

Expand All @@ -39,7 +39,7 @@ interface ConnectionItemProps {

export function ConnectionItem(props: ConnectionItemProps) {
const offline = !props.item.connected;
const { isActive } = useKeyboardArrowsNavigation({
const { isActive, scrollIntoViewIfActive } = useKeyboardArrowsNavigation({
index: props.index,
onRun: props.onActivate,
});
Expand All @@ -58,11 +58,17 @@ export function ConnectionItem(props: ConnectionItemProps) {
};

const actionIcon = offline ? actionIcons.remove : actionIcons.disconnect;
const ref = useRef<HTMLElement>();

useEffect(() => {
scrollIntoViewIfActive(ref.current);
}, [scrollIntoViewIfActive]);

return (
<ListItem
onClick={props.onActivate}
isActive={isActive}
ref={ref}
css={`
padding: 6px 8px;
height: unset;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function Node({ item }: { item: TestItem }) {
return <li>{item.title}</li>;
}

test('render first 10 items by default', () => {
test('render all items by default', () => {
render(
<FilterableList<TestItem>
items={mockedItems}
Expand All @@ -42,7 +42,7 @@ test('render first 10 items by default', () => {
);
const items = screen.getAllByRole('listitem');

expect(items).toHaveLength(10);
expect(items).toHaveLength(30);
items.forEach((item, index) => {
expect(item).toHaveTextContent(mockedItems[index].title);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,14 @@ interface FilterableListProps<T> {
onFilterChange?(filter: string): void;
}

const maxItemsToShow = 10;

export function FilterableList<T>(props: FilterableListProps<T>) {
export function FilterableList<T>(
props: React.PropsWithChildren<FilterableListProps<T>>
) {
const { items } = props;
const [searchValue, setSearchValue] = useState<string>();

const filteredItems = useMemo(
() =>
filterItems(searchValue, items, props.filterBy).slice(0, maxItemsToShow),
() => filterItems(searchValue, items, props.filterBy),
[items, searchValue]
);

Expand Down Expand Up @@ -79,7 +78,7 @@ const UnorderedList = styled.ul`
`;

const StyledInput = styled(Input)`
background: inherit;
background-color: inherit;
border-radius: 51px;
margin-bottom: 8px;
font-size: 14px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { useContext, useEffect } from 'react';
import { useCallback, useContext, useEffect } from 'react';

import {
KeyboardArrowsNavigationContext,
Expand Down Expand Up @@ -46,8 +46,35 @@ export function useKeyboardArrowsNavigation({
return () => navigationContext.removeItem(index);
}, [index, onRun, navigationContext.addItem, navigationContext.removeItem]);

const isActive = index === navigationContext.activeIndex;

const scrollIntoViewIfActive = useCallback(
(el: HTMLElement | undefined) => {
if (!isActive || !el) {
return;
}

// By default, scrollIntoView uses 'start'. This is a problem in two cases:
//
// 1. When scrolling from the last to the first element, the top of the scrollable area gets
// aligned to the top of the first active element, not to the top of the parent container.
// 2. When scrolling from any other element to the next one, the scrollable area gets aligned
// to the top of the active element, meaning that the previous element immediately disappears.
//
// 'center' fixes both problems, while being closer than 'nearest' to how the browser adjusts
// the scrollable area when tabbing through focusable elements. It ensures that you see what's
// after and before the active element.
//
// Compared to 'nearest', it also makes sure that the scrollable area is aligned to its bottom
// when scrolling to the last element – 'nearest' aligns it to the bottom of the active item.
el.scrollIntoView({ block: 'center' });
},
[isActive]
);

return {
isActive: index === navigationContext.activeIndex,
isActive,
scrollIntoViewIfActive,
};
}

Expand Down