Compare commits
10 Commits
python-run
...
functional
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8457818de | ||
|
|
bca9a5d2a2 | ||
|
|
ff3bd783ed | ||
|
|
3e1b5d7958 | ||
|
|
fe36c6aa15 | ||
|
|
d90fdac935 | ||
|
|
e0714f936b | ||
|
|
140a59e1c1 | ||
|
|
38477beb2d | ||
|
|
30e7cda857 |
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DashboardGrid";
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user