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
9 changes: 6 additions & 3 deletions web/packages/build/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@
"@opentelemetry/sdk-trace-web": "1.8.0",
"@opentelemetry/semantic-conventions": "1.8.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
"@storybook/addon-toolbars": "^6.5.16",
"@storybook/builder-webpack5": "^6.5.16",
"@storybook/components": "^6.5.16",
"@storybook/manager-webpack5": "^6.5.16",
"@storybook/react": "^6.5.16",
"@storybook/addon-toolbars": "^6.5.16",
"@storybook/components": "^6.5.16",
"@swc/plugin-styled-components": "1.5.41",
"@testing-library/jest-dom": "^5.15.1",
"@testing-library/react": "^12.1.2",
Expand Down Expand Up @@ -72,8 +72,8 @@
"eslint-plugin-jest": "^25.3.0",
"eslint-plugin-jest-dom": "^4.0.2",
"eslint-plugin-react": "^7.27.1",
"eslint-plugin-testing-library": "^5.6.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-testing-library": "^5.6.0",
"events": "1.0.2",
"fork-ts-checker-webpack-plugin": "^8.0.0",
"html-webpack-plugin": "^5.5.0",
Expand All @@ -96,5 +96,8 @@
"webpack-bundle-analyzer": "^4.6.1",
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.9.3"
},
"devDependencies": {
"msw-storybook-addon": "^1.8.0"
}
}
87 changes: 68 additions & 19 deletions web/packages/teleport/src/UnifiedResources/ResourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@
* limitations under the License.
*/

import React, { useCallback, useState, useLayoutEffect, useRef } from 'react';
import styled from 'styled-components';
import React, {
useCallback,
useState,
useEffect,
useLayoutEffect,
useRef,
} from 'react';
import styled, { css } from 'styled-components';

import {
Box,
Expand Down Expand Up @@ -76,14 +82,18 @@ export function ResourceCard({ resource, onLabelClick }: Props) {
const ResTypeIcon = resourceTypeIcon(resource.kind);
const description = resourceDescription(resource);

const labelsInnerContainer = useRef(null);

const [showMoreLabelsButton, setShowMoreLabelsButton] = useState(false);
const [showAllLabels, setShowAllLabels] = useState(false);
const [numMoreLabels, setNumMoreLabels] = useState(0);

const nameTextRef = useRef<HTMLDivElement | null>(null);
const [isNameOverflowed, setIsNameOverflowed] = useState(false);

const [hovered, setHovered] = useState(false);

const innerContainer = useRef<Element | null>(null);
const labelsInnerContainer = useRef(null);
const nameText = useRef<HTMLDivElement | null>(null);
const collapseTimeout = useRef<ReturnType<typeof setTimeout>>(null);

// This effect installs a resize observer whose purpose is to detect the size
// of the component that contains all the labels. If this component is taller
// than the height of a single label row, we show a "+x more" button.
Expand All @@ -94,8 +104,8 @@ export function ResourceCard({ resource, onLabelClick }: Props) {
// This check will let us know if the name text has overflowed. We do this
// to conditionally render a tooltip for only overflowed names
if (
nameTextRef.current?.scrollWidth >
nameTextRef.current?.parentElement.offsetWidth
nameText.current?.scrollWidth >
nameText.current?.parentElement.offsetWidth
) {
setIsNameOverflowed(true);
} else {
Expand Down Expand Up @@ -133,17 +143,47 @@ export function ResourceCard({ resource, onLabelClick }: Props) {
};
});

// Clear the timeout on unmount to prevent changing a state of an unmounted
// component.
useEffect(() => () => clearTimeout(collapseTimeout.current), []);

const onMoreLabelsClick = () => {
setShowAllLabels(true);
};

const onMouseLeave = () => {
// If the user expanded the labels and then scrolled down enough to hide the
// top of the card, we scroll back up and collapse the labels with a small
// delay to keep the user from losing focus on the card that they were
// looking at. The delay is picked by hand, since there's no (easy) way to
// know when the animation ends.
if (
showAllLabels &&
(innerContainer.current?.getBoundingClientRect().top ?? 0) < 0
) {
innerContainer.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
clearTimeout(collapseTimeout.current);
collapseTimeout.current = setTimeout(() => setShowAllLabels(false), 700);
} else {
// Otherwise, we just collapse the labels immediately.
setShowAllLabels(false);
}
};

return (
<CardContainer>
<CardContainer
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<CardInnerContainer
ref={innerContainer}
p={3}
alignItems="start"
showAllLabels={showAllLabels}
onMouseLeave={() => setShowAllLabels(false)}
onMouseLeave={onMouseLeave}
>
<ResourceIcon name={resIcon} width="45px" height="45px" ml={2} />
{/* MinWidth is important to prevent descriptions from overflowing. */}
Expand All @@ -152,17 +192,17 @@ export function ResourceCard({ resource, onLabelClick }: Props) {
<SingleLineBox flex="1">
{isNameOverflowed ? (
<HoverTooltip tipContent={<>{name}</>}>
<Text ref={nameTextRef} typography="h5" fontWeight={300}>
<Text ref={nameText} typography="h5" fontWeight={300}>
{name}
</Text>
</HoverTooltip>
) : (
<Text ref={nameTextRef} typography="h5" fontWeight={300}>
<Text ref={nameText} typography="h5" fontWeight={300}>
{name}
</Text>
)}
</SingleLineBox>
<CopyButton name={name} />
{hovered && <CopyButton name={name} />}
<ResourceActionButton resource={resource} />
</Flex>
<Flex flexDirection="row" alignItems="center">
Expand Down Expand Up @@ -384,6 +424,12 @@ const CardContainer = styled(Box)`
position: relative;
`;

const elevatedCardMixin = css`
background-color: ${props => props.theme.colors.levels.elevated};
border-color: ${props => props.theme.colors.levels.elevated};
box-shadow: ${props => props.theme.boxShadow[1]};
`;

/**
* The inner container that normally holds a regular layout of the card, and is
* fully contained inside the outer container. Once the user clicks the "more"
Expand All @@ -400,16 +446,19 @@ const CardInnerContainer = styled(Flex)`
border-radius: ${props => props.theme.radii[3]}px;

${props =>
props.showAllLabels
? 'position: absolute; left: 0; right: 0; z-index: 1;'
: ''}
props.showAllLabels &&
css`
position: absolute;
left: 0;
right: 0;
z-index: 1;
${elevatedCardMixin}
`}

transition: all 150ms;

${CardContainer}:hover & {
background-color: ${props => props.theme.colors.levels.elevated};
border-color: ${props => props.theme.colors.levels.elevated};
box-shadow: ${props => props.theme.boxShadow[1]};
${elevatedCardMixin}
}
`;

Expand Down
107 changes: 107 additions & 0 deletions web/packages/teleport/src/UnifiedResources/Resources.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* 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 { MemoryRouter } from 'react-router';

import { initialize, mswLoader } from 'msw-storybook-addon';

import { rest, ResponseResolver, MockedRequest, RestContext } from 'msw';

import { createTeleportContext } from 'teleport/mocks/contexts';
import { ContextProvider } from 'teleport';
import cfg from 'teleport/config';
import { apps } from 'teleport/Apps/fixtures';
import { databases } from 'teleport/Databases/fixtures';
import { kubes } from 'teleport/Kubes/fixtures';
import { desktops } from 'teleport/Desktops/fixtures';
import { nodes } from 'teleport/Nodes/fixtures';

import { Resources } from './Resources';

initialize();

export default {
title: 'Teleport/UnifiedResources',
loaders: [mswLoader],
};

const aLotOfLabels = {
...databases[0],
name: 'A DB with a lot of labels',
labels: Array(300)
.fill(0)
.map((_, i) => ({ name: `label-${i}`, value: `value ${i}` })),
};

const allResources = [
...apps,
aLotOfLabels,
...databases,
...kubes,
...desktops,
...nodes,
...apps,
...databases,
...kubes,
...desktops,
...nodes,
];

const story = (resolver: ResponseResolver<MockedRequest, RestContext>) => {
const ctx = createTeleportContext();

const s = () => (
<MemoryRouter>
<ContextProvider ctx={ctx}>
<Resources />
</ContextProvider>
</MemoryRouter>
);

s.parameters = {
msw: {
handlers: [
rest.get(cfg.getUnifiedResourcesUrl('localhost', {}), resolver),
],
},
};
return s;
};

export const Empty = story((_, res, ctx) => res(ctx.json({ items: [] })));

export const List = story((_, res, ctx) =>
res(ctx.json({ items: allResources }))
);

export const Loading = story((_, res, ctx) => res(ctx.delay('infinite')));

export const LoadingAfterScrolling = story((req, res, ctx) => {
if (req.url.searchParams.get('startKey') === 'next-key') {
return res(ctx.delay('infinite'));
}
return res(ctx.json({ items: allResources, startKey: 'next-key' }));
});

export const Error = story((_, res, ctx) => res(ctx.status(500)));

export const ErrorAfterScrolling = story((req, res, ctx) => {
if (req.url.searchParams.get('startKey') === 'next-key') {
return res(ctx.status(500));
}
return res(ctx.json({ items: allResources, startKey: 'next-key' }));
});
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11608,6 +11608,13 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==

msw-storybook-addon@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/msw-storybook-addon/-/msw-storybook-addon-1.8.0.tgz#090b55b9a586f3e1620782dc156e8d5ce951ab7a"
integrity sha512-dw3vZwqjixmiur0vouRSOax7wPSu9Og2Hspy9JZFHf49bZRjwDiLF0Pfn2NXEkGviYJOJiGxS1ejoTiUwoSg4A==
dependencies:
is-node-process "^1.0.1"

msw@^0.47.4:
version "0.47.4"
resolved "https://registry.yarnpkg.com/msw/-/msw-0.47.4.tgz#5551011609890c6b62a2047055f475a9afae2ad4"
Expand Down