Compare commits

...

3 Commits

Author SHA1 Message Date
Raphael Krut-Landau
59d1e868ed Add simple preview of first question of dashboard 2025-02-18 21:06:01 -05:00
Raphael Krut-Landau
b083db6e11 Fix icon issue 2025-02-18 21:06:01 -05:00
Raphael Krut-Landau
d07f78fd74 Content previews work in home page too 2025-02-18 21:06:01 -05:00
20 changed files with 305 additions and 24 deletions

View File

@@ -25,18 +25,22 @@ export const CardPreviewSkeleton = styled(ChartSkeleton)`
interface CardRootProps {
isPreview?: boolean;
withBorder?: boolean;
}
export const CardRoot = styled(
Link,
doNotForwardProps("isPreview"),
doNotForwardProps("isPreview", "withBorder"),
)<CardRootProps>`
position: relative;
display: block;
overflow: hidden;
height: ${props => props.isPreview && "15.625rem"};
padding: ${props => (props.isPreview ? "0" : "0.5rem 0")};
border: 1px solid var(--mb-color-border);
${props =>
(props.withBorder ?? true)
? "border: 1px solid var(--mb-color-border);"
: ""}
border-radius: 0.375rem;
background-color: var(--mb-color-bg-white);

View File

@@ -12,7 +12,7 @@ import {
} from "metabase/collections/utils";
import EventSandbox from "metabase/components/EventSandbox";
import CS from "metabase/css/core/index.css";
import type { IconName } from "metabase/ui";
import { Box, CloseButton, type IconName } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import type Database from "metabase-lib/v1/metadata/Database";
import type { Bookmark, Collection, CollectionItem } from "metabase-types/api";
@@ -25,16 +25,32 @@ import {
} from "./PinnedQuestionCard.styled";
import PinnedQuestionLoader from "./PinnedQuestionLoader";
export interface PinnedQuestionCardProps {
export type PinnedQuestionCardProps = {
item: CollectionItem;
collection: Collection;
databases?: Database[];
bookmarks?: Bookmark[];
onCopy: (items: CollectionItem[]) => void;
onMove: (items: CollectionItem[]) => void;
onCreateBookmark?: CreateBookmark;
onDeleteBookmark?: DeleteBookmark;
}
withBorder?: boolean;
withCloseButton?: boolean;
} & ( // If there's no action menu, we can skip certain props
| {
withActionMenu: false;
collection?: never;
onCopy?: never;
onMove?: never;
}
| {
withActionMenu: true;
collection: Collection;
onCopy: (items: CollectionItem[]) => void;
onMove: (items: CollectionItem[]) => void;
}
) &
(
| { withCloseButton: true; onClose?: () => void }
| { withCloseButton: false; onClose?: never }
);
const PinnedQuestionCard = ({
item,
@@ -45,10 +61,14 @@ const PinnedQuestionCard = ({
onMove,
onCreateBookmark,
onDeleteBookmark,
withActionMenu = true,
withBorder = true,
withCloseButton = false,
onClose,
}: PinnedQuestionCardProps): JSX.Element => {
const isPreview = isPreviewShown(item);
const actionMenu = (
const actionMenu = withActionMenu ? (
// This component is used within a `<Link>` component,
// so we must prevent events from triggering the activation of the link
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
@@ -63,7 +83,7 @@ const PinnedQuestionCard = ({
deleteBookmark={onDeleteBookmark}
/>
</EventSandbox>
);
) : null;
const positionedActionMenu = (
<CardActionMenuContainer>{actionMenu}</CardActionMenuContainer>
@@ -76,10 +96,32 @@ const PinnedQuestionCard = ({
return (
<CardRoot
to={item.getUrl()}
to={item.getUrl?.()}
isPreview={isPreview}
className={cx(CS.hoverParent, CS.hoverVisibility)}
withBorder={withBorder}
>
{withCloseButton && (
<EventSandbox preventDefault sandboxedEvents={["onClick"]}>
<Box pos="absolute" right=".5rem" top=".5rem">
<a
onClick={e => {
onClose?.();
e.stopPropagation();
e.preventDefault();
}}
>
<CloseButton
style={{
// HACK: Apply color correctly via Mantine
color: "#495057",
backgroundColor: "rgba(255, 255, 255, .8)",
}}
/>
</a>
</Box>
</EventSandbox>
)}
{!isPreview && positionedActionMenu}
{isPreview ? (
<PinnedQuestionLoader id={item.id}>
@@ -109,7 +151,7 @@ const PinnedQuestionCard = ({
description={
item.description || DEFAULT_DESCRIPTION[item.model] || ""
}
icon={item.getIcon() as unknown as { name: IconName }}
icon={item.getIcon?.() as unknown as { name: IconName }}
tooltip={getSkeletonTooltip(item)}
/>
)}

View File

@@ -13,6 +13,7 @@ import { RawMaybeLink } from "../Badge/Badge.styled";
import type { ResponsiveProps } from "./utils";
import { getContainerQuery } from "./utils";
import { Link_MaybeWithPreview } from "metabase/core/components/Link/Link_MaybeWithPreview";
export const Table = styled.table<{ isInDragLayer?: boolean }>`
background-color: var(--mb-color-bg-white);
@@ -95,7 +96,7 @@ export const ItemButton = styled(Text)<
TextProps & HTMLAttributes<HTMLDivElement>
>(itemLinkStyle);
export const ItemLink = styled(Link)(itemLinkStyle);
export const ItemLink = styled(Link_MaybeWithPreview)(itemLinkStyle);
export const MaybeItemLink = styled(RawMaybeLink)(itemLinkStyle);

View File

@@ -46,8 +46,9 @@ const ItemLinkComponent = ({
if (isEmbeddingSdk) {
return <ItemButton onClick={() => onClick?.(item)}>{children}</ItemButton>;
}
return (
<ItemLink to={item.getUrl()} onClick={() => onClick?.(item)}>
<ItemLink preview={item} to={item.getUrl()} onClick={() => onClick?.(item)}>
{children}
</ItemLink>
);

View File

@@ -1,9 +1,10 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import { Link, type LinkProps } from "react-router";
import { doNotForwardProps } from "metabase/common/utils/doNotForwardProps";
import { focusOutlineStyle } from "metabase/core/style/input";
import { LinkProps } from "./types";
import { Link } from "react-router";
type LinkVariantProp = { variant?: "default" | "brand" | "brandBold" };

View File

@@ -2,6 +2,7 @@ import Tooltip from "metabase/core/components/Tooltip";
import { LinkRoot } from "./Link.styled";
import type { LinkProps } from "./types";
import { Link_MaybeWithPreviewProps } from "./Link_MaybeWithPreview";
const Link = ({
to,
@@ -10,7 +11,7 @@ const Link = ({
tooltip,
variant,
...props
}: LinkProps): JSX.Element => {
}: Link_MaybeWithPreviewProps): JSX.Element => {
const link = (
<LinkRoot
{...props}
@@ -31,7 +32,8 @@ const Link = ({
}
: tooltip;
return tooltip ? (
// disable tooltips for now since they visually conflict with HoverCard
return false && tooltip ? (
<Tooltip {...tooltipProps}>
<span>{link}</span>
</Tooltip>

View File

@@ -0,0 +1,24 @@
import { Link, type LinkProps } from "react-router";
import { type CollectionItem } from "metabase-types/api";
import { PreviewHoverCard } from "./PreviewHoverCard";
export type Link_MaybeWithPreviewProps = {
preview?: CollectionItem;
} & LinkProps;
export const Link_MaybeWithPreview = ({
preview,
children,
...props
}: Link_MaybeWithPreviewProps) => {
const link = <Link {...props}>{children}</Link>;
return preview ? (
<PreviewHoverCard preview={preview} {...props}>
{link}
</PreviewHoverCard>
) : (
link
);
};

View File

@@ -0,0 +1,170 @@
import { type ReactNode, useCallback, useMemo, useRef, useState } from "react";
import { useUnmount } from "react-use";
import { P, match } from "ts-pattern";
import ErrorBoundary from "metabase/ErrorBoundary";
import { useGetCardQuery, useGetDashboardQuery } from "metabase/api";
import PinnedQuestionCard from "metabase/collections/components/PinnedQuestionCard";
import { Flex, HoverCard } from "metabase/ui";
import type { CollectionItem, RecentItem } from "metabase-types/api";
export type PreviewHoverCardProps = {
/** The item that is previewed in the hovercard's dropdown */
preview: CollectionItem | RecentItem;
/** The target of the hovercard */
children: ReactNode;
};
/** NOTE: This only previews questions at the moment */
export const PreviewHoverCard = ({
preview,
children,
}: PreviewHoverCardProps) => {
const [shouldHideDropdown, setShouldHideDropdown] = useState(false);
const timeoutRef = useRef<number>();
const onClose = useCallback(() => {
setShouldHideDropdown(true);
// HACK:Re-enable dropdown after waiting
clearTimeout(timeoutRef.current);
setTimeout(() => {
setShouldHideDropdown(false);
}, 500);
}, [setShouldHideDropdown]);
useUnmount(() => {
clearTimeout(timeoutRef.current);
});
const previewProps = {
item: preview,
onClose,
};
return (
<HoverCard
openDelay={200}
shadow="xs"
offset={
// northeast of the target
{ mainAxis: -10, crossAxis: 36 }
}
position="top-start"
transitionProps={{
transition: "pop-bottom-left",
}}
>
<HoverCard.Target>
{/* <Flex> or <div> is required because a HoverCard target must be able to accept a ref */}
<Flex style={{ flexGrow: 1 }}>{children}</Flex>
</HoverCard.Target>
<HoverCard.Dropdown
display={shouldHideDropdown ? "none" : undefined}
p={0}
bd="1px solid rgba(0,0,0,.1)"
mih="10rem"
miw="30rem"
>
{match(preview.model)
.with(P.union("dataset", "table", "card", "metric"), () => (
<QuestionPreview {...previewProps} />
))
.with("dashboard", () => <DashboardPreview {...previewProps} />)
.with(P.union("collection", "snippet", "indexed-entity"), () => null)
.exhaustive()}
</HoverCard.Dropdown>
</HoverCard>
);
};
const QuestionPreview = ({
item,
onClose,
}: {
item: CollectionItem | RecentItem;
onClose: () => void;
}) => {
const itemAsCollectionItem = {
...item,
getUrl: () => {},
} as CollectionItem; // TODO: Avoid this type coercion
return (
<PinnedQuestionCard
item={itemAsCollectionItem}
withActionMenu={false}
withBorder={false}
withCloseButton
onClose={onClose}
/>
);
};
const DashboardPreview = ({
item,
onClose,
}: {
item: CollectionItem | RecentItem;
onClose: () => void;
}) => {
const { data: dashboard } = useGetDashboardQuery({ id: item.id });
// Sort dashcards by row and col
const dashcardsSorted = useMemo(() => {
return dashboard?.dashcards.toSorted((a, b) => {
if (a.row < b.row) {
return -1;
}
if (a.row > b.row) {
return 1;
}
if (a.col < b.col) {
return -1;
}
if (a.col > b.col) {
return 1;
}
return 0;
});
}, [dashboard]);
const firstQuestion = dashcardsSorted?.[0];
// const { data: firstQuestion } = useGetCardQuery(
// dashboard?.dashcards.length
// ? { id: dashboard.dashcards[0].card_id }
// : skipToken,
// );
if (!firstQuestion) {
return null;
}
return (
<ErrorBoundary>
<DashcardPreview id={firstQuestion.card_id} onClose={onClose} />
</ErrorBoundary>
);
};
export const DashcardPreview = ({
id,
onClose,
}: {
id: number;
onClose: () => void;
}) => {
const { data: card } = useGetCardQuery({ id });
if (!card) {
return null;
}
return (
<PinnedQuestionCard
item={card as unknown as CollectionItem} // TODO: Don't coerce type
withActionMenu={false}
withBorder={false}
withCloseButton
onClose={onClose}
/>
);
};

View File

@@ -17,10 +17,6 @@ export const CardRoot = styled(Link)`
box-shadow: 0 7px 20px var(--mb-color-shadow);
max-width: 100%;
${breakpointMinSmall} {
max-width: 50%;
}
${breakpointMinLarge} {
padding: 1.5rem;
}

View File

@@ -1,22 +1,31 @@
import type { ReactNode } from "react";
import { CardRoot } from "./HomeCard.styled";
import { PreviewHoverCard } from "metabase/core/components/Link/PreviewHoverCard";
import { CollectionItem } from "metabase-types/api";
interface HomeCardProps {
className?: string;
url?: string;
external?: boolean;
children?: ReactNode;
preview?: CollectionItem;
}
export const HomeCard = ({
className,
url = "",
preview,
children,
}: HomeCardProps): JSX.Element => {
return (
const card = (
<CardRoot className={className} to={url}>
{children}
</CardRoot>
);
return preview ? (
<PreviewHoverCard preview={preview}>{card}</PreviewHoverCard>
) : (
card
);
};

View File

@@ -3,11 +3,13 @@ import type { IconName } from "metabase/ui";
import { HomeCard } from "../HomeCard";
import { CardIcon, CardTitle } from "./HomeModelCard.styled";
import { RecentItem } from "metabase-types/api";
interface HomeModelCardProps {
title: string;
icon: HomeModelIconProps;
url: string;
preview?: RecentItem;
}
export interface HomeModelIconProps {
@@ -18,9 +20,10 @@ export const HomeModelCard = ({
title,
icon,
url,
preview,
}: HomeModelCardProps): JSX.Element => {
return (
<HomeCard url={url}>
<HomeCard url={url} preview={preview}>
<CardIcon {...icon} />
<CardTitle>{title}</CardTitle>
</HomeCard>

View File

@@ -35,6 +35,7 @@ export const HomePopularSection = (): JSX.Element => {
title={getName(item)}
icon={getIcon(item)}
url={Urls.modelToUrl(item) ?? ""}
preview={item}
/>
))}
<HomeHelpCard />

View File

@@ -36,6 +36,7 @@ export const HomeRecentSection = () => {
title={getName(item)}
icon={getIcon(item)}
url={Urls.modelToUrl(item) ?? ""}
preview={item}
/>
))}
{hasHelpCard && <HomeHelpCard />}

View File

@@ -0,0 +1,7 @@
import { CloseButton } from "@mantine/core";
import S from "./CloseButton.module.css";
export const closeButtonOverrides = {
CloseButton: CloseButton.extend({}),
};

View File

@@ -0,0 +1,8 @@
import {
CloseButton as MantineCloseButton,
type CloseButtonProps as MantineCloseButtonProps,
} from "@mantine/core";
export const CloseButton = (props: MantineCloseButtonProps) => {
return <MantineCloseButton {...props} />;
};

View File

@@ -0,0 +1,3 @@
export * from "./CloseButton";
export { closeButtonOverrides } from "./CloseButton.config";

View File

@@ -1,4 +1,5 @@
export * from "./ActionIcon";
export * from "./Button";
export * from "./CloseButton";
export * from "./PopoverBackButton";
export * from "./UnstyledButton";

View File

@@ -15,6 +15,7 @@ import {
cardOverrides,
checkboxOverrides,
chipOverrides,
closeButtonOverrides,
codeOverrides,
dateInputOverrides,
datePickerOverrides,
@@ -127,6 +128,7 @@ export const getThemeOverrides = (): MantineThemeOverride => ({
...cardOverrides,
...checkboxOverrides,
...chipOverrides,
...closeButtonOverrides,
...codeOverrides,
...dateInputOverrides,
...datePickerOverrides,

View File

@@ -12,6 +12,7 @@ import {
TIMELINE_EVENT_SERIES_ID,
} from "../constants/dataset";
import type { BaseCartesianChartModel, DataKey } from "../model/types";
import { Portal } from "metabase/ui";
interface ChartItemTooltip {
dataIndex: number;
@@ -44,7 +45,11 @@ const ChartItemTooltip = ({
return null;
}
return <EChartsTooltip {...tooltipModel} />;
return (
<Portal>
<EChartsTooltip {...tooltipModel} />
</Portal>
);
};
export const getTooltipOption = (