Compare commits
116 Commits
adm-759-de
...
tsp-visual
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad62fb8f0d | ||
|
|
acae9dfd2e | ||
|
|
e47c0fa567 | ||
|
|
26037fa455 | ||
|
|
229a47ae02 | ||
|
|
8aeefb8c7d | ||
|
|
d203a9113c | ||
|
|
8ccfbd1652 | ||
|
|
2af57420e5 | ||
|
|
ffd1ac0f77 | ||
|
|
18259ef17f | ||
|
|
6376fef7e7 | ||
|
|
6b52a420be | ||
|
|
2f9131d4e9 | ||
|
|
0b26fd8c90 | ||
|
|
7855ffa391 | ||
|
|
72fb8213e4 | ||
|
|
1089410da3 | ||
|
|
1a59dead49 | ||
|
|
3093b66985 | ||
|
|
6c40b0df42 | ||
|
|
2fedaa22f1 | ||
|
|
dad79a7395 | ||
|
|
312d06d60b | ||
|
|
25f596c73d | ||
|
|
7c52ef1c66 | ||
|
|
ac83610019 | ||
|
|
38d97c813a | ||
|
|
1113eba7e9 | ||
|
|
97780a2802 | ||
|
|
4d0532459a | ||
|
|
b53eaee151 | ||
|
|
99f8a7ddc6 | ||
|
|
e77c022f7e | ||
|
|
c2f1738b60 | ||
|
|
9c3fb7f816 | ||
|
|
d78bfc43cf | ||
|
|
89a3e5d073 | ||
|
|
b1c342a4f2 | ||
|
|
863019a431 | ||
|
|
83dbc97e3e | ||
|
|
0536e03d79 | ||
|
|
552bec5261 | ||
|
|
95d70e8982 | ||
|
|
0c1d468c33 | ||
|
|
172b43aad8 | ||
|
|
dce52eb1cf | ||
|
|
53537a094e | ||
|
|
4ea9070da0 | ||
|
|
4347f96db1 | ||
|
|
e90952591b | ||
|
|
017bd49c2c | ||
|
|
dce32407d4 | ||
|
|
a05e6805a2 | ||
|
|
100c4013d2 | ||
|
|
b6e1c61fb3 | ||
|
|
ffafee1ee9 | ||
|
|
9f84294476 | ||
|
|
aaae363ce3 | ||
|
|
672e6d8862 | ||
|
|
302beef76f | ||
|
|
81d4aff6c7 | ||
|
|
b9530848c8 | ||
|
|
302ff52159 | ||
|
|
6ec3813e01 | ||
|
|
1f74334c0d | ||
|
|
ddfcb2ac4a | ||
|
|
cc32ab99ae | ||
|
|
9b07d9812c | ||
|
|
f8ec344ee8 | ||
|
|
1228d5cd53 | ||
|
|
73e7055e81 | ||
|
|
52e07386cb | ||
|
|
a01beddd2f | ||
|
|
b0d64b4956 | ||
|
|
108915f231 | ||
|
|
811bc5871c | ||
|
|
0bcac59912 | ||
|
|
f7678aff45 | ||
|
|
2ae5dc679e | ||
|
|
223620ab93 | ||
|
|
93f22e28dd | ||
|
|
04f3242041 | ||
|
|
7bb3dce3cc | ||
|
|
f3c579d42c | ||
|
|
969e109476 | ||
|
|
9e348794e8 | ||
|
|
187334284d | ||
|
|
93614feb66 | ||
|
|
28c69f7f9f | ||
|
|
4c4fdc5277 | ||
|
|
0894fc05b4 | ||
|
|
2b5051d96b | ||
|
|
b26c3fd9e9 | ||
|
|
7d9ec78335 | ||
|
|
6f6a8cb957 | ||
|
|
fd4681bde8 | ||
|
|
cf2494685c | ||
|
|
54077021b7 | ||
|
|
fad96e4275 | ||
|
|
aba9a695f9 | ||
|
|
62f1abb393 | ||
|
|
0dd03cbca4 | ||
|
|
97698a36f7 | ||
|
|
93398edfb4 | ||
|
|
b7d0cb0658 | ||
|
|
c162ae1a2c | ||
|
|
ce8c928661 | ||
|
|
a1f4e00d83 | ||
|
|
dbaf0c2f8d | ||
|
|
ea56baeaad | ||
|
|
3a65db46a7 | ||
|
|
b06b8261d6 | ||
|
|
af7cba10cc | ||
|
|
28af981218 | ||
|
|
2327da2dc8 |
@@ -16,6 +16,7 @@ import type { SettingsState } from "./settings";
|
||||
import type { SetupState } from "./setup";
|
||||
import type { UndoState } from "./undo";
|
||||
import type { FileUploadState } from "./upload";
|
||||
import type { VisualizerState } from "./visualizer";
|
||||
|
||||
type ModalName = null | "collection" | "dashboard" | "action";
|
||||
|
||||
@@ -37,6 +38,7 @@ export interface State {
|
||||
modal: ModalName;
|
||||
undo: UndoState;
|
||||
downloads: DownloadsState;
|
||||
visualizer: VisualizerState;
|
||||
}
|
||||
|
||||
export type Dispatch<T = any> = (action: T) => unknown | Promise<unknown>;
|
||||
|
||||
77
frontend/src/metabase-types/store/visualizer.ts
Normal file
77
frontend/src/metabase-types/store/visualizer.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
Card,
|
||||
Dataset,
|
||||
DatasetColumn,
|
||||
VisualizationDisplay,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type VisualizerDataSourceType = "card";
|
||||
export type VisualizerDataSourceId = `${VisualizerDataSourceType}:${number}`;
|
||||
|
||||
export type VisualizerDataSource = {
|
||||
id: VisualizerDataSourceId;
|
||||
sourceId: number;
|
||||
type: VisualizerDataSourceType;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type VisualizerColumnReference = {
|
||||
sourceId: VisualizerDataSourceId;
|
||||
name: string; // in combined dataset
|
||||
originalName: string; // in original dataset
|
||||
};
|
||||
|
||||
type BaseDraggedItem<T> = {
|
||||
id: string;
|
||||
data: {
|
||||
current: T;
|
||||
};
|
||||
};
|
||||
|
||||
export type DraggedColumn = BaseDraggedItem<{
|
||||
type: "COLUMN";
|
||||
column: DatasetColumn;
|
||||
dataSource: VisualizerDataSource;
|
||||
}>;
|
||||
|
||||
export type DraggedWellItem = BaseDraggedItem<{
|
||||
type: "WELL_ITEM";
|
||||
wellId: string;
|
||||
column: DatasetColumn;
|
||||
}>;
|
||||
|
||||
export type DraggedItem = DraggedColumn | DraggedWellItem;
|
||||
|
||||
// a way to use dataset's name as a value
|
||||
export type VisualizerDataSourceNameReference =
|
||||
`$_${VisualizerDataSourceId}_name`;
|
||||
|
||||
export type VisualizerColumnValueSource =
|
||||
| VisualizerColumnReference
|
||||
| VisualizerDataSourceNameReference;
|
||||
|
||||
export type VisualizerHistoryItem = {
|
||||
display: VisualizationDisplay | null;
|
||||
columns: DatasetColumn[];
|
||||
columnValuesMapping: Record<string, VisualizerColumnValueSource[]>;
|
||||
settings: VisualizationSettings;
|
||||
};
|
||||
|
||||
export type VisualizerCommonState = {
|
||||
cards: Card[];
|
||||
datasets: Record<VisualizerDataSourceId, Dataset>;
|
||||
expandedDataSources: Record<VisualizerDataSourceId, boolean>;
|
||||
loadingDataSources: Record<VisualizerDataSourceId, boolean>;
|
||||
loadingDatasets: Record<VisualizerDataSourceId, boolean>;
|
||||
isFullscreen: boolean;
|
||||
isVizSettingsSidebarOpen: boolean;
|
||||
error: string | null;
|
||||
draggedItem: DraggedItem | null;
|
||||
};
|
||||
|
||||
export interface VisualizerState extends VisualizerCommonState {
|
||||
past: VisualizerHistoryItem[];
|
||||
present: VisualizerHistoryItem;
|
||||
future: VisualizerHistoryItem[];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
:root {
|
||||
--default-border-radius: 8px;
|
||||
--border-radius-xl: 99px;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { createThunkAction } from "metabase/lib/redux";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import { getDefaultSize } from "metabase/visualizations";
|
||||
import { getCardIdsFromColumnValueMappings } from "metabase/visualizer/utils";
|
||||
import type {
|
||||
Card,
|
||||
CardId,
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
VirtualCard,
|
||||
} from "metabase-types/api";
|
||||
import type { Dispatch, GetState } from "metabase-types/store";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
import {
|
||||
trackCardCreated,
|
||||
@@ -57,6 +59,7 @@ export type NewDashCardOpts = {
|
||||
export type AddDashCardOpts = NewDashCardOpts & {
|
||||
dashcardOverrides: Partial<NewDashboardCard> & {
|
||||
card: Card | VirtualCard;
|
||||
series?: Card[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -250,6 +253,97 @@ export const replaceCard =
|
||||
dashboardId && trackQuestionReplaced(dashboardId);
|
||||
};
|
||||
|
||||
export const addCardWithVisualization =
|
||||
({ visualization }: { visualization: VisualizerHistoryItem }) =>
|
||||
async (dispatch: Dispatch, getState: GetState) => {
|
||||
const cardIds = getCardIdsFromColumnValueMappings(
|
||||
visualization.columnValuesMapping,
|
||||
);
|
||||
const cards: Card[] = [];
|
||||
|
||||
for (const cardId of cardIds) {
|
||||
await dispatch(Questions.actions.fetch({ id: cardId }));
|
||||
const card: Card = Questions.selectors
|
||||
.getObject(getState(), { entityId: cardId })
|
||||
.card();
|
||||
cards.push(card);
|
||||
}
|
||||
|
||||
const [mainCard, ...secondaryCards] = cards;
|
||||
|
||||
const dashcardId = generateTemporaryDashcardId();
|
||||
const dashcard = dispatch(
|
||||
addDashCardToDashboard({
|
||||
dashId: getState().dashboard.dashboardId!,
|
||||
tabId: getState().dashboard.selectedTabId,
|
||||
dashcardOverrides: {
|
||||
id: dashcardId,
|
||||
card: mainCard,
|
||||
card_id: mainCard.id,
|
||||
series: secondaryCards,
|
||||
visualization_settings: {
|
||||
visualization,
|
||||
},
|
||||
},
|
||||
}),
|
||||
) as DashboardCard;
|
||||
|
||||
for (const card of cards) {
|
||||
dispatch(
|
||||
fetchCardData(card, dashcard, { reload: true, clearCache: true }),
|
||||
);
|
||||
await dispatch(loadMetadataForCard(card));
|
||||
}
|
||||
};
|
||||
|
||||
export const replaceCardWithVisualization =
|
||||
({
|
||||
dashcardId,
|
||||
visualization,
|
||||
}: {
|
||||
dashcardId: DashCardId;
|
||||
visualization: VisualizerHistoryItem;
|
||||
}) =>
|
||||
async (dispatch: Dispatch, getState: GetState) => {
|
||||
const cardIds = getCardIdsFromColumnValueMappings(
|
||||
visualization.columnValuesMapping,
|
||||
);
|
||||
const cards: Card[] = [];
|
||||
|
||||
for (const cardId of cardIds) {
|
||||
await dispatch(Questions.actions.fetch({ id: cardId }));
|
||||
const card: Card = Questions.selectors
|
||||
.getObject(getState(), { entityId: cardId })
|
||||
.card();
|
||||
cards.push(card);
|
||||
}
|
||||
|
||||
const [mainCard, ...secondaryCards] = cards;
|
||||
|
||||
await dispatch(
|
||||
setDashCardAttributes({
|
||||
id: dashcardId,
|
||||
attributes: {
|
||||
card_id: mainCard.id,
|
||||
card: mainCard,
|
||||
series: secondaryCards,
|
||||
parameter_mappings: [],
|
||||
visualization_settings: {
|
||||
visualization,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const dashcard = getDashCardById(getState(), dashcardId);
|
||||
|
||||
for (const card of cards) {
|
||||
dispatch(
|
||||
fetchCardData(card, dashcard, { reload: true, clearCache: true }),
|
||||
);
|
||||
await dispatch(loadMetadataForCard(card));
|
||||
}
|
||||
};
|
||||
|
||||
export const removeCardFromDashboard = createThunkAction(
|
||||
REMOVE_CARD_FROM_DASH,
|
||||
({
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface DashCardProps {
|
||||
/** Callback to execute when the dashcard has auto-scrolled to itself */
|
||||
reportAutoScrolledToDashcard: () => void;
|
||||
|
||||
editDashboard: () => void;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -127,6 +129,7 @@ function DashCardInner({
|
||||
downloadsEnabled,
|
||||
autoScroll,
|
||||
reportAutoScrolledToDashcard,
|
||||
editDashboard,
|
||||
className,
|
||||
}: DashCardProps) {
|
||||
const dashcardData = useSelector(state =>
|
||||
@@ -399,6 +402,7 @@ function DashCardInner({
|
||||
onChangeLocation={onChangeLocation}
|
||||
onTogglePreviewing={handlePreviewToggle}
|
||||
downloadsEnabled={downloadsEnabled}
|
||||
editDashboard={editDashboard}
|
||||
/>
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { DashCardActionButton } from "./DashCardActionButton/DashCardActionButto
|
||||
import S from "./DashCardActionsPanel.module.css";
|
||||
import { DashCardTabMenu } from "./DashCardTabMenu/DashCardTabMenu";
|
||||
import { LinkCardEditButton } from "./LinkCardEditButton/LinkCardEditButton";
|
||||
import { VisualizerButton } from "./VisualizerButton";
|
||||
import { useDuplicateDashCard } from "./use-duplicate-dashcard";
|
||||
|
||||
interface Props {
|
||||
@@ -151,6 +152,16 @@ function DashCardActionsPanelInner({
|
||||
}
|
||||
|
||||
if (!isLoading && !hasError) {
|
||||
if (dashcard && !isVirtualDashCard(dashcard)) {
|
||||
buttons.push(
|
||||
<VisualizerButton
|
||||
key="visualizer-button"
|
||||
series={series}
|
||||
dashcard={dashcard}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (!disableSettingsConfig) {
|
||||
buttons.push(
|
||||
<ChartSettingsButton
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { replaceCardWithVisualization } from "metabase/dashboard/actions";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { VisualizerModal } from "metabase/visualizer/components/VisualizerModal";
|
||||
import { getInitialStateForCardDataSource } from "metabase/visualizer/utils";
|
||||
import type { DashboardCard, Series } from "metabase-types/api";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
import { DashCardActionButton } from "../DashCardActionButton";
|
||||
|
||||
interface VisualizerButtonProps {
|
||||
series: Series;
|
||||
dashcard: DashboardCard;
|
||||
}
|
||||
|
||||
export function VisualizerButton({ series, dashcard }: VisualizerButtonProps) {
|
||||
const [isOpen, { open, close }] = useDisclosure(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const initialState = useMemo(() => {
|
||||
if (isOpen) {
|
||||
if (dashcard?.visualization_settings?.visualization) {
|
||||
return {
|
||||
state: dashcard?.visualization_settings?.visualization,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
state: getInitialStateForCardDataSource(series[0]),
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [isOpen, dashcard, series]);
|
||||
|
||||
const handleChangeVisualization = (visualization: VisualizerHistoryItem) => {
|
||||
dispatch(
|
||||
replaceCardWithVisualization({
|
||||
dashcardId: dashcard.id,
|
||||
visualization,
|
||||
}),
|
||||
);
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashCardActionButton
|
||||
as="div"
|
||||
tooltip={t`Edit visualization`}
|
||||
aria-label={t`Edit visualization`}
|
||||
onClick={open}
|
||||
>
|
||||
<DashCardActionButton.Icon name="pencil" />
|
||||
</DashCardActionButton>
|
||||
{!!initialState && (
|
||||
<VisualizerModal
|
||||
initialState={initialState}
|
||||
onSave={handleChangeVisualization}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VisualizerButton";
|
||||
@@ -45,6 +45,7 @@ interface DashCardMenuProps {
|
||||
token?: string;
|
||||
visualizationSettings?: VisualizationSettings;
|
||||
downloadsEnabled: boolean;
|
||||
onEditVisualization?: () => void;
|
||||
}
|
||||
|
||||
export type DashCardMenuItem = {
|
||||
@@ -75,6 +76,7 @@ export const DashCardMenu = ({
|
||||
dashcardId,
|
||||
uuid,
|
||||
token,
|
||||
onEditVisualization,
|
||||
}: DashCardMenuProps) => {
|
||||
const store = useStore();
|
||||
const { plugins } = useInteractiveDashboardContext();
|
||||
@@ -130,6 +132,7 @@ export const DashCardMenu = ({
|
||||
result={result}
|
||||
isDownloadingData={isDownloadingData}
|
||||
onDownload={() => setMenuView("download")}
|
||||
onEditVisualization={onEditVisualization}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,12 +21,14 @@ type DashCardMenuItemsProps = {
|
||||
result: Dataset;
|
||||
isDownloadingData: boolean;
|
||||
onDownload: () => void;
|
||||
onEditVisualization?: () => void;
|
||||
};
|
||||
export const DashCardMenuItems = ({
|
||||
question,
|
||||
result,
|
||||
isDownloadingData,
|
||||
onDownload,
|
||||
onEditVisualization,
|
||||
}: DashCardMenuItemsProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -51,7 +53,14 @@ export const DashCardMenuItems = ({
|
||||
key: string;
|
||||
})[] = [];
|
||||
|
||||
if (withEditLink && canEditQuestion(question)) {
|
||||
if (onEditVisualization) {
|
||||
items.push({
|
||||
key: "MB_EDIT_VISUALIZER_QUESTION",
|
||||
iconName: "pencil",
|
||||
label: t`Edit visualization`,
|
||||
onClick: onEditVisualization,
|
||||
});
|
||||
} else if (withEditLink && canEditQuestion(question)) {
|
||||
const type = question.type();
|
||||
if (type === "question") {
|
||||
items.push({
|
||||
@@ -116,6 +125,7 @@ export const DashCardMenuItems = ({
|
||||
result,
|
||||
withDownloads,
|
||||
withEditLink,
|
||||
onEditVisualization,
|
||||
]);
|
||||
|
||||
return menuItems.map(item => {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import cx from "classnames";
|
||||
import type { LocationDescriptor } from "history";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { replaceCardWithVisualization } from "metabase/dashboard/actions";
|
||||
import { useClickBehaviorData } from "metabase/dashboard/hooks";
|
||||
import { getDashcardData } from "metabase/dashboard/selectors";
|
||||
import {
|
||||
getVirtualCardType,
|
||||
isQuestionCard,
|
||||
isVirtualDashCard,
|
||||
} from "metabase/dashboard/utils";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { isJWT } from "metabase/lib/utils";
|
||||
import { isUuid } from "metabase/lib/uuid";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
@@ -18,6 +20,12 @@ import { Flex, type IconName, type IconProps, Title } from "metabase/ui";
|
||||
import { getVisualizationRaw } from "metabase/visualizations";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import type { ClickActionModeGetter } from "metabase/visualizations/types";
|
||||
import { VisualizerModal } from "metabase/visualizer/components/VisualizerModal";
|
||||
import {
|
||||
createDataSource,
|
||||
isVisualizerDashboardCard,
|
||||
mergeVisualizerData,
|
||||
} from "metabase/visualizer/utils";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type {
|
||||
DashCardId,
|
||||
@@ -28,6 +36,7 @@ import type {
|
||||
VirtualCardDisplay,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
import { ClickBehaviorSidebarOverlay } from "./ClickBehaviorSidebarOverlay/ClickBehaviorSidebarOverlay";
|
||||
import { DashCardMenu } from "./DashCardMenu/DashCardMenu";
|
||||
@@ -84,6 +93,7 @@ interface DashCardVisualizationProps {
|
||||
onTogglePreviewing: () => void;
|
||||
|
||||
downloadsEnabled: boolean;
|
||||
editDashboard: () => void;
|
||||
}
|
||||
|
||||
// This is done to add the `getExtraDataForClick` prop.
|
||||
@@ -92,7 +102,7 @@ interface DashCardVisualizationProps {
|
||||
export function DashCardVisualization({
|
||||
dashcard,
|
||||
dashboard,
|
||||
series,
|
||||
series: _series,
|
||||
getClickActionMode,
|
||||
getHref,
|
||||
gridSize,
|
||||
@@ -121,7 +131,100 @@ export function DashCardVisualization({
|
||||
onChangeLocation,
|
||||
onUpdateVisualizationSettings,
|
||||
downloadsEnabled,
|
||||
editDashboard,
|
||||
}: DashCardVisualizationProps) {
|
||||
const datasets = useSelector(
|
||||
state => getDashcardData(state, dashcard.id) ?? {},
|
||||
);
|
||||
const [isVisualizerModalOpen, setIsVisualizerModalOpen] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const editVisualization = useMemo(() => {
|
||||
if (isVisualizerDashboardCard(dashcard)) {
|
||||
return () => {
|
||||
setIsVisualizerModalOpen(true);
|
||||
editDashboard();
|
||||
};
|
||||
}
|
||||
}, [editDashboard, dashcard]);
|
||||
|
||||
const onVisualizerModalSave = useCallback(
|
||||
(visualization: VisualizerHistoryItem) => {
|
||||
dispatch(
|
||||
replaceCardWithVisualization({
|
||||
dashcardId: dashcard.id,
|
||||
visualization,
|
||||
}),
|
||||
);
|
||||
setIsVisualizerModalOpen(false);
|
||||
},
|
||||
[dashcard.id, dispatch],
|
||||
);
|
||||
|
||||
const onVisualizerModalClose = useCallback(() => {
|
||||
setIsVisualizerModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const visualizerModalInitialState = useMemo(
|
||||
() => ({
|
||||
state: dashcard.visualization_settings
|
||||
?.visualization as Partial<VisualizerHistoryItem>,
|
||||
}),
|
||||
[dashcard.visualization_settings],
|
||||
);
|
||||
|
||||
const series = useMemo(() => {
|
||||
if (
|
||||
!dashcard ||
|
||||
!_series ||
|
||||
_series.length === 0 ||
|
||||
!isVisualizerDashboardCard(dashcard)
|
||||
) {
|
||||
return _series;
|
||||
}
|
||||
|
||||
const { display, columns, columnValuesMapping, settings } = dashcard
|
||||
.visualization_settings!.visualization as VisualizerHistoryItem;
|
||||
|
||||
const cards = [dashcard.card];
|
||||
if (Array.isArray(dashcard.series)) {
|
||||
cards.push(...dashcard.series);
|
||||
}
|
||||
|
||||
const dataSources = cards.map(card =>
|
||||
createDataSource("card", card.id, card.name),
|
||||
);
|
||||
|
||||
const dataSourceDatasets = Object.fromEntries(
|
||||
Object.entries(datasets).map(([cardId, dataset]) => [
|
||||
`card:${cardId}`,
|
||||
dataset,
|
||||
]),
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
card: {
|
||||
display,
|
||||
name: settings["card.title"],
|
||||
visualization_settings: settings,
|
||||
},
|
||||
|
||||
data: mergeVisualizerData({
|
||||
columns,
|
||||
columnValuesMapping,
|
||||
datasets: dataSourceDatasets,
|
||||
dataSources,
|
||||
}),
|
||||
|
||||
// Certain visualizations memoize settings computation based on series keys
|
||||
// This guarantees a visualization always rerenders on changes
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}, [_series, dashcard, datasets]);
|
||||
|
||||
const metadata = useSelector(getMetadata);
|
||||
const question = useMemo(() => {
|
||||
return isQuestionCard(dashcard.card)
|
||||
@@ -223,6 +326,7 @@ export function DashCardVisualization({
|
||||
: undefined
|
||||
}
|
||||
uuid={isUuid(dashcard.dashboard_id) ? dashcard.dashboard_id : undefined}
|
||||
onEditVisualization={editVisualization}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
@@ -235,6 +339,7 @@ export function DashCardVisualization({
|
||||
dashcard.dashboard_id,
|
||||
dashboard.id,
|
||||
downloadsEnabled,
|
||||
editVisualization,
|
||||
]);
|
||||
|
||||
const { getExtraDataForClick } = useClickBehaviorData({
|
||||
@@ -242,42 +347,52 @@ export function DashCardVisualization({
|
||||
});
|
||||
|
||||
return (
|
||||
<Visualization
|
||||
className={cx(CS.flexFull, {
|
||||
[CS.pointerEventsNone]: isEditingDashboardLayout,
|
||||
[CS.overflowAuto]: visualizationOverlay,
|
||||
[CS.overflowHidden]: !visualizationOverlay,
|
||||
})}
|
||||
dashboard={dashboard}
|
||||
dashcard={dashcard}
|
||||
rawSeries={series}
|
||||
metadata={metadata}
|
||||
mode={getClickActionMode}
|
||||
getHref={getHref}
|
||||
gridSize={gridSize}
|
||||
totalNumGridCols={totalNumGridCols}
|
||||
headerIcon={headerIcon}
|
||||
expectedDuration={expectedDuration}
|
||||
error={error?.message}
|
||||
errorIcon={error?.icon}
|
||||
showTitle={withTitle}
|
||||
canToggleSeriesVisibility={!isEditing}
|
||||
isAction={isAction}
|
||||
isDashboard
|
||||
isSlow={isSlow}
|
||||
isFullscreen={isFullscreen}
|
||||
isNightMode={isNightMode}
|
||||
isEditing={isEditing}
|
||||
isPreviewing={isPreviewing}
|
||||
isEditingParameter={isEditingParameter}
|
||||
isMobile={isMobile}
|
||||
actionButtons={actionButtons}
|
||||
replacementContent={visualizationOverlay}
|
||||
getExtraDataForClick={getExtraDataForClick}
|
||||
onUpdateVisualizationSettings={handleOnUpdateVisualizationSettings}
|
||||
onTogglePreviewing={onTogglePreviewing}
|
||||
onChangeCardAndRun={onChangeCardAndRun}
|
||||
onChangeLocation={onChangeLocation}
|
||||
/>
|
||||
<>
|
||||
<Visualization
|
||||
className={cx(CS.flexFull, {
|
||||
[CS.pointerEventsNone]: isEditingDashboardLayout,
|
||||
[CS.overflowAuto]: visualizationOverlay,
|
||||
[CS.overflowHidden]: !visualizationOverlay,
|
||||
})}
|
||||
dashboard={dashboard}
|
||||
dashcard={dashcard}
|
||||
rawSeries={series}
|
||||
metadata={metadata}
|
||||
mode={getClickActionMode}
|
||||
getHref={getHref}
|
||||
gridSize={gridSize}
|
||||
totalNumGridCols={totalNumGridCols}
|
||||
headerIcon={headerIcon}
|
||||
expectedDuration={expectedDuration}
|
||||
error={error?.message}
|
||||
errorIcon={error?.icon}
|
||||
showTitle={withTitle}
|
||||
canToggleSeriesVisibility={!isEditing}
|
||||
isAction={isAction}
|
||||
isDashboard
|
||||
isSlow={isSlow}
|
||||
isFullscreen={isFullscreen}
|
||||
isNightMode={isNightMode}
|
||||
isEditing={isEditing}
|
||||
isPreviewing={isPreviewing}
|
||||
isEditingParameter={isEditingParameter}
|
||||
isMobile={isMobile}
|
||||
actionButtons={actionButtons}
|
||||
replacementContent={visualizationOverlay}
|
||||
getExtraDataForClick={getExtraDataForClick}
|
||||
onUpdateVisualizationSettings={handleOnUpdateVisualizationSettings}
|
||||
onTogglePreviewing={onTogglePreviewing}
|
||||
onChangeCardAndRun={onChangeCardAndRun}
|
||||
onChangeLocation={onChangeLocation}
|
||||
/>
|
||||
{isVisualizerModalOpen && (
|
||||
<VisualizerModal
|
||||
onSave={onVisualizerModalSave}
|
||||
onClose={onVisualizerModalClose}
|
||||
initialState={visualizerModalInitialState}
|
||||
saveLabel={t`Save`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ function setup({
|
||||
downloadsEnabled
|
||||
autoScroll={false}
|
||||
reportAutoScrolledToDashcard={jest.fn()}
|
||||
editDashboard={jest.fn()}
|
||||
/>,
|
||||
{
|
||||
storeInitialState: {
|
||||
|
||||
@@ -497,6 +497,7 @@ function Dashboard(props: DashboardProps) {
|
||||
reportAutoScrolledToDashcard={
|
||||
reportAutoScrolledToDashcard
|
||||
}
|
||||
handleSetEditing={handleSetEditing}
|
||||
/>
|
||||
</FullWidthContainer>
|
||||
)}
|
||||
|
||||
@@ -161,6 +161,7 @@ type OwnProps = {
|
||||
downloadsEnabled: boolean;
|
||||
autoScrollToDashcardId: DashCardId | undefined;
|
||||
reportAutoScrolledToDashcard: () => void;
|
||||
handleSetEditing: (dashboard: Dashboard | null) => void;
|
||||
};
|
||||
|
||||
type DashboardGridProps = OwnProps &
|
||||
@@ -570,10 +571,15 @@ class DashboardGridInner extends Component<
|
||||
autoScroll={shouldAutoScrollTo}
|
||||
isTrashedOnRemove={this.getIsLastDashboardQuestionDashcard(dashcard)}
|
||||
reportAutoScrolledToDashcard={reportAutoScrolledToDashcard}
|
||||
editDashboard={this.handleSetEditing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
handleSetEditing = () => {
|
||||
this.props.handleSetEditing(this.props.dashboard);
|
||||
};
|
||||
|
||||
get isEditingLayout() {
|
||||
const { isEditing, isEditingParameter, clickBehaviorSidebarDashcard } =
|
||||
this.props;
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
.QuestionListItem {
|
||||
.QuestionListItemRoot {
|
||||
.QuestionListItem {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.QuestionListItemIcon:last-child {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.VisualizerButton {
|
||||
visibility: hidden;
|
||||
|
||||
background-color: var(--mb-color-brand);
|
||||
color: var(--mb-color-text-white);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-text-white);
|
||||
background-color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .VisualizerButton {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,14 +11,16 @@ import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapp
|
||||
import { PaginationControls } from "metabase/components/PaginationControls";
|
||||
import SelectList from "metabase/components/SelectList";
|
||||
import type { BaseSelectListItemProps } from "metabase/components/SelectList/BaseSelectListItem";
|
||||
import { addCardWithVisualization } from "metabase/dashboard/actions";
|
||||
import Search from "metabase/entities/search";
|
||||
import { isEmbeddingSdk } from "metabase/env";
|
||||
import { usePagination } from "metabase/hooks/use-pagination";
|
||||
import { DEFAULT_SEARCH_LIMIT } from "metabase/lib/constants";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { PLUGIN_MODERATION } from "metabase/plugins";
|
||||
import { Box, Flex } from "metabase/ui";
|
||||
import type { CollectionId } from "metabase-types/api";
|
||||
import { ActionIcon, Box, Flex, Icon, Tooltip } from "metabase/ui";
|
||||
import { VisualizerModal } from "metabase/visualizer/components/VisualizerModal";
|
||||
import type { CardId, CollectionId } from "metabase-types/api";
|
||||
|
||||
import S from "./QuestionList.module.css";
|
||||
|
||||
@@ -40,6 +42,10 @@ export function QuestionList({
|
||||
const [queryOffset, setQueryOffset] = useState(0);
|
||||
const { handleNextPage, handlePreviousPage, page, setPage } = usePagination();
|
||||
|
||||
const [visualizerModalCardId, setVisualizerModalCardId] =
|
||||
useState<CardId | null>(null);
|
||||
const isVisualizerModalOpen = !!visualizerModalCardId;
|
||||
|
||||
useEffect(() => {
|
||||
setQueryOffset(0);
|
||||
setPage(0);
|
||||
@@ -124,21 +130,31 @@ export function QuestionList({
|
||||
<>
|
||||
<SelectList>
|
||||
{list.map(item => (
|
||||
<SelectList.Item
|
||||
className={S.QuestionListItem}
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
name={item.getName()}
|
||||
icon={{
|
||||
name: item.getIcon().name,
|
||||
size: item.model === "dataset" ? 18 : 16,
|
||||
className: S.QuestionListItemIcon,
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
rightIcon={PLUGIN_MODERATION.getStatusIcon(
|
||||
item.moderated_status ?? undefined,
|
||||
)}
|
||||
/>
|
||||
<Flex key={item.id} className={S.QuestionListItemRoot} gap="2px">
|
||||
<SelectList.Item
|
||||
id={item.id}
|
||||
className={S.QuestionListItem}
|
||||
name={item.getName()}
|
||||
icon={{
|
||||
name: item.getIcon().name,
|
||||
size: item.model === "dataset" ? 18 : 16,
|
||||
className: S.QuestionListItemIcon,
|
||||
}}
|
||||
onSelect={onSelect}
|
||||
rightIcon={PLUGIN_MODERATION.getStatusIcon(
|
||||
item.moderated_status ?? undefined,
|
||||
)}
|
||||
/>
|
||||
<Tooltip label={t`Visualize another way`}>
|
||||
<ActionIcon
|
||||
className={S.VisualizerButton}
|
||||
size="41px"
|
||||
onClick={() => setVisualizerModalCardId(Number(item.id))}
|
||||
>
|
||||
<Icon name="add_data" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
))}
|
||||
</SelectList>
|
||||
<Flex justify="flex-end">
|
||||
@@ -152,6 +168,22 @@ export function QuestionList({
|
||||
onPreviousPage={handleClickPreviousPage}
|
||||
/>
|
||||
</Flex>
|
||||
{isVisualizerModalOpen && (
|
||||
<VisualizerModal
|
||||
initialState={{
|
||||
state: {
|
||||
display: list.find(item => item.id === visualizerModalCardId)
|
||||
?.display,
|
||||
},
|
||||
extraDataSources: [`card:${visualizerModalCardId}`],
|
||||
}}
|
||||
onSave={visualization => {
|
||||
dispatch(addCardWithVisualization({ visualization }));
|
||||
setVisualizerModalCardId(null);
|
||||
}}
|
||||
onClose={() => setVisualizerModalCardId(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ export function PublicOrEmbeddedDashboardView({
|
||||
downloadsEnabled={downloadsEnabled}
|
||||
autoScrollToDashcardId={undefined}
|
||||
reportAutoScrolledToDashcard={_.noop}
|
||||
handleSetEditing={_.noop}
|
||||
/>
|
||||
</DashboardContainer>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as qb from "metabase/query_builder/reducers";
|
||||
import revisions from "metabase/redux/revisions";
|
||||
import reference from "metabase/reference/reference";
|
||||
import { reducer as setup } from "metabase/setup/reducers";
|
||||
import { reducer as visualizer } from "metabase/visualizer/visualizer.slice";
|
||||
|
||||
import { commonReducers } from "./reducers-common";
|
||||
|
||||
@@ -23,4 +24,5 @@ export const mainReducers = {
|
||||
setup,
|
||||
admin,
|
||||
plugins: combineReducers(PLUGIN_REDUCERS),
|
||||
visualizer,
|
||||
};
|
||||
|
||||
@@ -63,6 +63,10 @@ const staticTextMeasurer: TextWidthMeasurer = (
|
||||
);
|
||||
|
||||
const StaticRowChart = ({ data, settings, getColor }: StaticRowChartProps) => {
|
||||
|
||||
console.log("TSP StaticRowChart data: ", JSON.stringify(data, null, 2));
|
||||
console.log("TSP StaticRowChart settings: ", JSON.stringify(settings, null, 2));
|
||||
|
||||
const remappedColumnsData = extractRemappedColumns(
|
||||
data,
|
||||
) as RemappingHydratedChartData;
|
||||
|
||||
@@ -3,6 +3,7 @@ import "fast-text-encoding";
|
||||
|
||||
import { setPlatformAPI } from "echarts/core";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
import _ from "underscore";
|
||||
|
||||
import "metabase/lib/dayjs";
|
||||
|
||||
@@ -13,6 +14,12 @@ import { createStaticRenderingContext } from "metabase/static-viz/lib/rendering-
|
||||
import { measureTextEChartsAdapter } from "metabase/static-viz/lib/text";
|
||||
import { extractRemappings } from "metabase/visualizations";
|
||||
import { extendCardWithDashcardSettings } from "metabase/visualizations/lib/settings/typed-utils";
|
||||
import {
|
||||
extractReferencedColumns,
|
||||
getDataSourceIdFromNameRef,
|
||||
isDataSourceNameRef,
|
||||
parseDataSourceId,
|
||||
} from "metabase/visualizer/utils";
|
||||
|
||||
import { LegacyStaticChart } from "./containers/LegacyStaticChart";
|
||||
|
||||
@@ -44,11 +51,148 @@ function getRawSeriesWithDashcardSettings(rawSeries, dashcardSettings) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
export function extractReferencedColumns(
|
||||
mappings: Record<string, VisualizerColumnValueSource[]>,
|
||||
): VisualizerColumnReference[] {
|
||||
const sources = Object.values(mappings).flat();
|
||||
return sources.filter(
|
||||
(valueSource): valueSource is VisualizerColumnReference =>
|
||||
typeof valueSource !== "string",
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDataSourceId(id: VisualizerDataSourceId) {
|
||||
const [type, sourceId] = id.split(":");
|
||||
return { type, sourceId: Number(sourceId) };
|
||||
}
|
||||
|
||||
export function isDataSourceNameRef(
|
||||
value: VisualizerColumnValueSource,
|
||||
): value is VisualizerDataSourceNameReference {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.startsWith("$_") &&
|
||||
value.endsWith("_name")
|
||||
);
|
||||
}
|
||||
|
||||
export function getDataSourceIdFromNameRef(str: string) {
|
||||
const [, dataSourceId] = str.split("_");
|
||||
return dataSourceId;
|
||||
}
|
||||
*/
|
||||
|
||||
// The function that combines both the data merging and series creation
|
||||
function getVisualizerRawSeries(datasets, dashcardSettings) {
|
||||
const { columns, columnValuesMapping } = dashcardSettings.visualization;
|
||||
|
||||
console.log("TSP columnValuesMapping: ", JSON.stringify(columnValuesMapping, null, 2));
|
||||
|
||||
// Extract all referenced columns from the column mappings
|
||||
const referencedColumns = extractReferencedColumns(columnValuesMapping);
|
||||
|
||||
console.log("TSP referencedColumns: ", JSON.stringify(referencedColumns, null, 2));
|
||||
|
||||
// Create a map to store the actual values for each referenced column
|
||||
const referencedColumnValuesMap = {};
|
||||
|
||||
// For each referenced column, find its values in the corresponding dataset
|
||||
referencedColumns.forEach(ref => {
|
||||
// Extract the source ID from references like "card:7166" -> "7166"
|
||||
const { sourceId } = parseDataSourceId(ref.sourceId);
|
||||
|
||||
//const dataset = datasets[sourceId];
|
||||
const dataset = datasets.find(d => d.card.id === sourceId);
|
||||
|
||||
if (!dataset) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the column index in the dataset that matches our reference
|
||||
const columnIndex = dataset.data.cols.findIndex(
|
||||
col => col.name === ref.originalName,
|
||||
);
|
||||
|
||||
// If we found the column, extract its values
|
||||
if (columnIndex >= 0) {
|
||||
const values = dataset.data.rows.map(row => row[columnIndex]);
|
||||
referencedColumnValuesMap[ref.name] = values;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle special case for pivot grouping
|
||||
const hasPivotGrouping = columns.some(col => col.name === "pivot-grouping");
|
||||
if (hasPivotGrouping) {
|
||||
// Create an array of zeros with length matching the longest column
|
||||
const rowLengths = Object.values(referencedColumnValuesMap).map(
|
||||
values => values.length,
|
||||
);
|
||||
const maxLength = rowLengths.length > 0 ? Math.max(...rowLengths) : 0;
|
||||
referencedColumnValuesMap["pivot-grouping"] = new Array(maxLength).fill(0);
|
||||
}
|
||||
|
||||
console.log("TSP referencedColumnValuesMap: ", JSON.stringify(referencedColumnValuesMap, null, 2));
|
||||
|
||||
// Create rows by mapping and flattening values for each column
|
||||
const unzippedRows = columns.map(column =>
|
||||
(columnValuesMapping[column.name] ?? [])
|
||||
.map(valueSource => {
|
||||
if (isDataSourceNameRef(valueSource)) {
|
||||
const id = getDataSourceIdFromNameRef(valueSource);
|
||||
return `Not supported yet (card ${id})`;
|
||||
}
|
||||
const values = referencedColumnValuesMap[valueSource.name];
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
return values;
|
||||
})
|
||||
.flat(),
|
||||
);
|
||||
|
||||
console.log("TSP unzippedRows: ", unzippedRows);
|
||||
console.log("TSP unzippedRows.length: ", unzippedRows.length);
|
||||
|
||||
const mergedData = {
|
||||
cols: columns,
|
||||
rows: _.zip(...unzippedRows),
|
||||
results_metadata: { columns },
|
||||
};
|
||||
|
||||
const { display, settings } = dashcardSettings.visualization;
|
||||
|
||||
return [
|
||||
{
|
||||
card: {
|
||||
display,
|
||||
visualization_settings: settings,
|
||||
},
|
||||
data: mergedData,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function RenderChart(rawSeries, dashcardSettings, options) {
|
||||
const renderingContext = createStaticRenderingContext(
|
||||
options.applicationColors,
|
||||
);
|
||||
|
||||
//console.log("TSP rawSeries before: ", JSON.stringify(rawSeries, null, 2));
|
||||
|
||||
// TODO @tsp - more robust way of checking if this is a visualizer rendering
|
||||
if (
|
||||
"visualization" in dashcardSettings &&
|
||||
"columnValuesMapping" in dashcardSettings.visualization
|
||||
) {
|
||||
rawSeries = getVisualizerRawSeries(rawSeries, dashcardSettings);
|
||||
}
|
||||
|
||||
//console.log("TSP rawSeries after: ", JSON.stringify(rawSeries, null, 2));
|
||||
//console.log("TSP dashcardSettings: ", JSON.stringify(dashcardSettings, null, 2));
|
||||
|
||||
updateStartOfWeek(options.startOfWeek);
|
||||
MetabaseSettings.set("custom-formatting", options.customFormatting);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ChartCaptionProps {
|
||||
actionButtons?: ReactNode;
|
||||
width?: number;
|
||||
getHref?: () => string | undefined;
|
||||
onChangeCardAndRun: OnChangeCardAndRun;
|
||||
onChangeCardAndRun?: OnChangeCardAndRun;
|
||||
}
|
||||
|
||||
const ChartCaption = ({
|
||||
@@ -38,16 +38,14 @@ const ChartCaption = ({
|
||||
const canSelectTitle = cardIds.size === 1 && onChangeCardAndRun;
|
||||
|
||||
const handleSelectTitle = useCallback(() => {
|
||||
onChangeCardAndRun({
|
||||
nextCard: card,
|
||||
});
|
||||
onChangeCardAndRun?.({ nextCard: card });
|
||||
}, [card, onChangeCardAndRun]);
|
||||
|
||||
return (
|
||||
<ChartCaptionRoot
|
||||
title={title}
|
||||
description={description}
|
||||
getHref={getHref}
|
||||
getHref={canSelectTitle ? getHref : undefined}
|
||||
icon={icon}
|
||||
actionButtons={actionButtons}
|
||||
onSelectTitle={canSelectTitle ? handleSelectTitle : undefined}
|
||||
|
||||
@@ -80,7 +80,7 @@ export default class FunnelNormal extends Component {
|
||||
// Initial infos (required for step calculation)
|
||||
let infos = [
|
||||
{
|
||||
value: sortedRows[0][metricIndex],
|
||||
value: sortedRows[0]?.[metricIndex],
|
||||
graph: {
|
||||
startBottom: 0.0,
|
||||
startTop: 1.0,
|
||||
@@ -90,7 +90,7 @@ export default class FunnelNormal extends Component {
|
||||
},
|
||||
];
|
||||
|
||||
let remaining = sortedRows[0][metricIndex];
|
||||
let remaining = sortedRows[0]?.[metricIndex];
|
||||
|
||||
sortedRows.map((row, rowIndex) => {
|
||||
remaining -= infos[rowIndex].value - row[metricIndex];
|
||||
@@ -177,11 +177,11 @@ export default class FunnelNormal extends Component {
|
||||
<FunnelStep isFirst>
|
||||
<Head isNarrow={isNarrow}>
|
||||
<Ellipsified data-testid="funnel-chart-header">
|
||||
{formatDimension(sortedRows[0][dimensionIndex])}
|
||||
{formatDimension(sortedRows[0]?.[dimensionIndex])}
|
||||
</Ellipsified>
|
||||
</Head>
|
||||
<FunnelStart isNarrow={isNarrow}>
|
||||
<Title>{formatMetric(sortedRows[0][metricIndex])}</Title>
|
||||
<Title>{formatMetric(sortedRows[0]?.[metricIndex])}</Title>
|
||||
<Subtitle>{cols[metricIndex].display_name}</Subtitle>
|
||||
</FunnelStart>
|
||||
{/* This part of code in used only to share height between .Start and .Graph columns. */}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
|
||||
import { getCardKey, isSameSeries } from "metabase/visualizations/lib/utils";
|
||||
import { isRegularClickAction } from "metabase/visualizations/types";
|
||||
import { isVisualizerDashboardCard } from "metabase/visualizer/utils";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import { datasetContainsNoResults } from "metabase-lib/v1/queries/utils/dataset";
|
||||
import { memoizeClass } from "metabase-lib/v1/utils";
|
||||
@@ -320,7 +321,8 @@ class Visualization extends PureComponent {
|
||||
onRender = ({ warnings = [] } = {}) => {
|
||||
const currentWarnings = this.state.warnings;
|
||||
if (!_.isEqual(currentWarnings, warnings)) {
|
||||
this.setState({ warnings });
|
||||
// using requestAnimationFrame to avoid setting state in render
|
||||
requestAnimationFrame(() => this.setState({ warnings }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -413,7 +415,7 @@ class Visualization extends PureComponent {
|
||||
/>
|
||||
);
|
||||
} else if (e instanceof MinRowsError) {
|
||||
noResults = true;
|
||||
// noResults = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,6 +468,8 @@ class Visualization extends PureComponent {
|
||||
|
||||
const CardVisualization = visualization;
|
||||
|
||||
const isVisualizerViz = dashcard && isVisualizerDashboardCard(dashcard);
|
||||
|
||||
const title = settings["card.title"];
|
||||
const hasHeaderContent = title || extra;
|
||||
const isHeaderEnabled = !(visualization && visualization.noHeader);
|
||||
@@ -476,6 +480,11 @@ class Visualization extends PureComponent {
|
||||
(loading || error || noResults || isHeaderEnabled)) ||
|
||||
(replacementContent && (dashcard.size_y !== 1 || isMobile) && !isAction);
|
||||
|
||||
// We can't navigate a user to a particular card from a visualizer viz,
|
||||
// so title selection is disabled in this case
|
||||
const canSelectTitle =
|
||||
this.props.onChangeCardAndRun && !replacementContent && !isVisualizerViz;
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={this.onErrorBoundaryError}
|
||||
@@ -497,9 +506,7 @@ class Visualization extends PureComponent {
|
||||
width={width}
|
||||
getHref={getHref}
|
||||
onChangeCardAndRun={
|
||||
this.props.onChangeCardAndRun && !replacementContent
|
||||
? this.handleOnChangeCardAndRun
|
||||
: null
|
||||
canSelectTitle ? this.handleOnChangeCardAndRun : null
|
||||
}
|
||||
/>
|
||||
</VisualizationHeader>
|
||||
@@ -534,6 +541,7 @@ class Visualization extends PureComponent {
|
||||
)}
|
||||
isPlaceholder={isPlaceholder}
|
||||
isMobile={isMobile}
|
||||
isVisualizerViz={isVisualizerViz}
|
||||
series={series}
|
||||
settings={settings}
|
||||
card={series[0].card} // convenience for single-series visualizations
|
||||
|
||||
@@ -173,7 +173,7 @@ const getXAxisTicksWidth = (
|
||||
dataset[index][X_AXIS_DATA_KEY] as number,
|
||||
);
|
||||
}
|
||||
return dataset[index][X_AXIS_DATA_KEY];
|
||||
return dataset[index]?.[X_AXIS_DATA_KEY];
|
||||
});
|
||||
|
||||
const [firstXTickWidth, lastXTickWidth] = valuesToMeasure.map(value =>
|
||||
|
||||
@@ -143,6 +143,26 @@ export function getDefaultSize(display: VisualizationDisplay) {
|
||||
return visualization?.defaultSize;
|
||||
}
|
||||
|
||||
export function isCartesianChart(display: VisualizationDisplay) {
|
||||
const visualization = visualizations.get(display);
|
||||
const settingNames = Object.keys(visualization?.settings ?? {});
|
||||
return (
|
||||
settingNames.includes("graph.dimensions") &&
|
||||
settingNames.includes("graph.metrics")
|
||||
);
|
||||
}
|
||||
|
||||
export function getColumnVizSettings(display: VisualizationDisplay) {
|
||||
const visualization = visualizations.get(display);
|
||||
const settings = visualization?.settings ?? {};
|
||||
|
||||
// TODO Come up with a better condition
|
||||
return Object.keys(settings).filter(key => {
|
||||
const { widget } = settings[key] ?? {};
|
||||
return widget === "field" || widget === "fields";
|
||||
});
|
||||
}
|
||||
|
||||
// removes columns with `remapped_from` property and adds a `remapping` to the appropriate column
|
||||
export const extractRemappedColumns = (data: DatasetData) => {
|
||||
const cols: RemappingHydratedDatasetColumn[] = data.cols.map(col => ({
|
||||
|
||||
@@ -32,12 +32,6 @@ export class LatitudeLongitudeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class NoBreakoutError extends Error {
|
||||
constructor(message) {
|
||||
super(message || t`This visualization requires you to group by a field.`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ChartSettingsError extends Error {
|
||||
initial;
|
||||
buttonText;
|
||||
|
||||
@@ -350,7 +350,7 @@ export const GRAPH_TREND_SETTINGS = {
|
||||
title: t`Trend line`,
|
||||
widget: "toggle",
|
||||
default: false,
|
||||
getHidden: (series, vizSettings) => {
|
||||
getHidden: series => {
|
||||
const { insights } = series[0].data;
|
||||
return !insights || insights.length === 0;
|
||||
},
|
||||
|
||||
@@ -4,13 +4,13 @@ import _ from "underscore";
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import {
|
||||
ChartSettingsError,
|
||||
MinRowsError,
|
||||
// MinRowsError,
|
||||
} from "metabase/visualizations/lib/errors";
|
||||
|
||||
export const validateDatasetRows = series => {
|
||||
const singleSeriesHasNoRows = ({ data: { rows } }) => rows.length === 0;
|
||||
if (_.every(series, singleSeriesHasNoRows)) {
|
||||
throw new MinRowsError(1, 0);
|
||||
// throw new MinRowsError(1, 0);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface VisualizationProps {
|
||||
isEmbeddingSdk: boolean;
|
||||
showTitle: boolean;
|
||||
isDashboard: boolean;
|
||||
isVisualizerViz: boolean;
|
||||
isEditing: boolean;
|
||||
isNightMode: boolean;
|
||||
isSettings: boolean;
|
||||
|
||||
@@ -50,6 +50,7 @@ function _CartesianChart(props: VisualizationProps) {
|
||||
headerIcon,
|
||||
actionButtons,
|
||||
isQueryBuilder,
|
||||
isVisualizerViz,
|
||||
isFullscreen,
|
||||
hovered,
|
||||
onChangeCardAndRun,
|
||||
@@ -130,7 +131,10 @@ function _CartesianChart(props: VisualizationProps) {
|
||||
setChartSize({ width, height });
|
||||
}, []);
|
||||
|
||||
const canSelectTitle = !!onChangeCardAndRun;
|
||||
// We can't navigate a user to a particular card from a visualizer viz,
|
||||
// so title selection is disabled in this case
|
||||
const canSelectTitle = !!onChangeCardAndRun && !isVisualizerViz;
|
||||
|
||||
const seriesColorsCss = useCartesianChartSeriesColorsClasses(
|
||||
chartModel,
|
||||
settings,
|
||||
|
||||
@@ -5,10 +5,15 @@ import {
|
||||
QuestionPickerModal,
|
||||
type QuestionPickerValueItem,
|
||||
} from "metabase/common/components/QuestionPicker";
|
||||
import { replaceCard } from "metabase/dashboard/actions";
|
||||
import {
|
||||
replaceCard,
|
||||
replaceCardWithVisualization,
|
||||
} from "metabase/dashboard/actions";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { Button, Flex } from "metabase/ui";
|
||||
import { VisualizerModal } from "metabase/visualizer/components/VisualizerModal";
|
||||
import type { Dashboard, VirtualDashboardCard } from "metabase-types/api";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
import type { VisualizationProps } from "../types";
|
||||
|
||||
@@ -26,6 +31,7 @@ function DashCardPlaceholderInner({
|
||||
isEditingParameter,
|
||||
}: Props) {
|
||||
const [isQuestionPickerOpen, setQuestionPickerOpen] = useState(false);
|
||||
const [isVisualizerModalOpen, setVisualizerModalOpen] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSelectQuestion = (nextCard: QuestionPickerValueItem) => {
|
||||
@@ -33,6 +39,13 @@ function DashCardPlaceholderInner({
|
||||
setQuestionPickerOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectVisualization = (visualization: VisualizerHistoryItem) => {
|
||||
dispatch(
|
||||
replaceCardWithVisualization({ dashcardId: dashcard.id, visualization }),
|
||||
);
|
||||
setVisualizerModalOpen(false);
|
||||
};
|
||||
|
||||
if (!isDashboard) {
|
||||
return null;
|
||||
}
|
||||
@@ -42,18 +55,29 @@ function DashCardPlaceholderInner({
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
p={2}
|
||||
style={{ flex: 1, pointerEvents }}
|
||||
opacity={isEditingParameter ? 0.25 : 1}
|
||||
>
|
||||
{isEditing && (
|
||||
<Button
|
||||
onClick={() => setQuestionPickerOpen(true)}
|
||||
onMouseDown={preventDragging}
|
||||
style={{ pointerEvents }}
|
||||
>{t`Select question`}</Button>
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="sm"
|
||||
w="100%"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setQuestionPickerOpen(true)}
|
||||
onMouseDown={preventDragging}
|
||||
style={{ pointerEvents }}
|
||||
>{t`Select question`}</Button>
|
||||
<Button
|
||||
onClick={() => setVisualizerModalOpen(true)}
|
||||
onMouseDown={preventDragging}
|
||||
style={{ pointerEvents }}
|
||||
>{t`Visualize`}</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
{isQuestionPickerOpen && (
|
||||
@@ -72,6 +96,12 @@ function DashCardPlaceholderInner({
|
||||
onClose={() => setQuestionPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{isVisualizerModalOpen && (
|
||||
<VisualizerModal
|
||||
onSave={handleSelectVisualization}
|
||||
onClose={() => setVisualizerModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useBrowserRenderingContext } from "metabase/visualizations/hooks/use-br
|
||||
import { groupRawSeriesMetrics } from "metabase/visualizations/lib/dataset";
|
||||
import {
|
||||
ChartSettingsError,
|
||||
MinRowsError,
|
||||
// MinRowsError,
|
||||
} from "metabase/visualizations/lib/errors";
|
||||
import { columnSettings } from "metabase/visualizations/lib/settings/column";
|
||||
import {
|
||||
@@ -52,18 +52,18 @@ Object.assign(Funnel, {
|
||||
series: RawSeries,
|
||||
settings: ComputedVisualizationSettings,
|
||||
) => {
|
||||
const [
|
||||
{
|
||||
data: { rows },
|
||||
},
|
||||
] = series;
|
||||
// const [
|
||||
// {
|
||||
// data: { rows },
|
||||
// },
|
||||
// ] = series;
|
||||
if (series.length > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length < 1) {
|
||||
throw new MinRowsError(1, rows.length);
|
||||
}
|
||||
// if (rows.length < 1) {
|
||||
// throw new MinRowsError(1, rows.length);
|
||||
// }
|
||||
if (!settings["funnel.dimension"] || !settings["funnel.metric"]) {
|
||||
throw new ChartSettingsError(
|
||||
t`Which fields do you want to use?`,
|
||||
@@ -206,6 +206,7 @@ export function Funnel(props: VisualizationProps) {
|
||||
headerIcon,
|
||||
settings,
|
||||
showTitle,
|
||||
isVisualizerViz,
|
||||
actionButtons,
|
||||
className,
|
||||
onChangeCardAndRun,
|
||||
@@ -233,6 +234,10 @@ export function Funnel(props: VisualizationProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// We can't navigate a user to a particular card from a visualizer viz,
|
||||
// so title selection is disabled in this case
|
||||
const canSelectTitle = !!onChangeCardAndRun && !isVisualizerViz;
|
||||
|
||||
return (
|
||||
<div className={cx(className, CS.flex, CS.flexColumn, CS.p1)}>
|
||||
{hasTitle && (
|
||||
@@ -240,9 +245,9 @@ export function Funnel(props: VisualizationProps) {
|
||||
series={groupedRawSeries}
|
||||
settings={settings}
|
||||
icon={headerIcon}
|
||||
getHref={getHref}
|
||||
getHref={canSelectTitle ? getHref : undefined}
|
||||
actionButtons={actionButtons}
|
||||
onChangeCardAndRun={onChangeCardAndRun}
|
||||
onChangeCardAndRun={canSelectTitle ? onChangeCardAndRun : undefined}
|
||||
/>
|
||||
)}
|
||||
<FunnelNormal
|
||||
|
||||
@@ -4,7 +4,7 @@ import _ from "underscore";
|
||||
import { formatValue } from "metabase/lib/formatting";
|
||||
import {
|
||||
ChartSettingsError,
|
||||
MinRowsError,
|
||||
// MinRowsError,
|
||||
} from "metabase/visualizations/lib/errors";
|
||||
import { columnSettings } from "metabase/visualizations/lib/settings/column";
|
||||
import { nestedSettings } from "metabase/visualizations/lib/settings/nested";
|
||||
@@ -65,18 +65,19 @@ export const PIE_CHART_DEFINITION: VisualizationDefinition = {
|
||||
);
|
||||
},
|
||||
checkRenderable: (
|
||||
[
|
||||
{
|
||||
data: { rows },
|
||||
},
|
||||
],
|
||||
// [
|
||||
// {
|
||||
// data: { rows },
|
||||
// },
|
||||
// ],
|
||||
series,
|
||||
settings,
|
||||
) => {
|
||||
// This prevents showing "Which columns do you want to use" when
|
||||
// the piechart is displayed with no results in the dashboard
|
||||
if (rows.length < 1) {
|
||||
throw new MinRowsError(1, 0);
|
||||
}
|
||||
// if (rows.length < 1) {
|
||||
// throw new MinRowsError(1, 0);
|
||||
// }
|
||||
const isDimensionMissing =
|
||||
!settings["pie.dimension"] ||
|
||||
(Array.isArray(settings["pie.dimension"]) &&
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDebouncedValue } from "metabase/hooks/use-debounced-value";
|
||||
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Box, Flex, Text, TextInput } from "metabase/ui";
|
||||
import { getDataSources } from "metabase/visualizer/selectors";
|
||||
import {
|
||||
addDataSource,
|
||||
removeDataSource,
|
||||
} from "metabase/visualizer/visualizer.slice";
|
||||
import type { VisualizerDataSource } from "metabase-types/store/visualizer";
|
||||
|
||||
import { RecentsList } from "./RecentsList";
|
||||
import type { ResultsListProps } from "./ResultsList";
|
||||
import { SearchResultsList } from "./SearchResultsList";
|
||||
|
||||
export const DataImporter = () => {
|
||||
const dispatch = useDispatch();
|
||||
const dataSources = useSelector(getDataSources);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, SEARCH_DEBOUNCE_DURATION);
|
||||
|
||||
const dataSourceIds = useMemo(
|
||||
() => new Set(dataSources.map(s => s.id)),
|
||||
[dataSources],
|
||||
);
|
||||
|
||||
const handleDataSourceSelect: ResultsListProps["onSelect"] = useCallback(
|
||||
(source: VisualizerDataSource) => {
|
||||
if (dataSourceIds.has(source.id)) {
|
||||
dispatch(removeDataSource(source));
|
||||
} else {
|
||||
dispatch(addDataSource(source.id));
|
||||
}
|
||||
},
|
||||
[dataSourceIds, dispatch],
|
||||
);
|
||||
|
||||
const handleSearchChange: React.ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback(e => {
|
||||
setSearch(e.target.value);
|
||||
}, []);
|
||||
|
||||
const showRecents = search.trim() === "";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
bg="white"
|
||||
style={{
|
||||
borderRadius: "var(--default-border-radius)",
|
||||
height: "100%",
|
||||
border: `1px solid var(--mb-color-border)`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
borderBottom: `
|
||||
1px solid var(--mb-color-border)`,
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
m="xs"
|
||||
variant="filled"
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={t`Search for something`}
|
||||
/>
|
||||
</Box>
|
||||
<Flex
|
||||
direction="column"
|
||||
pt="sm"
|
||||
px="sm"
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{showRecents ? (
|
||||
<>
|
||||
<Text>{t`Recents`}</Text>
|
||||
<RecentsList
|
||||
onSelect={handleDataSourceSelect}
|
||||
dataSourceIds={dataSourceIds}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SearchResultsList
|
||||
search={debouncedSearch}
|
||||
onSelect={handleDataSourceSelect}
|
||||
dataSourceIds={dataSourceIds}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useListRecentsQuery } from "metabase/api";
|
||||
import { Loader } from "metabase/ui";
|
||||
import { createDataSource } from "metabase/visualizer/utils";
|
||||
import type { VisualizerDataSourceId } from "metabase-types/store/visualizer";
|
||||
|
||||
import { ResultsList, type ResultsListProps } from "./ResultsList";
|
||||
|
||||
interface RecentsListProps {
|
||||
onSelect: ResultsListProps["onSelect"];
|
||||
dataSourceIds: Set<VisualizerDataSourceId>;
|
||||
}
|
||||
|
||||
export function RecentsList({ onSelect, dataSourceIds }: RecentsListProps) {
|
||||
const { data: allRecents = [] } = useListRecentsQuery(undefined, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
return allRecents
|
||||
.filter(maybeCard =>
|
||||
["card", "dataset", "metric"].includes(maybeCard.model),
|
||||
)
|
||||
.map(card => createDataSource("card", card.id, card.name));
|
||||
}, [allRecents]);
|
||||
|
||||
if (!items) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResultsList
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
dataSourceIds={dataSourceIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Box, Text } from "metabase/ui";
|
||||
import type {
|
||||
VisualizerDataSource,
|
||||
VisualizerDataSourceId,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
export interface ResultsListProps {
|
||||
items: VisualizerDataSource[];
|
||||
onSelect?: (item: VisualizerDataSource) => void;
|
||||
dataSourceIds: Set<VisualizerDataSourceId>;
|
||||
}
|
||||
|
||||
export const ResultsList = ({
|
||||
items,
|
||||
onSelect,
|
||||
dataSourceIds,
|
||||
}: ResultsListProps) => {
|
||||
return (
|
||||
<Box component="ul">
|
||||
{items.map((item, index) => (
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--mb-color-border)",
|
||||
borderRadius: 5,
|
||||
cursor: "pointer",
|
||||
backgroundColor: dataSourceIds.has(item.id)
|
||||
? "var(--mb-color-bg-light)"
|
||||
: "transparent",
|
||||
}}
|
||||
key={index}
|
||||
component="li"
|
||||
px={14}
|
||||
py={10}
|
||||
mb={4}
|
||||
onClick={() => onSelect?.(item)}
|
||||
>
|
||||
<Text truncate>{item.name}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { skipToken, useSearchQuery } from "metabase/api";
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import { Loader } from "metabase/ui";
|
||||
import { createDataSource } from "metabase/visualizer/utils";
|
||||
import type { VisualizerDataSourceId } from "metabase-types/store/visualizer";
|
||||
|
||||
import { ResultsList, type ResultsListProps } from "./ResultsList";
|
||||
|
||||
interface SearchResultsListProps {
|
||||
search: string;
|
||||
onSelect: ResultsListProps["onSelect"];
|
||||
dataSourceIds: Set<VisualizerDataSourceId>;
|
||||
}
|
||||
|
||||
export function SearchResultsList({
|
||||
search,
|
||||
onSelect,
|
||||
dataSourceIds,
|
||||
}: SearchResultsListProps) {
|
||||
const { data: result = { data: [] } } = useSearchQuery(
|
||||
search.length > 0
|
||||
? {
|
||||
q: search,
|
||||
limit: 10,
|
||||
models: ["card"],
|
||||
}
|
||||
: skipToken,
|
||||
{
|
||||
refetchOnMountOrArgChange: true,
|
||||
},
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!Array.isArray(result.data)) {
|
||||
return [];
|
||||
}
|
||||
return result.data
|
||||
.map(item =>
|
||||
typeof item.id === "number"
|
||||
? createDataSource("card", item.id, item.name)
|
||||
: null,
|
||||
)
|
||||
.filter(isNotNull);
|
||||
}, [result]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResultsList
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
dataSourceIds={dataSourceIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DataImporter";
|
||||
@@ -0,0 +1,19 @@
|
||||
.grabber {
|
||||
visibility: hidden;
|
||||
color: var(--mb-color-text-medium);
|
||||
}
|
||||
|
||||
.parent {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--default-border-radius);
|
||||
}
|
||||
|
||||
.parent:hover .grabber {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.parent:hover {
|
||||
background-color: var(--mb-color-bg-light);
|
||||
cursor: grab;
|
||||
border-color: var(--mb-color-border);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type MouseEvent, forwardRef } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { displayNameForColumn } from "metabase/lib/formatting";
|
||||
import {
|
||||
ActionIcon,
|
||||
Flex,
|
||||
type FlexProps,
|
||||
Icon,
|
||||
type IconName,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import { getIconForField } from "metabase-lib/v1/metadata/utils/fields";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import S from "./ColumnListItem.module.css";
|
||||
|
||||
export interface ColumnListItemProps extends FlexProps {
|
||||
column: DatasetColumn;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export const ColumnListItem = forwardRef<HTMLDivElement, ColumnListItemProps>(
|
||||
function ColumnListItem({ column, onRemove, ...props }, ref) {
|
||||
const handleRemoveClick = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onRemove?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
{...props}
|
||||
className={S.parent}
|
||||
mt={2}
|
||||
px={8}
|
||||
py={4}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
ref={ref}
|
||||
>
|
||||
<Flex align="center">
|
||||
<Icon className={S.grabber} name="grabber" mr={4} size={16} />
|
||||
<Icon name={getIconForField(column) as IconName} mr={4} size={16} />
|
||||
<Text truncate>{displayNameForColumn(column)}</Text>
|
||||
</Flex>
|
||||
{!!onRemove && (
|
||||
<ActionIcon aria-label={t`Remove`} onClick={handleRemoveClick}>
|
||||
<Icon name="close" size={14} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { Box, Center, Flex, Text } from "metabase/ui";
|
||||
import { getDataSources } from "metabase/visualizer/selectors";
|
||||
|
||||
import { DatasetList } from "./DatasetList";
|
||||
|
||||
export const DataManager = () => {
|
||||
const dataSources = useSelector(getDataSources);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
bg="white"
|
||||
style={{
|
||||
borderRadius: "var(--default-border-radius)",
|
||||
height: "100%",
|
||||
border: `1px solid var(--mb-color-border)`,
|
||||
}}
|
||||
>
|
||||
<Box px={12} py={8}>
|
||||
<Text fw="bold" mb={3}>
|
||||
{t`Data`}
|
||||
</Text>
|
||||
</Box>
|
||||
{dataSources.length > 0 ? (
|
||||
<DatasetList />
|
||||
) : (
|
||||
<Center h="100%" w="100%" mx="auto">
|
||||
<Text>{t`Pick a dataset first`}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
.parent {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--default-border-radius);
|
||||
}
|
||||
|
||||
.close {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.parent:hover .close {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Box, Flex, Icon, Loader, Text } from "metabase/ui";
|
||||
import { DRAGGABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getDataSources,
|
||||
getDatasets,
|
||||
getExpandedDataSources,
|
||||
getLoadingDatasets,
|
||||
getReferencedColumns,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isReferenceToColumn } from "metabase/visualizer/utils";
|
||||
import {
|
||||
addColumn,
|
||||
removeColumn,
|
||||
removeDataSource,
|
||||
toggleDataSourceExpanded,
|
||||
} from "metabase/visualizer/visualizer.slice";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
import type { VisualizerDataSource } from "metabase-types/store/visualizer";
|
||||
|
||||
import { ColumnListItem, type ColumnListItemProps } from "./ColumnListItem";
|
||||
import S from "./DatasetList.module.css";
|
||||
|
||||
export const DatasetList = () => {
|
||||
const dataSources = useSelector(getDataSources);
|
||||
const datasets = useSelector(getDatasets);
|
||||
const loadingDatasets = useSelector(getLoadingDatasets);
|
||||
const expandedDataSources = useSelector(getExpandedDataSources);
|
||||
const referencedColumns = useSelector(getReferencedColumns);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleAddColumn = (
|
||||
dataSource: VisualizerDataSource,
|
||||
column: DatasetColumn,
|
||||
) => {
|
||||
dispatch(addColumn({ dataSource, column }));
|
||||
};
|
||||
|
||||
const handleRemoveColumn = (columnRefName: string) => {
|
||||
dispatch(removeColumn({ name: columnRefName }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
px={12}
|
||||
direction="column"
|
||||
style={{
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{dataSources.map(source => {
|
||||
const dataset = datasets[source.id];
|
||||
const isLoading = loadingDatasets[source.id];
|
||||
const isExpanded = expandedDataSources[source.id];
|
||||
|
||||
return (
|
||||
<Box key={source.id} mb={4}>
|
||||
<Flex align="center" px={8} py={4} className={S.parent}>
|
||||
{!isLoading ? (
|
||||
<Icon
|
||||
style={{ flexShrink: 0 }}
|
||||
name={isExpanded ? "chevronup" : "chevrondown"}
|
||||
aria-label={t`Expand`}
|
||||
size={12}
|
||||
mr={6}
|
||||
cursor="pointer"
|
||||
onClick={() => dispatch(toggleDataSourceExpanded(source.id))}
|
||||
/>
|
||||
) : (
|
||||
<Loader size={12} mr={6} />
|
||||
)}
|
||||
<Text truncate mr={4}>
|
||||
{source.name}
|
||||
</Text>
|
||||
{!isLoading && (
|
||||
<Icon
|
||||
style={{ flexShrink: 0 }}
|
||||
className={S.close}
|
||||
name="close"
|
||||
ml="auto"
|
||||
size={12}
|
||||
aria-label={t`Remove the dataset ${source.name} from the list`}
|
||||
cursor="pointer"
|
||||
onClick={() => dispatch(removeDataSource(source))}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{isExpanded && dataset && dataset.data.cols && (
|
||||
<Box ml={12} mt={2}>
|
||||
{dataset.data.cols.map(column => {
|
||||
const columnReference = referencedColumns.find(ref =>
|
||||
isReferenceToColumn(column, source.id, ref),
|
||||
);
|
||||
const isSelected = !!columnReference;
|
||||
return (
|
||||
<DraggableColumnListItem
|
||||
key={column.name}
|
||||
column={column}
|
||||
dataSource={source}
|
||||
isSelected={isSelected}
|
||||
onClick={() => {
|
||||
if (!isSelected) {
|
||||
handleAddColumn(source, column);
|
||||
}
|
||||
}}
|
||||
onRemove={
|
||||
isSelected
|
||||
? () => handleRemoveColumn(columnReference.name)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
type DraggableColumnListItemProps = ColumnListItemProps & {
|
||||
isSelected: boolean;
|
||||
dataSource: VisualizerDataSource;
|
||||
};
|
||||
|
||||
function DraggableColumnListItem({
|
||||
column,
|
||||
dataSource,
|
||||
isSelected,
|
||||
...props
|
||||
}: DraggableColumnListItemProps) {
|
||||
const { attributes, listeners, isDragging, setNodeRef } = useDraggable({
|
||||
id: `${DRAGGABLE_ID.COLUMN}:${dataSource.id}:${column.name}`,
|
||||
data: {
|
||||
type: DRAGGABLE_ID.COLUMN,
|
||||
column,
|
||||
dataSource,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ColumnListItem
|
||||
{...props}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
bg={isSelected ? "var(--mb-color-brand-lighter)" : undefined}
|
||||
column={column}
|
||||
style={{ visibility: isDragging ? "hidden" : "visible" }}
|
||||
ref={setNodeRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./ColumnListItem";
|
||||
export * from "./DataManager";
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Box, type BoxProps, Text } from "metabase/ui";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
isDraggedColumnItem,
|
||||
isDraggedWellItem,
|
||||
} from "metabase/visualizer/utils";
|
||||
import type { DraggedItem } from "metabase-types/store/visualizer";
|
||||
|
||||
import { ColumnListItem } from "../DataManager";
|
||||
import { WellItem } from "../VisualizationCanvas/WellItem";
|
||||
|
||||
interface DragOverlayProps {
|
||||
item: DraggedItem;
|
||||
}
|
||||
|
||||
export function DragOverlay({ item }: DragOverlayProps) {
|
||||
if (isDraggedColumnItem(item)) {
|
||||
return (
|
||||
<DragOverlayWrapper // FIX
|
||||
style={{ borderRadius: "var(--default-border-radius)" }}
|
||||
>
|
||||
<ColumnListItem column={item.data.current.column} />
|
||||
</DragOverlayWrapper>
|
||||
);
|
||||
}
|
||||
if (isDraggedWellItem(item)) {
|
||||
const { column, wellId } = item.data.current;
|
||||
const isVertical = wellId === DROPPABLE_ID.Y_AXIS_WELL;
|
||||
return (
|
||||
<DragOverlayWrapper
|
||||
miw="140px"
|
||||
style={{
|
||||
borderRadius: "var(--border-radius-xl)",
|
||||
transform: isVertical ? "rotate(-90deg)" : undefined,
|
||||
}}
|
||||
>
|
||||
<WellItem>
|
||||
<Text>{column.display_name}</Text>
|
||||
</WellItem>
|
||||
</DragOverlayWrapper>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface DragOverlayWrapperProps extends BoxProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DragOverlayWrapper({ style, ...props }: DragOverlayWrapperProps) {
|
||||
return (
|
||||
<Box
|
||||
{...props}
|
||||
bg="#F8FBFE"
|
||||
style={{
|
||||
...style,
|
||||
border: "2px solid #358CD9",
|
||||
boxShadow: "0px 1px 4px 1px rgba(0, 0, 0, 0.2)",
|
||||
cursor: "grab",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DragOverlay";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { ActionIcon, Button, Flex, Icon, Tooltip } from "metabase/ui";
|
||||
import { useVisualizerHistory } from "metabase/visualizer/hooks/use-visualizer-history";
|
||||
import {
|
||||
getCurrentVisualizerState,
|
||||
getIsDirty,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import {
|
||||
toggleFullscreenMode,
|
||||
toggleVizSettingsSidebar,
|
||||
} from "metabase/visualizer/visualizer.slice";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
interface HeaderProps {
|
||||
onSave?: (visualization: VisualizerHistoryItem) => void;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function Header({ onSave, saveLabel }: HeaderProps) {
|
||||
const { canUndo, canRedo, undo, redo } = useVisualizerHistory();
|
||||
|
||||
const visualization = useSelector(getCurrentVisualizerState);
|
||||
const isDirty = useSelector(getIsDirty);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.(visualization);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex p="md" pb="sm" justify="space-between">
|
||||
<Flex align="center">
|
||||
<Tooltip label={t`Back`}>
|
||||
<ActionIcon disabled={!canUndo} onClick={undo}>
|
||||
<Icon name="chevronleft" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t`Forward`}>
|
||||
<ActionIcon disabled={!canRedo} onClick={redo}>
|
||||
<Icon name="chevronright" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap="sm">
|
||||
<Tooltip label={t`Settings`}>
|
||||
<ActionIcon
|
||||
disabled={!isDirty}
|
||||
onClick={() => dispatch(toggleVizSettingsSidebar())}
|
||||
>
|
||||
<Icon name="gear" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t`Share`}>
|
||||
<ActionIcon>
|
||||
<Icon name="share" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t`Fullscreen`}>
|
||||
<ActionIcon
|
||||
disabled={!isDirty}
|
||||
onClick={() => dispatch(toggleFullscreenMode())}
|
||||
>
|
||||
<Icon name="expand" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button variant="filled" disabled={!isDirty} onClick={handleSave}>
|
||||
{saveLabel ?? t`Add to dashboard`}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Header";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { Box, Modal } from "metabase/ui";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import { getTabularPreviewSeries } from "metabase/visualizer/selectors";
|
||||
|
||||
interface TabularPreviewModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TabularPreviewModal({
|
||||
opened,
|
||||
onClose,
|
||||
}: TabularPreviewModalProps) {
|
||||
const series = useSelector(getTabularPreviewSeries);
|
||||
|
||||
if (series.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={opened} title={t`Preview`} size="xl" onClose={onClose}>
|
||||
<Box h="800px">
|
||||
<Visualization
|
||||
rawSeries={series}
|
||||
// TableInteractive crashes when trying to use metabase-lib
|
||||
isDashboard
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./TabularPreviewModal";
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import { Flex, type FlexProps, Text } from "metabase/ui";
|
||||
import { getDefaultDimensionFilter } from "metabase/visualizations/shared/settings/cartesian-chart";
|
||||
import { DRAGGABLE_ID, DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getVisualizationType,
|
||||
getVisualizerComputedSettings,
|
||||
getVisualizerDatasetColumns,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isDraggedColumnItem } from "metabase/visualizer/utils";
|
||||
import { removeColumn } from "metabase/visualizer/visualizer.slice";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import { WellItem } from "../WellItem";
|
||||
|
||||
export function CartesianHorizontalWell({ style, ...props }: FlexProps) {
|
||||
const display = useSelector(getVisualizationType);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { active, setNodeRef, isOver } = useDroppable({
|
||||
id: DROPPABLE_ID.X_AXIS_WELL,
|
||||
});
|
||||
|
||||
const dimensions = useMemo(() => {
|
||||
const dimensionNames = settings["graph.dimensions"] ?? [];
|
||||
return dimensionNames
|
||||
.map(name => columns.find(column => column.name === name))
|
||||
.filter(isNotNull);
|
||||
}, [columns, settings]);
|
||||
|
||||
const canHandleActiveItem = useMemo(() => {
|
||||
if (!display || !active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
const isSuitableColumn = getDefaultDimensionFilter(display);
|
||||
return isSuitableColumn(column);
|
||||
}, [active, display]);
|
||||
|
||||
const handleRemoveDimension = (dimension: DatasetColumn) => {
|
||||
dispatch(removeColumn({ name: dimension.name }));
|
||||
};
|
||||
|
||||
const borderColor = canHandleActiveItem
|
||||
? "var(--mb-color-brand)"
|
||||
: "var(--border-color)";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
{...props}
|
||||
bg={canHandleActiveItem ? "var(--mb-color-brand-light)" : "bg-light"}
|
||||
p="sm"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
style={{
|
||||
...style,
|
||||
height: "100%",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
borderRadius: "var(--border-radius-xl)",
|
||||
border: `1px solid ${borderColor}`,
|
||||
transform: canHandleActiveItem ? "scale(1.025)" : "scale(1)",
|
||||
transition:
|
||||
"transform 0.2s ease-in-out 0.2s, border-color 0.2s ease-in-out 0.2s, background 0.2s ease-in-out 0.2s",
|
||||
outline:
|
||||
isOver && canHandleActiveItem
|
||||
? "1px solid var(--mb-color-brand)"
|
||||
: "none",
|
||||
}}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{dimensions.map(dimension => (
|
||||
<DimensionWellItem
|
||||
key={dimension.name}
|
||||
dimension={dimension}
|
||||
onRemove={() => handleRemoveDimension(dimension)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface DimensionWellItemProps {
|
||||
dimension: DatasetColumn;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function DimensionWellItem({ dimension, onRemove }: DimensionWellItemProps) {
|
||||
const { attributes, listeners, isDragging, setNodeRef } = useDraggable({
|
||||
id: `${DROPPABLE_ID.X_AXIS_WELL}:${DRAGGABLE_ID.WELL_ITEM}:${dimension.name}`,
|
||||
data: {
|
||||
type: DRAGGABLE_ID.WELL_ITEM,
|
||||
wellId: DROPPABLE_ID.X_AXIS_WELL,
|
||||
column: dimension,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<WellItem
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ visibility: isDragging ? "hidden" : "visible" }}
|
||||
onRemove={onRemove}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
<Text truncate>{dimension.display_name}</Text>
|
||||
</WellItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
restrictToHorizontalAxis,
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
|
||||
import { Sortable } from "metabase/core/components/Sortable";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Box, Flex, type FlexProps, Text } from "metabase/ui";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import { getVisualizerComputedSettings } from "metabase/visualizer/selectors";
|
||||
import {
|
||||
removeColumn,
|
||||
updateSettings,
|
||||
} from "metabase/visualizer/visualizer.slice";
|
||||
|
||||
import { WellItem, type WellItemProps } from "../WellItem";
|
||||
|
||||
export function FunnelHorizontalWell({ style, ...props }: FlexProps) {
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { active, setNodeRef, isOver } = useDroppable({
|
||||
id: DROPPABLE_ID.X_AXIS_WELL,
|
||||
});
|
||||
|
||||
const sensor = useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 10 },
|
||||
});
|
||||
|
||||
const rows = settings?.["funnel.rows"] ?? [];
|
||||
const rowKeys = rows.map(row => row.key);
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent) => {
|
||||
const newIndex = rows.findIndex(row => row.key === over?.id);
|
||||
const oldIndex = rows.findIndex(row => row.key === active.id);
|
||||
const nextRows = arrayMove(rows, oldIndex, newIndex);
|
||||
|
||||
const dimension = settings["funnel.dimension"];
|
||||
const orderDimension = settings["funnel.order_dimension"] ?? dimension;
|
||||
|
||||
dispatch(
|
||||
updateSettings({
|
||||
"funnel.dimension": dimension,
|
||||
"funnel.order_dimension": orderDimension,
|
||||
"funnel.rows": nextRows,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
dispatch(removeColumn({ name: settings["funnel.dimension"] }));
|
||||
};
|
||||
|
||||
const borderStyle = rows.length > 0 ? "solid" : "dashed";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
{...props}
|
||||
bg={active ? "var(--mb-color-brand-light)" : "bg-light"}
|
||||
p="sm"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
...style,
|
||||
height: "100%",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
borderRadius: "var(--border-radius-xl)",
|
||||
border: `1px ${borderStyle} ${active ? "var(--mb-color-brand)" : "var(--border-color)"}`,
|
||||
transform: active ? "scale(1.025)" : "scale(1)",
|
||||
transition:
|
||||
"transform 0.2s ease-in-out 0.2s, border-color 0.2s ease-in-out 0.2s, background 0.2s ease-in-out 0.2s",
|
||||
outline: isOver ? "1px solid var(--mb-color-brand)" : "none",
|
||||
}}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
<DndContext
|
||||
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
|
||||
sensors={[sensor]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{settings["funnel.dimension"] && (
|
||||
<FunnelWellItem mr="md" id="dimension" onRemove={handleRemove}>
|
||||
<Text truncate>{settings["funnel.dimension"]}</Text>
|
||||
</FunnelWellItem>
|
||||
)}
|
||||
<SortableContext
|
||||
items={rowKeys}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<Box
|
||||
component="ul"
|
||||
display="flex"
|
||||
style={{ flexDirection: "row", gap: "1rem" }}
|
||||
>
|
||||
{rows.map(row => (
|
||||
<FunnelWellItem
|
||||
key={row.key}
|
||||
component="li"
|
||||
id={row.key}
|
||||
isDraggable
|
||||
>
|
||||
<Text truncate>{row.name}</Text>
|
||||
</FunnelWellItem>
|
||||
))}
|
||||
</Box>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface FunnelWellItemProps extends WellItemProps {
|
||||
id: string;
|
||||
component?: any;
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
function FunnelWellItem({
|
||||
id,
|
||||
isDraggable = false,
|
||||
...props
|
||||
}: FunnelWellItemProps) {
|
||||
const box = (
|
||||
<WellItem
|
||||
{...props}
|
||||
bg={isDraggable ? "var(--mb-color-bg-white)" : "transparent"}
|
||||
style={{
|
||||
cursor: isDraggable ? "grab" : "default",
|
||||
border: `1px solid hsla(204,66,8,51%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!isDraggable) {
|
||||
return box;
|
||||
}
|
||||
|
||||
return <Sortable id={id}>{box}</Sortable>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FlexProps } from "metabase/ui";
|
||||
import { isCartesianChart } from "metabase/visualizations";
|
||||
import type { VisualizationDisplay } from "metabase-types/api";
|
||||
|
||||
import { CartesianHorizontalWell } from "./CartesianHorizontallWell";
|
||||
import { FunnelHorizontalWell } from "./FunnelHorizontalWell";
|
||||
|
||||
interface HorizontalWellProps extends FlexProps {
|
||||
display: VisualizationDisplay;
|
||||
}
|
||||
|
||||
export function HorizontalWell({ display, ...props }: HorizontalWellProps) {
|
||||
if (isCartesianChart(display)) {
|
||||
return <CartesianHorizontalWell {...props} />;
|
||||
}
|
||||
if (display === "funnel") {
|
||||
return <FunnelHorizontalWell {...props} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./HorizontalWell";
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { Stack, Text } from "metabase/ui";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getVisualizerComputedSettings,
|
||||
getVisualizerDatasetColumns,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isDraggedColumnItem } from "metabase/visualizer/utils";
|
||||
import { isNumber } from "metabase-lib/v1/types/utils/isa";
|
||||
|
||||
import { WellItem } from "../WellItem";
|
||||
|
||||
export function ScatterFloatingWell() {
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
|
||||
const { active, isOver, setNodeRef } = useDroppable({
|
||||
id: DROPPABLE_ID.SCATTER_BUBBLE_SIZE_WELL,
|
||||
});
|
||||
|
||||
const canHandleActiveItem = useMemo(() => {
|
||||
if (!active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
return isNumber(column);
|
||||
}, [active]);
|
||||
|
||||
const bubbleSize = columns.find(
|
||||
col => col.name === settings["scatter.bubble"],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
p="md"
|
||||
bg="bg-medium"
|
||||
style={{
|
||||
borderRadius: "var(--default-border-radius)",
|
||||
outline:
|
||||
isOver && canHandleActiveItem
|
||||
? "1px solid var(--mb-color-brand)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<WellItem ref={setNodeRef}>
|
||||
<Text truncate>
|
||||
{bubbleSize
|
||||
? t`Bubble size` + `: ${bubbleSize.display_name}`
|
||||
: t`Bubble size`}
|
||||
</Text>
|
||||
</WellItem>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ScatterFloatingWell";
|
||||
@@ -0,0 +1,20 @@
|
||||
.card {
|
||||
border: 2px solid var(--mb-color-border);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--default-border-radius);
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--mb-color-brand);
|
||||
box-shadow: 0 1px 8px 2px var(--mb-color-shadow);
|
||||
transform: scale(1.025);
|
||||
transition:
|
||||
transform 300ms ease-in-out,
|
||||
box-shadow 300ms ease-in-out;
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { Box, Flex, Grid, Icon, type IconName, Text, Title } from "metabase/ui";
|
||||
import { setDisplay } from "metabase/visualizer/visualizer.slice";
|
||||
import type { VisualizationDisplay } from "metabase-types/api";
|
||||
|
||||
import S from "./StartFromViz.module.css";
|
||||
|
||||
const options = _.shuffle([
|
||||
{
|
||||
label: t`Bar`,
|
||||
value: "bar",
|
||||
icon: "bar",
|
||||
},
|
||||
{
|
||||
label: t`Region map`,
|
||||
value: "map",
|
||||
icon: "pinmap",
|
||||
},
|
||||
{
|
||||
label: t`Scatterplot`,
|
||||
value: "scatter",
|
||||
icon: "bubble",
|
||||
},
|
||||
{
|
||||
label: t`Pie`,
|
||||
value: "pie",
|
||||
icon: "pie",
|
||||
},
|
||||
{
|
||||
label: t`Funnel`,
|
||||
value: "funnel",
|
||||
icon: "funnel",
|
||||
},
|
||||
]);
|
||||
|
||||
export function StartFromViz() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleVizTypeClick = useCallback(
|
||||
(nextDisplay: string) => {
|
||||
dispatch(setDisplay(nextDisplay as VisualizationDisplay));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column" align="center">
|
||||
<Title>{t`Pick the type of viz you'd like to make`}</Title>
|
||||
<Grid mt="lg" mb="md">
|
||||
{options.map(vizType => {
|
||||
return (
|
||||
<Grid.Col key={vizType.label} span={4} miw={200} mih={180}>
|
||||
<>
|
||||
<Box
|
||||
className={S.card}
|
||||
onClick={() => handleVizTypeClick(vizType.value)}
|
||||
>
|
||||
<Icon name={vizType.icon as IconName} size={32} mb="md" />
|
||||
{vizType.label}
|
||||
</Box>
|
||||
</>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
<Text>
|
||||
(
|
||||
{t`Use the panel on the left to get going with a dataset if you know what you want already`}
|
||||
)
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./StartFromViz";
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import { Flex, Text } from "metabase/ui";
|
||||
import { getDefaultMetricFilter } from "metabase/visualizations/shared/settings/cartesian-chart";
|
||||
import { DRAGGABLE_ID, DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getVisualizationType,
|
||||
getVisualizerComputedSettings,
|
||||
getVisualizerDatasetColumns,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isDraggedColumnItem } from "metabase/visualizer/utils";
|
||||
import { removeColumn } from "metabase/visualizer/visualizer.slice";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import { WellItem } from "../WellItem";
|
||||
|
||||
import { SimpleVerticalWell } from "./SimpleVerticalWell";
|
||||
|
||||
export function CartesianVerticalWell() {
|
||||
const display = useSelector(getVisualizationType);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { active, isOver, setNodeRef } = useDroppable({
|
||||
id: DROPPABLE_ID.Y_AXIS_WELL,
|
||||
});
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const metricNames = settings["graph.metrics"] ?? [];
|
||||
return metricNames
|
||||
.map(name => columns.find(column => column.name === name))
|
||||
.filter(isNotNull);
|
||||
}, [columns, settings]);
|
||||
|
||||
const canHandleActiveItem = useMemo(() => {
|
||||
if (!display || !active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
const isSuitableColumn = getDefaultMetricFilter(display);
|
||||
return isSuitableColumn(column);
|
||||
}, [active, display]);
|
||||
|
||||
const handleRemoveMetric = (metric: DatasetColumn) => {
|
||||
dispatch(removeColumn({ name: metric.name }));
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleVerticalWell
|
||||
hasValues={metrics.length > 1}
|
||||
isHighlighted={canHandleActiveItem}
|
||||
isOver={isOver}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
pos="relative"
|
||||
gap="sm"
|
||||
style={{ transform: "rotate(-90deg)" }}
|
||||
>
|
||||
{metrics.map(metric => (
|
||||
<MetricWellItem
|
||||
key={metric.name}
|
||||
metric={metric}
|
||||
onRemove={() => handleRemoveMetric(metric)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</SimpleVerticalWell>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricWellItemProps {
|
||||
metric: DatasetColumn;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function MetricWellItem({ metric, onRemove }: MetricWellItemProps) {
|
||||
const { attributes, listeners, isDragging, setNodeRef } = useDraggable({
|
||||
id: `${DROPPABLE_ID.Y_AXIS_WELL}:${DRAGGABLE_ID.WELL_ITEM}:${metric.name}`,
|
||||
data: {
|
||||
type: DRAGGABLE_ID.WELL_ITEM,
|
||||
wellId: DROPPABLE_ID.Y_AXIS_WELL,
|
||||
column: metric,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<WellItem
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ visibility: isDragging ? "hidden" : "visible" }}
|
||||
onRemove={onRemove}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
<Text truncate>{metric.display_name}</Text>
|
||||
</WellItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Text } from "metabase/ui";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getVisualizerComputedSettings,
|
||||
getVisualizerDatasetColumns,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isDraggedColumnItem } from "metabase/visualizer/utils";
|
||||
import { removeColumn } from "metabase/visualizer/visualizer.slice";
|
||||
import { isMetric } from "metabase-lib/v1/types/utils/isa";
|
||||
|
||||
import { WellItem } from "../WellItem";
|
||||
|
||||
import { SimpleVerticalWell } from "./SimpleVerticalWell";
|
||||
|
||||
export function FunnelVerticalWell() {
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { active, isOver, setNodeRef } = useDroppable({
|
||||
id: DROPPABLE_ID.Y_AXIS_WELL,
|
||||
});
|
||||
|
||||
const metric = columns.find(
|
||||
column => column.name === settings["funnel.metric"],
|
||||
);
|
||||
|
||||
const isHighlighted = useMemo(() => {
|
||||
if (!active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
return isMetric(column);
|
||||
}, [active]);
|
||||
|
||||
const handleRemoveMetric = () => {
|
||||
if (metric) {
|
||||
dispatch(removeColumn({ name: metric.name }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleVerticalWell
|
||||
hasValues={!!metric}
|
||||
isHighlighted={isHighlighted}
|
||||
isOver={isOver}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{!!metric && (
|
||||
<WellItem
|
||||
onRemove={handleRemoveMetric}
|
||||
style={{ position: "absolute", transform: "rotate(-90deg)" }}
|
||||
>
|
||||
<Text truncate>{metric.display_name}</Text>
|
||||
</WellItem>
|
||||
)}
|
||||
</SimpleVerticalWell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Ellipsified } from "metabase/core/components/Ellipsified";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Box, Stack, Text } from "metabase/ui";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getVisualizerComputedSettings,
|
||||
getVisualizerDatasetColumns,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isDraggedColumnItem } from "metabase/visualizer/utils";
|
||||
import { removeColumn } from "metabase/visualizer/visualizer.slice";
|
||||
import { isDimension, isMetric } from "metabase-lib/v1/types/utils/isa";
|
||||
|
||||
import { WellItem } from "../WellItem";
|
||||
|
||||
import { WellBox } from "./WellBox";
|
||||
|
||||
export function MapVerticalWell() {
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const mapType = settings["map.type"];
|
||||
|
||||
switch (mapType) {
|
||||
case "region":
|
||||
return (
|
||||
<Box>
|
||||
<MapMetricWell />
|
||||
<MapDimensionWell
|
||||
settingsKey="map.dimension"
|
||||
droppableId={DROPPABLE_ID.MAP_DIMENSION}
|
||||
label={t`Map region`}
|
||||
placeholder={t`Drop your map Dimension here`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case "pin":
|
||||
return (
|
||||
<Box>
|
||||
<MapDimensionWell
|
||||
settingsKey="map.latitude_column"
|
||||
droppableId={DROPPABLE_ID.MAP_LATITUDE}
|
||||
label={t`Latitude`}
|
||||
placeholder={t`Drop your Latitude Dimension here`}
|
||||
/>
|
||||
<MapDimensionWell
|
||||
settingsKey="map.longitude_column"
|
||||
droppableId={DROPPABLE_ID.MAP_LONGITUDE}
|
||||
label={t`Latitude`}
|
||||
placeholder={t`Drop your Longitude Dimension here`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
case "grid":
|
||||
return (
|
||||
<Box>
|
||||
<MapMetricWell />
|
||||
<MapDimensionWell
|
||||
settingsKey="map.latitude_column"
|
||||
droppableId={DROPPABLE_ID.MAP_LATITUDE}
|
||||
label={t`Latitude`}
|
||||
placeholder={t`Drop your Latitude Dimension here`}
|
||||
/>
|
||||
<MapDimensionWell
|
||||
settingsKey="map.longitude_column"
|
||||
droppableId={DROPPABLE_ID.MAP_LONGITUDE}
|
||||
label={t`Latitude`}
|
||||
placeholder={t`Drop your Longitude Dimension here`}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function MapMetricWell() {
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { active, isOver, setNodeRef } = useDroppable({
|
||||
id: DROPPABLE_ID.MAP_METRIC,
|
||||
});
|
||||
|
||||
const isHighlighted = useMemo(() => {
|
||||
if (!active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
return isMetric(column);
|
||||
}, [active]);
|
||||
|
||||
const metricColumnName = settings["map.metric_column"];
|
||||
|
||||
const metric = useMemo(
|
||||
() => columns.find(col => col.name === metricColumnName),
|
||||
[metricColumnName, columns],
|
||||
);
|
||||
|
||||
const handleRemoveMetric = useCallback(() => {
|
||||
if (metric) {
|
||||
dispatch(removeColumn({ name: metric.name }));
|
||||
}
|
||||
}, [metric, dispatch]);
|
||||
|
||||
return (
|
||||
<Box mt="lg">
|
||||
<Text>{t`Map metric`}</Text>
|
||||
<WellBox isHighlighted={isHighlighted} isOver={isOver} ref={setNodeRef}>
|
||||
<Stack>
|
||||
<WellItem onRemove={metric && handleRemoveMetric}>
|
||||
<Ellipsified>
|
||||
{metric?.display_name ?? t`Drop your map Metric here`}
|
||||
</Ellipsified>
|
||||
</WellItem>
|
||||
</Stack>
|
||||
</WellBox>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface MapDimensionWellProps {
|
||||
settingsKey: string;
|
||||
droppableId: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
function MapDimensionWell(props: MapDimensionWellProps) {
|
||||
const { settingsKey, droppableId, label, placeholder } = props;
|
||||
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: droppableId,
|
||||
});
|
||||
|
||||
const isHighlighted = useMemo(() => {
|
||||
if (!active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
return isDimension(column);
|
||||
}, [active]);
|
||||
|
||||
const dimension = useMemo(
|
||||
() => columns.find(col => col.name === settings[settingsKey]),
|
||||
[settings, settingsKey, columns],
|
||||
);
|
||||
|
||||
const handleRemoveDimension = useCallback(() => {
|
||||
if (dimension) {
|
||||
dispatch(removeColumn({ name: dimension.name }));
|
||||
}
|
||||
}, [dimension, dispatch]);
|
||||
|
||||
return (
|
||||
<Box mt="lg">
|
||||
<Text>{label}</Text>
|
||||
<WellBox isHighlighted={isHighlighted} isOver={isOver} ref={setNodeRef}>
|
||||
<WellItem onRemove={dimension && handleRemoveDimension}>
|
||||
<Ellipsified>{dimension?.display_name ?? placeholder}</Ellipsified>
|
||||
</WellItem>
|
||||
</WellBox>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Box, Stack, Text } from "metabase/ui";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getVisualizerComputedSettings,
|
||||
getVisualizerDatasetColumns,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isDraggedColumnItem } from "metabase/visualizer/utils";
|
||||
import { removeColumn } from "metabase/visualizer/visualizer.slice";
|
||||
import { isDimension, isMetric } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
|
||||
import { WellItem } from "../WellItem";
|
||||
|
||||
import { WellBox } from "./WellBox";
|
||||
|
||||
export function PieVerticalWell() {
|
||||
return (
|
||||
<Box>
|
||||
<PieMetricWell />
|
||||
<PieDimensionWell />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PieMetricWell() {
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { active, isOver, setNodeRef } = useDroppable({
|
||||
id: DROPPABLE_ID.PIE_METRIC,
|
||||
});
|
||||
|
||||
const metric = columns.find(col => col.name === settings["pie.metric"]);
|
||||
|
||||
const isHighlighted = useMemo(() => {
|
||||
if (!active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
return isMetric(column);
|
||||
}, [active]);
|
||||
|
||||
const handleRemoveMetric = () => {
|
||||
if (metric) {
|
||||
dispatch(removeColumn({ name: metric.name }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mt="lg">
|
||||
<Text>{t`Pie chart metric`}</Text>
|
||||
<WellBox isHighlighted={isHighlighted} isOver={isOver} ref={setNodeRef}>
|
||||
<Stack>
|
||||
<WellItem onRemove={metric && handleRemoveMetric}>
|
||||
<Text>
|
||||
{metric?.display_name ?? t`Drop your chart Metric here`}
|
||||
</Text>
|
||||
</WellItem>
|
||||
</Stack>
|
||||
</WellBox>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function PieDimensionWell() {
|
||||
const columns = useSelector(getVisualizerDatasetColumns);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isOver, setNodeRef, active } = useDroppable({
|
||||
id: DROPPABLE_ID.PIE_DIMENSION,
|
||||
});
|
||||
|
||||
const isHighlighted = useMemo(() => {
|
||||
if (!active || !isDraggedColumnItem(active)) {
|
||||
return false;
|
||||
}
|
||||
const { column } = active.data.current;
|
||||
return isDimension(column);
|
||||
}, [active]);
|
||||
|
||||
const dimensions = columns.filter(col =>
|
||||
(settings["pie.dimension"] ?? []).includes(col.name),
|
||||
);
|
||||
|
||||
const handleRemoveDimension = (dimension: DatasetColumn) => {
|
||||
dispatch(removeColumn({ name: dimension.name }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box mt="lg">
|
||||
<Text>{t`Pie chart dimensions`}</Text>
|
||||
<WellBox isHighlighted={isHighlighted} isOver={isOver} ref={setNodeRef}>
|
||||
{dimensions.length > 0 ? (
|
||||
<Stack>
|
||||
{dimensions.map(dimension => (
|
||||
<WellItem
|
||||
key={dimension.id}
|
||||
onRemove={() => handleRemoveDimension(dimension)}
|
||||
>
|
||||
{dimension.display_name}
|
||||
</WellItem>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>{t`Drop your chart Dimensions here`}</Text>
|
||||
)}
|
||||
</WellBox>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { type ReactNode, forwardRef } from "react";
|
||||
|
||||
import { Flex } from "metabase/ui";
|
||||
|
||||
interface SimpleVerticalWellProps {
|
||||
hasValues: boolean;
|
||||
isHighlighted: boolean;
|
||||
isOver: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SimpleVerticalWell = forwardRef<
|
||||
HTMLDivElement,
|
||||
SimpleVerticalWellProps
|
||||
>(function SimpleVerticalWell(
|
||||
{ hasValues, isHighlighted, isOver, children },
|
||||
ref,
|
||||
) {
|
||||
const borderStyle = hasValues ? "solid" : "dashed";
|
||||
const borderColor = isHighlighted
|
||||
? "var(--mb-color-brand)"
|
||||
: "var(--border-color)";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
h="100%"
|
||||
w="42px"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg={isHighlighted ? "var(--mb-color-brand-light)" : "bg-light"}
|
||||
p="md"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderRadius: "var(--border-radius-xl)",
|
||||
border: `1px ${borderStyle} ${borderColor}`,
|
||||
transform: isHighlighted ? "scale(1.025)" : "scale(1)",
|
||||
transition:
|
||||
"transform 0.2s ease-in-out 0.2s, border-color 0.2s ease-in-out 0.2s, background 0.2s ease-in-out 0.2s",
|
||||
outline:
|
||||
isOver && isHighlighted ? "1px solid var(--mb-color-brand)" : "none",
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { isCartesianChart } from "metabase/visualizations";
|
||||
import type { VisualizationDisplay } from "metabase-types/api";
|
||||
|
||||
import { CartesianVerticalWell } from "./CartesianVerticalWell";
|
||||
import { FunnelVerticalWell } from "./FunnelVerticalWell";
|
||||
import { MapVerticalWell } from "./MapVerticalWell";
|
||||
import { PieVerticalWell } from "./PieVerticalWell";
|
||||
|
||||
interface VerticalWellProps {
|
||||
display: VisualizationDisplay;
|
||||
}
|
||||
|
||||
export function VerticalWell({ display }: VerticalWellProps) {
|
||||
if (isCartesianChart(display)) {
|
||||
return <CartesianVerticalWell />;
|
||||
}
|
||||
|
||||
switch (display) {
|
||||
case "funnel":
|
||||
return <FunnelVerticalWell />;
|
||||
case "pie":
|
||||
return <PieVerticalWell />;
|
||||
case "map":
|
||||
return <MapVerticalWell />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { type ReactNode, forwardRef } from "react";
|
||||
|
||||
import { Box } from "metabase/ui";
|
||||
|
||||
interface WellBoxProps {
|
||||
isHighlighted: boolean;
|
||||
isOver: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WellBox = forwardRef<HTMLDivElement, WellBoxProps>(
|
||||
function WellBox({ children, isHighlighted, isOver }, ref) {
|
||||
const borderColor = isHighlighted
|
||||
? "var(--mb-color-brand)"
|
||||
: "var(--mb-color-border)";
|
||||
return (
|
||||
<Box
|
||||
bg={isHighlighted ? "var(--mb-color-brand-light)" : "bg-light"}
|
||||
p="sm"
|
||||
mih="120px"
|
||||
w="300px"
|
||||
style={{
|
||||
borderRadius: "var(--default-border-radius)",
|
||||
border: `1px solid ${borderColor}`,
|
||||
transform: isHighlighted ? "scale(1.025)" : "scale(1)",
|
||||
transition:
|
||||
"transform 0.2s ease-in-out 0.2s, border-color 0.2s ease-in-out 0.2s, background 0.2s ease-in-out 0.2s",
|
||||
outline:
|
||||
isOver && isHighlighted
|
||||
? "1px solid var(--mb-color-brand)"
|
||||
: "none",
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VerticalWell";
|
||||
@@ -0,0 +1,11 @@
|
||||
.Container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto 1fr minmax(auto, 42px);
|
||||
grid-template-areas:
|
||||
"left main top-right"
|
||||
"left main ."
|
||||
"bottom-left bottom .";
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Center,
|
||||
Flex,
|
||||
Icon,
|
||||
Loader,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
getIsLoading,
|
||||
getVisualizationType,
|
||||
getVisualizerRawSeries,
|
||||
} from "metabase/visualizer/selectors";
|
||||
|
||||
import { TabularPreviewModal } from "../TabularPreviewModal";
|
||||
|
||||
import { HorizontalWell } from "./HorizontalWell";
|
||||
import { ScatterFloatingWell } from "./ScatterFloatingWell";
|
||||
import { StartFromViz } from "./StartFromViz";
|
||||
import { VerticalWell } from "./VerticalWell";
|
||||
import Styles from "./VisualizationCanvas.module.css";
|
||||
|
||||
export function VisualizationCanvas() {
|
||||
const [isTabularPreviewOpen, setTabularPreviewOpen] = useState(false);
|
||||
|
||||
const display = useSelector(getVisualizationType);
|
||||
const rawSeries = useSelector(getVisualizerRawSeries);
|
||||
const isLoading = useSelector(getIsLoading);
|
||||
|
||||
const { setNodeRef } = useDroppable({ id: DROPPABLE_ID.CANVAS_MAIN });
|
||||
|
||||
if (!display && !isLoading) {
|
||||
return (
|
||||
<Center h="100%" w="100%" mx="auto">
|
||||
<StartFromViz />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!display || rawSeries.length === 0) {
|
||||
return (
|
||||
<Center h="100%" w="100%" mx="auto">
|
||||
{isLoading ? (
|
||||
<Loader size="lg" />
|
||||
) : (
|
||||
<Text>{t`Visualization will appear here`}</Text>
|
||||
)}
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className={Styles.Container} ref={setNodeRef}>
|
||||
<Box style={{ gridArea: "left" }}>
|
||||
<VerticalWell display={display} />
|
||||
</Box>
|
||||
|
||||
<Box style={{ gridArea: "main" }}>
|
||||
<Visualization
|
||||
rawSeries={rawSeries}
|
||||
// TableInteractive crashes when trying to use metabase-lib
|
||||
isDashboard={display === "table"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Flex
|
||||
align="center"
|
||||
justify="left"
|
||||
pl="7px"
|
||||
style={{ gridArea: "bottom-left" }}
|
||||
>
|
||||
<Tooltip label={t`View as table`}>
|
||||
<ActionIcon onClick={() => setTabularPreviewOpen(true)}>
|
||||
<Icon name="table" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Box style={{ gridArea: "bottom" }}>
|
||||
<HorizontalWell display={display} />
|
||||
</Box>
|
||||
{display === "scatter" && (
|
||||
<Box style={{ gridArea: "top-right" }}>
|
||||
<ScatterFloatingWell />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<TabularPreviewModal
|
||||
opened={isTabularPreviewOpen}
|
||||
onClose={() => setTabularPreviewOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { forwardRef } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { ActionIcon, Flex, type FlexProps, Icon } from "metabase/ui";
|
||||
|
||||
export interface WellItemProps extends FlexProps {
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export const WellItem = forwardRef<HTMLDivElement, WellItemProps>(
|
||||
function WellItem({ children, style, onRemove, ...props }, ref) {
|
||||
return (
|
||||
<Flex
|
||||
direction="row"
|
||||
align="center"
|
||||
bg="var(--mb-color-bg-white)"
|
||||
px="sm"
|
||||
h="24px"
|
||||
{...props}
|
||||
style={{
|
||||
...style,
|
||||
borderRadius: "var(--border-radius-xl)",
|
||||
border: `1px solid var(--mb-color-border)`,
|
||||
boxShadow: "0 0 1px var(--mb-color-shadow)",
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||
{!!onRemove && (
|
||||
<ActionIcon
|
||||
aria-label={t`Remove`}
|
||||
size="sm"
|
||||
ml="xs"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Icon name="close" size="0.8rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VisualizationCanvas";
|
||||
@@ -0,0 +1,14 @@
|
||||
.ListItem[aria-selected="true"] {
|
||||
background-color: var(--mb-color-brand);
|
||||
color: var(--mb-color-text-white);
|
||||
|
||||
.ListItemLabel {
|
||||
color: var(--mb-color-text-white);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.ListItemLabel {
|
||||
color: var(--mb-color-text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import IconButtonWrapper from "metabase/components/IconButtonWrapper";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { Flex, Icon, Menu, Text } from "metabase/ui";
|
||||
import visualizations from "metabase/visualizations";
|
||||
import { getVisualizerRawSeries } from "metabase/visualizer/selectors";
|
||||
import type { VisualizationDisplay } from "metabase-types/api";
|
||||
|
||||
import S from "./VisualizationPicker.module.css";
|
||||
|
||||
interface VisualizationPickerProps {
|
||||
value: VisualizationDisplay | null;
|
||||
onChange: (vizType: string) => void;
|
||||
}
|
||||
|
||||
export function VisualizationPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: VisualizationPickerProps) {
|
||||
const series = useSelector(getVisualizerRawSeries);
|
||||
|
||||
const vizOptions = useMemo(() => {
|
||||
const [mainSeries] = series ?? [];
|
||||
const { data } = mainSeries ?? {};
|
||||
return Array.from(visualizations)
|
||||
.filter(([, viz]) => !viz.hidden)
|
||||
.map(([vizType, viz]) => {
|
||||
return {
|
||||
label: viz.uiName,
|
||||
value: vizType,
|
||||
icon: viz.iconName,
|
||||
isSensible: Boolean(data && viz.isSensible?.(data)),
|
||||
};
|
||||
});
|
||||
}, [series]);
|
||||
|
||||
const [sensibleOptions, nonsensibleOptions] = useMemo(
|
||||
() => _.partition(vizOptions, option => option.isSensible),
|
||||
[vizOptions],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => vizOptions.find(option => option.value === value),
|
||||
[value, vizOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<IconButtonWrapper style={{ marginRight: "4px" }}>
|
||||
<Icon name={selectedOption?.icon ?? "empty"} />
|
||||
<Icon name="chevrondown" size={8} />
|
||||
</IconButtonWrapper>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{sensibleOptions.map(({ label, value, icon }) => (
|
||||
<Menu.Item
|
||||
key={value}
|
||||
className={S.ListItem}
|
||||
aria-selected={value === selectedOption?.value}
|
||||
onClick={() => onChange(value)}
|
||||
>
|
||||
<Flex align="center">
|
||||
<Icon name={icon} />
|
||||
<Text className={S.ListItemLabel} ml="sm">
|
||||
{label}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Menu.Item>
|
||||
))}
|
||||
{nonsensibleOptions.length > 0 && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label>{t`Other charts`}</Menu.Label>
|
||||
</>
|
||||
)}
|
||||
{nonsensibleOptions.map(({ label, value, icon }) => (
|
||||
<Menu.Item
|
||||
key={value}
|
||||
className={S.ListItem}
|
||||
aria-selected={value === selectedOption?.value}
|
||||
onClick={() => onChange(value)}
|
||||
>
|
||||
<Flex align="center">
|
||||
<Icon name={icon} />
|
||||
<Text className={S.ListItemLabel} ml="sm">
|
||||
{label}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VisualizationPicker";
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
DragOverlay,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
} from "@dnd-kit/core";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useKeyPressEvent, usePrevious, useUnmount } from "react-use";
|
||||
|
||||
import EditableText from "metabase/core/components/EditableText";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Box, Flex } from "metabase/ui";
|
||||
import { useVisualizerHistory } from "metabase/visualizer/hooks/use-visualizer-history";
|
||||
import {
|
||||
getDatasets,
|
||||
getDraggedItem,
|
||||
getIsDirty,
|
||||
getIsFullscreenModeEnabled,
|
||||
getIsVizSettingsSidebarOpen,
|
||||
getVisualizationTitle,
|
||||
getVisualizationType,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { isValidDraggedItem } from "metabase/visualizer/utils";
|
||||
import {
|
||||
closeVizSettingsSidebar,
|
||||
handleDrop,
|
||||
resetVisualizer,
|
||||
setDisplay,
|
||||
setDraggedItem,
|
||||
setTitle,
|
||||
turnOffFullscreenMode,
|
||||
} from "metabase/visualizer/visualizer.slice";
|
||||
import type { VisualizationDisplay } from "metabase-types/api";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
import { DataImporter } from "../DataImporter";
|
||||
import { DataManager } from "../DataManager";
|
||||
import { DragOverlay as VisualizerDragOverlay } from "../DragOverlay";
|
||||
import { Header } from "../Header";
|
||||
import { VisualizationCanvas } from "../VisualizationCanvas";
|
||||
import { VisualizationPicker } from "../VisualizationPicker";
|
||||
import { VizSettingsSidebar } from "../VizSettingsSidebar/VizSettingsSidebar";
|
||||
|
||||
interface VisualizerProps {
|
||||
className?: string;
|
||||
onSave?: (visualization: VisualizerHistoryItem) => void;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export const Visualizer = (props: VisualizerProps) => {
|
||||
const { className, onSave, saveLabel } = props;
|
||||
const { canUndo, canRedo, undo, redo } = useVisualizerHistory();
|
||||
|
||||
const title = useSelector(getVisualizationTitle);
|
||||
const display = useSelector(getVisualizationType);
|
||||
const draggedItem = useSelector(getDraggedItem);
|
||||
const datasets = useSelector(getDatasets);
|
||||
const isFullscreen = useSelector(getIsFullscreenModeEnabled);
|
||||
const isVizSettingsSidebarOpen = useSelector(getIsVizSettingsSidebarOpen);
|
||||
|
||||
const isDirty = useSelector(getIsDirty);
|
||||
const wasDirty = usePrevious(isDirty);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const hasDatasets = Object.values(datasets).length > 0;
|
||||
|
||||
const canvasSensor = useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 10 },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (wasDirty && !isDirty) {
|
||||
dispatch(closeVizSettingsSidebar());
|
||||
dispatch(turnOffFullscreenMode());
|
||||
}
|
||||
}, [isDirty, wasDirty, dispatch]);
|
||||
|
||||
useUnmount(() => {
|
||||
dispatch(resetVisualizer({ full: true }));
|
||||
});
|
||||
|
||||
useKeyPressEvent("z", event => {
|
||||
event.preventDefault();
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
if (event.shiftKey) {
|
||||
if (canRedo) {
|
||||
redo();
|
||||
}
|
||||
} else if (canUndo) {
|
||||
undo();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (isValidDraggedItem(event.active)) {
|
||||
dispatch(
|
||||
setDraggedItem({
|
||||
id: event.active.id,
|
||||
data: {
|
||||
current: event.active.data.current,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
dispatch(handleDrop(event));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleChangeTitle = useCallback(
|
||||
(nextTitle: string) => {
|
||||
dispatch(setTitle(nextTitle));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleChangeDisplay = useCallback(
|
||||
(nextDisplay: string) => {
|
||||
dispatch(setDisplay(nextDisplay as VisualizationDisplay));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={[canvasSensor]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Flex className={className} direction="column">
|
||||
<Header onSave={onSave} saveLabel={saveLabel} />
|
||||
<Flex style={{ overflow: "hidden", flexGrow: 1 }}>
|
||||
{!isFullscreen && (
|
||||
<Flex direction="column" miw={320}>
|
||||
<Box h="50%" p={10} pr={0} style={{ overflowY: "hidden" }}>
|
||||
<DataImporter />
|
||||
</Box>
|
||||
<Box h="50%" pl={10} pb={10} style={{ overflowY: "auto" }}>
|
||||
<DataManager />
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<Box
|
||||
w="100%"
|
||||
m={10}
|
||||
px="xl"
|
||||
bg="white"
|
||||
style={{
|
||||
borderRadius: "var(--default-border-radius)",
|
||||
overflowY: "hidden",
|
||||
border: `1px solid var(--mb-color-border)`,
|
||||
boxShadow: "0 1px 2px 2px var(--mb-color-border)",
|
||||
}}
|
||||
>
|
||||
{hasDatasets && (
|
||||
<Flex direction="row" align="center" justify="space-between">
|
||||
<EditableText
|
||||
initialValue={title}
|
||||
onChange={handleChangeTitle}
|
||||
/>
|
||||
<VisualizationPicker
|
||||
value={display}
|
||||
onChange={handleChangeDisplay}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<Box h="90%">
|
||||
<VisualizationCanvas />
|
||||
</Box>
|
||||
</Box>
|
||||
{!isFullscreen && isVizSettingsSidebarOpen && (
|
||||
<Flex direction="column" miw={320}>
|
||||
<VizSettingsSidebar />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{draggedItem && <VisualizerDragOverlay item={draggedItem} />}
|
||||
</DragOverlay>
|
||||
</Flex>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Visualizer";
|
||||
@@ -0,0 +1,4 @@
|
||||
.VisualizerRoot {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect } from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useModalOpen } from "metabase/hooks/use-modal-open";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { Modal } from "metabase/ui";
|
||||
import { initializeVisualizer } from "metabase/visualizer/visualizer.slice";
|
||||
import type {
|
||||
VisualizerDataSourceId,
|
||||
VisualizerHistoryItem,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
import { Visualizer } from "../Visualizer";
|
||||
|
||||
import S from "./VisualizerModal.module.css";
|
||||
|
||||
interface VisualizerModalProps {
|
||||
initialState?: {
|
||||
state?: Partial<VisualizerHistoryItem>;
|
||||
extraDataSources?: VisualizerDataSourceId[];
|
||||
};
|
||||
onSave: (visualization: VisualizerHistoryItem) => void;
|
||||
onClose: () => void;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function VisualizerModal({
|
||||
initialState,
|
||||
onSave,
|
||||
onClose,
|
||||
saveLabel,
|
||||
}: VisualizerModalProps) {
|
||||
const { open } = useModalOpen();
|
||||
const wasOpen = usePrevious(open);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !wasOpen && initialState) {
|
||||
dispatch(initializeVisualizer(initialState));
|
||||
}
|
||||
}, [open, wasOpen, initialState, dispatch]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
title={t`Visualize`}
|
||||
size="100%"
|
||||
transitionProps={{ transition: "fade", duration: 200 }}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Visualizer
|
||||
className={S.VisualizerRoot}
|
||||
onSave={onSave}
|
||||
saveLabel={saveLabel}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VisualizerModal";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { QuestionChartSettings } from "metabase/visualizations/components/ChartSettings";
|
||||
import {
|
||||
getVisualizerComputedSettings,
|
||||
getVisualizerRawSeries,
|
||||
} from "metabase/visualizer/selectors";
|
||||
import { updateSettings } from "metabase/visualizer/visualizer.slice";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type { VisualizationSettings } from "metabase-types/api";
|
||||
|
||||
export function VizSettingsSidebar() {
|
||||
const series = useSelector(getVisualizerRawSeries);
|
||||
const settings = useSelector(getVisualizerComputedSettings);
|
||||
const metadata = useSelector(getMetadata);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const question = useMemo(() => {
|
||||
if (series.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [{ card }] = series;
|
||||
return new Question(card, metadata);
|
||||
}, [series, metadata]);
|
||||
|
||||
const handleChangeSettings = (settings: VisualizationSettings) => {
|
||||
dispatch(updateSettings(settings));
|
||||
};
|
||||
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<QuestionChartSettings
|
||||
question={question}
|
||||
series={series}
|
||||
computedSettings={settings}
|
||||
onChange={handleChangeSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
frontend/src/metabase/visualizer/constants.ts
Normal file
21
frontend/src/metabase/visualizer/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const DRAGGABLE_ID = {
|
||||
COLUMN: "COLUMN",
|
||||
WELL_ITEM: "WELL_ITEM",
|
||||
};
|
||||
|
||||
export const DROPPABLE_ID = {
|
||||
CANVAS_MAIN: "CANVAS_MAIN",
|
||||
|
||||
X_AXIS_WELL: "X_AXIS_WELL",
|
||||
Y_AXIS_WELL: "Y_AXIS_WELL",
|
||||
|
||||
PIE_METRIC: "PIE_METRIC_WELL",
|
||||
PIE_DIMENSION: "PIE_DIMENSION_WELL",
|
||||
|
||||
MAP_METRIC: "MAP_METRIC_WELL",
|
||||
MAP_DIMENSION: "MAP_DIMENSION_WELL",
|
||||
MAP_LATITUDE: "MAP_LATITUDE_WELL",
|
||||
MAP_LONGITUDE: "MAP_LONGITUDE_WELL",
|
||||
|
||||
SCATTER_BUBBLE_SIZE_WELL: "SCATTER_BUBBLE_SIZE_WELL",
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
|
||||
import { getCanRedo, getCanUndo } from "../selectors";
|
||||
import { redo, undo } from "../visualizer.slice";
|
||||
|
||||
export function useVisualizerHistory() {
|
||||
const dispatch = useDispatch();
|
||||
return {
|
||||
canUndo: useSelector(getCanUndo),
|
||||
canRedo: useSelector(getCanRedo),
|
||||
undo: () => dispatch(undo()),
|
||||
redo: () => dispatch(redo()),
|
||||
};
|
||||
}
|
||||
218
frontend/src/metabase/visualizer/selectors.ts
Normal file
218
frontend/src/metabase/visualizer/selectors.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { createSelector } from "@reduxjs/toolkit";
|
||||
|
||||
import { utf8_to_b64 } from "metabase/lib/encoding";
|
||||
import { isCartesianChart } from "metabase/visualizations";
|
||||
import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
|
||||
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
|
||||
import type { DatasetData, RawSeries } from "metabase-types/api";
|
||||
import type {
|
||||
VisualizerHistoryItem,
|
||||
VisualizerState,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
import {
|
||||
createDataSource,
|
||||
extractReferencedColumns,
|
||||
getDefaultVisualizationName,
|
||||
mergeVisualizerData,
|
||||
} from "./utils";
|
||||
|
||||
type State = { visualizer: VisualizerState };
|
||||
|
||||
// Private selectors
|
||||
|
||||
const getCurrentHistoryItem = (state: State) => state.visualizer.present;
|
||||
|
||||
const getCards = (state: State) => state.visualizer.cards;
|
||||
|
||||
const getRawSettings = (state: State) => getCurrentHistoryItem(state).settings;
|
||||
|
||||
const getSettings = createSelector(
|
||||
[getVisualizationType, getRawSettings],
|
||||
(display, rawSettings) => {
|
||||
if (display && isCartesianChart(display)) {
|
||||
// Visualizer wells display labels
|
||||
return {
|
||||
...rawSettings,
|
||||
"graph.x_axis.labels_enabled": false,
|
||||
"graph.y_axis.labels_enabled": false,
|
||||
};
|
||||
}
|
||||
return rawSettings;
|
||||
},
|
||||
);
|
||||
|
||||
const getVisualizationColumns = (state: State) =>
|
||||
getCurrentHistoryItem(state).columns;
|
||||
|
||||
const getVisualizerColumnValuesMapping = (state: State) =>
|
||||
getCurrentHistoryItem(state).columnValuesMapping;
|
||||
|
||||
// Public selectors
|
||||
|
||||
export function getVisualizationTitle(state: State) {
|
||||
const settings = getRawSettings(state);
|
||||
return settings["card.title"] ?? getDefaultVisualizationName();
|
||||
}
|
||||
|
||||
export function getVisualizationType(state: State) {
|
||||
return getCurrentHistoryItem(state).display;
|
||||
}
|
||||
|
||||
export const getDatasets = (state: State) => state.visualizer.datasets;
|
||||
|
||||
export const getLoadingDatasets = (state: State) =>
|
||||
state.visualizer.loadingDatasets;
|
||||
|
||||
export const getExpandedDataSources = (state: State) =>
|
||||
state.visualizer.expandedDataSources;
|
||||
|
||||
export const getIsLoading = createSelector(
|
||||
[state => state.visualizer.loadingDataSources, getLoadingDatasets],
|
||||
(loadingDataSources, loadingDatasets) => {
|
||||
return (
|
||||
Object.values(loadingDataSources).includes(true) ||
|
||||
Object.values(loadingDatasets).includes(true)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const getDraggedItem = (state: State) => state.visualizer.draggedItem;
|
||||
|
||||
export const getIsFullscreenModeEnabled = (state: State) =>
|
||||
state.visualizer.isFullscreen;
|
||||
|
||||
export const getIsVizSettingsSidebarOpen = (state: State) =>
|
||||
state.visualizer.isVizSettingsSidebarOpen;
|
||||
|
||||
export const getCanUndo = (state: State) => state.visualizer.past.length > 0;
|
||||
export const getCanRedo = (state: State) => state.visualizer.future.length > 0;
|
||||
|
||||
export const getReferencedColumns = createSelector(
|
||||
[getVisualizerColumnValuesMapping],
|
||||
mappings => extractReferencedColumns(mappings),
|
||||
);
|
||||
|
||||
export const getDataSources = createSelector([getCards], cards =>
|
||||
cards.map(card => createDataSource("card", card.id, card.name)),
|
||||
);
|
||||
|
||||
export const getUsedDataSources = createSelector(
|
||||
[getDataSources, getReferencedColumns],
|
||||
(dataSources, referencedColumns) => {
|
||||
if (dataSources.length === 1) {
|
||||
return dataSources;
|
||||
}
|
||||
const usedDataSourceIds = new Set(
|
||||
referencedColumns.map(ref => ref.sourceId),
|
||||
);
|
||||
return dataSources.filter(dataSource =>
|
||||
usedDataSourceIds.has(dataSource.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const getVisualizerDatasetData = createSelector(
|
||||
[
|
||||
getUsedDataSources,
|
||||
getDatasets,
|
||||
getVisualizationColumns,
|
||||
getVisualizerColumnValuesMapping,
|
||||
],
|
||||
(dataSources, datasets, columns, columnValuesMapping): DatasetData =>
|
||||
mergeVisualizerData({
|
||||
columns,
|
||||
columnValuesMapping,
|
||||
datasets,
|
||||
dataSources,
|
||||
}),
|
||||
);
|
||||
|
||||
export const getVisualizerDatasetColumns = createSelector(
|
||||
[getVisualizerDatasetData],
|
||||
data => data.cols,
|
||||
);
|
||||
|
||||
export const getVisualizerRawSeries = createSelector(
|
||||
[getVisualizationType, getSettings, getVisualizerDatasetData],
|
||||
(display, settings, data): RawSeries => {
|
||||
if (!display) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
card: {
|
||||
display,
|
||||
visualization_settings: settings,
|
||||
},
|
||||
data,
|
||||
|
||||
// Certain visualizations memoize settings computation based on series keys
|
||||
// This guarantees a visualization always rerenders on changes
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
export const getVisualizerComputedSettings = createSelector(
|
||||
[getVisualizerRawSeries],
|
||||
(rawSeries): ComputedVisualizationSettings =>
|
||||
rawSeries.length > 0 ? getComputedSettingsForSeries(rawSeries) : {},
|
||||
);
|
||||
|
||||
export const getTabularPreviewSeries = createSelector(
|
||||
[getVisualizerRawSeries],
|
||||
rawSeries => {
|
||||
if (rawSeries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const [{ card, ...rest }] = rawSeries;
|
||||
if (card.display === "table") {
|
||||
return rawSeries;
|
||||
}
|
||||
return [
|
||||
{
|
||||
...rest,
|
||||
card: {
|
||||
display: "table",
|
||||
visualization_settings: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
export const getCurrentVisualizerState = getCurrentHistoryItem;
|
||||
|
||||
export const getIsDirty = createSelector([getCurrentHistoryItem], state =>
|
||||
checkIfStateDirty(state),
|
||||
);
|
||||
|
||||
export const getVisualizerUrlHash = createSelector(
|
||||
[getCurrentVisualizerState],
|
||||
state => getStateHash(state),
|
||||
);
|
||||
|
||||
export const getPastVisualizerUrlHashes = createSelector(
|
||||
[state => state.visualizer.past],
|
||||
items => items.map(getStateHash),
|
||||
);
|
||||
|
||||
export const getFutureVisualizerUrlHashes = createSelector(
|
||||
[state => state.visualizer.future],
|
||||
items => items.map(getStateHash),
|
||||
);
|
||||
|
||||
function getStateHash(state: VisualizerHistoryItem) {
|
||||
return checkIfStateDirty(state) ? utf8_to_b64(JSON.stringify({ state })) : "";
|
||||
}
|
||||
|
||||
function checkIfStateDirty(state: VisualizerHistoryItem) {
|
||||
return (
|
||||
!!state.display ||
|
||||
state.columns.length > 0 ||
|
||||
Object.keys(state.settings).length > 0 ||
|
||||
Object.keys(state.columnValuesMapping).length > 0
|
||||
);
|
||||
}
|
||||
117
frontend/src/metabase/visualizer/utils/column.ts
Normal file
117
frontend/src/metabase/visualizer/utils/column.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { isDate } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
import type {
|
||||
VisualizerColumnReference,
|
||||
VisualizerColumnValueSource,
|
||||
VisualizerDataSource,
|
||||
VisualizerDataSourceId,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
export function isReferenceToColumn(
|
||||
column: DatasetColumn,
|
||||
dataSourceId: VisualizerDataSourceId,
|
||||
ref: VisualizerColumnReference,
|
||||
) {
|
||||
return dataSourceId === ref.sourceId && column.name === ref.originalName;
|
||||
}
|
||||
|
||||
export function compareColumnReferences(
|
||||
r1: VisualizerColumnReference,
|
||||
r2: VisualizerColumnReference,
|
||||
) {
|
||||
return r1.sourceId === r2.sourceId && r1.originalName === r2.originalName;
|
||||
}
|
||||
|
||||
function checkColumnMappingExists(
|
||||
columnValueSources: VisualizerColumnValueSource[],
|
||||
valueSource: VisualizerColumnValueSource,
|
||||
) {
|
||||
if (typeof valueSource === "string") {
|
||||
return columnValueSources.includes(valueSource);
|
||||
}
|
||||
|
||||
return columnValueSources.some(
|
||||
source =>
|
||||
typeof source !== "string" &&
|
||||
compareColumnReferences(source, valueSource),
|
||||
);
|
||||
}
|
||||
|
||||
export function createVisualizerColumnReference(
|
||||
dataSource: VisualizerDataSource,
|
||||
column: DatasetColumn,
|
||||
otherReferencedColumns: VisualizerColumnReference[],
|
||||
): VisualizerColumnReference {
|
||||
const existingRef = otherReferencedColumns.find(ref =>
|
||||
isReferenceToColumn(column, dataSource.id, ref),
|
||||
);
|
||||
if (existingRef) {
|
||||
return existingRef;
|
||||
}
|
||||
|
||||
let nameIndex = otherReferencedColumns.length + 1;
|
||||
let hasDuplicate = otherReferencedColumns.some(
|
||||
ref => ref.name === `COLUMN_${nameIndex}`,
|
||||
);
|
||||
while (hasDuplicate) {
|
||||
nameIndex++;
|
||||
hasDuplicate = otherReferencedColumns.some(
|
||||
ref => ref.name === `COLUMN_${nameIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
sourceId: dataSource.id,
|
||||
originalName: column.name,
|
||||
name: `COLUMN_${nameIndex}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function copyColumn(name: string, column: DatasetColumn): DatasetColumn {
|
||||
const copy: DatasetColumn = {
|
||||
...column,
|
||||
name,
|
||||
// @ts-expect-error TODO name is supposed to be a number (FieldId) but here it's a string?
|
||||
field_ref: ["field", name, { "base-type": column.base_type }],
|
||||
};
|
||||
|
||||
// TODO Remove manual MBQL manipulation
|
||||
if (isDate(column)) {
|
||||
const opts = copy.field_ref?.[2];
|
||||
const temporalUnit = maybeGetTemporalUnit(column);
|
||||
if (temporalUnit && opts) {
|
||||
opts["temporal-unit"] = temporalUnit;
|
||||
}
|
||||
copy.field_ref = ["field", name, opts];
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function addColumnMapping(
|
||||
mapping: VisualizerColumnValueSource[] | undefined,
|
||||
source: VisualizerColumnValueSource,
|
||||
) {
|
||||
const nextMapping = mapping ? [...mapping] : [];
|
||||
if (!checkColumnMappingExists(nextMapping, source)) {
|
||||
nextMapping.push(source);
|
||||
}
|
||||
return nextMapping;
|
||||
}
|
||||
|
||||
export function extractReferencedColumns(
|
||||
mappings: Record<string, VisualizerColumnValueSource[]>,
|
||||
): VisualizerColumnReference[] {
|
||||
const sources = Object.values(mappings).flat();
|
||||
return sources.filter(
|
||||
(valueSource): valueSource is VisualizerColumnReference =>
|
||||
typeof valueSource !== "string",
|
||||
);
|
||||
}
|
||||
|
||||
function maybeGetTemporalUnit(col: DatasetColumn) {
|
||||
const maybeOpts = col.field_ref?.[2];
|
||||
if (maybeOpts && "temporal-unit" in maybeOpts) {
|
||||
return maybeOpts["temporal-unit"];
|
||||
}
|
||||
}
|
||||
70
frontend/src/metabase/visualizer/utils/data-source.ts
Normal file
70
frontend/src/metabase/visualizer/utils/data-source.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
VisualizerColumnValueSource,
|
||||
VisualizerDataSource,
|
||||
VisualizerDataSourceId,
|
||||
VisualizerDataSourceNameReference,
|
||||
VisualizerDataSourceType,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
import { extractReferencedColumns } from "./column";
|
||||
|
||||
export function createDataSource(
|
||||
type: VisualizerDataSourceType,
|
||||
sourceId: number,
|
||||
name: string,
|
||||
): VisualizerDataSource {
|
||||
return {
|
||||
id: `${type}:${sourceId}`,
|
||||
sourceId,
|
||||
type,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDataSourceId(id: VisualizerDataSourceId) {
|
||||
const [type, sourceId] = id.split(":");
|
||||
return { type, sourceId: Number(sourceId) };
|
||||
}
|
||||
|
||||
export function isDataSourceId(id: string): id is VisualizerDataSourceId {
|
||||
try {
|
||||
const { type, sourceId } = parseDataSourceId(id as VisualizerDataSourceId);
|
||||
return type === "card" && Number.isSafeInteger(sourceId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createDataSourceNameRef(
|
||||
id: VisualizerDataSourceId,
|
||||
): VisualizerDataSourceNameReference {
|
||||
return `$_${id}_name`;
|
||||
}
|
||||
|
||||
export function isDataSourceNameRef(
|
||||
value: VisualizerColumnValueSource,
|
||||
): value is VisualizerDataSourceNameReference {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.startsWith("$_") &&
|
||||
value.endsWith("_name")
|
||||
);
|
||||
}
|
||||
|
||||
export function getDataSourceIdFromNameRef(str: string) {
|
||||
const [, dataSourceId] = str.split("_");
|
||||
return dataSourceId;
|
||||
}
|
||||
|
||||
export function getCardIdsFromColumnValueMappings(
|
||||
columnValuesMapping: Record<string, VisualizerColumnValueSource[]>,
|
||||
) {
|
||||
const referencedColumns = extractReferencedColumns(columnValuesMapping);
|
||||
const usedDataSourceIds = Array.from(
|
||||
new Set(referencedColumns.map(ref => ref.sourceId)),
|
||||
);
|
||||
return usedDataSourceIds.map(id => {
|
||||
const { sourceId } = parseDataSourceId(id);
|
||||
return sourceId;
|
||||
});
|
||||
}
|
||||
23
frontend/src/metabase/visualizer/utils/drag-and-drop.ts
Normal file
23
frontend/src/metabase/visualizer/utils/drag-and-drop.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Active } from "@dnd-kit/core";
|
||||
|
||||
import type {
|
||||
DraggedColumn,
|
||||
DraggedItem,
|
||||
DraggedWellItem,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
import { DRAGGABLE_ID } from "../constants";
|
||||
|
||||
type DndItem = Omit<Active, "rect">;
|
||||
|
||||
export function isDraggedColumnItem(item: DndItem): item is DraggedColumn {
|
||||
return item.data?.current?.type === DRAGGABLE_ID.COLUMN;
|
||||
}
|
||||
|
||||
export function isDraggedWellItem(item: DndItem): item is DraggedWellItem {
|
||||
return item.data?.current?.type === DRAGGABLE_ID.WELL_ITEM;
|
||||
}
|
||||
|
||||
export function isValidDraggedItem(item: DndItem): item is DraggedItem {
|
||||
return isDraggedColumnItem(item) || isDraggedWellItem(item);
|
||||
}
|
||||
6
frontend/src/metabase/visualizer/utils/index.ts
Normal file
6
frontend/src/metabase/visualizer/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./column";
|
||||
export * from "./data-source";
|
||||
export * from "./drag-and-drop";
|
||||
export * from "./is-visualizer-dashboard-card";
|
||||
export * from "./merge-data";
|
||||
export * from "./misc";
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { DashboardCard, QuestionDashboardCard } from "metabase-types/api";
|
||||
|
||||
export function isVisualizerDashboardCard(
|
||||
dashcard: DashboardCard | undefined,
|
||||
): dashcard is QuestionDashboardCard {
|
||||
if (!dashcard?.visualization_settings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return dashcard.visualization_settings["visualization"] !== undefined;
|
||||
}
|
||||
70
frontend/src/metabase/visualizer/utils/merge-data.ts
Normal file
70
frontend/src/metabase/visualizer/utils/merge-data.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import _ from "underscore";
|
||||
|
||||
import type {
|
||||
Dataset,
|
||||
DatasetColumn,
|
||||
DatasetData,
|
||||
RowValues,
|
||||
} from "metabase-types/api";
|
||||
import type {
|
||||
VisualizerColumnValueSource,
|
||||
VisualizerDataSource,
|
||||
VisualizerDataSourceId,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
import { extractReferencedColumns } from "./column";
|
||||
import { getDataSourceIdFromNameRef, isDataSourceNameRef } from "./data-source";
|
||||
|
||||
type MergeVisualizerSeries = {
|
||||
columns: DatasetColumn[];
|
||||
columnValuesMapping: Record<string, VisualizerColumnValueSource[]>;
|
||||
datasets: Record<VisualizerDataSourceId, Dataset | null | undefined>;
|
||||
dataSources: VisualizerDataSource[];
|
||||
};
|
||||
|
||||
export function mergeVisualizerData({
|
||||
columns,
|
||||
columnValuesMapping,
|
||||
datasets,
|
||||
dataSources,
|
||||
}: MergeVisualizerSeries): DatasetData {
|
||||
const referencedColumns = extractReferencedColumns(columnValuesMapping);
|
||||
|
||||
const referencedColumnValuesMap: Record<string, RowValues> = {};
|
||||
referencedColumns.forEach(ref => {
|
||||
const dataset = datasets[ref.sourceId];
|
||||
if (!dataset) {
|
||||
return;
|
||||
}
|
||||
const columnIndex = dataset.data.cols.findIndex(
|
||||
col => col.name === ref.originalName,
|
||||
);
|
||||
if (columnIndex >= 0) {
|
||||
const values = dataset.data.rows.map(row => row[columnIndex]);
|
||||
referencedColumnValuesMap[ref.name] = values;
|
||||
}
|
||||
});
|
||||
|
||||
const unzippedRows = columns.map(column =>
|
||||
(columnValuesMapping[column.name] ?? [])
|
||||
.map(valueSource => {
|
||||
if (isDataSourceNameRef(valueSource)) {
|
||||
const id = getDataSourceIdFromNameRef(valueSource);
|
||||
const dataSource = dataSources.find(source => source.id === id);
|
||||
return dataSource?.name ? [dataSource.name] : [];
|
||||
}
|
||||
const values = referencedColumnValuesMap[valueSource.name];
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
return values;
|
||||
})
|
||||
.flat(),
|
||||
);
|
||||
|
||||
return {
|
||||
cols: columns,
|
||||
rows: _.zip(...unzippedRows),
|
||||
results_metadata: { columns },
|
||||
};
|
||||
}
|
||||
142
frontend/src/metabase/visualizer/utils/misc.ts
Normal file
142
frontend/src/metabase/visualizer/utils/misc.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import { getColumnVizSettings } from "metabase/visualizations";
|
||||
import { isDate, isNumeric } from "metabase-lib/v1/types/utils/isa";
|
||||
import type {
|
||||
Card,
|
||||
DatasetColumn,
|
||||
SingleSeries,
|
||||
VisualizationDisplay,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
import {
|
||||
copyColumn,
|
||||
createVisualizerColumnReference,
|
||||
extractReferencedColumns,
|
||||
} from "./column";
|
||||
import { createDataSource } from "./data-source";
|
||||
|
||||
export function getDefaultVisualizationName() {
|
||||
return t`My new visualization`;
|
||||
}
|
||||
|
||||
const areaBarLine = ["area", "bar", "line"];
|
||||
|
||||
export function canCombineCard(
|
||||
display: VisualizationDisplay,
|
||||
columns: DatasetColumn[],
|
||||
settings: VisualizationSettings,
|
||||
card: Card,
|
||||
) {
|
||||
if (areaBarLine.includes(display) && areaBarLine.includes(card.display)) {
|
||||
return areAreaBarLineSeriesCompatible(columns, settings, card);
|
||||
}
|
||||
|
||||
if (display === "funnel" && card.display === "scalar") {
|
||||
return columns.length === 1;
|
||||
}
|
||||
|
||||
if (display === "scalar" && card.display === "scalar") {
|
||||
return columns.length === 1 && card.result_metadata.length === 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mimics the `area-bar-line-series-are-compatible?` fn from `GET /api/card/:id/series`
|
||||
// https://github.com/metabase/metabase/blob/5cfc079d1db6e69bf42705f0eeba431a6e39c6b5/src/metabase/api/card.clj#L219
|
||||
function areAreaBarLineSeriesCompatible(
|
||||
columns: DatasetColumn[],
|
||||
settings: VisualizationSettings,
|
||||
card: Card,
|
||||
) {
|
||||
const initialDimensions = (settings["graph.dimensions"] ?? []).map(col =>
|
||||
columns.find(c => c.name === col),
|
||||
);
|
||||
const newDimensions = (
|
||||
card.visualization_settings["graph.dimensions"] ?? []
|
||||
).map(col => card.result_metadata.find(c => c.name === col));
|
||||
const newMetrics = (card.visualization_settings["graph.metrics"] ?? []).map(
|
||||
col => card.result_metadata.find(c => c.name === col),
|
||||
);
|
||||
|
||||
if (
|
||||
newDimensions.length === 0 ||
|
||||
newMetrics.length === 0 ||
|
||||
!newMetrics.every(isNumeric)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [primaryInitialDimension] = initialDimensions;
|
||||
const [primaryNewDimension] = newDimensions;
|
||||
|
||||
// both or neither primary dimension must be dates
|
||||
// both or neither primary dimension must be numeric
|
||||
// TODO handle 👇
|
||||
// a timestamp field is both date and number so don't enforce the condition if both fields are dates; see #2811
|
||||
return (
|
||||
primaryNewDimension &&
|
||||
primaryInitialDimension &&
|
||||
(isDate(primaryInitialDimension) === isDate(primaryNewDimension) ||
|
||||
isNumeric(primaryInitialDimension) !== isNumeric(primaryNewDimension))
|
||||
);
|
||||
}
|
||||
|
||||
export function getInitialStateForCardDataSource({
|
||||
card,
|
||||
data,
|
||||
}: SingleSeries): VisualizerHistoryItem {
|
||||
const state: VisualizerHistoryItem = {
|
||||
display: card.display,
|
||||
columns: [],
|
||||
columnValuesMapping: {},
|
||||
settings: {},
|
||||
};
|
||||
const dataSource = createDataSource("card", card.id, card.name);
|
||||
|
||||
data.cols.forEach(column => {
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
dataSource,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
state.columns.push(copyColumn(columnRef.name, column));
|
||||
state.columnValuesMapping[columnRef.name] = [columnRef];
|
||||
});
|
||||
|
||||
const entries = getColumnVizSettings(card.display)
|
||||
.map(setting => {
|
||||
const originalValue = card.visualization_settings[setting];
|
||||
|
||||
if (!originalValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Array.isArray(originalValue)) {
|
||||
return [
|
||||
setting,
|
||||
originalValue.map(originalColumnName => {
|
||||
const index = data.cols.findIndex(
|
||||
col => col.name === originalColumnName,
|
||||
);
|
||||
return state.columns[index].name;
|
||||
}),
|
||||
];
|
||||
} else {
|
||||
const index = data.cols.findIndex(col => col.name === originalValue);
|
||||
return [setting, state.columns[index].name];
|
||||
}
|
||||
})
|
||||
.filter(isNotNull);
|
||||
|
||||
state.settings = {
|
||||
...card.visualization_settings,
|
||||
...Object.fromEntries(entries),
|
||||
};
|
||||
|
||||
return state;
|
||||
}
|
||||
143
frontend/src/metabase/visualizer/visualizations/cartesian.ts
Normal file
143
frontend/src/metabase/visualizer/visualizations/cartesian.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
copyColumn,
|
||||
createVisualizerColumnReference,
|
||||
extractReferencedColumns,
|
||||
isDraggedColumnItem,
|
||||
isDraggedWellItem,
|
||||
} from "metabase/visualizer/utils";
|
||||
import type { DatasetColumn } from "metabase-types/api";
|
||||
import type {
|
||||
VisualizerColumnReference,
|
||||
VisualizerHistoryItem,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
export const cartesianDropHandler = (
|
||||
state: VisualizerHistoryItem,
|
||||
{ active, over }: DragEndEvent,
|
||||
) => {
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.CANVAS_MAIN && isDraggedWellItem(active)) {
|
||||
const { wellId, column } = active.data.current;
|
||||
|
||||
if (wellId === DROPPABLE_ID.X_AXIS_WELL) {
|
||||
const dimensions = state.settings["graph.dimensions"] ?? [];
|
||||
const nextDimensions = dimensions.filter(
|
||||
dimension => dimension !== column.name,
|
||||
);
|
||||
|
||||
state.columns = state.columns.filter(col => col.name !== column.name);
|
||||
delete state.columnValuesMapping[column.name];
|
||||
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"graph.dimensions": nextDimensions,
|
||||
};
|
||||
}
|
||||
|
||||
if (wellId === DROPPABLE_ID.Y_AXIS_WELL) {
|
||||
const metrics = state.settings["graph.metrics"] ?? [];
|
||||
const nextMetrics = metrics.filter(metric => metric !== column.name);
|
||||
|
||||
state.columns = state.columns.filter(col => col.name !== column.name);
|
||||
delete state.columnValuesMapping[column.name];
|
||||
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"graph.metrics": nextMetrics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDraggedColumnItem(active)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { column, dataSource } = active.data.current;
|
||||
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
dataSource,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
|
||||
if (over.id === DROPPABLE_ID.X_AXIS_WELL) {
|
||||
const dimensions = state.settings["graph.dimensions"] ?? [];
|
||||
const isInUse = dimensions.includes(columnRef.name);
|
||||
if (isInUse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDimension = copyColumn(columnRef.name, column);
|
||||
state.columns.push(newDimension);
|
||||
state.columnValuesMapping[newDimension.name] = [columnRef];
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"graph.dimensions": [...dimensions, newDimension.name],
|
||||
};
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.Y_AXIS_WELL) {
|
||||
addMetricColumnToCartesianChart(state, column, columnRef);
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.SCATTER_BUBBLE_SIZE_WELL) {
|
||||
let bubbleColumnName = state.settings["scatter.bubble"];
|
||||
|
||||
if (!bubbleColumnName) {
|
||||
bubbleColumnName = columnRef.name;
|
||||
state.columns.push(copyColumn(bubbleColumnName, column));
|
||||
state.settings["scatter.bubble"] = bubbleColumnName;
|
||||
}
|
||||
|
||||
state.columnValuesMapping[bubbleColumnName] = [columnRef];
|
||||
}
|
||||
};
|
||||
|
||||
export function addMetricColumnToCartesianChart(
|
||||
state: VisualizerHistoryItem,
|
||||
column: DatasetColumn,
|
||||
columnRef: VisualizerColumnReference,
|
||||
) {
|
||||
const metrics = state.settings["graph.metrics"] ?? [];
|
||||
const isInUse = metrics.includes(columnRef.name);
|
||||
if (isInUse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMetric = copyColumn(columnRef.name, column);
|
||||
state.columns.push(newMetric);
|
||||
state.columnValuesMapping[newMetric.name] = [columnRef];
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"graph.metrics": [...metrics, newMetric.name],
|
||||
};
|
||||
}
|
||||
|
||||
export function removeColumnFromCartesianChart(
|
||||
state: VisualizerHistoryItem,
|
||||
columnName: string,
|
||||
) {
|
||||
if (state.settings["graph.dimensions"]) {
|
||||
const dimensions = state.settings["graph.dimensions"];
|
||||
state.settings["graph.dimensions"] = dimensions.filter(
|
||||
dimension => dimension !== columnName,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.settings["graph.metrics"]) {
|
||||
const metrics = state.settings["graph.metrics"];
|
||||
state.settings["graph.metrics"] = metrics.filter(
|
||||
metric => metric !== columnName,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.settings["scatter.bubble"] === columnName) {
|
||||
delete state.settings["scatter.bubble"];
|
||||
}
|
||||
}
|
||||
148
frontend/src/metabase/visualizer/visualizations/funnel.ts
Normal file
148
frontend/src/metabase/visualizer/visualizations/funnel.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
addColumnMapping,
|
||||
copyColumn,
|
||||
createDataSourceNameRef,
|
||||
createVisualizerColumnReference,
|
||||
extractReferencedColumns,
|
||||
isDraggedColumnItem,
|
||||
} from "metabase/visualizer/utils";
|
||||
import { isNumeric } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { Card, Dataset, DatasetColumn } from "metabase-types/api";
|
||||
import type {
|
||||
VisualizerDataSource,
|
||||
VisualizerHistoryItem,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
export const funnelDropHandler = (
|
||||
state: VisualizerHistoryItem,
|
||||
{ active, over }: DragEndEvent,
|
||||
) => {
|
||||
if (!over || !isDraggedColumnItem(active)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { column, dataSource } = active.data.current;
|
||||
|
||||
if (over.id === DROPPABLE_ID.CANVAS_MAIN && isNumeric(column)) {
|
||||
addScalarToFunnel(state, dataSource, column);
|
||||
}
|
||||
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
dataSource,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
|
||||
if (over.id === DROPPABLE_ID.X_AXIS_WELL) {
|
||||
let dimensionColumnName = state.settings["funnel.dimension"];
|
||||
if (!dimensionColumnName) {
|
||||
dimensionColumnName = columnRef.name;
|
||||
state.columns.push(copyColumn(dimensionColumnName, column));
|
||||
state.settings["funnel.dimension"] = dimensionColumnName;
|
||||
} else {
|
||||
const index = state.columns.findIndex(
|
||||
col => col.name === dimensionColumnName,
|
||||
);
|
||||
state.columns[index] = copyColumn(dimensionColumnName, column);
|
||||
}
|
||||
|
||||
if (dimensionColumnName) {
|
||||
state.columnValuesMapping[dimensionColumnName] = addColumnMapping(
|
||||
state.columnValuesMapping[dimensionColumnName],
|
||||
columnRef,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.Y_AXIS_WELL && isNumeric(column)) {
|
||||
let metricColumnName = state.settings["funnel.metric"];
|
||||
if (!metricColumnName) {
|
||||
metricColumnName = columnRef.name;
|
||||
state.columns.push(copyColumn(metricColumnName, column));
|
||||
state.settings["funnel.metric"] = metricColumnName;
|
||||
} else {
|
||||
const index = state.columns.findIndex(
|
||||
col => col.name === metricColumnName,
|
||||
);
|
||||
state.columns[index] = copyColumn(metricColumnName, column);
|
||||
}
|
||||
|
||||
if (metricColumnName) {
|
||||
state.columnValuesMapping[metricColumnName] = addColumnMapping(
|
||||
state.columnValuesMapping[metricColumnName],
|
||||
columnRef,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function canCombineCardWithFunnel(card: Card, dataset: Dataset) {
|
||||
return (
|
||||
card.display === "scalar" &&
|
||||
dataset.data?.cols?.length === 1 &&
|
||||
isNumeric(dataset.data.cols[0]) &&
|
||||
dataset.data.rows?.length === 1
|
||||
);
|
||||
}
|
||||
|
||||
export function addScalarToFunnel(
|
||||
state: VisualizerHistoryItem,
|
||||
dataSource: VisualizerDataSource,
|
||||
column: DatasetColumn,
|
||||
) {
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
dataSource,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
|
||||
let metricColumnName = state.settings["funnel.metric"];
|
||||
let dimensionColumnName = state.settings["funnel.dimension"];
|
||||
|
||||
if (!metricColumnName) {
|
||||
metricColumnName = columnRef.name;
|
||||
state.columns.push(copyColumn(metricColumnName, column));
|
||||
state.settings["funnel.metric"] = metricColumnName;
|
||||
}
|
||||
if (!dimensionColumnName) {
|
||||
dimensionColumnName = "DIMENSION";
|
||||
state.columns.push(createDimensionColumn(dimensionColumnName));
|
||||
state.settings["funnel.dimension"] = dimensionColumnName;
|
||||
}
|
||||
|
||||
state.columnValuesMapping[metricColumnName] = addColumnMapping(
|
||||
state.columnValuesMapping[metricColumnName],
|
||||
columnRef,
|
||||
);
|
||||
state.columnValuesMapping[dimensionColumnName] = addColumnMapping(
|
||||
state.columnValuesMapping[dimensionColumnName],
|
||||
createDataSourceNameRef(dataSource.id),
|
||||
);
|
||||
}
|
||||
|
||||
function createDimensionColumn(name: string): DatasetColumn {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
base_type: "type/Text",
|
||||
effective_type: "type/Text",
|
||||
field_ref: ["field", name, { "base-type": "type/Text" }],
|
||||
source: "artificial",
|
||||
};
|
||||
}
|
||||
|
||||
export function removeColumnFromFunnel(
|
||||
state: VisualizerHistoryItem,
|
||||
columnName: string,
|
||||
) {
|
||||
if (state.settings["funnel.dimension"] === columnName) {
|
||||
delete state.settings["funnel.dimension"];
|
||||
}
|
||||
|
||||
if (state.settings["funnel.metric"] === columnName) {
|
||||
delete state.settings["funnel.metric"];
|
||||
}
|
||||
}
|
||||
112
frontend/src/metabase/visualizer/visualizations/map.ts
Normal file
112
frontend/src/metabase/visualizer/visualizations/map.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
addColumnMapping,
|
||||
copyColumn,
|
||||
createVisualizerColumnReference,
|
||||
extractReferencedColumns,
|
||||
isDraggedColumnItem,
|
||||
} from "metabase/visualizer/utils";
|
||||
import { isNumeric } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
export const mapDropHandler = (
|
||||
state: VisualizerHistoryItem,
|
||||
{ active, over }: DragEndEvent,
|
||||
) => {
|
||||
if (!over || !isDraggedColumnItem(active)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { column, dataSource } = active.data.current;
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
dataSource,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
|
||||
if (over.id === DROPPABLE_ID.MAP_METRIC && isNumeric(column)) {
|
||||
let metricColumnName = state.settings["map.metric"];
|
||||
|
||||
if (!metricColumnName) {
|
||||
metricColumnName = columnRef.name;
|
||||
state.columns.push(copyColumn(metricColumnName, column));
|
||||
} else {
|
||||
const index = state.columns.findIndex(
|
||||
col => col.name === metricColumnName,
|
||||
);
|
||||
state.columns[index] = copyColumn(metricColumnName, column);
|
||||
}
|
||||
|
||||
if (metricColumnName) {
|
||||
state.columnValuesMapping[metricColumnName] = addColumnMapping(
|
||||
state.columnValuesMapping[metricColumnName],
|
||||
columnRef,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.MAP_DIMENSION) {
|
||||
if (state.settings["map.dimension"]?.name === columnRef.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDimension = copyColumn(columnRef.name, column);
|
||||
state.columns.push(newDimension);
|
||||
state.columnValuesMapping[newDimension.name] = [columnRef];
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"map.dimension": newDimension.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.MAP_LATITUDE) {
|
||||
if (state.settings["map.latitude"]?.name === columnRef.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDimension = copyColumn(columnRef.name, column);
|
||||
state.columns.push(newDimension);
|
||||
state.columnValuesMapping[newDimension.name] = [columnRef];
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"map.latitude": newDimension.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.MAP_LONGITUDE) {
|
||||
if (state.settings["map.longitude"]?.name === columnRef.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDimension = copyColumn(columnRef.name, column);
|
||||
state.columns.push(newDimension);
|
||||
state.columnValuesMapping[newDimension.name] = [columnRef];
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"map.longitude": newDimension.name,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function removeColumnFromMap(
|
||||
state: VisualizerHistoryItem,
|
||||
columnName: string,
|
||||
) {
|
||||
if (state.settings["map.metric"] === columnName) {
|
||||
delete state.settings["map.metric"];
|
||||
}
|
||||
|
||||
if (state.settings["map.dimension"] === columnName) {
|
||||
delete state.settings["map.dimension"];
|
||||
}
|
||||
|
||||
if (state.settings["map.latitude"] === columnName) {
|
||||
delete state.settings["map.latitude"];
|
||||
}
|
||||
|
||||
if (state.settings["map.longitude"] === columnName) {
|
||||
delete state.settings["map.longitude"];
|
||||
}
|
||||
}
|
||||
85
frontend/src/metabase/visualizer/visualizations/pie.ts
Normal file
85
frontend/src/metabase/visualizer/visualizations/pie.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import { DROPPABLE_ID } from "metabase/visualizer/constants";
|
||||
import {
|
||||
addColumnMapping,
|
||||
copyColumn,
|
||||
createVisualizerColumnReference,
|
||||
extractReferencedColumns,
|
||||
isDraggedColumnItem,
|
||||
} from "metabase/visualizer/utils";
|
||||
import { isNumeric } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
|
||||
|
||||
export const pieDropHandler = (
|
||||
state: VisualizerHistoryItem,
|
||||
{ active, over }: DragEndEvent,
|
||||
) => {
|
||||
if (!over || !isDraggedColumnItem(active)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { column, dataSource } = active.data.current;
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
dataSource,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
|
||||
if (over.id === DROPPABLE_ID.PIE_METRIC && isNumeric(column)) {
|
||||
let metricColumnName = state.settings["pie.metric"];
|
||||
|
||||
if (!metricColumnName) {
|
||||
metricColumnName = columnRef.name;
|
||||
state.columns.push(copyColumn(metricColumnName, column));
|
||||
} else {
|
||||
const index = state.columns.findIndex(
|
||||
col => col.name === metricColumnName,
|
||||
);
|
||||
state.columns[index] = copyColumn(metricColumnName, column);
|
||||
}
|
||||
|
||||
if (metricColumnName) {
|
||||
state.columnValuesMapping[metricColumnName] = addColumnMapping(
|
||||
state.columnValuesMapping[metricColumnName],
|
||||
columnRef,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (over.id === DROPPABLE_ID.PIE_DIMENSION) {
|
||||
const dimensions = state.settings["pie.dimension"] ?? [];
|
||||
const isInUse = dimensions.includes(columnRef.name);
|
||||
if (isInUse) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDimension = copyColumn(columnRef.name, column);
|
||||
state.columns.push(newDimension);
|
||||
state.columnValuesMapping[newDimension.name] = [columnRef];
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
"pie.dimension": [...dimensions, newDimension.name],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function removeColumnFromPieChart(
|
||||
state: VisualizerHistoryItem,
|
||||
columnName: string,
|
||||
) {
|
||||
const dimensions = Array.isArray(state.settings["pie.dimension"])
|
||||
? state.settings["pie.dimension"]
|
||||
: [state.settings["pie.dimension"]].filter(isNotNull);
|
||||
|
||||
if (dimensions.includes(columnName)) {
|
||||
state.settings["pie.dimension"] = dimensions.filter(
|
||||
dimension => dimension !== columnName,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.settings["pie.metric"] === columnName) {
|
||||
delete state.settings["pie.metric"];
|
||||
}
|
||||
}
|
||||
518
frontend/src/metabase/visualizer/visualizer.slice.ts
Normal file
518
frontend/src/metabase/visualizer/visualizer.slice.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
type Action,
|
||||
type PayloadAction,
|
||||
createAction,
|
||||
createSlice,
|
||||
} from "@reduxjs/toolkit";
|
||||
import _ from "underscore";
|
||||
|
||||
import { cardApi } from "metabase/api";
|
||||
import { createAsyncThunk } from "metabase/lib/redux";
|
||||
import {
|
||||
getColumnVizSettings,
|
||||
isCartesianChart,
|
||||
} from "metabase/visualizations";
|
||||
import type {
|
||||
Card,
|
||||
CardId,
|
||||
Dataset,
|
||||
DatasetColumn,
|
||||
VisualizationDisplay,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
import type {
|
||||
DraggedItem,
|
||||
VisualizerCommonState,
|
||||
VisualizerDataSource,
|
||||
VisualizerDataSourceId,
|
||||
VisualizerHistoryItem,
|
||||
VisualizerState,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
import {
|
||||
canCombineCard,
|
||||
copyColumn,
|
||||
createDataSource,
|
||||
createVisualizerColumnReference,
|
||||
extractReferencedColumns,
|
||||
getDataSourceIdFromNameRef,
|
||||
getDefaultVisualizationName,
|
||||
getInitialStateForCardDataSource,
|
||||
parseDataSourceId,
|
||||
} from "./utils";
|
||||
import {
|
||||
addMetricColumnToCartesianChart,
|
||||
cartesianDropHandler,
|
||||
removeColumnFromCartesianChart,
|
||||
} from "./visualizations/cartesian";
|
||||
import {
|
||||
addScalarToFunnel,
|
||||
canCombineCardWithFunnel,
|
||||
funnelDropHandler,
|
||||
removeColumnFromFunnel,
|
||||
} from "./visualizations/funnel";
|
||||
import { mapDropHandler, removeColumnFromMap } from "./visualizations/map";
|
||||
import { pieDropHandler, removeColumnFromPieChart } from "./visualizations/pie";
|
||||
|
||||
const initialCommonState: VisualizerCommonState = {
|
||||
cards: [],
|
||||
datasets: {},
|
||||
loadingDataSources: {},
|
||||
loadingDatasets: {},
|
||||
expandedDataSources: {},
|
||||
isFullscreen: false,
|
||||
isVizSettingsSidebarOpen: false,
|
||||
error: null,
|
||||
draggedItem: null,
|
||||
};
|
||||
|
||||
function getInitialVisualizerHistoryItem(): VisualizerHistoryItem {
|
||||
return {
|
||||
display: null,
|
||||
columns: [],
|
||||
columnValuesMapping: {},
|
||||
settings: {
|
||||
"card.title": getDefaultVisualizationName(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialState(): VisualizerState {
|
||||
return {
|
||||
...initialCommonState,
|
||||
past: [],
|
||||
present: getInitialVisualizerHistoryItem(),
|
||||
future: [],
|
||||
};
|
||||
}
|
||||
|
||||
type InitVisualizerPayload = {
|
||||
state?: Partial<VisualizerHistoryItem>;
|
||||
extraDataSources?: VisualizerDataSourceId[];
|
||||
};
|
||||
|
||||
export const initializeVisualizer = createAsyncThunk(
|
||||
"visualizer/initializeVisualizer",
|
||||
async (
|
||||
{ state: initialState = {}, extraDataSources = [] }: InitVisualizerPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
const columnRefs = initialState.columnValuesMapping
|
||||
? extractReferencedColumns(initialState.columnValuesMapping)
|
||||
: [];
|
||||
const dataSourceIds = Array.from(
|
||||
new Set(columnRefs.map(ref => ref.sourceId)),
|
||||
);
|
||||
if (extraDataSources.length > 0) {
|
||||
dataSourceIds.push(...extraDataSources);
|
||||
}
|
||||
await Promise.all(
|
||||
dataSourceIds
|
||||
.map(sourceId => {
|
||||
const [, cardId] = sourceId.split(":");
|
||||
return [
|
||||
dispatch(fetchCard(Number(cardId))),
|
||||
dispatch(fetchCardQuery(Number(cardId))),
|
||||
];
|
||||
})
|
||||
.flat(),
|
||||
);
|
||||
return initialState;
|
||||
},
|
||||
);
|
||||
|
||||
export const addDataSource = createAsyncThunk<
|
||||
{ card: Card; dataset: Dataset },
|
||||
VisualizerDataSourceId
|
||||
>("visualizer/dataImporter/addDataSource", async (id, { dispatch }) => {
|
||||
const { type, sourceId } = parseDataSourceId(id);
|
||||
if (type === "card") {
|
||||
const cardAction = await dispatch(fetchCard(sourceId));
|
||||
const cardQueryAction = await dispatch(fetchCardQuery(sourceId));
|
||||
|
||||
// TODO handle rejected requests
|
||||
return {
|
||||
card: cardAction.payload as Card,
|
||||
dataset: cardQueryAction.payload as Dataset,
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported data source type: ${type}`);
|
||||
});
|
||||
|
||||
export const removeDataSource = createAction<VisualizerDataSource>(
|
||||
"metabase/visualizer/removeDataSource",
|
||||
);
|
||||
|
||||
export const handleDrop = createAction<DragEndEvent>(
|
||||
"metabase/visualizer/handleDrop",
|
||||
);
|
||||
|
||||
const fetchCard = createAsyncThunk<Card, CardId>(
|
||||
"visualizer/fetchCard",
|
||||
async (cardId, { dispatch }) => {
|
||||
const result = await dispatch(
|
||||
cardApi.endpoints.getCard.initiate({ id: cardId }),
|
||||
);
|
||||
if (result.data != null) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error("Failed to fetch card");
|
||||
},
|
||||
);
|
||||
|
||||
const fetchCardQuery = createAsyncThunk<Dataset, CardId>(
|
||||
"visualizer/fetchCardQuery",
|
||||
async (cardId, { dispatch }) => {
|
||||
const result = await dispatch(
|
||||
cardApi.endpoints.getCardQuery.initiate({ cardId, parameters: [] }),
|
||||
);
|
||||
if (result.data != null) {
|
||||
return result.data;
|
||||
}
|
||||
throw new Error("Failed to fetch card query");
|
||||
},
|
||||
);
|
||||
|
||||
const visualizerHistoryItemSlice = createSlice({
|
||||
name: "present",
|
||||
initialState: getInitialVisualizerHistoryItem(),
|
||||
reducers: {
|
||||
setTitle: (state, action: PayloadAction<string>) => {
|
||||
if (!state.settings) {
|
||||
state.settings = {};
|
||||
}
|
||||
state.settings["card.title"] = action.payload;
|
||||
},
|
||||
setDisplay: (state, action: PayloadAction<VisualizationDisplay | null>) => {
|
||||
state.display = action.payload;
|
||||
},
|
||||
updateSettings: (state, action: PayloadAction<VisualizationSettings>) => {
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
...action.payload,
|
||||
};
|
||||
},
|
||||
addColumn: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
dataSource: VisualizerDataSource;
|
||||
column: DatasetColumn;
|
||||
}>,
|
||||
) => {
|
||||
const { dataSource, column } = action.payload;
|
||||
|
||||
if (!state.display) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
dataSource,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
|
||||
const newColumn = copyColumn(columnRef.name, column);
|
||||
state.columns.push(newColumn);
|
||||
state.columnValuesMapping[newColumn.name] = [columnRef];
|
||||
},
|
||||
removeColumn: (state, action: PayloadAction<{ name: string }>) => {
|
||||
const { name } = action.payload;
|
||||
if (!state.display) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.columns = state.columns.filter(col => col.name !== name);
|
||||
delete state.columnValuesMapping[name];
|
||||
|
||||
if (isCartesianChart(state.display)) {
|
||||
removeColumnFromCartesianChart(state, name);
|
||||
} else if (state.display === "funnel") {
|
||||
removeColumnFromFunnel(state, name);
|
||||
} else if (state.display === "pie") {
|
||||
removeColumnFromPieChart(state, name);
|
||||
} else if (state.display === "map") {
|
||||
removeColumnFromMap(state, name);
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(initializeVisualizer.fulfilled, (state, action) => {
|
||||
const initialState = action.payload;
|
||||
if (initialState) {
|
||||
Object.assign(state, initialState);
|
||||
}
|
||||
if (!state.settings?.["card.title"]) {
|
||||
state.settings["card.title"] = getDefaultVisualizationName();
|
||||
}
|
||||
})
|
||||
.addCase(handleDrop, (state, action) => {
|
||||
if (!state.display) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = action.payload;
|
||||
|
||||
if (isCartesianChart(state.display)) {
|
||||
cartesianDropHandler(state, event);
|
||||
} else if (state.display === "funnel") {
|
||||
funnelDropHandler(state, event);
|
||||
} else if (state.display === "pie") {
|
||||
pieDropHandler(state, event);
|
||||
} else if (state.display === "map") {
|
||||
mapDropHandler(state, event);
|
||||
}
|
||||
})
|
||||
.addCase(addDataSource.fulfilled, (state, action) => {
|
||||
const { card, dataset } = action.payload;
|
||||
Object.assign(state, maybeCombineDataset(state, card, dataset));
|
||||
})
|
||||
.addCase(removeDataSource, (state, action) => {
|
||||
const source = action.payload;
|
||||
|
||||
const columnsToRemove: string[] = [];
|
||||
const columnVizSettings = state.display
|
||||
? getColumnVizSettings(state.display)
|
||||
: [];
|
||||
|
||||
state.columnValuesMapping = _.mapObject(
|
||||
state.columnValuesMapping,
|
||||
(valueSources, columnName) => {
|
||||
const nextValueSources = valueSources.filter(valueSource => {
|
||||
if (typeof valueSource === "string") {
|
||||
const dataSourceId = getDataSourceIdFromNameRef(valueSource);
|
||||
return dataSourceId !== source.id;
|
||||
}
|
||||
return valueSource.sourceId !== source.id;
|
||||
});
|
||||
if (nextValueSources.length === 0) {
|
||||
columnsToRemove.push(columnName);
|
||||
}
|
||||
return nextValueSources;
|
||||
},
|
||||
);
|
||||
|
||||
state.columns = state.columns.filter(
|
||||
column => !columnsToRemove.includes(column.name),
|
||||
);
|
||||
columnsToRemove.forEach(columName => {
|
||||
delete state.columnValuesMapping[columName];
|
||||
});
|
||||
columnVizSettings.forEach(setting => {
|
||||
const value = state.settings[setting];
|
||||
if (columnsToRemove.includes(value)) {
|
||||
delete state.settings[setting];
|
||||
} else if (Array.isArray(value)) {
|
||||
state.settings[setting] = value.filter(
|
||||
v => !columnsToRemove.includes(v),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const visualizerSlice = createSlice({
|
||||
name: "visualizer",
|
||||
initialState: getInitialState(),
|
||||
reducers: {
|
||||
setDraggedItem: (state, action: PayloadAction<DraggedItem | null>) => {
|
||||
state.draggedItem = action.payload;
|
||||
},
|
||||
toggleDataSourceExpanded: (
|
||||
state,
|
||||
action: PayloadAction<VisualizerDataSourceId>,
|
||||
) => {
|
||||
state.expandedDataSources[action.payload] =
|
||||
!state.expandedDataSources[action.payload];
|
||||
},
|
||||
toggleFullscreenMode: state => {
|
||||
state.isFullscreen = !state.isFullscreen;
|
||||
if (!state.isFullscreen) {
|
||||
state.isVizSettingsSidebarOpen = false;
|
||||
}
|
||||
},
|
||||
turnOffFullscreenMode: state => {
|
||||
state.isFullscreen = false;
|
||||
state.isVizSettingsSidebarOpen = false;
|
||||
},
|
||||
toggleVizSettingsSidebar: state => {
|
||||
state.isVizSettingsSidebarOpen = !state.isVizSettingsSidebarOpen;
|
||||
},
|
||||
closeVizSettingsSidebar: state => {
|
||||
state.isVizSettingsSidebarOpen = false;
|
||||
},
|
||||
undo: state => {
|
||||
const canUndo = state.past.length > 0;
|
||||
if (canUndo) {
|
||||
state.future = [state.present, ...state.future];
|
||||
state.present = state.past[state.past.length - 1];
|
||||
state.past = state.past.slice(0, state.past.length - 1);
|
||||
}
|
||||
},
|
||||
redo: state => {
|
||||
const canRedo = state.future.length > 0;
|
||||
if (canRedo) {
|
||||
state.past = [...state.past, state.present];
|
||||
state.present = state.future[0];
|
||||
state.future = state.future.slice(1);
|
||||
}
|
||||
},
|
||||
resetVisualizer: (
|
||||
state,
|
||||
action: PayloadAction<{ full?: boolean } | undefined>,
|
||||
) => {
|
||||
if (action.payload?.full) {
|
||||
Object.assign(state, getInitialState());
|
||||
} else {
|
||||
state.past = [];
|
||||
state.future = [...state.future, state.present];
|
||||
state.present = getInitialVisualizerHistoryItem();
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(handleDrop, (state, action) => {
|
||||
state.draggedItem = null;
|
||||
maybeUpdateHistory(state, action);
|
||||
})
|
||||
.addCase(removeDataSource, (state, action) => {
|
||||
const source = action.payload;
|
||||
if (source.type === "card") {
|
||||
const cardId = source.sourceId;
|
||||
state.cards = state.cards.filter(card => card.id !== cardId);
|
||||
}
|
||||
delete state.expandedDataSources[source.id];
|
||||
delete state.loadingDataSources[source.id];
|
||||
delete state.datasets[source.id];
|
||||
delete state.loadingDatasets[source.id];
|
||||
|
||||
const isLastDataSource = Object.keys(state.datasets).length === 0;
|
||||
if (isLastDataSource) {
|
||||
const present = _.clone(state.present);
|
||||
state.past = [...state.past, present];
|
||||
state.present = getInitialVisualizerHistoryItem();
|
||||
state.future = [];
|
||||
state.isVizSettingsSidebarOpen = false;
|
||||
} else {
|
||||
maybeUpdateHistory(state, action);
|
||||
}
|
||||
})
|
||||
.addCase(fetchCard.pending, (state, action) => {
|
||||
const cardId = action.meta.arg;
|
||||
state.loadingDataSources[`card:${cardId}`] = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchCard.fulfilled, (state, action) => {
|
||||
const card = action.payload;
|
||||
const index = state.cards.findIndex(c => c.id === card.id);
|
||||
if (index !== -1) {
|
||||
state.cards[index] = card;
|
||||
} else {
|
||||
state.cards.push(card);
|
||||
}
|
||||
state.loadingDataSources[`card:${card.id}`] = false;
|
||||
state.expandedDataSources[`card:${card.id}`] = true;
|
||||
maybeUpdateHistory(state, action);
|
||||
})
|
||||
.addCase(fetchCard.rejected, (state, action) => {
|
||||
const cardId = action.meta.arg;
|
||||
if (cardId) {
|
||||
state.loadingDataSources[`card:${cardId}`] = false;
|
||||
}
|
||||
state.error = action.error.message || "Failed to fetch card";
|
||||
})
|
||||
.addCase(fetchCardQuery.pending, (state, action) => {
|
||||
const cardId = action.meta.arg;
|
||||
state.loadingDatasets[`card:${cardId}`] = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchCardQuery.fulfilled, (state, action) => {
|
||||
const cardId = action.meta.arg;
|
||||
const dataset = action.payload;
|
||||
state.datasets[`card:${cardId}`] = dataset;
|
||||
state.loadingDatasets[`card:${cardId}`] = false;
|
||||
maybeUpdateHistory(state, action);
|
||||
})
|
||||
.addCase(fetchCardQuery.rejected, (state, action) => {
|
||||
const cardId = action.meta.arg;
|
||||
if (cardId) {
|
||||
state.loadingDatasets[`card:${cardId}`] = false;
|
||||
}
|
||||
state.error = action.error.message || "Failed to fetch card query";
|
||||
})
|
||||
.addDefaultCase((state, action) => {
|
||||
maybeUpdateHistory(state, action);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function maybeUpdateHistory(state: VisualizerState, action: Action) {
|
||||
const present = _.clone(state.present);
|
||||
const newPresent = visualizerHistoryItemSlice.reducer(state.present, action);
|
||||
if (!_.isEqual(present, newPresent)) {
|
||||
state.past = [...state.past, present];
|
||||
state.present = newPresent;
|
||||
state.future = [];
|
||||
}
|
||||
}
|
||||
|
||||
function maybeCombineDataset(
|
||||
currentState: VisualizerHistoryItem,
|
||||
card: Card,
|
||||
dataset: Dataset,
|
||||
) {
|
||||
const state = { ...currentState };
|
||||
const source = createDataSource("card", card.id, card.name);
|
||||
|
||||
if (
|
||||
!state.display ||
|
||||
(card.display === state.display && state.columns.length === 0)
|
||||
) {
|
||||
return getInitialStateForCardDataSource({ card, ...dataset });
|
||||
}
|
||||
|
||||
if (
|
||||
["area", "bar", "line"].includes(state.display) &&
|
||||
canCombineCard(state.display, state.columns, state.settings, card)
|
||||
) {
|
||||
const metrics = card.visualization_settings["graph.metrics"] ?? [];
|
||||
const columns = dataset.data.cols.filter(col => metrics.includes(col.name));
|
||||
columns.forEach(column => {
|
||||
const columnRef = createVisualizerColumnReference(
|
||||
source,
|
||||
column,
|
||||
extractReferencedColumns(state.columnValuesMapping),
|
||||
);
|
||||
addMetricColumnToCartesianChart(state, column, columnRef);
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
if (state.display === "funnel" && canCombineCardWithFunnel(card, dataset)) {
|
||||
const [column] = dataset.data.cols;
|
||||
addScalarToFunnel(state, source, column);
|
||||
return state;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export const { setTitle, setDisplay, updateSettings, addColumn, removeColumn } =
|
||||
visualizerHistoryItemSlice.actions;
|
||||
|
||||
export const {
|
||||
setDraggedItem,
|
||||
toggleDataSourceExpanded,
|
||||
toggleFullscreenMode,
|
||||
turnOffFullscreenMode,
|
||||
toggleVizSettingsSidebar,
|
||||
closeVizSettingsSidebar,
|
||||
undo,
|
||||
redo,
|
||||
resetVisualizer,
|
||||
} = visualizerSlice.actions;
|
||||
|
||||
export const { reducer } = visualizerSlice;
|
||||
@@ -249,6 +249,8 @@
|
||||
:card.id
|
||||
:card.database_id
|
||||
:card.display
|
||||
:card.result_metadata
|
||||
:card.visualization_settings
|
||||
[:dashboard.id :dashboard_id]
|
||||
[:dashboard.name :dashboard_name]
|
||||
[:card.collection_id :entity-coll-id]
|
||||
@@ -300,6 +302,8 @@
|
||||
:database_id (:database_id card)
|
||||
:description (:description card)
|
||||
:display (some-> card :display name)
|
||||
:result_metadata (:result_metadata card)
|
||||
:visualization_settings (:visualization_settings card)
|
||||
:model :card
|
||||
:can_write (mi/can-write? card)
|
||||
:timestamp (str timestamp)
|
||||
|
||||
@@ -388,11 +388,170 @@
|
||||
[:img {:style (style/style {:display :block :width :100%})
|
||||
:src (:image-src image-bundle)}]]}))
|
||||
|
||||
(defn- get-col-by-name
|
||||
[cols col-name]
|
||||
(->> (map-indexed (fn [idx m] [idx m]) cols)
|
||||
(some (fn [[idx col]]
|
||||
(when (= col-name (:name col))
|
||||
[idx col])))))
|
||||
|
||||
;; Extract column references from mappings
|
||||
(defn- extract-referenced-columns
|
||||
"Extracts column references from mappings that aren't strings"
|
||||
[mappings]
|
||||
(->> mappings
|
||||
vals
|
||||
(apply concat)
|
||||
(filter (complement string?))))
|
||||
|
||||
;; Parse data source ID into components
|
||||
(defn- parse-data-source-id
|
||||
"Parses a data source ID string into type and source-id"
|
||||
[id]
|
||||
(let [[type source-id] (str/split id #":")]
|
||||
{:type type
|
||||
:source-id (Integer/parseInt source-id)}))
|
||||
|
||||
;; Check if a value is a data source name reference
|
||||
(defn- is-data-source-name-ref?
|
||||
"Checks if value is a data source name reference"
|
||||
[value]
|
||||
(and (string? value)
|
||||
(str/starts-with? value "$_")
|
||||
(str/ends-with? value "_name")))
|
||||
|
||||
;; Extract data source ID from a name reference
|
||||
(defn- get-data-source-id-from-name-ref
|
||||
"Gets data source ID from a name reference string"
|
||||
[s]
|
||||
(second (str/split s #"_")))
|
||||
|
||||
;; Main function for visualizer raw series
|
||||
(defn- get-visualizer-raw-series
|
||||
"Combines data merging and series creation for visualizer"
|
||||
[datasets dashcard-settings]
|
||||
(let [{:keys [columns columnValuesMapping]} (:visualization dashcard-settings)
|
||||
|
||||
;; Extract all referenced columns from the column mappings
|
||||
referenced-columns (extract-referenced-columns columnValuesMapping)
|
||||
|
||||
;; Create a map to store the actual values for each referenced column
|
||||
referenced-column-values-map
|
||||
(reduce
|
||||
(fn [acc ref]
|
||||
;; Extract the source ID from references
|
||||
(let [{:keys [source-id]} (parse-data-source-id (:sourceId ref))
|
||||
|
||||
;; Find the dataset with matching ID
|
||||
dataset (first (filter #(= (get-in % [:card :id]) source-id) datasets))]
|
||||
|
||||
(if dataset
|
||||
(let [;; Find column index matching our reference
|
||||
column-index (or
|
||||
(first
|
||||
(keep-indexed
|
||||
(fn [idx col]
|
||||
(when (= (:name col) (:originalName ref))
|
||||
idx))
|
||||
(get-in dataset [:data :cols])))
|
||||
-1)]
|
||||
|
||||
;; If column found, extract its values
|
||||
(if (>= column-index 0)
|
||||
(let [values (mapv #(nth % column-index) (get-in dataset [:data :rows]))]
|
||||
(assoc acc (:name ref) values))
|
||||
acc))
|
||||
acc)))
|
||||
{}
|
||||
referenced-columns)
|
||||
|
||||
_ (println "referenced-columns:" referenced-columns)
|
||||
_ (println "referenced-column-values-map:" referenced-column-values-map)
|
||||
|
||||
;; Handle special case for pivot grouping
|
||||
has-pivot-grouping? (some #(= (:name %) "pivot-grouping") columns)
|
||||
referenced-column-values-map
|
||||
(if has-pivot-grouping?
|
||||
(let [row-lengths (map count (vals referenced-column-values-map))
|
||||
max-length (if (seq row-lengths) (apply max row-lengths) 0)]
|
||||
(assoc referenced-column-values-map
|
||||
"pivot-grouping"
|
||||
(vec (repeat max-length 0))))
|
||||
referenced-column-values-map)
|
||||
|
||||
;; Create rows by mapping and flattening values for each column
|
||||
unzipped-rows
|
||||
(doto (mapv
|
||||
(fn [column]
|
||||
(let [value-sources (get columnValuesMapping (keyword (:name column)) [])]
|
||||
(->> value-sources
|
||||
(mapcat
|
||||
(fn [value-source]
|
||||
(if (is-data-source-name-ref? value-source)
|
||||
(let [id (get-data-source-id-from-name-ref value-source)]
|
||||
[(str "Not supported yet (card " id ")")])
|
||||
(let [values (get referenced-column-values-map (:name value-source))]
|
||||
(if values values [])))))
|
||||
vec)))
|
||||
columns)
|
||||
(println "TSP unzipped-rows:"))
|
||||
|
||||
;; Create merged data structure
|
||||
merged-data {:cols columns
|
||||
:rows (apply mapv vector unzipped-rows)
|
||||
:results_metadata {:columns columns}}
|
||||
|
||||
;; Extract display and settings
|
||||
display (get-in dashcard-settings [:visualization :display])
|
||||
settings (get-in dashcard-settings [:visualization :settings])]
|
||||
|
||||
(def tsp-column-values-mapping columnValuesMapping)
|
||||
(def tsp-columns columns)
|
||||
(def tsp-referenced-columns referenced-columns)
|
||||
(def tsp-referenced-column-values-map referenced-column-values-map)
|
||||
(def tsp-merged-data merged-data)
|
||||
|
||||
;; def the result in the same format as TypeScript
|
||||
[{:card {:display display
|
||||
:visualization_settings settings}
|
||||
:data merged-data
|
||||
:started_at (.format
|
||||
(java.text.SimpleDateFormat. "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
|
||||
(java.util.Date.))}]))
|
||||
|
||||
(defn- raise-data-one-level
|
||||
"Raise the :data key inside the given result map up to the top level. This is the expected shape of `add-dashcard-timeline`."
|
||||
[{:keys [result] :as m}]
|
||||
(-> m
|
||||
(assoc :data (:data result))
|
||||
(dissoc :result)))
|
||||
|
||||
(mu/defmethod render :row :- ::RenderedPartCard
|
||||
[_chart-type render-type _timezone-id card _dashcard {:keys [rows cols] :as _data}]
|
||||
(let [viz-settings (get card :visualization_settings)
|
||||
data {:rows rows
|
||||
:cols cols}
|
||||
[_chart-type render-type _timezone-id card dashcard {:keys [cols rows] :as data}]
|
||||
(println "TSP inside render :row")
|
||||
(def card card)
|
||||
(def dashcard dashcard)
|
||||
(let [is-visualizer? (and (some? dashcard)
|
||||
(some? (get-in dashcard [:visualization_settings :visualization :columnValuesMapping])))
|
||||
_ (println "TSP is-visualizer?:" is-visualizer?)
|
||||
viz-settings (if is-visualizer?
|
||||
{:graph.dimensions ["COLUMN_2"], :graph.metrics ["COLUMN_1" "COLUMN_3"]}
|
||||
(get card :visualization_settings))
|
||||
|
||||
_ (println "TSP viz-settings:" viz-settings)
|
||||
|
||||
data (if is-visualizer?
|
||||
(let [cards-with-data (->> (:series-results dashcard)
|
||||
(map raise-data-one-level)
|
||||
(cons {:card card :data data})
|
||||
(map add-dashcard-timeline-events)
|
||||
(m/distinct-by #(get-in % [:card :id])))
|
||||
updated-data (get-visualizer-raw-series cards-with-data (get dashcard :visualization_settings))]
|
||||
(:data (first updated-data)))
|
||||
data)
|
||||
|
||||
_ (println "TSP data:" data)
|
||||
|
||||
image-bundle (image-bundle/make-image-bundle
|
||||
render-type
|
||||
(js.svg/row-chart viz-settings data))]
|
||||
@@ -405,13 +564,6 @@
|
||||
[:img {:style (style/style {:display :block :width :100%})
|
||||
:src (:image-src image-bundle)}]]}))
|
||||
|
||||
(defn- get-col-by-name
|
||||
[cols col-name]
|
||||
(->> (map-indexed (fn [idx m] [idx m]) cols)
|
||||
(some (fn [[idx col]]
|
||||
(when (= col-name (:name col))
|
||||
[idx col])))))
|
||||
|
||||
(mu/defmethod render :scalar :- ::RenderedPartCard
|
||||
[_chart-type _render-type timezone-id _card _dashcard {:keys [cols rows viz-settings]}]
|
||||
(let [field-name (:scalar.field viz-settings)
|
||||
@@ -429,13 +581,6 @@
|
||||
(h value)]
|
||||
:render/text (str value)}))
|
||||
|
||||
(defn- raise-data-one-level
|
||||
"Raise the :data key inside the given result map up to the top level. This is the expected shape of `add-dashcard-timeline`."
|
||||
[{:keys [result] :as m}]
|
||||
(-> m
|
||||
(assoc :data (:data result))
|
||||
(dissoc :result)))
|
||||
|
||||
;; the `:javascript_visualization` render method
|
||||
;; is and will continue to handle more and more 'isomorphic' chart types.
|
||||
;; Isomorphic in this context just means the frontend Code is mostly shared between the app and the static-viz
|
||||
@@ -444,6 +589,9 @@
|
||||
;; Trend charts were added more recently and will not have multi-series.
|
||||
(mu/defmethod render :javascript_visualization :- ::RenderedPartCard
|
||||
[_chart-type render-type _timezone-id card dashcard data]
|
||||
(def tsp-dashcard dashcard)
|
||||
(def tsp-card card)
|
||||
(def tsp-data data)
|
||||
(let [series-cards-results (:series-results dashcard)
|
||||
cards-with-data (->> series-cards-results
|
||||
(map raise-data-one-level)
|
||||
|
||||
@@ -67,11 +67,23 @@
|
||||
:margin-bottom :8px})}
|
||||
(markdown/process-markdown description :html)]})))
|
||||
|
||||
;; TODO - Update this "is this a visualizer dashcard" check to something more robust
|
||||
(defn- is-visualizer-dashcard?
|
||||
"true if dashcard has visualizer specific viz settings"
|
||||
[dashcard]
|
||||
(if (and (some? dashcard)
|
||||
(get-in dashcard [:visualization_settings :visualization :columnValuesMapping]))
|
||||
true
|
||||
false))
|
||||
|
||||
(defn detect-pulse-chart-type
|
||||
"Determine the pulse (visualization) type of a `card`, e.g. `:scalar` or `:bar`."
|
||||
[{display-type :display card-name :name} maybe-dashcard {:keys [cols rows] :as data}]
|
||||
(let [col-sample-count (delay (count (take 3 cols)))
|
||||
row-sample-count (delay (count (take 2 rows)))]
|
||||
row-sample-count (delay (count (take 2 rows)))
|
||||
updated-display (if (is-visualizer-dashcard? maybe-dashcard)
|
||||
(keyword (get-in maybe-dashcard [:visualization_settings :visualization :display]))
|
||||
display-type)]
|
||||
(letfn [(chart-type [tyype reason & args]
|
||||
(log/tracef "Detected chart type %s for Card %s because %s"
|
||||
tyype (pr-str card-name) (apply format reason args))
|
||||
@@ -82,15 +94,16 @@
|
||||
(= [[nil]] (-> data :rows)))
|
||||
(chart-type :empty "there are no rows in results")
|
||||
|
||||
(#{:pin_map :state :country} display-type)
|
||||
(chart-type nil "display-type is %s" display-type)
|
||||
(#{:pin_map :state :country} updated-display)
|
||||
(chart-type nil "display-type is %s" updated-display)
|
||||
|
||||
(and (some? maybe-dashcard)
|
||||
(= false (is-visualizer-dashcard? maybe-dashcard))
|
||||
(pos? (count (dashboard-card/dashcard->multi-cards maybe-dashcard))))
|
||||
(chart-type :javascript_visualization "result has multiple card semantics, a multiple chart")
|
||||
|
||||
;; for scalar/smartscalar, the display-type might actually be :line, so we can't have line above
|
||||
(and (not (contains? #{:progress :gauge} display-type))
|
||||
(and (not (contains? #{:progress :gauge} updated-display))
|
||||
(= @col-sample-count @row-sample-count 1))
|
||||
(chart-type :scalar "result has one row and one column")
|
||||
|
||||
@@ -99,8 +112,8 @@
|
||||
:progress
|
||||
:gauge
|
||||
:table
|
||||
:funnel} display-type)
|
||||
(chart-type display-type "display-type is %s" display-type)
|
||||
:funnel} updated-display)
|
||||
(chart-type updated-display "display-type is %s" updated-display)
|
||||
|
||||
(#{:smartscalar
|
||||
:sankey
|
||||
@@ -111,7 +124,7 @@
|
||||
:line
|
||||
:area
|
||||
:bar
|
||||
:combo} display-type)
|
||||
:combo} updated-display)
|
||||
(chart-type :javascript_visualization "display-type is javascript_visualization")
|
||||
|
||||
:else
|
||||
@@ -127,6 +140,10 @@
|
||||
card
|
||||
dashcard
|
||||
{:keys [data error] :as results}]
|
||||
(def data data)
|
||||
(def card card)
|
||||
(def dashcard dashcard)
|
||||
(println "TSP in render-pulse-card-body")
|
||||
(try
|
||||
(when error
|
||||
(throw (ex-info (tru "Card has errors: {0}" error) (assoc results :card-error true))))
|
||||
@@ -153,7 +170,7 @@
|
||||
- render/text : raw text suitable for substituting on clients when text is preferable. (Currently slack uses this for
|
||||
scalar results where text is preferable to an image of a div of a single result."
|
||||
([render-type timezone-id card dashcard results]
|
||||
(render-pulse-card render-type timezone-id card dashcard results nil))
|
||||
(render-pulse-card render-type timezone-id card dashcard results nil))
|
||||
|
||||
([render-type
|
||||
timezone-id :- [:maybe :string]
|
||||
|
||||
Reference in New Issue
Block a user