Skip to content

Commit

Permalink
Squashed branch
Browse files Browse the repository at this point in the history
  • Loading branch information
rafpaf committed May 1, 2024
1 parent 4ca9adf commit 1589c87
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 11 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import styled from "@emotion/styled";
import type { AnchorHTMLAttributes } from "react";

import { ResponsiveChild } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { color } from "metabase/lib/colors";
import type { AnchorProps } from "metabase/ui";
import { Anchor } from "metabase/ui";

import type { RefProp } from "./types";

export const Breadcrumb = styled(Anchor)<
AnchorProps &
AnchorHTMLAttributes<HTMLAnchorElement> &
RefProp<HTMLAnchorElement>
>`
color: ${color("text-dark")};
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 1px;
padding-bottom: 1px;
:hover {
color: ${color("brand")};
text-decoration: none;
}
`;

export const CollectionBreadcrumbsWrapper = styled(ResponsiveChild)`
line-height: 1;
${props => {
const breakpoint = "10rem";
return `
.initial-ellipsis {
display: none;
}
@container ${props.containerName} (width < ${breakpoint}) {
.ellipsis-and-separator {
display: none;
}
.initial-ellipsis {
display: inline;
}
.for-index-0:not(.sole-breadcrumb) {
display: none;
}
.breadcrumb {
max-width: calc(95cqw - 3rem) ! important;
}
.sole-breadcrumb {
max-width: calc(95cqw - 1rem) ! important;
}
}
`;
}}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import classNames from "classnames";
import type { FC } from "react";
import { forwardRef, useLayoutEffect, useRef, useState } from "react";
import { c } from "ttag";

import { ResponsiveContainer } from "metabase/components/ResponsiveContainer/ResponsiveContainer";
import { useAreAnyTruncated } from "metabase/hooks/use-is-truncated";
import resizeObserver from "metabase/lib/resize-observer";
import * as Urls from "metabase/lib/urls";
import type { FlexProps } from "metabase/ui";
import { FixedSizeIcon, Flex, Group, Text, Tooltip } from "metabase/ui";
import type { Collection } from "metabase-types/api";

import { getCollectionName } from "../utils";

import {
Breadcrumb,
CollectionBreadcrumbsWrapper,
} from "./CollectionBreadcrumbsWithTooltip.styled";
import type { RefProp } from "./types";
import { getBreadcrumbMaxWidths } from "./utils";

const separatorCharacter = c(
"Character that separates the names of collections in a path, as in 'Europe / Belgium / Antwerp' or 'Products / Prototypes / Alice's Prototypes'",
).t`/`;

export const CollectionBreadcrumbsWithTooltip = ({
collection,
containerName,
}: {
collection: Collection;
containerName: string;
}) => {
const collections = (collection.effective_ancestors || []).concat(collection);
const pathString = collections
.map(coll => getCollectionName(coll))
.join(` ${separatorCharacter} `);
const ellipsifyPath = collections.length > 2;
const shownCollections = ellipsifyPath
? [collections[0], collections[collections.length - 1]]
: collections;
const justOneShown = shownCollections.length === 1;

const { areAnyTruncated, ref } = useAreAnyTruncated<HTMLAnchorElement>();

const initialEllipsisRef = useRef<HTMLDivElement | null>(null);
const [
isFirstCollectionDisplayedAsEllipsis,
setIsFirstCollectionDisplayedAsEllipsis,
] = useState(false);

useLayoutEffect(() => {
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
disabled={!isTooltipEnabled}
variant="multiline"
label={pathString}
>
<ResponsiveContainer
aria-label={pathString}
data-testid={`breadcrumbs-for-collection: ${collection.name}`}
name={containerName}
w="auto"
>
<Flex align="center" w="100%" lh="1" style={{ flexFlow: "row nowrap" }}>
<FixedSizeIcon name="folder" style={{ marginInlineEnd: ".5rem" }} />
{shownCollections.map((collection, index) => {
const key = `collection${collection.id}`;
return (
<Group spacing={0} style={{ flexFlow: "row nowrap" }} key={key}>
{index > 0 && <PathSeparator />}
<CollectionBreadcrumbsWrapper
containerName={containerName}
style={{ alignItems: "center" }}
w="auto"
display="flex"
>
{index === 0 && !justOneShown && (
<Ellipsis
ref={initialEllipsisRef}
includeSep={false}
className="initial-ellipsis"
/>
)}
{index > 0 && ellipsifyPath && <Ellipsis />}
<Breadcrumb
href={Urls.collection(collection)}
className={classNames("breadcrumb", `for-index-${index}`, {
"sole-breadcrumb": collections.length === 1,
})}
ref={(el: HTMLAnchorElement) => ref.current.set(key, el)}
maw={maxWidths[index]}
key={collection.id}
>
{getCollectionName(collection)}
</Breadcrumb>
</CollectionBreadcrumbsWrapper>
</Group>
);
})}
</Flex>
</ResponsiveContainer>
</Tooltip>
);
};

type EllipsisProps = {
includeSep?: boolean;
} & FlexProps;

const Ellipsis: FC<EllipsisProps & Partial<RefProp<HTMLDivElement | null>>> =
forwardRef<HTMLDivElement, EllipsisProps>(
({ includeSep = true, ...flexProps }, ref) => (
<Flex
ref={ref}
align="center"
className="ellipsis-and-separator"
{...flexProps}
>
<Text lh={1}></Text>
{includeSep && <PathSeparator />}
</Flex>
),
);
Ellipsis.displayName = "Ellipsis";

const PathSeparator = () => (
<Text color="text-light" mx="xs" py={1}>
{separatorCharacter}
</Text>
);
17 changes: 12 additions & 5 deletions frontend/src/metabase/browse/components/ModelsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import type { CollectionItem } from "metabase-types/api";

import { getCollectionName } from "../utils";

import { CollectionBreadcrumbsWithTooltip } from "./CollectionBreadcrumbsWithTooltip";
import { EllipsifiedWithMarkdown } from "./EllipsifiedWithMarkdown";
import { getModelDescription } from "./utils";

export interface ModelsTableProps {
items: CollectionItem[];
Expand Down Expand Up @@ -70,6 +72,9 @@ const TBodyRow = ({ item }: { item: CollectionItem }) => {
if (item.model === "card") {
icon.color = color("text-light");
}

const containerName = `collections-path-for-${item.id}`;

return (
<tr>
{/* Type */}
Expand All @@ -81,7 +86,7 @@ const TBodyRow = ({ item }: { item: CollectionItem }) => {
{/* Description */}
<ItemCell {...descriptionProps}>
<EllipsifiedWithMarkdown>
{item.description || ""}
{getModelDescription(item) || ""}
</EllipsifiedWithMarkdown>
</ItemCell>

Expand All @@ -94,10 +99,12 @@ const TBodyRow = ({ item }: { item: CollectionItem }) => {
}`}
{...collectionProps}
>
{item.collection &&
item.collection?.effective_ancestors
?.map(collection => getCollectionName(collection))
.join(" / ")}
{item.collection && (
<CollectionBreadcrumbsWithTooltip
containerName={containerName}
collection={item.collection}
/>
)}
</ItemCell>

{/* Adds a border-radius to the table */}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/metabase/browse/components/types.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { MutableRefObject } from "react";

/** @template T: The type of the value the ref will hold */
export type RefProp<T> = { ref: MutableRefObject<T> | ((el: T) => void) };
45 changes: 45 additions & 0 deletions frontend/src/metabase/browse/components/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { t } from "ttag";

import { isRootCollection } from "metabase/collections/utils";
import type { CollectionItem, Collection } from "metabase-types/api";

import { getCollectionName } from "../utils";

export const getBreadcrumbMaxWidths = (
collections: Collection["effective_ancestors"],
totalUnitsOfWidthAvailable: number,
isPathEllipsified: boolean,
) => {
if (!collections || collections.length < 2) {
return [];
}
const lengths = collections.map(
collection => getCollectionName(collection).length,
);
const ratio = lengths[0] / (lengths[0] + lengths[1]);
const firstWidth = Math.max(
Math.round(ratio * totalUnitsOfWidthAvailable),
25,
);
const secondWidth = totalUnitsOfWidthAvailable - firstWidth;
const padding = isPathEllipsified ? "2rem" : "1rem";
return [
`calc(${firstWidth}cqw - ${padding})`,
`calc(${secondWidth}cqw - ${padding})`,
];
};

export const isModel = (item: CollectionItem) => item.model === "dataset";

export const getModelDescription = (item: CollectionItem) => {
if (
item.collection &&
isRootCollection(item.collection) &&
isModel(item) &&
!item.description?.trim()
) {
return t`A model`;
} else {
return item.description;
}
};
63 changes: 57 additions & 6 deletions frontend/src/metabase/hooks/use-is-truncated.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { useLayoutEffect, useRef, useState } from "react";
import _ from "underscore";

import resizeObserver from "metabase/lib/resize-observer";

type UseIsTruncatedProps = {
disabled?: boolean;
/** To avoid rounding errors, we can require that the truncation is at least a certain number of pixels */
tolerance?: number;
};

export const useIsTruncated = <E extends Element>({
disabled = false,
}: { disabled?: boolean } = {}) => {
tolerance = 0,
}: UseIsTruncatedProps = {}) => {
const ref = useRef<E | null>(null);
const [isTruncated, setIsTruncated] = useState(false);

Expand All @@ -16,7 +24,7 @@ export const useIsTruncated = <E extends Element>({
}

const handleResize = () => {
setIsTruncated(getIsTruncated(element));
setIsTruncated(getIsTruncated(element, tolerance));
};

handleResize();
Expand All @@ -25,14 +33,57 @@ export const useIsTruncated = <E extends Element>({
return () => {
resizeObserver.unsubscribe(element, handleResize);
};
}, [disabled]);
}, [disabled, tolerance]);

return { isTruncated, ref };
};

const getIsTruncated = (element: Element): boolean => {
const getIsTruncated = (element: Element, tolerance: number): boolean => {
tolerance = 0;
return (
element.scrollHeight > element.clientHeight ||
element.scrollWidth > element.clientWidth
element.scrollHeight > element.clientHeight + tolerance ||
element.scrollWidth > element.clientWidth + tolerance
);
};

export const useAreAnyTruncated = <E extends Element>({
disabled = false,
tolerance = 0,
}: UseIsTruncatedProps = {}) => {
const ref = useRef(new Map<string, E>());
const [truncatedStatuses, setTruncatedStatuses] = useState<
Map<string, boolean>
>(new Map());

useLayoutEffect(() => {
const elementsMap = ref.current;

if (!elementsMap.size || disabled) {
return;
}
const unsubscribeFns: (() => void)[] = [];

[...elementsMap.entries()].forEach(([elementKey, element]) => {
const handleResize = () => {
const isTruncated = getIsTruncated(element, tolerance);
setTruncatedStatuses(statuses => {
const newStatuses = new Map(statuses);
newStatuses.set(elementKey, isTruncated);
return newStatuses;
});
};
const handleResizeDebounced = _.debounce(handleResize, 200);
resizeObserver.subscribe(element, handleResizeDebounced);
unsubscribeFns.push(() =>
resizeObserver.unsubscribe(element, handleResizeDebounced),
);
});

return () => {
unsubscribeFns.forEach(fn => fn());
};
}, [disabled, tolerance]);

const areAnyTruncated = [...truncatedStatuses.values()].some(Boolean);
return { areAnyTruncated, ref };
};

0 comments on commit 1589c87

Please sign in to comment.