Compare commits

...

116 Commits

Author SHA1 Message Date
Tyler Plude
ad62fb8f0d tsp WIP support for row charts in subscriptions for visualizer cards 2025-02-26 15:53:23 -08:00
Tyler Plude
acae9dfd2e Merge branch 'feature-visualizer' into tsp-visualizer-subscriptions 2025-02-26 15:34:15 -08:00
Anton Kulyk
e47c0fa567 Fix type error 2025-02-26 16:05:21 -05:00
Anton Kulyk
26037fa455 Fix some type errors 2025-02-26 15:48:03 -05:00
Anton Kulyk
229a47ae02 Remove pivot table support 2025-02-26 15:35:09 -05:00
Anton Kulyk
8aeefb8c7d Remove SmartScalar prototyping hacks 2025-02-26 15:25:44 -05:00
Anton Kulyk
d203a9113c Support card title for visualizer-made charts (#54309)
* Add "card.title" to visualizer settings state

* Make visualization title editable

* Disable title clicks for visualizer dashcards

* Fix title flashing while loading

* Fix typos

* Use `isVisualizerDashboardCard` helper
2025-02-26 18:02:42 +00:00
Anton Kulyk
8ccfbd1652 Add recents label (#54346) 2025-02-26 12:56:29 -05:00
Anton Kulyk
2af57420e5 Fix visualizer modal size 2025-02-26 12:51:48 -05:00
Anton Kulyk
ffd1ac0f77 Merge branch 'master' into feature-visualizer 2025-02-26 12:44:32 -05:00
Anton Kulyk
18259ef17f Extract code for merging visualizer data 2025-02-25 17:50:02 -05:00
Anton Kulyk
6376fef7e7 Extract getCardIdsFromColumnValueMappings util 2025-02-25 17:31:41 -05:00
Anton Kulyk
6b52a420be Remove no longer used VisualizerVisualization 2025-02-25 17:27:31 -05:00
Anton Kulyk
2f9131d4e9 Fix button position 2025-02-25 17:21:19 -05:00
Anton Kulyk
0b26fd8c90 Bring back chart settings button 2025-02-25 16:51:11 -05:00
Anton Kulyk
7855ffa391 Bring back "Add series" modal for now 2025-02-25 16:38:47 -05:00
Anton Kulyk
72fb8213e4 Remove /visualizer route 2025-02-25 16:38:04 -05:00
Anton Kulyk
1089410da3 Remove "Visualize with" button from QB (prototype) 2025-02-25 16:37:44 -05:00
Tyler Plude
1a59dead49 Adding static viz subscription support for visualizer dashcards 2025-02-25 13:07:06 -08:00
Kyle Doherty
3093b66985 fix hover state on split items in dashboard question picker (#54305) 2025-02-25 14:30:17 -05:00
Sébastien
6c40b0df42 Added "Edit visualization from card menu" flow for visualizer dash cards (#54241)
* reorg utils

* edit visualization from card menu
2025-02-25 18:38:57 +01:00
Anton Kulyk
2fedaa22f1 Use one feature branch to visualizer (#54215)
* Decouple visualizer and visualizer page

* Add Visualize button to dashcard placeholder

* WIP — Add visualizer viz to dashboard

* Fix fullscreen mode

* Fix dashcard ignores visualizer settings

* basic round trip

* add tooltip

* prototype edit change

* Decouple visualizer redux state from URL hash

* Open visualizer from question list

* Open existing dashcard in visualizer

* Add/update dashcard via new UI triggers

* Fix modal size

---------

Co-authored-by: Kyle Doherty <5248953+kdoh@users.noreply.github.com>
2025-02-24 19:27:37 +00:00
Sébastien
dad79a7395 added map wells (#53684)
* added map wells

* cleanup

* uncleaned a little
2025-02-18 16:51:07 +01:00
Sébastien
312d06d60b grid layout for wells 2025-02-12 16:59:20 +01:00
Anton Kulyk
25f596c73d Merge branch 'master' into feature-visualizer 2025-01-27 13:48:31 +00:00
Anton Kulyk
7c52ef1c66 Merge branch 'master' into feature-visualizer 2025-01-20 13:22:22 +00:00
Anton Kulyk
ac83610019 Merge branch 'master' into feature-visualizer 2025-01-10 14:06:34 +00:00
Anton Kulyk
38d97c813a Merge branch 'master' into feature-visualizer 2025-01-08 13:52:43 +00:00
Anton Kulyk
1113eba7e9 Merge branch 'master' into feature-visualizer 2025-01-03 15:31:02 +00:00
Anton Kulyk
97780a2802 Merge branch 'master' into feature-visualizer 2025-01-02 13:01:34 +00:00
Anton Kulyk
4d0532459a Basic click to add column from the data manager 2024-12-23 17:02:25 +00:00
Anton Kulyk
b53eaee151 Don't reset state when changing viz type 2024-12-23 17:02:24 +00:00
Anton Kulyk
99f8a7ddc6 Update viz type picker 2024-12-23 17:02:24 +00:00
Anton Kulyk
e77c022f7e Merge branch 'master' into feature-visualizer 2024-12-23 13:34:39 +00:00
Anton Kulyk
c2f1738b60 Add fullscreen mode 2024-12-20 17:05:31 +00:00
Anton Kulyk
9c3fb7f816 Close settings sidebar on returning to clear state 2024-12-20 16:09:40 +00:00
Anton Kulyk
d78bfc43cf Disable viz settings button in clear state 2024-12-20 16:09:00 +00:00
Anton Kulyk
89a3e5d073 Merge branch 'master' into feature-visualizer 2024-12-20 16:00:21 +00:00
Anton Kulyk
b1c342a4f2 Merge branch 'master' into feature-visualizer 2024-12-18 16:07:23 +00:00
Anton Kulyk
863019a431 Better loading state for slower queries 2024-12-12 11:31:51 +00:00
Anton Kulyk
83dbc97e3e Highlight wells only when dragging suitable column 2024-12-11 12:15:00 +00:00
Anton Kulyk
0536e03d79 Add tabular preview modal 2024-12-11 12:12:54 +00:00
Anton Kulyk
552bec5261 Remove columns from the data manager 2024-12-10 17:05:00 +00:00
Anton Kulyk
95d70e8982 Remove wellId arg from removeColumn 2024-12-10 17:04:43 +00:00
Anton Kulyk
0c1d468c33 Add viz settings sidebar 2024-12-10 16:49:27 +00:00
Anton Kulyk
172b43aad8 Merge branch 'master' into feature-visualizer 2024-12-09 15:24:34 +00:00
Anton Kulyk
dce52eb1cf Merge branch 'master' into feature-visualizer 2024-12-06 13:14:32 +00:00
Anton Kulyk
53537a094e Fix state reset 2024-12-05 15:38:04 +00:00
Anton Kulyk
4ea9070da0 Fix item highlighting in data importer 2024-12-05 15:28:17 +00:00
Anton Kulyk
4347f96db1 Update loading state 2024-12-05 15:28:17 +00:00
Anton Kulyk
e90952591b Fix unexpected history items after init 2024-12-05 15:28:17 +00:00
Anton Kulyk
017bd49c2c Serialize visualizer state into a url hash 2024-12-05 15:28:17 +00:00
Anton Kulyk
dce32407d4 Reset state when removing last dataset 2024-12-04 15:33:55 +00:00
Anton Kulyk
a05e6805a2 Merge branch 'master' into feature-visualizer 2024-12-04 15:09:57 +00:00
Anton Kulyk
100c4013d2 Update canvas empty states 2024-11-29 15:13:12 +00:00
Anton Kulyk
b6e1c61fb3 Move columns between pivot wells 2024-11-29 15:10:14 +00:00
Anton Kulyk
ffafee1ee9 Fix tables rendering 2024-11-29 12:12:40 +00:00
Anton Kulyk
9f84294476 Fix trend charts 2024-11-29 12:06:53 +00:00
Anton Kulyk
aaae363ce3 Merge branch 'master' into feature-visualizer 2024-11-28 14:59:31 +00:00
Anton Kulyk
672e6d8862 Clean up columns after removing a data source 2024-11-28 14:59:04 +00:00
Anton Kulyk
302beef76f Merge branch 'master' into feature-visualizer 2024-11-27 13:48:44 +00:00
Anton Kulyk
81d4aff6c7 Fix funnels 2024-11-26 17:39:05 +00:00
Anton Kulyk
b9530848c8 Hide pivot grouping column 2024-11-26 17:24:27 +00:00
Anton Kulyk
302ff52159 Add remove icon to well items 2024-11-26 17:05:13 +00:00
Anton Kulyk
6ec3813e01 Merge branch 'master' into feature-visualizer 2024-11-25 12:59:17 +00:00
Anton Kulyk
1f74334c0d Auto combine bar/area/line charts 2024-11-21 15:11:29 +00:00
Anton Kulyk
ddfcb2ac4a Fix loading state 2024-11-20 17:08:05 +00:00
Anton Kulyk
cc32ab99ae Fix viz settings handling 2024-11-20 16:23:13 +00:00
Anton Kulyk
9b07d9812c Add "Visualize with" button to QB chill mode 2024-11-20 16:02:32 +00:00
Anton Kulyk
f8ec344ee8 Add Urls.visualizer fn 2024-11-20 16:02:19 +00:00
Anton Kulyk
1228d5cd53 Fix cartesian charts 2024-11-20 16:01:55 +00:00
Anton Kulyk
73e7055e81 Support dataSource (ID) query parameter 2024-11-20 15:51:33 +00:00
Anton Kulyk
52e07386cb Make addDataSource accept a data source id 2024-11-20 15:51:13 +00:00
Anton Kulyk
a01beddd2f Add automatic funnel+scalar addition 2024-11-20 15:21:23 +00:00
Anton Kulyk
b0d64b4956 Use names from column refs 2024-11-20 15:21:23 +00:00
Anton Kulyk
108915f231 Temporary remove funnel auto combine 2024-11-20 15:21:23 +00:00
Anton Kulyk
811bc5871c Fix DND for other visualizations 2024-11-20 15:21:23 +00:00
Anton Kulyk
0bcac59912 Use computed settings in well components 2024-11-20 15:21:23 +00:00
Anton Kulyk
f7678aff45 Don't pre-create columns for cartesian charts 2024-11-20 15:21:23 +00:00
Anton Kulyk
2ae5dc679e Fix bad import 2024-11-19 12:18:59 +00:00
Anton Kulyk
223620ab93 Merge branch 'master' into feature-visualizer 2024-11-19 12:11:39 +00:00
Anton Kulyk
93f22e28dd Add basic pie charts support to visualizer (#50195)
* scaffold out basic ui

* wip pie handler

* pie visualizer state working

* Clean up

* Fix pies rendering

* Support multiple pie dimensions

---------

Co-authored-by: Kyle Doherty <5248953+kdoh@users.noreply.github.com>
2024-11-19 12:07:37 +00:00
Anton Kulyk
04f3242041 Fix search 2024-11-14 13:38:44 +00:00
Anton Kulyk
7bb3dce3cc Add back/forward history navigation 2024-11-14 13:26:40 +00:00
Anton Kulyk
f3c579d42c Merge branch 'master' into feature-visualizer 2024-11-13 15:41:58 +00:00
Anton Kulyk
969e109476 Reorg visualizer redux state (#49966)
* Move column makers to utils

* Keep column values mapping in its own map

* Use column names instead of column keys

* Rename type

* Remove `referencedColumns`

* Remove redundant selectors

* Extract selectors into their own file

* Break down `visualizer.slice.ts`
2024-11-13 15:40:53 +00:00
Anton Kulyk
9e348794e8 Temporary remove empty rows checks 2024-11-12 16:25:08 +00:00
Anton Kulyk
187334284d Support pivot tables 2024-11-11 17:30:19 +00:00
Anton Kulyk
93614feb66 Merge branch 'master' into feature-visualizer 2024-11-11 14:32:42 +00:00
Anton Kulyk
28c69f7f9f Visualizer — Basic cartesian charts support (#49618)
* Clean up well components

* Create metric/dimension cols for cartesian charts

* Add hover state to vertical well

* Build a simple cartesian chart with DND

* Keep state when switching between cartesian charts

* Move all DND logic to Redux

* Handle already referenced columns

* Minor column assoc update

* Fix type

* Rework x/y well components

* Build multi series cartesian charts

* DND to remove metrics/dimensions

* Use own settings in well components (vs computed)

* Fix DND logic

* Support scatter charts
2024-11-08 16:10:47 +00:00
Anton Kulyk
4c4fdc5277 Reset visualizer state on unmount 2024-11-06 12:10:15 +00:00
Anton Kulyk
0894fc05b4 Fix type error 2024-11-06 12:09:43 +00:00
Anton Kulyk
2b5051d96b Merge branch 'master' into feature-visualizer 2024-11-06 11:52:38 +00:00
Anton Kulyk
b26c3fd9e9 Clean up StartFromViz 2024-11-06 11:46:55 +00:00
Kyle Doherty
7d9ec78335 start from viz experience (#49412) 2024-11-04 10:30:22 -05:00
Anton Kulyk
6f6a8cb957 Merge branch 'master' into feature-visualizer 2024-11-04 14:37:58 +00:00
Anton Kulyk
fd4681bde8 Fix vertical well 2024-11-01 17:04:33 +00:00
Anton Kulyk
cf2494685c Extract DragOverlayWrapper 2024-11-01 17:04:33 +00:00
Anton Kulyk
54077021b7 Fix dataset column draggable ID 2024-11-01 17:04:33 +00:00
Anton Kulyk
fad96e4275 Reset columns when changing viz type 2024-11-01 17:04:33 +00:00
Anton Kulyk
aba9a695f9 Always add column ref when dispatching importColumn 2024-11-01 17:04:33 +00:00
Anton Kulyk
62f1abb393 Handle duplicate column names 2024-11-01 17:04:33 +00:00
Anton Kulyk
0dd03cbca4 Extract funnel drop handler into its own file 2024-11-01 17:04:33 +00:00
Anton Kulyk
97698a36f7 Extract WellItem component 2024-11-01 17:04:33 +00:00
Anton Kulyk
93398edfb4 Extract visualizer drag handlers 2024-11-01 17:04:32 +00:00
Anton Kulyk
b7d0cb0658 Extract FunnelVerticalWell 2024-11-01 17:04:32 +00:00
Anton Kulyk
c162ae1a2c Visualizer — Build a funnel out of many scalar cards (#49245)
* Add visualization picker

* Add `VizDataSourceMapping`

* Basic DND to make a funnel out of many scalars

* Use map instead of array for `mappings`

* Use column keys instead of column names for mappings

* Rework mapping

* Extract `getVisualizerDataset` selector

* Highlight used columns in the data manager

* Clean up state on removing data source

* Extract utils

* Fix remove data source action

* Rename wells' droppable IDs

* Handle case when there's only one data source

* Rename `importedColumns` to `referencedColumns`

* Combine scalar data sources automatically

* Extract common code
2024-10-31 17:47:28 +00:00
Kyle Doherty
ce8c928661 Visualizer - Stub out header UI (#49395)
* stub out header

* i18n strings
2024-10-31 13:26:48 -04:00
Anton Kulyk
a1f4e00d83 Use VisualizerDataSource instead of Card 2024-10-29 11:35:03 +00:00
Anton Kulyk
dbaf0c2f8d Add VisualizerDataSource type 2024-10-29 11:35:03 +00:00
Anton Kulyk
ea56baeaad Remove selectedCardId 2024-10-28 16:37:01 +00:00
Anton Kulyk
3a65db46a7 Visualizer — Single dataset funnels (#49146)
* Render static bottom well for funnel charts

* Extend redux store to unlock viz settings changes

* Reorder funnel steps with the bottom well

* Minor prop type fix

* Use border radius CSS variable

* Render static vertical well

* Basic drag and drop for swapping metric/dimension

* Move `draggedItem` state to Redux

* Add `getVisualizationType` selector

* Rename `BottomWell` to `HorizontalWell`

* Remove not used file

* Fix type errors

* Rename selector

* Visualizer simple funnel vibe cleanup (#49242)

* play with drag over animation

* bump borders on visualizer zones

* add icon and separator to data importer

* add grabber to column list item

* column list item hover state

* dataset list item styling

* use miw instead of width to prevent jump

* drag overlay style

---------

Co-authored-by: Kyle Doherty <5248953+kdoh@users.noreply.github.com>
2024-10-28 16:36:09 +00:00
Aleksandr Lesnenko
b06b8261d6 filter out non-cards 2024-10-17 13:16:05 -04:00
Aleksandr Lesnenko
af7cba10cc basic data manager 2024-10-17 10:31:18 -04:00
Aleksandr Lesnenko
28af981218 new -> visualization 2024-10-16 10:17:08 -04:00
Aleksandr Lesnenko
2327da2dc8 app skeleton 2024-10-15 19:19:17 -04:00
98 changed files with 4947 additions and 138 deletions

View File

@@ -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>;

View 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[];
}

View File

@@ -1,5 +1,6 @@
:root {
--default-border-radius: 8px;
--border-radius-xl: 99px;
}
.rounded {

View File

@@ -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,
({

View File

@@ -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>

View File

@@ -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

View File

@@ -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}
/>
)}
</>
);
}

View File

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

View File

@@ -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}
/>
);
};

View File

@@ -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 => {

View File

@@ -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`}
/>
)}
</>
);
}

View File

@@ -92,6 +92,7 @@ function setup({
downloadsEnabled
autoScroll={false}
reportAutoScrolledToDashcard={jest.fn()}
editDashboard={jest.fn()}
/>,
{
storeInitialState: {

View File

@@ -497,6 +497,7 @@ function Dashboard(props: DashboardProps) {
reportAutoScrolledToDashcard={
reportAutoScrolledToDashcard
}
handleSetEditing={handleSetEditing}
/>
</FullWidthContainer>
)}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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)}
/>
)}
</>
);
}

View File

@@ -236,6 +236,7 @@ export function PublicOrEmbeddedDashboardView({
downloadsEnabled={downloadsEnabled}
autoScrollToDashcardId={undefined}
reportAutoScrolledToDashcard={_.noop}
handleSetEditing={_.noop}
/>
</DashboardContainer>
);

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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}

View File

@@ -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. */}

View File

@@ -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

View File

@@ -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 =>

View File

@@ -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 => ({

View File

@@ -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;

View File

@@ -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;
},

View File

@@ -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);
}
};

View File

@@ -107,6 +107,7 @@ export interface VisualizationProps {
isEmbeddingSdk: boolean;
showTitle: boolean;
isDashboard: boolean;
isVisualizerViz: boolean;
isEditing: boolean;
isNightMode: boolean;
isSettings: boolean;

View File

@@ -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,

View File

@@ -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)}
/>
)}
</>
);
}

View File

@@ -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

View File

@@ -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"]) &&

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
}

View File

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

View File

@@ -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);
}

View File

@@ -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>
);
},
);

View File

@@ -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>
);
};

View File

@@ -0,0 +1,12 @@
.parent {
border: 1px solid transparent;
border-radius: var(--default-border-radius);
}
.close {
visibility: hidden;
}
.parent:hover .close {
visibility: visible;
}

View File

@@ -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}
/>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./ColumnListItem";
export * from "./DataManager";

View File

@@ -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",
}}
/>
);
}

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
}

View File

@@ -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>;
}

View File

@@ -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;
}

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
});

View File

@@ -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;
}

View File

@@ -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>
);
},
);

View File

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

View File

@@ -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 .";
}

View File

@@ -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)}
/>
</>
);
}

View File

@@ -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>
);
},
);

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
);
};

View File

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

View File

@@ -0,0 +1,4 @@
.VisualizerRoot {
width: 100%;
height: 80vh;
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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}
/>
);
}

View 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",
};

View File

@@ -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()),
};
}

View 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
);
}

View 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"];
}
}

View 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;
});
}

View 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);
}

View 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";

View File

@@ -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;
}

View 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 },
};
}

View 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;
}

View 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"];
}
}

View 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"];
}
}

View 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"];
}
}

View 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"];
}
}

View 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;

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]