diff --git a/web/packages/build/package.json b/web/packages/build/package.json index f858c44b65089..2c8a4b56b602b 100644 --- a/web/packages/build/package.json +++ b/web/packages/build/package.json @@ -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", @@ -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", @@ -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" } } diff --git a/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx b/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx index 9af965794213a..373c109eaef0d 100644 --- a/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx +++ b/web/packages/teleport/src/UnifiedResources/ResourceCard.tsx @@ -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, @@ -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(null); const [isNameOverflowed, setIsNameOverflowed] = useState(false); + + const [hovered, setHovered] = useState(false); + + const innerContainer = useRef(null); + const labelsInnerContainer = useRef(null); + const nameText = useRef(null); + const collapseTimeout = useRef>(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. @@ -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 { @@ -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 ( - + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > setShowAllLabels(false)} + onMouseLeave={onMouseLeave} > {/* MinWidth is important to prevent descriptions from overflowing. */} @@ -152,17 +192,17 @@ export function ResourceCard({ resource, onLabelClick }: Props) { {isNameOverflowed ? ( {name}}> - + {name} ) : ( - + {name} )} - + {hovered && } @@ -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" @@ -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} } `; diff --git a/web/packages/teleport/src/UnifiedResources/Resources.story.tsx b/web/packages/teleport/src/UnifiedResources/Resources.story.tsx new file mode 100644 index 0000000000000..bd939e7373655 --- /dev/null +++ b/web/packages/teleport/src/UnifiedResources/Resources.story.tsx @@ -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) => { + const ctx = createTeleportContext(); + + const s = () => ( + + + + + + ); + + 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' })); +}); diff --git a/yarn.lock b/yarn.lock index b75f2436d3f09..7383bee6c2b8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"