Skip to content
Snippets Groups Projects
Unverified Commit 2b7741f5 authored by Raphael Krut-Landau's avatar Raphael Krut-Landau Committed by GitHub
Browse files

fix(browse): Use a simple, CSS-based approach to representing paths responsively (#46530)

parent e426b1a3
No related branches found
No related tags found
No related merge requests found
Showing
with 149 additions and 393 deletions
......@@ -4,7 +4,6 @@ import {
setupSettingsEndpoints,
} from "__support__/server-mocks";
import { renderWithProviders, screen, within } from "__support__/ui";
import { defaultRootCollection } from "metabase/admin/permissions/pages/CollectionPermissionsPage/tests/setup";
import {
createMockCollection,
createMockSearchResult,
......@@ -15,6 +14,11 @@ import { createMockModelResult, createMockRecentModel } from "../test-utils";
import { BrowseModels } from "./BrowseModels";
const defaultRootCollection = createMockCollection({
id: "root",
name: "Our analytics",
});
const setup = (modelCount: number, recentModelCount = 5) => {
const mockModelResults = mockModels.map(model =>
createMockModelResult(model),
......@@ -249,14 +253,14 @@ const mockModels = [
{
id: 21,
name: "Model 21",
collection: defaultRootCollection,
collection: defaultRootCollection, // Our analytics
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
{
id: 22,
name: "Model 22",
collection: defaultRootCollection,
collection: defaultRootCollection, // Our analytics
last_editor_common_name: "Bobby",
last_edited_at: "2000-01-01T00:00:00.000Z",
},
......@@ -284,7 +288,9 @@ describe("BrowseModels", () => {
});
expect(modelsTable).toBeInTheDocument();
expect(
await screen.findAllByTestId("path-for-collection: Our analytics"),
await within(modelsTable).findAllByTestId(
"path-for-collection: Our analytics",
),
).toHaveLength(2);
expect(
await within(modelsTable).findByText("Model 20"),
......@@ -304,9 +310,7 @@ describe("BrowseModels", () => {
});
expect(await within(modelsTable).findByText("Model 1")).toBeInTheDocument();
expect(
await within(modelsTable).findAllByTestId(
"breadcrumbs-for-collection: Alpha",
),
await within(modelsTable).findAllByTestId("path-for-collection: Alpha"),
).toHaveLength(3);
});
......
import styled from "@emotion/styled";
import { ResponsiveChild } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import Link from "metabase/core/components/Link";
import { FixedSizeIcon, Flex, Group } from "metabase/ui";
import { Ellipsis } from "./Ellipsis";
/** When a cell is narrower than this width, breadcrumbs within it change significantly */
const breadcrumbBreakpoint = "10rem";
export const Breadcrumb = styled(ResponsiveChild)<{
maxWidth: string;
isSoleBreadcrumb: boolean;
index: number;
}>`
${({ maxWidth }) => {
return maxWidth ? `td & { max-width: ${maxWidth} };` : "";
}}
color: var(--mb-color-text-dark);
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 1px;
padding-bottom: 1px;
${props => {
return `
@container ${props.containerName} (width < ${breadcrumbBreakpoint}) {
${props.index === 0 && !props.isSoleBreadcrumb ? `display: none;` : ""}
td & { max-width: calc(95cqw - ${props.isSoleBreadcrumb ? 1 : 3}rem); };
}`;
}}
`;
export const CollectionLink = styled(Link)`
:hover {
&,
* {
color: var(--mb-color-brand);
.collection-path-separator {
color: var(--mb-color-brand-alpha-88);
}
}
}
`;
export const InitialEllipsis = styled(Ellipsis)``;
InitialEllipsis.defaultProps = {
includeSep: false,
};
export const CollectionBreadcrumbsWrapper = styled(ResponsiveChild)`
line-height: 1;
${InitialEllipsis} {
display: none;
}
${props => {
return `
@container ${props.containerName} (width < ${breadcrumbBreakpoint}) {
${EllipsisAndSeparator} {
display: none;
}
${InitialEllipsis} {
display: inline;
}
}
`;
}}
`;
export const BreadcrumbGroup = styled(Group)`
flex-flow: row nowrap;
`;
export const CollectionsIcon = styled(FixedSizeIcon)`
margin-inline-end: 0.5rem;
`;
export const EllipsisAndSeparator = styled(Flex)``;
import { useEffect, useRef, useState } from "react";
import { getCollectionName } from "metabase/collections/utils";
import { ResponsiveContainer } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
import resizeObserver from "metabase/lib/resize-observer";
import * as Urls from "metabase/lib/urls";
import { Flex, Tooltip } from "metabase/ui";
import type { CollectionEssentials } from "metabase-types/api";
import {
Breadcrumb,
BreadcrumbGroup,
CollectionBreadcrumbsWrapper,
CollectionLink,
CollectionsIcon,
InitialEllipsis,
} from "./CollectionBreadcrumbsWithTooltip.styled";
import { Ellipsis } from "./Ellipsis";
import { PathSeparator } from "./PathSeparator";
import { getBreadcrumbMaxWidths, getCollectionPathString } from "./utils";
export const CollectionBreadcrumbsWithTooltip = ({
collection,
containerName,
}: {
collection: CollectionEssentials;
containerName: string;
}) => {
const collections = (
(collection.effective_ancestors as CollectionEssentials[]) || []
).concat(collection);
const pathString = getCollectionPathString(collection);
const ellipsifyPath = collections.length > 2;
const shownCollections = ellipsifyPath
? [collections[0], collections[collections.length - 1]]
: collections;
const justOneShown = shownCollections.length === 1;
const { areAnyTruncated, ref } = useAreAnyTruncated<HTMLDivElement>();
const initialEllipsisRef = useRef<HTMLDivElement | null>(null);
const [
isFirstCollectionDisplayedAsEllipsis,
setIsFirstCollectionDisplayedAsEllipsis,
] = useState(false);
useEffect(() => {
const initialEllipsis = initialEllipsisRef.current;
if (!initialEllipsis) {
return;
}
const handleResize = () => {
// The initial ellipsis might be hidden via CSS,
// so we need to check whether it is displayed via getComputedStyle
const style = window.getComputedStyle(initialEllipsis);
setIsFirstCollectionDisplayedAsEllipsis(style.display !== "none");
};
resizeObserver.subscribe(initialEllipsis, handleResize);
return () => {
resizeObserver.unsubscribe(initialEllipsis, handleResize);
};
}, [initialEllipsisRef]);
const isTooltipEnabled =
areAnyTruncated || ellipsifyPath || isFirstCollectionDisplayedAsEllipsis;
const maxWidths = getBreadcrumbMaxWidths(shownCollections, 96, ellipsifyPath);
return (
<Tooltip
label={pathString}
disabled={!isTooltipEnabled}
multiline
maw="20rem"
>
<ResponsiveContainer
aria-label={pathString}
data-testid={`breadcrumbs-for-collection: ${collection.name}`}
name={containerName}
w="auto"
>
<CollectionLink to={Urls.collection(collection)}>
<Flex
align="center"
w="100%"
lh="1"
style={{ flexFlow: "row nowrap" }}
>
<CollectionsIcon
name="folder"
// Stopping propagation so that the parent <tr>'s onclick won't fire
onClick={(e: React.MouseEvent) => e.stopPropagation()}
/>
{shownCollections.map((collection, index) => {
const key = `collection${collection.id}`;
return (
<BreadcrumbGroup
spacing={0}
key={key}
// Stopping propagation so that the parent <tr>'s onclick won't fire
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{index > 0 && <PathSeparator />}
<CollectionBreadcrumbsWrapper
containerName={containerName}
style={{ alignItems: "center" }}
w="auto"
display="flex"
>
{index === 0 && !justOneShown && (
<InitialEllipsis ref={initialEllipsisRef} />
)}
{index > 0 && ellipsifyPath && <Ellipsis />}
<Breadcrumb
maxWidth={maxWidths[index]}
index={index}
isSoleBreadcrumb={collections.length === 1}
containerName={containerName}
ref={(el: HTMLDivElement | null) =>
el && ref.current.set(key, el)
}
key={collection.id}
>
{getCollectionName(collection)}
</Breadcrumb>
</CollectionBreadcrumbsWrapper>
</BreadcrumbGroup>
);
})}
</Flex>
</CollectionLink>
</ResponsiveContainer>
</Tooltip>
);
};
export const SimpleCollectionDisplay = ({
collection,
}: {
collection: CollectionEssentials;
}) => {
return (
<Flex align="center">
<CollectionsIcon name="folder" />
<Ellipsified>{getCollectionName(collection)}</Ellipsified>
</Flex>
);
};
import { renderWithProviders, screen } from "__support__/ui";
import type { Collection } from "metabase-types/api";
import { createMockCollection } from "metabase-types/api/mocks";
import { CollectionBreadcrumbsWithTooltip } from "./CollectionBreadcrumbsWithTooltip";
const setup = (collection: Collection) => {
return renderWithProviders(
<CollectionBreadcrumbsWithTooltip
collection={collection}
containerName="Container"
/>,
);
};
const collectionAlpha = createMockCollection({ id: 99, name: "Alpha" });
const collectionBeta = createMockCollection({
id: 1,
name: "Beta",
effective_ancestors: [collectionAlpha],
});
const collectionCharlie = createMockCollection({
id: 2,
name: "Charlie",
effective_ancestors: [collectionAlpha, collectionBeta],
});
describe("CollectionBreadcrumbsWithTooltip", () => {
it("should show a single collection", () => {
setup(collectionAlpha);
expect(screen.getByLabelText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Alpha")).toBeInTheDocument();
});
it("should display a path of length two without abbreviations", () => {
setup(collectionBeta);
const breadcrumbs = screen.getByLabelText("Alpha / Beta");
expect(breadcrumbs).toBeInTheDocument();
expect(breadcrumbs?.textContent).toMatch(/Alpha\/Beta/);
});
it("should display a path of length three as the first and last collection with an ellipsis in between", () => {
setup(collectionCharlie);
const breadcrumbs = screen.getByLabelText("Alpha / Beta / Charlie");
expect(breadcrumbs).toBeInTheDocument();
expect(breadcrumbs?.textContent).toMatch(/Alpha\/\/Charlie/);
});
});
import type { FC } from "react";
import { forwardRef } from "react";
import type { FlexProps } from "metabase/ui";
import { Text } from "metabase/ui";
import type { RefProp } from "../types";
import { EllipsisAndSeparator } from "./CollectionBreadcrumbsWithTooltip.styled";
import { PathSeparator } from "./PathSeparator";
type EllipsisProps = {
includeSep?: boolean;
} & FlexProps;
export const Ellipsis: FC<
EllipsisProps & Partial<RefProp<HTMLDivElement | null>>
> = forwardRef<HTMLDivElement, EllipsisProps>(
({ includeSep = true, ...flexProps }, ref) => (
<EllipsisAndSeparator
ref={ref}
align="center"
className="ellipsis-and-separator"
{...flexProps}
>
<Text lh={1}></Text>
{includeSep && <PathSeparator />}
</EllipsisAndSeparator>
),
);
Ellipsis.displayName = "Ellipsis";
.collectionLink {
:hover {
color: var(--mb-color-brand) !important;
}
}
import {
type PropsWithChildren,
useEffect,
useState,
type CSSProperties,
} from "react";
import { type PropsWithChildren, useState, type CSSProperties } from "react";
import { push } from "react-router-redux";
import { t } from "ttag";
import { getCollectionName } from "metabase/collections/utils";
import { EllipsifiedPath } from "metabase/common/components/EllipsifiedPath";
import { useLocale } from "metabase/common/hooks/use-locale/use-locale";
import EntityItem from "metabase/components/EntityItem";
import { SortableColumnHeader } from "metabase/components/ItemsTable/BaseItemsTable";
......@@ -21,7 +17,7 @@ import {
import { Columns } from "metabase/components/ItemsTable/Columns";
import type { ResponsiveProps } from "metabase/components/ItemsTable/utils";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import { color } from "metabase/lib/colors";
import Link from "metabase/core/components/Link";
import { useDispatch } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import {
......@@ -30,6 +26,8 @@ import {
type IconProps,
type IconName,
Skeleton,
FixedSizeIcon,
Box,
} from "metabase/ui";
import { Repeat } from "metabase/ui/components/feedback/Skeleton/Repeat";
import { SortDirection, type SortingOptions } from "metabase-types/api/sorting";
......@@ -38,18 +36,18 @@ import { trackModelClick } from "../analytics";
import type { ModelResult } from "../types";
import { getIcon } from "../utils";
import {
CollectionBreadcrumbsWithTooltip,
SimpleCollectionDisplay,
} from "./CollectionBreadcrumbsWithTooltip";
import { CollectionsIcon } from "./CollectionBreadcrumbsWithTooltip.styled";
import { EllipsifiedWithMarkdownTooltip } from "./EllipsifiedWithMarkdownTooltip";
import S from "./ModelsTable.module.css";
import {
ModelCell,
ModelNameColumn,
ModelTableRow,
} from "./ModelsTable.styled";
import { getModelDescription, sortModels } from "./utils";
import {
getCollectionPathString,
getModelDescription,
sortModels,
} from "./utils";
export interface ModelsTableProps {
models?: ModelResult[];
......@@ -74,18 +72,10 @@ const DEFAULT_SORTING_OPTIONS: SortingOptions = {
sort_direction: SortDirection.Asc,
};
const LARGE_DATASET_THRESHOLD = 500;
export const ModelsTable = ({
models = [],
skeleton = false,
}: ModelsTableProps) => {
// for large datasets, we need to simplify the display to avoid performance issues
const isLargeDataset = models.length > LARGE_DATASET_THRESHOLD;
const [showLoadingManyRows, setShowLoadingManyRows] =
useState(isLargeDataset);
const [sortingOptions, setSortingOptions] = useState<SortingOptions>(
DEFAULT_SORTING_OPTIONS,
);
......@@ -100,20 +90,9 @@ export const ModelsTable = ({
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 && showLoadingManyRows) {
setTimeout(() => setShowLoadingManyRows(false), 10);
}
}, [isLargeDataset, showLoadingManyRows, sortedModels]);
return (
<Table aria-label={skeleton ? undefined : t`Table of models`}>
<colgroup>
......@@ -169,19 +148,13 @@ export const ModelsTable = ({
</tr>
</thead>
<TBody>
{showLoadingManyRows ? (
<TableLoader />
) : skeleton ? (
{skeleton ? (
<Repeat times={7}>
<TBodyRowSkeleton />
</Repeat>
) : (
sortedModels.map((model: ModelResult) => (
<TBodyRow
model={model}
key={`${model.model}-${model.id}`}
simpleDisplay={isLargeDataset}
/>
<TBodyRow model={model} key={`${model.model}-${model.id}`} />
))
)}
</TBody>
......@@ -191,15 +164,12 @@ export const ModelsTable = ({
const TBodyRow = ({
model,
simpleDisplay,
skeleton,
}: {
model: ModelResult;
simpleDisplay: boolean;
skeleton?: boolean;
}) => {
const icon = getIcon(model);
const containerName = `collections-path-for-${model.id}`;
const dispatch = useDispatch();
const { id, name } = model;
......@@ -242,14 +212,24 @@ const TBodyRow = ({
}`}
{...collectionProps}
>
{simpleDisplay ? (
<SimpleCollectionDisplay collection={model.collection} />
) : (
<CollectionBreadcrumbsWithTooltip
containerName={containerName}
collection={model.collection}
/>
)}
<Link
className={S.collectionLink}
to={Urls.collection(model.collection)}
onClick={e => e.stopPropagation()}
>
<Flex gap="sm">
<FixedSizeIcon name="folder" />
<Box w="calc(100% - 1.5rem)">
<EllipsifiedPath
tooltip={getCollectionPathString(model.collection)}
items={[
...(model.collection?.effective_ancestors || []),
model.collection,
].map(c => getCollectionName(c))}
/>
</Box>
</Flex>
</Link>
</ModelCell>
{/* Description */}
......@@ -295,7 +275,7 @@ const NameCell = ({
<Icon
size={16}
{...icon}
color={color("brand")}
color={"var(--mb-color-brand)"}
style={{ flexShrink: 0 }}
/>
{children || (
......@@ -310,16 +290,6 @@ const NameCell = ({
);
};
const TableLoader = () => (
<tr>
<td colSpan={4}>
<Flex justify="center" color="text-light">
{t`Loading…`}
</Flex>
</td>
</tr>
);
const CellTextSkeleton = () => {
return <Skeleton natural h="16.8px" />;
};
......@@ -335,8 +305,8 @@ const TBodyRowSkeleton = ({ style }: { style?: CSSProperties }) => {
{/* Collection */}
<ModelCell {...collectionProps}>
<Flex>
<CollectionsIcon name="folder" />
<Flex gap=".5rem">
<FixedSizeIcon name="folder" />
<CellTextSkeleton />
</Flex>
</ModelCell>
......
import { Text } from "metabase/ui";
import { pathSeparatorChar } from "./constants";
export const PathSeparator = () => (
<Text
className="collection-path-separator"
color="text-light"
mx=".2rem"
py={1}
>
{pathSeparatorChar}
</Text>
);
.path {
flex-grow: 1;
display: flex;
flex-flow: row nowrap;
overflow-x: hidden;
container-type: inline-size;
}
.slash {
opacity: 0.5;
min-width: 0;
flex-shrink: 0;
padding-inline: 0.1rem;
}
.item,
.slash {
@container (max-width: 6rem) {
&:not(&:last-child) {
display: none;
}
}
}
.dots {
opacity: 0.5;
display: none;
@container (max-width: 6rem) {
display: flex;
}
}
.item {
flex-grow: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: fit-content;
/* The first path item should try to be a bit bigger than average */
&:nth-child(2) {
flex-basis: 100%;
}
/* The last path item should show all of itself if possible */
&:last-child {
flex-basis: 1000%;
}
}
import React from "react";
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
import { Tooltip } from "metabase/ui";
import S from "./EllipsifiedPath.module.css";
type EllipsifiedPathProps = { items: string[]; tooltip: string };
/**
* Displays a path such as "Collection / Subcollection / Subsubcollection /
* Parent Collection".
*
* If the path is too long to fit, some items may be truncated, like this:
* "Collection / Subcollec... / Subsub... / Parent Collection".
*
* A tooltip is shown if any items are truncated.
*/
export const EllipsifiedPath = ({ items, tooltip }: EllipsifiedPathProps) => {
const { areAnyTruncated, ref } = useAreAnyTruncated<HTMLDivElement>();
return (
<Tooltip label={tooltip} disabled={!areAnyTruncated} multiline maw="20rem">
<div className={S.path}>
{items.length > 1 && (
<div className={S.dots}>
<div className={S.slash}>/</div>
</div>
)}
{items.map((item, index) => {
const key = `${item}${index}`;
return (
<React.Fragment key={key}>
<div
ref={el => el && ref.current.set(key, el)}
className={S.item}
>
{item}
</div>
{index < items.length - 1 && <div className={S.slash}>/</div>}
</React.Fragment>
);
})}
</div>
</Tooltip>
);
};
export { EllipsifiedPath } from "./EllipsifiedPath";
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment