diff --git a/frontend/src/metabase/browse/components/BrowseContainer.styled.tsx b/frontend/src/metabase/browse/components/BrowseContainer.styled.tsx index e6e7cb96839673d5118ec2e8c67c377161114da2..90a42eca12d4df8036e2b51ba1bae4bef0b136da 100644 --- a/frontend/src/metabase/browse/components/BrowseContainer.styled.tsx +++ b/frontend/src/metabase/browse/components/BrowseContainer.styled.tsx @@ -59,6 +59,7 @@ export const CenteredEmptyState = styled(EmptyState)` flex-flow: column nowrap; align-items: center; justify-content: center; + width: 100%; height: 100%; `; diff --git a/frontend/src/metabase/browse/components/BrowseModels.tsx b/frontend/src/metabase/browse/components/BrowseModels.tsx index 02bb684bf957a5dbceb307bd1f590899846f138f..a74eb318f4f523d71e6b2c32fa559f53c38a4b82 100644 --- a/frontend/src/metabase/browse/components/BrowseModels.tsx +++ b/frontend/src/metabase/browse/components/BrowseModels.tsx @@ -4,7 +4,7 @@ import { t } from "ttag"; import NoResults from "assets/img/no_results.svg"; import { useListRecentsQuery } from "metabase/api"; import { useFetchModels } from "metabase/common/hooks/use-fetch-models"; -import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper"; import { color } from "metabase/lib/colors"; import { PLUGIN_COLLECTIONS, @@ -78,6 +78,11 @@ export const BrowseModels = () => { return filteredRecentModels.slice(0, cap); }, [filteredRecentModels, allModels.length]); + const isEmpty = + !recentModelsResult.isLoading && + !modelsResult.isLoading && + !filteredModels.length; + return ( <BrowseContainer> <BrowseHeader> @@ -106,18 +111,8 @@ export const BrowseModels = () => { </BrowseHeader> <BrowseMain> <BrowseSection> - <LoadingAndErrorWrapper - error={modelsResult.error || recentModelsResult.error} - loading={modelsResult.isLoading || recentModelsResult.isLoading} - style={{ flex: 1 }} - > - {filteredModels.length ? ( - <Stack mb="lg" spacing="md"> - <ModelExplanationBanner /> - <RecentModels models={recentModels} /> - <ModelsTable models={filteredModels} /> - </Stack> - ) : ( + <Stack mb="lg" spacing="md" w="100%"> + {isEmpty ? ( <CenteredEmptyState title={<Box mb=".5rem">{t`No models here yet`}</Box>} message={ @@ -129,8 +124,35 @@ export const BrowseModels = () => { </Box> } /> + ) : ( + <> + <ModelExplanationBanner /> + <DelayedLoadingAndErrorWrapper + error={recentModelsResult.error} + loading={ + // If the main models result is still pending, the list of recently viewed + // models isn't ready yet, since the number of recently viewed models is + // capped according to the size of the main models result + recentModelsResult.isLoading || modelsResult.isLoading + } + style={{ flex: 1 }} + delay={0} + loader={<RecentModels skeleton />} + > + <RecentModels models={recentModels} /> + </DelayedLoadingAndErrorWrapper> + <DelayedLoadingAndErrorWrapper + error={modelsResult.error} + loading={modelsResult.isLoading} + style={{ flex: 1 }} + delay={0} + loader={<ModelsTable skeleton />} + > + <ModelsTable models={filteredModels} /> + </DelayedLoadingAndErrorWrapper> + </> )} - </LoadingAndErrorWrapper> + </Stack> </BrowseSection> </BrowseMain> </BrowseContainer> diff --git a/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx b/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx index 8e1d2a07139fd3fbe28e43909ddba7a47157da6e..956d2f64ae8720085f1bf75436b3c00c5e3d8aab 100644 --- a/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx +++ b/frontend/src/metabase/browse/components/BrowseModels.unit.spec.tsx @@ -279,7 +279,9 @@ describe("BrowseModels", () => { it("displays the Our Analytics collection if it has a model", async () => { setup(25); - const modelsTable = await screen.findByRole("table"); + const modelsTable = await screen.findByRole("table", { + name: /Table of models/, + }); expect(modelsTable).toBeInTheDocument(); expect( await screen.findAllByTestId("path-for-collection: Our analytics"), @@ -297,7 +299,9 @@ describe("BrowseModels", () => { it("displays collection breadcrumbs", async () => { setup(25); - const modelsTable = await screen.findByRole("table"); + const modelsTable = await screen.findByRole("table", { + name: /Table of models/, + }); expect(await within(modelsTable).findByText("Model 1")).toBeInTheDocument(); expect( await within(modelsTable).findAllByTestId( diff --git a/frontend/src/metabase/browse/components/ModelsTable.styled.tsx b/frontend/src/metabase/browse/components/ModelsTable.styled.tsx index 0ec163605509397dc185c95647ed8e5843260425..3fa1bb0eb0b3e17c829e7d011ed1d45dafcfb8e6 100644 --- a/frontend/src/metabase/browse/components/ModelsTable.styled.tsx +++ b/frontend/src/metabase/browse/components/ModelsTable.styled.tsx @@ -8,11 +8,17 @@ import { import type { ResponsiveProps } from "metabase/components/ItemsTable/utils"; import { breakpoints } from "metabase/ui/theme"; -export const ModelTableRow = styled.tr` - cursor: pointer; +export const ModelTableRow = styled.tr<{ skeleton?: boolean }>` :outline { outline: 2px solid var(--mb-color-brand); } + ${props => + props.skeleton + ? ` + :hover { background-color: unset ! important; } + td { cursor: unset ! important; } + ` + : `cursor: pointer;`} `; export const ModelNameLink = styled(ItemLink)` diff --git a/frontend/src/metabase/browse/components/ModelsTable.tsx b/frontend/src/metabase/browse/components/ModelsTable.tsx index 706da78740d60ed2b17729c2b666ac09a151d903..d404e51390510c2ca95c053699b3e935953cf4c2 100644 --- a/frontend/src/metabase/browse/components/ModelsTable.tsx +++ b/frontend/src/metabase/browse/components/ModelsTable.tsx @@ -1,12 +1,17 @@ -import { useEffect, useState } from "react"; +import { + type PropsWithChildren, + useEffect, + useState, + type CSSProperties, +} from "react"; import { push } from "react-router-redux"; import { t } from "ttag"; import EntityItem from "metabase/components/EntityItem"; import { SortableColumnHeader } from "metabase/components/ItemsTable/BaseItemsTable"; import { - ItemLink, ItemNameCell, + MaybeItemLink, Table, TableColumn, TBody, @@ -18,7 +23,14 @@ import { color } from "metabase/lib/colors"; import { useDispatch, useSelector } from "metabase/lib/redux"; import * as Urls from "metabase/lib/urls"; import { getLocale } from "metabase/setup/selectors"; -import { Flex, Icon, type IconProps } from "metabase/ui"; +import { + Flex, + Icon, + type IconProps, + type IconName, + Skeleton, +} from "metabase/ui"; +import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import { SortDirection, type SortingOptions } from "metabase-types/api/sorting"; import { trackModelClick } from "../analytics"; @@ -29,6 +41,7 @@ import { CollectionBreadcrumbsWithTooltip, SimpleCollectionDisplay, } from "./CollectionBreadcrumbsWithTooltip"; +import { CollectionsIcon } from "./CollectionBreadcrumbsWithTooltip.styled"; import { EllipsifiedWithMarkdownTooltip } from "./EllipsifiedWithMarkdownTooltip"; import { ModelCell, @@ -38,7 +51,9 @@ import { import { getModelDescription, sortModels } from "./utils"; export interface ModelsTableProps { - models: ModelResult[]; + models?: ModelResult[]; + /** True if this component is just rendering a loading skeleton */ + skeleton?: boolean; } export const itemsTableContainerName = "ItemsTableContainer"; @@ -60,14 +75,18 @@ const DEFAULT_SORTING_OPTIONS: SortingOptions = { const LARGE_DATASET_THRESHOLD = 500; -export const ModelsTable = ({ models }: ModelsTableProps) => { +export const ModelsTable = ({ + models = [], + skeleton = false, +}: ModelsTableProps) => { const locale = useSelector(getLocale); const localeCode: string | undefined = locale?.code; // for large datasets, we need to simplify the display to avoid performance issues const isLargeDataset = models.length > LARGE_DATASET_THRESHOLD; - const [showLoading, setShowLoading] = useState(isLargeDataset); + const [showLoadingManyRows, setShowLoadingManyRows] = + useState(isLargeDataset); const [sortingOptions, setSortingOptions] = useState<SortingOptions>( DEFAULT_SORTING_OPTIONS, @@ -79,23 +98,25 @@ export const ModelsTable = ({ models }: ModelsTableProps) => { const collectionWidth = 38.5; const descriptionWidth = 100 - collectionWidth; - const handleUpdateSortOptions = (newSortingOptions: SortingOptions) => { - if (isLargeDataset) { - setShowLoading(true); - } - setSortingOptions(newSortingOptions); - }; + const handleUpdateSortOptions = skeleton + ? undefined + : (newSortingOptions: SortingOptions) => { + if (isLargeDataset) { + setShowLoadingManyRows(true); + } + setSortingOptions(newSortingOptions); + }; useEffect(() => { // we need a better virtualized table solution for large datasets // for now, we show loading text to make this component feel more responsive - if (isLargeDataset && showLoading) { - setTimeout(() => setShowLoading(false), 10); + if (isLargeDataset && showLoadingManyRows) { + setTimeout(() => setShowLoadingManyRows(false), 10); } - }, [isLargeDataset, showLoading, sortedModels]); + }, [isLargeDataset, showLoadingManyRows, sortedModels]); return ( - <Table> + <Table aria-label={skeleton ? undefined : "Table of models"}> <colgroup> {/* <col> for Name column */} <ModelNameColumn containerName={itemsTableContainerName} /> @@ -149,8 +170,12 @@ export const ModelsTable = ({ models }: ModelsTableProps) => { </tr> </thead> <TBody> - {showLoading ? ( + {showLoadingManyRows ? ( <TableLoader /> + ) : skeleton ? ( + <Repeat times={7}> + <TBodyRowSkeleton /> + </Repeat> ) : ( sortedModels.map((model: ModelResult) => ( <TBodyRow @@ -168,9 +193,11 @@ export const ModelsTable = ({ models }: ModelsTableProps) => { const TBodyRow = ({ model, simpleDisplay, + skeleton, }: { model: ModelResult; simpleDisplay: boolean; + skeleton?: boolean; }) => { const icon = getIcon(model); const containerName = `collections-path-for-${model.id}`; @@ -180,6 +207,9 @@ const TBodyRow = ({ return ( <ModelTableRow onClick={(e: React.MouseEvent) => { + if (skeleton) { + return; + } const url = Urls.model({ id, name }); if ((e.ctrlKey || e.metaKey) && e.button === 0) { window.open(url, "_blank"); @@ -195,6 +225,9 @@ const TBodyRow = ({ model={model} icon={icon} onClick={() => { + if (skeleton) { + return; + } trackModelClick(model.id); }} /> @@ -236,21 +269,21 @@ const NameCell = ({ testIdPrefix = "table", onClick, icon, -}: { - model: ModelResult; + children, +}: PropsWithChildren<{ + model?: ModelResult; testIdPrefix?: string; onClick?: () => void; icon: IconProps; -}) => { - const { id, name } = model; - const headingId = `model-${id}-heading`; +}>) => { + const headingId = `model-${model?.id || "dummy"}-heading`; return ( <ItemNameCell data-testid={`${testIdPrefix}-name`} aria-labelledby={headingId} > - <ItemLink - to={Urls.model({ id, name })} + <MaybeItemLink + to={model ? Urls.model({ id: model.id, name: model.name }) : undefined} onClick={onClick} style={{ // To align the icons with "Name" in the <th> @@ -264,8 +297,14 @@ const NameCell = ({ color={color("brand")} style={{ flexShrink: 0 }} /> - <EntityItem.Name name={model.name} variant="list" id={headingId} /> - </ItemLink> + {children || ( + <EntityItem.Name + name={model?.name || ""} + variant="list" + id={headingId} + /> + )} + </MaybeItemLink> </ItemNameCell> ); }; @@ -279,3 +318,35 @@ const TableLoader = () => ( </td> </tr> ); + +const CellTextSkeleton = () => { + return <Skeleton natural h="16.8px" />; +}; + +const TBodyRowSkeleton = ({ style }: { style?: CSSProperties }) => { + const icon = { name: "model" as IconName }; + return ( + <ModelTableRow skeleton style={style}> + {/* Name */} + <NameCell icon={icon}> + <CellTextSkeleton /> + </NameCell> + + {/* Collection */} + <ModelCell {...collectionProps}> + <Flex> + <CollectionsIcon name="folder" /> + <CellTextSkeleton /> + </Flex> + </ModelCell> + + {/* Description */} + <ModelCell {...descriptionProps}> + <CellTextSkeleton /> + </ModelCell> + + {/* Adds a border-radius to the table */} + <Columns.RightEdge.Cell /> + </ModelTableRow> + ); +}; diff --git a/frontend/src/metabase/browse/components/RecentModels.tsx b/frontend/src/metabase/browse/components/RecentModels.tsx index db2d358e958ac9cd597a86be43129e955d54e11d..66b750040c38d112f0bbb2856a4232e1954d98bf 100644 --- a/frontend/src/metabase/browse/components/RecentModels.tsx +++ b/frontend/src/metabase/browse/components/RecentModels.tsx @@ -2,35 +2,55 @@ import { t } from "ttag"; import PinnedItemCard from "metabase/collections/components/PinnedItemCard"; import { Box, Text } from "metabase/ui"; +import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat"; import type { RecentCollectionItem } from "metabase-types/api"; import { trackModelClick } from "../analytics"; import { RecentModelsGrid } from "./RecentModels.styled"; -export function RecentModels({ models }: { models: RecentCollectionItem[] }) { - if (models.length === 0) { +export function RecentModels({ + models = [], + skeleton, +}: { + models?: RecentCollectionItem[]; + skeleton?: boolean; +}) { + if (!skeleton && models.length === 0) { return null; } - const headingId = "recently-viewed-models-heading"; return ( - <Box my="lg" role="grid" aria-labelledby={headingId}> + <Box + w="auto" + my="lg" + role="grid" + aria-labelledby={skeleton ? undefined : headingId} + mah={skeleton ? "11rem" : undefined} + style={skeleton ? { overflow: "hidden" } : undefined} + > <Text - id={headingId} + id={skeleton ? undefined : headingId} fw="bold" size={16} color="text-dark" mb="lg" + style={{ visibility: skeleton ? "hidden" : undefined }} >{t`Recents`}</Text> <RecentModelsGrid> - {models.map(model => ( - <PinnedItemCard - key={`model-${model.id}`} - item={model} - onClick={() => trackModelClick(model.id)} - /> - ))} + {skeleton ? ( + <Repeat times={2}> + <PinnedItemCard skeleton iconForSkeleton="model" /> + </Repeat> + ) : ( + models.map(model => ( + <PinnedItemCard + key={`model-${model.id}`} + item={model} + onClick={() => trackModelClick(model.id)} + /> + )) + )} </RecentModelsGrid> </Box> ); diff --git a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.styled.tsx b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.styled.tsx index f54a013c0b7b41b2e769269233f197ea9fe7e141..31c3d2a03841a4e1bbecf0df9c5bb2797315e532 100644 --- a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.styled.tsx +++ b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.styled.tsx @@ -1,15 +1,24 @@ +import { css } from "@emotion/react"; import styled from "@emotion/styled"; +import { RawMaybeLink } from "metabase/components/Badge/Badge.styled"; import Card from "metabase/components/Card"; -import Link from "metabase/core/components/Link"; import { MarkdownPreview } from "metabase/core/components/MarkdownPreview"; -import { Icon } from "metabase/ui"; +import { Box, type BoxProps, Icon } from "metabase/ui"; export const ItemCard = styled(Card)``; -export const ItemLink = styled(Link)` +export const ItemLink = styled(RawMaybeLink)<{ to?: string }>` display: block; height: min-content; + ${props => + props.to + ? "" + : css` + ${Body} { + cursor: default; + } + `} `; export const ItemIcon = styled(Icon)` @@ -18,7 +27,7 @@ export const ItemIcon = styled(Icon)` width: 1.5rem; `; -export const ActionsContainer = styled.div` +export const ActionsContainer = styled(Box)<BoxProps>` display: flex; align-items: center; gap: 0.5rem; diff --git a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx index cb589ea326d4b7ed64b2008c3ea93ad2b26dfe37..2f5cf4d019ff30cce2661b62479f5b93f7b12340 100644 --- a/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx +++ b/frontend/src/metabase/collections/components/PinnedItemCard/PinnedItemCard.tsx @@ -11,7 +11,7 @@ import Tooltip from "metabase/core/components/Tooltip"; import { getIcon } from "metabase/lib/icon"; import { modelToUrl } from "metabase/lib/urls"; import ModelDetailLink from "metabase/models/components/ModelDetailLink"; -import type { IconName } from "metabase/ui"; +import { Skeleton, type IconName } from "metabase/ui"; import type Database from "metabase-lib/v1/metadata/Database"; import type { Bookmark, @@ -31,18 +31,30 @@ import { Title, } from "./PinnedItemCard.styled"; +type ItemOrSkeleton = + | { + /** If `item` is undefined, the `skeleton` prop must be true */ + item: CollectionItem | RecentCollectionItem; + skeleton?: never; + iconForSkeleton?: never; + } + | { + item?: never; + skeleton: true; + iconForSkeleton: IconName; + }; + type Props = { databases?: Database[]; bookmarks?: Bookmark[]; createBookmark?: CreateBookmark; deleteBookmark?: DeleteBookmark; className?: string; - item: CollectionItem | RecentCollectionItem; collection?: Collection; onCopy?: (items: CollectionItem[]) => void; onMove?: (items: CollectionItem[]) => void; onClick?: () => void; -}; +} & ItemOrSkeleton; const TOOLTIP_MAX_WIDTH = 450; @@ -69,14 +81,15 @@ function PinnedItemCard({ onCopy, onMove, onClick, + iconForSkeleton, }: Props) { const [showTitleTooltip, setShowTitleTooltip] = useState(false); - const icon = getIcon({ - model: item.model, - moderated_status: item.moderated_status, - }).name; - const { description, name, model } = item; - const defaultedDescription = description || DEFAULT_DESCRIPTION[model] || ""; + const icon = + iconForSkeleton ?? + getIcon({ + model: item.model, + moderated_status: item.moderated_status, + }).name; const maybeEnableTooltip = ( event: MouseEvent<HTMLDivElement>, @@ -90,21 +103,22 @@ function PinnedItemCard({ }; const hasActions = + item && isCollectionItem(item) && (onCopy || onMove || createBookmark || deleteBookmark || collection); return ( <ItemLink className={className} - to={modelToUrl(item) ?? "/"} + to={item ? modelToUrl(item) ?? "/" : undefined} onClick={onClick} > <ItemCard flat> <Body> <Header> <ItemIcon name={icon as unknown as IconName} /> - <ActionsContainer> - {item.model === "dataset" && <ModelDetailLink model={item} />} + <ActionsContainer h={item ? undefined : "2rem"}> + {item?.model === "dataset" && <ModelDetailLink model={item} />} {hasActions && ( <ActionMenu databases={databases} @@ -119,22 +133,30 @@ function PinnedItemCard({ )} </ActionsContainer> </Header> - <Tooltip - tooltip={name} - placement="bottom" - maxWidth={TOOLTIP_MAX_WIDTH} - isEnabled={showTitleTooltip} - > - <Title - onMouseEnter={e => maybeEnableTooltip(e, setShowTitleTooltip)} - > - {name} - </Title> - </Tooltip> - - <Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}> - {defaultedDescription} - </Description> + {item ? ( + <> + <Tooltip + tooltip={item.name} + placement="bottom" + maxWidth={TOOLTIP_MAX_WIDTH} + isEnabled={showTitleTooltip} + > + <Title + onMouseEnter={e => maybeEnableTooltip(e, setShowTitleTooltip)} + > + {item.name} + </Title> + </Tooltip> + <Description tooltipMaxWidth={TOOLTIP_MAX_WIDTH}> + {item.description || DEFAULT_DESCRIPTION[item.model] || ""} + </Description> + </> + ) : ( + <> + <Skeleton natural h="1.5rem" /> + <Skeleton natural mt="xs" mb="4px" h="1rem" /> + </> + )} </Body> </ItemCard> </ItemLink> diff --git a/frontend/src/metabase/components/Badge/Badge.styled.tsx b/frontend/src/metabase/components/Badge/Badge.styled.tsx index 11248f53a73bbb17a68836e6ef4e623e250352f4..4c4963e4fa7a131a70566550c7cfe2ff416eaa1b 100644 --- a/frontend/src/metabase/components/Badge/Badge.styled.tsx +++ b/frontend/src/metabase/components/Badge/Badge.styled.tsx @@ -9,12 +9,12 @@ import { Icon } from "metabase/ui"; interface RawMaybeLinkProps { to?: string; - activeColor: string; - inactiveColor: string; - isSingleLine: boolean; + activeColor?: string; + inactiveColor?: string; + isSingleLine?: boolean; } -function RawMaybeLink({ +export function RawMaybeLink({ to, activeColor, inactiveColor, @@ -26,7 +26,7 @@ function RawMaybeLink({ const hoverStyle = (props: RawMaybeLinkProps) => css` cursor: pointer; - color: ${color(props.activeColor)}; + ${props.activeColor ? `color: ${color(props.activeColor)};` : ""} `; export const MaybeLink = styled(RawMaybeLink)` @@ -34,7 +34,8 @@ export const MaybeLink = styled(RawMaybeLink)` align-items: center; font-size: 0.875em; font-weight: bold; - color: ${props => color(props.inactiveColor)}; + ${props => + props.inactiveColor ? `color: ${color(props.inactiveColor)};` : ""} min-width: ${props => (props.isSingleLine ? 0 : "")}; :hover { diff --git a/frontend/src/metabase/components/ItemsTable/BaseItemsTable.styled.tsx b/frontend/src/metabase/components/ItemsTable/BaseItemsTable.styled.tsx index 227120a49841c5635ce6c3e1299d76f31fe7dbf2..f24b11e68086beab599ae7f984f1474b4985abe5 100644 --- a/frontend/src/metabase/components/ItemsTable/BaseItemsTable.styled.tsx +++ b/frontend/src/metabase/components/ItemsTable/BaseItemsTable.styled.tsx @@ -10,6 +10,8 @@ import BaseModelDetailLink from "metabase/models/components/ModelDetailLink"; import type { TextProps } from "metabase/ui"; import { Text, FixedSizeIcon } from "metabase/ui"; +import { RawMaybeLink } from "../Badge/Badge.styled"; + import type { ResponsiveProps } from "./utils"; import { getContainerQuery } from "./utils"; @@ -96,15 +98,17 @@ export const ItemButton = styled(Text)< export const ItemLink = styled(Link)(itemLinkStyle); +export const MaybeItemLink = styled(RawMaybeLink)(itemLinkStyle); + export const ItemNameCell = styled.td` padding: 0 !important; - ${ItemLink}, ${ItemButton} { + ${ItemLink}, ${MaybeItemLink}, ${ItemButton} { padding: 1em; } &:hover { - ${ItemLink}, ${ItemButton} { + ${ItemLink}, ${MaybeItemLink}, ${ItemButton} { color: var(--mb-color-brand); } diff --git a/frontend/src/metabase/components/Schedule/utils.tsx b/frontend/src/metabase/components/Schedule/utils.tsx index d38558955b969a49128c4288cbbe53ed5bf6d421..1a428c18d86d875544c00e25a4137c0cbcf1c20c 100644 --- a/frontend/src/metabase/components/Schedule/utils.tsx +++ b/frontend/src/metabase/components/Schedule/utils.tsx @@ -65,3 +65,5 @@ export const getLongestSelectLabel = (data: SelectProps["data"]) => const label = typeof option === "string" ? option : option.label || ""; return label.length > acc.length ? label : acc; }, ""); + +// HIIII diff --git a/frontend/src/metabase/ui/components/feedback/Skeleton/Repeat.tsx b/frontend/src/metabase/ui/components/feedback/Skeleton/Repeat.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc2bf2a1d4c7ba49845a7a9a2a340909c8904aaf --- /dev/null +++ b/frontend/src/metabase/ui/components/feedback/Skeleton/Repeat.tsx @@ -0,0 +1,20 @@ +import { type PropsWithChildren, isValidElement } from "react"; +import { cloneElement } from "react"; + +export const Repeat = ({ + times, + /** Must be a valid React element */ + children, +}: PropsWithChildren<{ + times: number; +}>) => { + if (!isValidElement(children)) { + return null; + } + return Array.from({ length: times }).map((_, index) => { + const props = { key: `${index}` }; + if (isValidElement(children)) { + return cloneElement(children, props); + } + }); +}; diff --git a/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.styled.tsx b/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.styled.tsx index 7f3079f1062171bf99b39a9adc2e35893054d62d..ff228d12c129cbf600fba874fbdea2e841348e3b 100644 --- a/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.styled.tsx +++ b/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.styled.tsx @@ -1,18 +1,33 @@ -import type { MantineThemeOverride } from "@mantine/core"; +import { keyframes, type MantineThemeOverride } from "@mantine/core"; -export const getSkeletonOverrides = (): MantineThemeOverride["components"] => ({ - Skeleton: { - styles: _theme => { - return { - root: { - "&::before": { - background: "transparent !important", - }, - "&::after": { - background: "var(--mb-color-border) !important", +const shimmerAnimation = keyframes` +0% { + transform: translateX(-100%); +} +100% { + transform: translateX(100%); +} +`; + +export const getSkeletonOverrides = (): MantineThemeOverride["components"] => { + return { + Skeleton: { + styles: _theme => { + return { + // We replace Mantine's pulsing animation with a shimmer animation + root: { + "background-color": "rgba(0, 0, 0, .03)", + "&::before": { + background: + "linear-gradient(100deg, transparent, rgba(0, 0, 0, .03) 50%, transparent) ! important", + animation: `${shimmerAnimation} 1.4s linear infinite`, + }, + "&::after": { + display: "none", + }, }, - }, - }; + }; + }, }, - }, -}); + }; +}; diff --git a/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.tsx b/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.tsx index 3f3b368cbffe5a1f555e49abbf61b90c3d8de2f7..31f6094225d0b9b37da6dfadd857a3d1b4aff2ec 100644 --- a/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.tsx +++ b/frontend/src/metabase/ui/components/feedback/Skeleton/Skeleton.tsx @@ -1,3 +1,19 @@ -export type { SkeletonProps } from "@mantine/core"; -export { Skeleton } from "@mantine/core"; -export { getSkeletonOverrides } from "./Skeleton.styled"; +import type { SkeletonProps } from "@mantine/core"; +import { Skeleton as MantineSkeleton } from "@mantine/core"; +import { useMemo } from "react"; + +export const Skeleton = ({ + natural, + ...props +}: SkeletonProps & { + /** Automatically assign a natural-looking, random width to the skeleton */ + natural?: boolean; +}) => { + const width = useMemo( + () => (natural ? `${Math.random() * 30 + 50}%` : props.width), + [natural, props.width], + ); + return <MantineSkeleton width={width} {...props} />; +}; + +export type { SkeletonProps }; diff --git a/frontend/src/metabase/ui/components/feedback/Skeleton/index.ts b/frontend/src/metabase/ui/components/feedback/Skeleton/index.ts index ad05c7df1139aa9628e69df17d7f3974a6316f48..6b2896a49b5b39753018cd7287ecf838f3412dc6 100644 --- a/frontend/src/metabase/ui/components/feedback/Skeleton/index.ts +++ b/frontend/src/metabase/ui/components/feedback/Skeleton/index.ts @@ -1,2 +1,2 @@ -export { Skeleton } from "@mantine/core"; +export { Skeleton } from "./Skeleton"; export * from "./Skeleton.styled";