Compare commits

...

10 Commits

Author SHA1 Message Date
Oisin Coveney
b8457818de fix another bad import 2025-05-09 14:42:02 +02:00
Oisin Coveney
bca9a5d2a2 Merge branch 'master' into functional-dashboard-grid 2025-05-09 13:25:46 +02:00
Oisin Coveney
ff3bd783ed Merge branch 'master' into functional-dashboard-grid 2025-05-08 17:45:32 +02:00
Oisin Coveney
3e1b5d7958 Merge branch 'master' into functional-dashboard-grid 2025-05-08 12:45:12 +02:00
Oisin Coveney
fe36c6aa15 Merge branch 'master' into functional-dashboard-grid 2025-05-07 16:15:22 +02:00
Oisin Coveney
d90fdac935 fix some types 2025-05-06 15:41:16 +02:00
Oisin Coveney
e0714f936b hopefully this fixes the actions 2025-05-06 15:41:16 +02:00
Oisin Coveney
140a59e1c1 Fix bad import 2025-05-06 15:41:16 +02:00
Oisin Coveney
38477beb2d Remove unused file 2025-05-06 15:41:16 +02:00
Oisin Coveney
30e7cda857 Convert DashboardGrid to a functional component 2025-05-06 15:41:16 +02:00
11 changed files with 961 additions and 788 deletions

View File

@@ -1,726 +0,0 @@
import cx from "classnames";
import type { ComponentType, ForwardedRef } from "react";
import { Component, forwardRef } from "react";
import type { ConnectedProps } from "react-redux";
import { push } from "react-router-redux";
import { t } from "ttag";
import _ from "underscore";
import type { QuestionPickerValueItem } from "metabase/common/components/QuestionPicker";
import {
QuestionPickerModal,
getQuestionPickerValue,
} from "metabase/common/components/QuestionPicker";
import ExplicitSize from "metabase/components/ExplicitSize";
import Modal from "metabase/components/Modal";
import { ContentViewportContext } from "metabase/core/context/ContentViewportContext";
import ModalS from "metabase/css/components/modal.module.css";
import DashboardS from "metabase/css/dashboard.module.css";
import type { NavigateToNewCardFromDashboardOpts } from "metabase/dashboard/components/DashCard/types";
import {
getVisibleCardIds,
isQuestionDashCard,
} from "metabase/dashboard/utils";
import {
GRID_ASPECT_RATIO,
GRID_BREAKPOINTS,
GRID_COLUMNS,
GRID_WIDTH,
MIN_ROW_HEIGHT,
} from "metabase/lib/dashboard_grid";
import { connect } from "metabase/lib/redux";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { addUndo } from "metabase/redux/undo";
import { Box, Flex } from "metabase/ui";
import LegendS from "metabase/visualizations/components/Legend.module.css";
import type { ClickActionModeGetter } from "metabase/visualizations/types";
import {
type BaseDashboardCard,
type Card,
type CardId,
type DashCardId,
type Dashboard,
type DashboardCard,
type DashboardTabId,
type RecentItem,
isRecentCollectionItem,
} from "metabase-types/api";
import type { State } from "metabase-types/store";
import type { SetDashCardAttributesOpts } from "../actions";
import {
fetchCardData,
markNewCardSeen,
onReplaceAllDashCardVisualizationSettings,
onUpdateDashCardVisualizationSettings,
removeCardFromDashboard,
replaceCard,
setDashCardAttributes,
setMultipleDashCardAttributes,
showClickBehaviorSidebar,
trashDashboardQuestion,
undoRemoveCardFromDashboard,
} from "../actions";
import {
getInitialCardSizes,
getLayoutForDashCard,
getLayouts,
getVisibleCards,
} from "../grid-utils";
import { getDashcardDataMap } from "../selectors";
import { AddSeriesModal } from "./AddSeriesModal/AddSeriesModal";
import { DashCard } from "./DashCard/DashCard";
import DashCardS from "./DashCard/DashCard.module.css";
import { FIXED_WIDTH } from "./Dashboard/DashboardComponents";
import S from "./DashboardGrid.module.css";
import { GridLayout } from "./grid/GridLayout";
type GridBreakpoint = "desktop" | "mobile";
type ExplicitSizeProps = {
width: number;
};
interface DashboardGridInnerState {
visibleCardIds: Set<number>;
initialCardSizes: { [key: string]: { w: number; h: number } };
layouts: {
desktop: ReactGridLayout.Layout[];
mobile: ReactGridLayout.Layout[];
};
addSeriesModalDashCard: BaseDashboardCard | null;
replaceCardModalDashCard: BaseDashboardCard | null;
isDragging: boolean;
isAnimationPaused: boolean;
dashcardCountByCardId: Record<CardId, number>;
_lastProps?: LastProps;
}
/** Props from the previous render to use for comparison in getDerivedStateFromProps */
type LastProps = {
dashboard: Dashboard;
isEditing: boolean;
selectedTabId: DashboardTabId | null;
};
const mapStateToProps = (state: State) => ({
dashcardData: getDashcardDataMap(state),
});
const mapDispatchToProps = {
addUndo,
removeCardFromDashboard,
trashDashboardQuestion,
showClickBehaviorSidebar,
markNewCardSeen,
setMultipleDashCardAttributes,
setDashCardAttributes,
undoRemoveCardFromDashboard,
replaceCard,
onChangeLocation: push,
onReplaceAllDashCardVisualizationSettings,
onUpdateDashCardVisualizationSettings,
fetchCardData,
};
const connector = connect(mapStateToProps, mapDispatchToProps, null, {
forwardRef: true,
});
type DashboardGridReduxProps = ConnectedProps<typeof connector>;
export type DashboardGridProps = {
dashboard: Dashboard;
selectedTabId: DashboardTabId | null;
slowCards: Record<DashCardId, boolean>;
isEditing?: boolean;
isEditingParameter?: boolean;
/** If public sharing or static/public embed */
isPublicOrEmbedded?: boolean;
isXray?: boolean;
isFullscreen?: boolean;
isNightMode?: boolean;
withCardTitle?: boolean;
clickBehaviorSidebarDashcard: DashboardCard | null;
getClickActionMode?: ClickActionModeGetter;
// public dashboard passes it explicitly
width?: number;
// public or embedded dashboard passes it as noop
navigateToNewCardFromDashboard?: (
opts: NavigateToNewCardFromDashboardOpts,
) => void;
downloadsEnabled: boolean;
autoScrollToDashcardId?: DashCardId;
reportAutoScrolledToDashcard?: () => void;
};
type DashboardGridInnerProps = Required<DashboardGridProps> &
DashboardGridReduxProps &
ExplicitSizeProps & {
forwardedRef?: ForwardedRef<HTMLDivElement>;
};
class DashboardGridInner extends Component<
DashboardGridInnerProps,
DashboardGridInnerState
> {
static contextType = ContentViewportContext;
_pauseAnimationTimer: ReturnType<typeof setTimeout> | null = null;
constructor(props: DashboardGridInnerProps, context: unknown) {
super(props, context);
const visibleCardIds = getVisibleCardIds(
props.dashboard.dashcards,
props.dashcardData,
);
const initialCardSizes = getInitialCardSizes(
props.dashboard.dashcards,
this.state?.initialCardSizes,
);
this.state = {
visibleCardIds,
dashcardCountByCardId: this.getDashcardCountByCardId(
props.dashboard.dashcards,
),
initialCardSizes,
layouts: getLayouts(
props.dashboard.dashcards,
this.state?.initialCardSizes,
),
addSeriesModalDashCard: null,
replaceCardModalDashCard: null,
isDragging: false,
isAnimationPaused: true,
_lastProps: {
dashboard: props.dashboard,
isEditing: props.isEditing,
selectedTabId: props.selectedTabId,
},
};
}
componentDidMount() {
// In order to skip the initial cards animation we must let the grid layout calculate
// the initial card positions. The timer is necessary to enable animation only
// after the grid layout has been calculated and applied to the DOM.
this._pauseAnimationTimer = setTimeout(() => {
this.setState({ isAnimationPaused: false });
}, 0);
}
componentWillUnmount() {
if (this._pauseAnimationTimer !== null) {
clearTimeout(this._pauseAnimationTimer);
}
}
componentDidUpdate(prevProps: DashboardGridInnerProps) {
if (prevProps.dashboard.dashcards !== this.props.dashboard.dashcards) {
this.setState({
dashcardCountByCardId: this.getDashcardCountByCardId(
this.props.dashboard.dashcards,
),
});
}
}
static getDerivedStateFromProps(
nextProps: DashboardGridInnerProps,
state: DashboardGridInnerState,
): Partial<DashboardGridInnerState> {
const { dashboard, dashcardData, isEditing, selectedTabId } = nextProps;
const lastProps = state._lastProps;
const visibleCardIds = !isEditing
? getVisibleCardIds(
dashboard.dashcards,
dashcardData,
state.visibleCardIds,
)
: new Set(dashboard.dashcards.map((card) => card.id));
const visibleCards = getVisibleCards(
dashboard.dashcards,
visibleCardIds,
isEditing,
selectedTabId,
);
const lastVisibleCards = lastProps?.dashboard?.dashcards
? getVisibleCards(
lastProps.dashboard.dashcards,
state.visibleCardIds,
lastProps.isEditing,
lastProps.selectedTabId,
)
: [];
const hasVisibleDashcardsChanged = !_.isEqual(
visibleCards,
lastVisibleCards,
);
const initialCardSizes =
!isEditing || hasVisibleDashcardsChanged
? getInitialCardSizes(visibleCards, state.initialCardSizes)
: state.initialCardSizes;
return {
visibleCardIds,
initialCardSizes,
layouts: getLayouts(visibleCards, state.initialCardSizes),
_lastProps: {
dashboard,
isEditing,
selectedTabId,
},
};
}
onLayoutChange = ({
layout,
breakpoint,
}: {
layout: ReactGridLayout.Layout[];
breakpoint: GridBreakpoint;
}) => {
const { setMultipleDashCardAttributes, isEditing } = this.props;
// We allow moving and resizing cards only on the desktop
// Ensures onLayoutChange triggered by window resize,
// won't break the main layout
if (!isEditing || breakpoint !== "desktop") {
return;
}
const changes: SetDashCardAttributesOpts[] = [];
layout.forEach((layoutItem) => {
const dashboardCard = this.getVisibleCards().find(
(card) => String(card.id) === layoutItem.i,
);
if (dashboardCard) {
const keys = ["h", "w", "x", "y"];
const changed = !_.isEqual(
_.pick(layoutItem, keys),
_.pick(this.getLayoutForDashCard(dashboardCard), keys),
);
if (changed) {
changes.push({
id: dashboardCard.id,
attributes: {
col: layoutItem.x,
row: layoutItem.y,
size_x: layoutItem.w,
size_y: layoutItem.h,
},
});
}
}
});
if (changes.length > 0) {
setMultipleDashCardAttributes({ dashcards: changes });
}
};
getLayoutForDashCard = (dashcard: BaseDashboardCard) => {
return getLayoutForDashCard(dashcard, this.state?.initialCardSizes);
};
getVisibleCards = (
cards = this.props.dashboard.dashcards,
visibleCardIds = this.state.visibleCardIds,
isEditing = this.props.isEditing,
selectedTabId = this.props.selectedTabId,
) => {
return getVisibleCards(
cards,
visibleCardIds,
isEditing,
selectedTabId,
) as DashboardCard[];
};
getDashcardCountByCardId = (cards: BaseDashboardCard[]) =>
_.countBy(cards, "card_id");
getIsLastDashboardQuestionDashcard = (dc: BaseDashboardCard): boolean => {
return Boolean(
dc.card.dashboard_id !== null &&
dc.card_id &&
this.state.dashcardCountByCardId[dc.card_id] <= 1,
);
};
getRowHeight() {
const { width } = this.props;
const contentViewportElement = this.context as any;
const hasScroll =
contentViewportElement?.clientHeight <
contentViewportElement?.scrollHeight;
const aspectHeight = width / GRID_WIDTH / GRID_ASPECT_RATIO;
const actualHeight = Math.max(aspectHeight, MIN_ROW_HEIGHT);
// prevent infinite re-rendering when the scroll bar appears/disappears
// https://github.com/metabase/metabase/issues/17229
return hasScroll ? Math.ceil(actualHeight) : Math.floor(actualHeight);
}
renderAddSeriesModal() {
// can't use PopoverWithTrigger due to strange interaction with ReactGridLayout
const { addSeriesModalDashCard } = this.state;
const isOpen =
!!addSeriesModalDashCard && isQuestionDashCard(addSeriesModalDashCard);
return (
<Modal
className={cx(
ModalS.Modal,
DashboardS.Modal,
DashboardS.AddSeriesModal,
)}
data-testid="add-series-modal"
isOpen={isOpen}
>
{isOpen && (
<AddSeriesModal
dashcard={addSeriesModalDashCard}
dashcardData={this.props.dashcardData}
fetchCardData={this.props.fetchCardData}
setDashCardAttributes={this.props.setDashCardAttributes}
onClose={() => this.setState({ addSeriesModalDashCard: null })}
/>
)}
</Modal>
);
}
renderReplaceCardModal() {
const { addUndo, replaceCard, setDashCardAttributes, dashboard } =
this.props;
const { replaceCardModalDashCard } = this.state;
const hasValidDashCard =
!!replaceCardModalDashCard &&
isQuestionDashCard(replaceCardModalDashCard);
const handleSelect = (nextCard: QuestionPickerValueItem) => {
if (!hasValidDashCard) {
return;
}
replaceCard({
dashcardId: replaceCardModalDashCard.id,
nextCardId: nextCard.id,
});
addUndo({
message: getUndoReplaceCardMessage(replaceCardModalDashCard.card),
undo: true,
action: () =>
setDashCardAttributes({
id: replaceCardModalDashCard.id,
attributes: replaceCardModalDashCard,
}),
});
handleClose();
};
const replaceCardModalRecentFilter = (items: RecentItem[]) => {
return items.filter((item) => {
if (isRecentCollectionItem(item) && item.dashboard) {
if (item.dashboard.id !== dashboard.id) {
return false;
}
}
return true;
});
};
const handleClose = () => {
this.setState({ replaceCardModalDashCard: null });
};
if (!hasValidDashCard) {
return null;
}
return (
<QuestionPickerModal
title={t`Pick what you want to replace this with`}
value={
replaceCardModalDashCard.card.id
? getQuestionPickerValue(replaceCardModalDashCard.card)
: undefined
}
models={["card", "dataset", "metric"]}
onChange={handleSelect}
onClose={handleClose}
recentFilter={replaceCardModalRecentFilter}
/>
);
}
// we need to track whether or not we're dragging so we can disable pointer events on action buttons :-/
onDrag = () => {
if (!this.state.isDragging) {
this.setState({ isDragging: true });
}
};
onDragStop = () => {
this.setState({ isDragging: false });
};
onDashCardRemove = (dc: DashboardCard) => {
this.props.removeCardFromDashboard({
dashcardId: dc.id,
cardId: dc.card_id,
});
this.props.addUndo({
message: this.getIsLastDashboardQuestionDashcard(dc)
? t`Trashed and removed card`
: t`Removed card`,
undo: true,
action: () =>
this.props.undoRemoveCardFromDashboard({ dashcardId: dc.id }),
});
};
onDashCardAddSeries = (dc: BaseDashboardCard) => {
this.setState({ addSeriesModalDashCard: dc });
};
onReplaceCard = (dashcard: BaseDashboardCard) => {
this.setState({ replaceCardModalDashCard: dashcard });
};
renderDashCard(
dashcard: DashboardCard,
{
isMobile,
gridItemWidth,
totalNumGridCols,
downloadsEnabled,
shouldAutoScrollTo,
reportAutoScrolledToDashcard,
}: {
isMobile: boolean;
gridItemWidth: number;
totalNumGridCols: number;
downloadsEnabled: boolean;
shouldAutoScrollTo: boolean;
reportAutoScrolledToDashcard?: () => void;
},
) {
return (
<DashCard
className={S.Card}
dashcard={dashcard}
slowCards={this.props.slowCards}
gridItemWidth={gridItemWidth}
totalNumGridCols={totalNumGridCols}
markNewCardSeen={this.props.markNewCardSeen}
isEditing={this.props.isEditing}
isEditingParameter={this.props.isEditingParameter}
isFullscreen={this.props.isFullscreen}
isNightMode={this.props.isNightMode}
isMobile={isMobile}
isPublicOrEmbedded={this.props.isPublicOrEmbedded}
isXray={this.props.isXray}
withTitle={this.props.withCardTitle}
onRemove={this.onDashCardRemove}
onAddSeries={this.onDashCardAddSeries}
onReplaceCard={this.onReplaceCard}
onUpdateVisualizationSettings={
this.props.onUpdateDashCardVisualizationSettings
}
onReplaceAllDashCardVisualizationSettings={
this.props.onReplaceAllDashCardVisualizationSettings
}
getClickActionMode={this.props.getClickActionMode}
navigateToNewCardFromDashboard={
this.props.navigateToNewCardFromDashboard
}
onChangeLocation={this.props.onChangeLocation}
dashboard={this.props.dashboard}
showClickBehaviorSidebar={this.props.showClickBehaviorSidebar}
clickBehaviorSidebarDashcard={this.props.clickBehaviorSidebarDashcard}
downloadsEnabled={downloadsEnabled}
autoScroll={shouldAutoScrollTo}
isTrashedOnRemove={this.getIsLastDashboardQuestionDashcard(dashcard)}
reportAutoScrolledToDashcard={reportAutoScrolledToDashcard}
/>
);
}
get isEditingLayout() {
const { isEditing, isEditingParameter, clickBehaviorSidebarDashcard } =
this.props;
return Boolean(
isEditing && !isEditingParameter && clickBehaviorSidebarDashcard == null,
);
}
renderGridItem = ({
item: dc,
breakpoint,
gridItemWidth,
totalNumGridCols,
}: {
item: DashboardCard;
breakpoint: GridBreakpoint;
gridItemWidth: number;
totalNumGridCols: number;
}) => {
const { isEditing, autoScrollToDashcardId, reportAutoScrolledToDashcard } =
this.props;
const shouldAutoScrollTo = autoScrollToDashcardId === dc.id;
const shouldChangeResizeHandle = isEditingTextOrHeadingCard(
dc.card.display,
isEditing,
);
return (
<Box
key={String(dc.id)}
data-testid="dashcard-container"
className={cx(
DashboardS.DashCard,
EmbedFrameS.DashCard,
LegendS.DashCard,
S.DashboardCardContainer,
{
[DashboardS.BrandColorResizeHandle]: shouldChangeResizeHandle,
[S.isAnimationDisabled]: this.state.isAnimationPaused,
},
)}
>
{this.renderDashCard(dc, {
isMobile: breakpoint === "mobile",
gridItemWidth,
totalNumGridCols,
downloadsEnabled: this.props.downloadsEnabled,
shouldAutoScrollTo,
reportAutoScrolledToDashcard,
})}
</Box>
);
};
renderGrid() {
const { width } = this.props;
const { layouts } = this.state;
const rowHeight = this.getRowHeight();
return (
<GridLayout<DashboardCard>
className={cx({
[DashboardS.DashEditing]: this.isEditingLayout,
[DashboardS.DashDragging]: this.state.isDragging,
// we use this class to hide a dashcard actions
// panel during dragging
[DashCardS.DashboardCardRootDragging]: this.state.isDragging,
})}
layouts={layouts}
breakpoints={GRID_BREAKPOINTS}
cols={GRID_COLUMNS}
width={width}
margin={{ desktop: [6, 6], mobile: [6, 10] }}
containerPadding={[0, 0]}
rowHeight={rowHeight}
onLayoutChange={this.onLayoutChange}
onDrag={this.onDrag}
onDragStop={this.onDragStop}
isEditing={this.isEditingLayout}
compactType="vertical"
items={this.getVisibleCards()}
itemRenderer={this.renderGridItem}
/>
);
}
render() {
const { dashboard, width, forwardedRef } = this.props;
return (
<Flex
align="center"
justify="center"
className={cx(S.DashboardGridContainer, {
[S.isFixedWidth]: dashboard?.width === "fixed",
})}
ref={forwardedRef}
data-testid="dashboard-grid"
style={{
"--dashboard-fixed-width": FIXED_WIDTH,
}}
>
{width > 0 ? this.renderGrid() : <div />}
{this.renderAddSeriesModal()}
{this.renderReplaceCardModal()}
</Flex>
);
}
}
function isEditingTextOrHeadingCard(display: string, isEditing: boolean) {
const isTextOrHeadingCard = display === "heading" || display === "text";
return isEditing && isTextOrHeadingCard;
}
const getUndoReplaceCardMessage = ({ type }: Card) => {
if (type === "model") {
return t`Model replaced`;
}
if (type === "metric") {
return t`Metric replaced`;
}
if (type === "question") {
return t`Question replaced`;
}
throw new Error(`Unknown card.type: ${type}`);
};
const DashboardGrid = forwardRef<HTMLDivElement, DashboardGridInnerProps>(
function _DashboardGrid(
{
isEditing = false,
isEditingParameter = false,
withCardTitle = true,
isNightMode = false,
width = 0,
...restProps
},
ref,
) {
return (
<DashboardGridInner
width={width}
isEditing={isEditing}
isEditingParameter={isEditingParameter}
withCardTitle={withCardTitle}
isNightMode={isNightMode}
{...restProps}
forwardedRef={ref}
/>
);
},
);
export const DashboardGridConnected = _.compose(
ExplicitSize(),
connector,
)(DashboardGrid) as ComponentType<DashboardGridProps>;

View File

@@ -0,0 +1,63 @@
import cx from "classnames";
import Modal from "metabase/components/Modal";
import ModalS from "metabase/css/components/modal.module.css";
import DashboardS from "metabase/css/dashboard.module.css";
import { isQuestionDashCard } from "metabase/dashboard/utils";
import type {
BaseDashboardCard,
Card,
DashCardDataMap,
DashCardId,
QuestionDashboardCard,
} from "metabase-types/api";
import { AddSeriesModal } from "../AddSeriesModal/AddSeriesModal";
type AddSeriesModalWrapperProps = {
addSeriesModalDashCard: BaseDashboardCard | null;
setAddSeriesModalDashCard: (card: BaseDashboardCard | null) => void;
dashcardData: DashCardDataMap;
fetchCardData: (
card: Card,
dashcard: QuestionDashboardCard,
options: {
clearCache?: boolean;
ignoreCache?: boolean;
reload?: boolean;
},
) => Promise<void>;
setDashCardAttributes: (options: {
id: DashCardId;
attributes: Partial<QuestionDashboardCard>;
}) => void;
};
export const AddSeriesModalWrapper = ({
addSeriesModalDashCard,
dashcardData,
fetchCardData,
setDashCardAttributes,
setAddSeriesModalDashCard,
}: AddSeriesModalWrapperProps) => {
const isOpen =
!!addSeriesModalDashCard && isQuestionDashCard(addSeriesModalDashCard);
return (
<Modal
className={cx(ModalS.Modal, DashboardS.Modal, DashboardS.AddSeriesModal)}
data-testid="add-series-modal"
isOpen={isOpen}
>
{isOpen && (
<AddSeriesModal
dashcard={addSeriesModalDashCard}
dashcardData={dashcardData}
fetchCardData={fetchCardData}
setDashCardAttributes={setDashCardAttributes}
onClose={() => setAddSeriesModalDashCard(null)}
/>
)}
</Modal>
);
};

View File

@@ -0,0 +1,515 @@
import cx from "classnames";
import type { ComponentType, ForwardedRef } from "react";
import {
forwardRef,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ConnectedProps } from "react-redux";
import { push } from "react-router-redux";
import { usePrevious } from "react-use";
import { t } from "ttag";
import _ from "underscore";
import ExplicitSize from "metabase/components/ExplicitSize";
import { ContentViewportContext } from "metabase/core/context/ContentViewportContext";
import type { NavigateToNewCardFromDashboardOpts } from "metabase/dashboard/components/DashCard/types";
import { getVisibleCardIds } from "metabase/dashboard/utils";
import {
GRID_ASPECT_RATIO,
GRID_WIDTH,
MIN_ROW_HEIGHT,
} from "metabase/lib/dashboard_grid";
import { connect, useDispatch, useSelector } from "metabase/lib/redux";
import { addUndo } from "metabase/redux/undo";
import { Flex } from "metabase/ui";
import type { ClickActionModeGetter } from "metabase/visualizations/types";
import type {
BaseDashboardCard,
Card,
CardId,
DashCardId,
Dashboard,
DashboardCard,
DashboardTabId,
QuestionDashboardCard,
} from "metabase-types/api";
import type { State } from "metabase-types/store";
import type { SetDashCardAttributesOpts } from "../../actions";
import {
fetchCardData,
markNewCardSeen,
onReplaceAllDashCardVisualizationSettings,
onUpdateDashCardVisualizationSettings,
removeCardFromDashboard,
replaceCard,
setDashCardAttributes,
setMultipleDashCardAttributes,
showClickBehaviorSidebar,
trashDashboardQuestion,
undoRemoveCardFromDashboard,
} from "../../actions";
import {
getInitialCardSizes,
getLayoutForDashCard,
getLayouts,
getVisibleCards,
} from "../../grid-utils";
import { getDashcardDataMap } from "../../selectors";
import { FIXED_WIDTH } from "../Dashboard/DashboardComponents";
import { AddSeriesModalWrapper } from "./AddSeriesModalWrapper";
import S from "./DashboardGrid.module.css";
import { DashboardGridRender } from "./DashboardGridRender";
import { ReplaceCardModal } from "./ReplaceCardModal";
export type GridBreakpoint = "desktop" | "mobile";
export type ExplicitSizeProps = {
width: number;
height: number;
};
const mapStateToProps = (state: State) => ({
dashcardData: getDashcardDataMap(state),
});
const mapDispatchToProps = {
addUndo,
removeCardFromDashboard,
trashDashboardQuestion,
showClickBehaviorSidebar,
markNewCardSeen,
setMultipleDashCardAttributes,
setDashCardAttributes,
undoRemoveCardFromDashboard,
replaceCard,
onChangeLocation: push,
onReplaceAllDashCardVisualizationSettings,
onUpdateDashCardVisualizationSettings,
fetchCardData,
};
const connector = connect(mapStateToProps, mapDispatchToProps, null, {
forwardRef: true,
});
type DashboardGridReduxProps = ConnectedProps<typeof connector>;
export type DashboardGridProps = {
dashboard: Dashboard;
selectedTabId: DashboardTabId | null;
slowCards: Record<DashCardId, boolean>;
isEditing?: boolean;
isEditingParameter?: boolean;
/** If public sharing or static/public embed */
isPublicOrEmbedded?: boolean;
isXray?: boolean;
isFullscreen?: boolean;
isNightMode?: boolean;
withCardTitle?: boolean;
clickBehaviorSidebarDashcard: DashboardCard | null;
getClickActionMode?: ClickActionModeGetter;
// public dashboard passes it explicitly
width?: number;
// public or embedded dashboard passes it as noop
navigateToNewCardFromDashboard?: (
opts: NavigateToNewCardFromDashboardOpts,
) => void;
downloadsEnabled: boolean;
autoScrollToDashcardId?: DashCardId;
reportAutoScrolledToDashcard?: () => void;
};
type DashboardGridInnerProps = Required<DashboardGridProps> &
DashboardGridReduxProps &
ExplicitSizeProps & {
forwardedRef?: ForwardedRef<HTMLDivElement>;
};
// Helper function to correctly compare two Sets
function areSetsEqual<T>(
setA: Set<T> | undefined | null,
setB: Set<T> | undefined | null,
): boolean {
if (setA === setB) {
// Handles cases where both are null/undefined or same instance
return true;
}
if (!setA || !setB) {
// If one is null/undefined and the other isn't
return false;
}
if (setA.size !== setB.size) {
return false;
}
for (const item of setA) {
if (!setB.has(item)) {
return false;
}
}
return true;
}
export const DashboardGrid = forwardRef<
HTMLDivElement,
DashboardGridInnerProps
>(function _DashboardGridInner2(
{
isEditing = false,
isEditingParameter = false,
withCardTitle = true,
isNightMode = false,
width = 0,
height = 0,
dashboard,
selectedTabId,
clickBehaviorSidebarDashcard,
autoScrollToDashcardId,
reportAutoScrolledToDashcard,
downloadsEnabled,
slowCards,
markNewCardSeen,
isFullscreen,
isXray,
isPublicOrEmbedded,
onUpdateDashCardVisualizationSettings,
onReplaceAllDashCardVisualizationSettings,
getClickActionMode,
navigateToNewCardFromDashboard,
onChangeLocation,
showClickBehaviorSidebar,
},
ref,
) {
const dashcardData = useSelector(getDashcardDataMap);
const dispatch = useDispatch();
const contentViewportElement = useContext(ContentViewportContext);
const initialSizes = useMemo(() => {
return getInitialCardSizes(dashboard.dashcards, undefined);
}, [dashboard.dashcards]); // Depends on initial props
const [visibleCardIds, setVisibleCardIds] = useState<Set<DashCardId>>(() =>
getVisibleCardIds(dashboard.dashcards, dashcardData),
);
const getDashcardCountByCardId = (cards: BaseDashboardCard[]) =>
_.countBy(cards, "card_id");
const [dashcardCountByCardId, setDashcardCountByCardId] = useState<
Record<CardId, number>
>(() => getDashcardCountByCardId(dashboard.dashcards));
const [initialCardSizes, setInitialCardSizes] =
useState<Record<string, any>>(initialSizes);
const [addSeriesModalDashCard, setAddSeriesModalDashCard] =
useState<BaseDashboardCard | null>(null);
const [replaceCardModalDashCard, setReplaceCardModalDashCard] =
useState<BaseDashboardCard | null>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [isAnimationPaused, setIsAnimationPaused] = useState<boolean>(true);
const pauseAnimationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const onDashCardRemove = (dc: DashboardCard) => {
dispatch(
removeCardFromDashboard({
dashcardId: dc.id,
cardId: dc.card_id,
}),
);
dispatch(
addUndo({
message: getIsLastDashboardQuestionDashcard(dc)
? t`Trashed and removed card`
: t`Removed card`,
undo: true,
action: () =>
dispatch(undoRemoveCardFromDashboard({ dashcardId: dc.id })),
}),
);
};
const onDashCardAddSeries = (dc: BaseDashboardCard) => {
setAddSeriesModalDashCard(dc);
};
const onReplaceCard = (dashcard: BaseDashboardCard) => {
setReplaceCardModalDashCard(dashcard);
};
useEffect(() => {
pauseAnimationTimerRef.current = setTimeout(() => {
setIsAnimationPaused(false);
}, 0);
return () => {
if (pauseAnimationTimerRef.current !== null) {
clearTimeout(pauseAnimationTimerRef.current);
}
};
});
useEffect(() => {
const newCount = getDashcardCountByCardId(dashboard.dashcards);
setDashcardCountByCardId(newCount);
}, [dashboard.dashcards]);
useEffect(() => {
let calculatedNextVisibleCardIds: Set<DashCardId>;
if (isEditing) {
// When editing, all cards are "visible" for layout purposes
calculatedNextVisibleCardIds = new Set(
dashboard.dashcards.map((card) => card.id),
);
} else {
// When not editing, calculate visible cards from scratch
calculatedNextVisibleCardIds = getVisibleCardIds(
dashboard.dashcards,
dashcardData,
);
}
// Use the custom areSetsEqual function for comparison
const setsAreDifferent = !areSetsEqual(
calculatedNextVisibleCardIds,
visibleCardIds,
);
if (setsAreDifferent) {
setVisibleCardIds(calculatedNextVisibleCardIds);
}
}, [isEditing, dashboard.dashcards, dashcardData, visibleCardIds]); // Dependencies remain the same
const visibleCards = useMemo(() => {
return getVisibleCards(
dashboard.dashcards,
visibleCardIds,
isEditing,
selectedTabId,
) as DashboardCard[];
}, [dashboard.dashcards, isEditing, selectedTabId, visibleCardIds]);
// --- initialCardSizes Effect ---
const prevVisibleCards = usePrevious(visibleCards);
useEffect(() => {
const hasVisibleDashcardsChanged = !_.isEqual(
visibleCards,
prevVisibleCards,
);
let nextInitialCardSizes = initialCardSizes;
if (!isEditing || hasVisibleDashcardsChanged) {
// Use direct variable
nextInitialCardSizes = getInitialCardSizes(
visibleCards,
initialCardSizes,
);
}
if (!_.isEqual(nextInitialCardSizes, initialCardSizes)) {
setInitialCardSizes(nextInitialCardSizes);
}
}, [
isEditing, // Direct variable
visibleCards,
prevVisibleCards,
initialCardSizes,
setInitialCardSizes,
]);
// --- layouts Memo ---
const layouts = useMemo(() => {
return getLayouts(visibleCards, initialCardSizes);
}, [visibleCards, initialCardSizes]);
const onLayoutChange = ({
layout,
breakpoint,
}: {
layout: ReactGridLayout.Layout[];
breakpoint: GridBreakpoint;
}) => {
// We allow moving and resizing cards only on the desktop
// Ensures onLayoutChange triggered by window resize,
// won't break the main layout
if (!isEditing || breakpoint !== "desktop") {
return;
}
const changes: SetDashCardAttributesOpts[] = [];
layout.forEach((layoutItem) => {
const dashboardCard = getVisibleCards(
dashboard.dashcards,
visibleCardIds,
isEditing,
selectedTabId,
).find((card) => String(card.id) === layoutItem.i);
if (dashboardCard) {
const keys = ["h", "w", "x", "y"];
const changed = !_.isEqual(
_.pick(layoutItem, keys),
_.pick(_getLayoutForDashCard(dashboardCard), keys),
);
if (changed) {
changes.push({
id: dashboardCard.id,
attributes: {
col: layoutItem.x,
row: layoutItem.y,
size_x: layoutItem.w,
size_y: layoutItem.h,
},
});
}
}
});
if (changes.length > 0) {
dispatch(setMultipleDashCardAttributes({ dashcards: changes }));
}
};
const _getLayoutForDashCard = (dashcard: BaseDashboardCard) => {
return getLayoutForDashCard(dashcard, initialCardSizes);
};
const getIsLastDashboardQuestionDashcard = (
dc: BaseDashboardCard,
): boolean => {
return Boolean(
dc.card.dashboard_id !== null &&
dc.card_id &&
dashcardCountByCardId[dc.card_id] <= 1,
);
};
const rowHeight = useMemo(() => {
const hasScroll =
contentViewportElement !== null &&
contentViewportElement?.clientHeight <
contentViewportElement?.scrollHeight;
const aspectHeight = width / GRID_WIDTH / GRID_ASPECT_RATIO;
const actualHeight = Math.max(aspectHeight, MIN_ROW_HEIGHT);
// prevent infinite re-rendering when the scroll bar appears/disappears
// https://github.com/metabase/metabase/issues/17229
return hasScroll ? Math.ceil(actualHeight) : Math.floor(actualHeight);
}, [contentViewportElement, width]);
// we need to track whether or not we're dragging so we can disable pointer events on action buttons :-/
const onDrag = () => {
if (!isDragging) {
setIsDragging(true);
}
};
const onDragStop = () => {
setIsDragging(false);
};
const isEditingLayout = useMemo(() => {
return Boolean(
isEditing && !isEditingParameter && clickBehaviorSidebarDashcard == null,
);
}, [clickBehaviorSidebarDashcard, isEditing, isEditingParameter]);
return (
<Flex
align="center"
justify="center"
ref={ref}
className={cx(S.DashboardGridContainer, {
[S.isFixedWidth]: dashboard?.width === "fixed",
})}
data-testid="dashboard-grid"
style={{
"--dashboard-fixed-width": FIXED_WIDTH,
}}
>
{/* {width > 0 ? this.renderGrid() : <div />} */}
{width > 0 && (
<DashboardGridRender
width={width}
height={height}
layouts={layouts}
rowHeight={rowHeight}
isEditingLayout={isEditingLayout}
isDragging={isDragging}
onLayoutChange={onLayoutChange}
onDrag={onDrag}
onDragStop={onDragStop}
visibleCards={visibleCards}
isEditing={isEditing}
autoScrollToDashcardId={autoScrollToDashcardId}
reportAutoScrolledToDashcard={reportAutoScrolledToDashcard}
downloadsEnabled={downloadsEnabled}
isAnimationPaused={isAnimationPaused}
slowCards={slowCards}
markNewCardSeen={markNewCardSeen}
isEditingParameter={isEditingParameter}
isFullscreen={isFullscreen}
isNightMode={isNightMode}
isPublicOrEmbedded={isPublicOrEmbedded}
isXray={isXray}
withCardTitle={withCardTitle}
onUpdateDashCardVisualizationSettings={
onUpdateDashCardVisualizationSettings
}
onReplaceAllDashCardVisualizationSettings={
onReplaceAllDashCardVisualizationSettings
}
getClickActionMode={getClickActionMode}
navigateToNewCardFromDashboard={navigateToNewCardFromDashboard}
onChangeLocation={onChangeLocation}
dashboard={dashboard}
showClickBehaviorSidebar={showClickBehaviorSidebar}
clickBehaviorSidebarDashcard={clickBehaviorSidebarDashcard}
onDashCardRemove={onDashCardRemove}
onDashCardAddSeries={onDashCardAddSeries}
onReplaceCard={onReplaceCard}
getIsLastDashboardQuestionDashcard={
getIsLastDashboardQuestionDashcard
}
/>
)}
<AddSeriesModalWrapper
addSeriesModalDashCard={addSeriesModalDashCard}
dashcardData={dashcardData}
fetchCardData={(
card: Card,
dashcard: QuestionDashboardCard,
options: {
clearCache?: boolean;
ignoreCache?: boolean;
reload?: boolean;
},
) => dispatch(fetchCardData(card, dashcard, options))}
setDashCardAttributes={(options: {
id: DashCardId;
attributes: Partial<QuestionDashboardCard>;
}) => dispatch(setDashCardAttributes(options))}
setAddSeriesModalDashCard={(val) => setAddSeriesModalDashCard(val)}
/>
<ReplaceCardModal
dashboard={dashboard}
replaceCardModalDashCard={replaceCardModalDashCard}
setReplaceCardModalDashCard={(val) => setReplaceCardModalDashCard(val)}
/>
</Flex>
);
});
export const DashboardGridConnected = _.compose(
ExplicitSize(),
connector,
)(DashboardGrid) as ComponentType<DashboardGridProps>;

View File

@@ -0,0 +1,196 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import DashboardS from "metabase/css/dashboard.module.css";
import { GRID_BREAKPOINTS, GRID_COLUMNS } from "metabase/lib/dashboard_grid";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
import { Box } from "metabase/ui";
import LegendS from "metabase/visualizations/components/Legend.module.css";
import type { DashboardCard } from "metabase-types/api";
import { DashCard, type DashCardProps } from "../DashCard/DashCard";
import DashCardS from "../DashCard/DashCard.module.css";
import { GridLayout, type GridLayoutProps } from "../grid/GridLayout";
import S from "./DashboardGrid.module.css";
export type DashboardGridRenderProps = Pick<
GridLayoutProps<DashboardCard>,
"layouts" | "onLayoutChange" | "onDrag" | "onDragStop"
> &
Pick<
DashCardProps,
| "markNewCardSeen"
| "isEditing"
| "isEditingParameter"
| "isFullscreen"
| "isNightMode"
| "isPublicOrEmbedded"
| "isXray"
| "onReplaceAllDashCardVisualizationSettings"
| "getClickActionMode"
| "navigateToNewCardFromDashboard"
| "onChangeLocation"
| "dashboard"
| "showClickBehaviorSidebar"
| "clickBehaviorSidebarDashcard"
| "downloadsEnabled"
> & {
width: number;
height: number;
rowHeight: number;
isEditingLayout: boolean;
isDragging: boolean;
visibleCards: DashboardCard[];
autoScrollToDashcardId?: string | number;
reportAutoScrolledToDashcard: () => void;
slowCards: Record<string | number, boolean>;
withCardTitle: boolean;
onDashCardRemove: DashCardProps["onRemove"];
onDashCardAddSeries: DashCardProps["onAddSeries"];
onReplaceCard: DashCardProps["onReplaceCard"];
onUpdateDashCardVisualizationSettings: DashCardProps["onUpdateVisualizationSettings"];
getIsLastDashboardQuestionDashcard: (dashcard: DashboardCard) => boolean;
isAnimationPaused: boolean;
};
export const DashboardGridRender = ({
width,
layouts,
rowHeight,
isEditingLayout,
isDragging,
onLayoutChange,
onDrag,
onDragStop,
visibleCards,
isEditing = false,
autoScrollToDashcardId,
reportAutoScrolledToDashcard,
downloadsEnabled,
isAnimationPaused,
slowCards,
markNewCardSeen,
isEditingParameter,
isFullscreen,
isNightMode,
isPublicOrEmbedded,
isXray,
withCardTitle,
onUpdateDashCardVisualizationSettings,
onReplaceAllDashCardVisualizationSettings,
getClickActionMode,
navigateToNewCardFromDashboard,
onChangeLocation,
dashboard,
showClickBehaviorSidebar,
clickBehaviorSidebarDashcard,
onDashCardRemove,
onDashCardAddSeries,
onReplaceCard,
getIsLastDashboardQuestionDashcard,
}: DashboardGridRenderProps) => (
<GridLayout<DashboardCard>
className={cx({
[DashboardS.DashEditing]: isEditingLayout,
[DashboardS.DashDragging]: isDragging,
// we use this class to hide a dashcard actions
// panel during dragging
[DashCardS.DashboardCardRootDragging]: isDragging,
})}
layouts={layouts}
breakpoints={GRID_BREAKPOINTS}
cols={GRID_COLUMNS}
width={width}
margin={{ desktop: [6, 6], mobile: [6, 10] }}
containerPadding={[0, 0]}
rowHeight={rowHeight}
onLayoutChange={onLayoutChange}
onDrag={onDrag}
onDragStop={onDragStop}
isEditing={isEditingLayout}
compactType="vertical"
items={visibleCards}
itemRenderer={({
item,
gridItemWidth,
breakpoint,
totalNumGridCols,
}: {
item: DashboardCard;
gridItemWidth: number;
breakpoint: "desktop" | "mobile";
totalNumGridCols: number;
}) => {
const shouldAutoScrollTo = autoScrollToDashcardId === item.id;
const shouldChangeResizeHandle = isEditingTextOrHeadingCard(
item.card.display,
isEditing,
);
return (
// Note: the GridLayout needs the Box to have a component that it can attach a ref to.
// It seems like the ref attachment is implicit so whatever component goes here must be able to accept a ref
// If you know something I don't feel free to write "oisin was wrong" below, with the explanation
<Box
key={String(item.id)}
data-testid="dashcard-container"
className={cx(
DashboardS.DashCard,
EmbedFrameS.DashCard,
LegendS.DashCard,
S.DashboardCardContainer,
{
[DashboardS.BrandColorResizeHandle]: shouldChangeResizeHandle,
[S.isAnimationDisabled]: isAnimationPaused,
},
)}
>
<DashCard
className={S.Card}
dashcard={item}
slowCards={slowCards}
gridItemWidth={gridItemWidth}
totalNumGridCols={totalNumGridCols}
markNewCardSeen={markNewCardSeen}
isEditing={isEditing}
isEditingParameter={isEditingParameter}
isFullscreen={isFullscreen}
isNightMode={isNightMode}
isMobile={breakpoint === "mobile"}
isPublicOrEmbedded={isPublicOrEmbedded}
isXray={isXray}
withTitle={withCardTitle}
onRemove={onDashCardRemove}
onAddSeries={onDashCardAddSeries}
onReplaceCard={onReplaceCard}
onUpdateVisualizationSettings={
onUpdateDashCardVisualizationSettings
}
onReplaceAllDashCardVisualizationSettings={
onReplaceAllDashCardVisualizationSettings
}
getClickActionMode={getClickActionMode}
navigateToNewCardFromDashboard={navigateToNewCardFromDashboard}
onChangeLocation={onChangeLocation}
dashboard={dashboard}
showClickBehaviorSidebar={showClickBehaviorSidebar}
clickBehaviorSidebarDashcard={clickBehaviorSidebarDashcard}
downloadsEnabled={downloadsEnabled}
autoScroll={shouldAutoScrollTo}
isTrashedOnRemove={getIsLastDashboardQuestionDashcard(item)}
reportAutoScrolledToDashcard={reportAutoScrolledToDashcard}
/>
</Box>
);
}}
/>
);
export function isEditingTextOrHeadingCard(
display: string,
isEditing: boolean,
) {
const isTextOrHeadingCard = display === "heading" || display === "text";
return isEditing && isTextOrHeadingCard;
}

View File

@@ -0,0 +1,114 @@
import { t } from "ttag";
import {
QuestionPickerModal,
type QuestionPickerValueItem,
getQuestionPickerValue,
} from "metabase/common/components/QuestionPicker";
import { isQuestionDashCard } from "metabase/dashboard/utils";
import { useDispatch } from "metabase/lib/redux";
import { addUndo } from "metabase/redux/undo";
import {
type BaseDashboardCard,
type Card,
type Dashboard,
type RecentItem,
isRecentCollectionItem,
} from "metabase-types/api";
import { replaceCard, setDashCardAttributes } from "../../actions";
type ReplaceCardModalProps = {
dashboard: Dashboard;
replaceCardModalDashCard: BaseDashboardCard | null;
setReplaceCardModalDashCard: (card: BaseDashboardCard | null) => void;
};
export const ReplaceCardModal = ({
dashboard,
replaceCardModalDashCard,
setReplaceCardModalDashCard,
}: ReplaceCardModalProps) => {
const dispatch = useDispatch();
const hasValidDashCard =
!!replaceCardModalDashCard && isQuestionDashCard(replaceCardModalDashCard);
const handleSelect = (nextCard: QuestionPickerValueItem) => {
if (!hasValidDashCard) {
return;
}
dispatch(
replaceCard({
dashcardId: replaceCardModalDashCard.id,
nextCardId: nextCard.id,
}),
);
dispatch(
addUndo({
message: getUndoReplaceCardMessage(replaceCardModalDashCard.card),
undo: true,
action: () =>
dispatch(
setDashCardAttributes({
id: replaceCardModalDashCard.id,
attributes: replaceCardModalDashCard,
}),
),
}),
);
handleClose();
};
const replaceCardModalRecentFilter = (items: RecentItem[]) => {
return items.filter((item) => {
if (isRecentCollectionItem(item) && item.dashboard) {
if (item.dashboard.id !== dashboard.id) {
return false;
}
}
return true;
});
};
const handleClose = () => {
setReplaceCardModalDashCard(null);
};
if (!hasValidDashCard) {
return null;
}
return (
<QuestionPickerModal
title={t`Pick what you want to replace this with`}
value={
replaceCardModalDashCard.card.id
? getQuestionPickerValue(replaceCardModalDashCard.card)
: undefined
}
models={["card", "dataset", "metric"]}
onChange={handleSelect}
onClose={handleClose}
recentFilter={replaceCardModalRecentFilter}
/>
);
};
export const getUndoReplaceCardMessage = ({ type }: Card) => {
if (type === "model") {
return t`Model replaced`;
}
if (type === "metric") {
return t`Metric replaced`;
}
if (type === "question") {
return t`Question replaced`;
}
throw new Error(`Unknown card.type: ${type}`);
};

View File

@@ -0,0 +1 @@
export * from "./DashboardGrid";

View File

@@ -68,8 +68,12 @@ type OwnProps<T extends { id: number | null }> = {
margin?: Record<string, [number, number]>;
};
export type GridLayoutProps<T extends { id: number | null }> = OwnProps<T> &
OmittedPropsFromGridLayout &
RequiredPropsFromGridLayout;
export function GridLayout<T extends { id: number | null }>(
props: OwnProps<T> & OmittedPropsFromGridLayout & RequiredPropsFromGridLayout,
props: GridLayoutProps<T>,
) {
const {
items,

View File

@@ -3,7 +3,7 @@ import { css } from "@emotion/react";
import GlobalDashboardS from "metabase/css/dashboard.module.css";
import DashboardS from "metabase/dashboard/components/Dashboard/Dashboard.module.css";
import DashboardGridS from "metabase/dashboard/components/DashboardGrid.module.css";
import DashboardGridS from "metabase/dashboard/components/DashboardGrid/DashboardGrid.module.css";
import { DASHBOARD_PARAMETERS_PDF_EXPORT_NODE_ID } from "metabase/dashboard/constants";
import { isEmbeddingSdk, isStorybookActive } from "metabase/env";
import { openImageBlobOnStorybook } from "metabase/lib/loki-utils";

View File

@@ -3,7 +3,7 @@ import { css } from "@emotion/react";
import GlobalDashboardS from "metabase/css/dashboard.module.css";
import DashboardS from "metabase/dashboard/components/Dashboard/Dashboard.module.css";
import DashboardGridS from "metabase/dashboard/components/DashboardGrid.module.css";
import DashboardGridS from "metabase/dashboard/components/DashboardGrid/DashboardGrid.module.css";
import { isEmbeddingSdk, isStorybookActive } from "metabase/env";
import { openImageBlobOnStorybook } from "metabase/lib/loki-utils";
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";

View File

@@ -207,66 +207,72 @@ export class Scalar extends Component {
};
return (
<ScalarWrapper>
<div
className={cx(
DashboardS.CardTitle,
CS.textDefault,
CS.textSmaller,
CS.absolute,
CS.top,
CS.right,
CS.p1,
CS.px2,
)}
>
{actionButtons}
</div>
<ScalarContainer
className={cx(
DashboardS.fullscreenNormalText,
DashboardS.fullscreenNightText,
EmbedFrameS.fullscreenNightText,
)}
data-testid="scalar-container"
tooltip={fullScalarValue}
alwaysShowTooltip={fullScalarValue !== displayValue}
isClickable={isClickable}
>
<span onClick={handleClick} ref={(scalar) => (this._scalar = scalar)}>
<ScalarValue
fontFamily={fontFamily}
gridSize={gridSize}
height={getValueHeight(height, { isDashboard, showSmallTitle })}
totalNumGridCols={totalNumGridCols}
value={displayValue}
width={getValueWidth(width)}
/>
</span>
</ScalarContainer>
height !== null &&
width !== null && (
<ScalarWrapper>
<div
className={cx(
DashboardS.CardTitle,
CS.textDefault,
CS.textSmaller,
CS.absolute,
CS.top,
CS.right,
CS.p1,
CS.px2,
)}
>
{actionButtons}
</div>
<ScalarContainer
className={cx(
DashboardS.fullscreenNormalText,
DashboardS.fullscreenNightText,
EmbedFrameS.fullscreenNightText,
)}
data-testid="scalar-container"
tooltip={fullScalarValue}
alwaysShowTooltip={fullScalarValue !== displayValue}
isClickable={isClickable}
>
<span
onClick={handleClick}
ref={(scalar) => (this._scalar = scalar)}
>
<ScalarValue
fontFamily={fontFamily}
gridSize={gridSize}
height={getValueHeight(height, { isDashboard, showSmallTitle })}
totalNumGridCols={totalNumGridCols}
value={displayValue}
width={getValueWidth(width)}
/>
</span>
</ScalarContainer>
{isDashboard &&
showTitle &&
(showSmallTitle ? (
<LabelIcon
data-testid="scalar-title-icon"
name="ellipsis"
tooltip={settings["card.title"]}
size={TITLE_ICON_SIZE}
/>
) : (
<ScalarTitle
lines={titleLinesCount}
title={settings["card.title"]}
description={settings["card.description"]}
onClick={
onChangeCardAndRun
? () => onChangeCardAndRun({ nextCard: card })
: undefined
}
/>
))}
</ScalarWrapper>
{isDashboard &&
showTitle &&
(showSmallTitle ? (
<LabelIcon
data-testid="scalar-title-icon"
name="ellipsis"
tooltip={settings["card.title"]}
size={TITLE_ICON_SIZE}
/>
) : (
<ScalarTitle
lines={titleLinesCount}
title={settings["card.title"]}
description={settings["card.description"]}
onClick={
onChangeCardAndRun
? () => onChangeCardAndRun({ nextCard: card })
: undefined
}
/>
))}
</ScalarWrapper>
)
);
}
}