Compare commits

...

90 Commits

Author SHA1 Message Date
Anton Kulyk
5ad22ffb2c Prototype table transposition 2025-01-09 13:30:37 +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
83 changed files with 4230 additions and 85 deletions

View File

@@ -811,6 +811,10 @@ class Question {
}
query(): Query {
if (!this._card.dataset_query) {
return null;
}
if (this._legacyQuery() instanceof InternalQuery) {
throw new Error("Internal query is not supported by MLv2");
}

View File

@@ -17,6 +17,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";
@@ -39,6 +40,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

@@ -132,6 +132,12 @@ const NewItemMenu = ({
});
}
items.push({
title: t`Visualization`,
icon: "lineandbar",
link: `/visualizer`,
});
return items;
}, [
hasDataAccess,

View File

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

View File

@@ -5,6 +5,7 @@ import _ from "underscore";
import { displayNameForColumn, formatValue } from "metabase/lib/formatting";
import { makeCellBackgroundGetter } from "metabase/visualizations/lib/table_format";
import { migratePivotColumnSplitSetting } from "metabase-lib/v1/queries/utils/pivot";
import { isCategory, isDimension } from "metabase-lib/v1/types/utils/isa";
export function isPivotGroupColumn(col) {
return col.name === "pivot-grouping";
@@ -679,3 +680,54 @@ class SortState {
}
}
}
export function transpose(data) {
const dimensionColIndex = data.cols.findIndex(
col => isDimension(col) && isCategory(col),
);
const dimension = data.cols[dimensionColIndex];
if (!dimension) {
throw new Error(t`No category dimension found`);
}
const cols = [
{
name: "_GROUP",
display_name: "",
base_type: "type/Text",
effective_type: "type/Text",
semantic_type: null,
source: "artificial",
},
];
const dimensionColIndexByName = {};
data.rows.forEach(row => {
const name = row[dimensionColIndex];
cols.push({
...dimension,
name,
display_name: name,
_dimension: {
column: dimension,
value: name,
},
});
dimensionColIndexByName[name] = cols.length - 1;
});
const rows = [];
data.cols.forEach((col, colIndex) => {
if (colIndex === dimensionColIndex) {
return;
}
rows.push([col.display_name, ...data.rows.map(row => row[colIndex])]);
});
return {
cols,
rows,
results_timezone: data.results_timezone,
};
}

View File

@@ -13,3 +13,4 @@ export * from "./pulses";
export * from "./questions";
export * from "./timelines";
export * from "./utils";
export * from "./visualizer";

View File

@@ -0,0 +1,8 @@
import type { VisualizerDataSourceType } from "metabase-types/store/visualizer";
export function visualizer(
dataSourceType: VisualizerDataSourceType,
dataSourceId: number,
) {
return `/visualizer?dataSource=${dataSourceType}:${dataSourceId}`;
}

View File

@@ -0,0 +1,8 @@
.visualizerLink {
margin-right: 0.5rem;
transition: color 0.2s;
&:hover {
color: var(--mb-color-brand);
}
}

View File

@@ -3,9 +3,11 @@ import type React from "react";
import { useCallback } from "react";
import { t } from "ttag";
import Link from "metabase/core/components/Link";
import CS from "metabase/css/core/index.css";
import { SERVER_ERROR_TYPES } from "metabase/lib/errors";
import MetabaseSettings from "metabase/lib/settings";
import * as Urls from "metabase/lib/urls";
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip";
import { canExploreResults } from "metabase/query_builder/components/view/ViewHeader/utils";
import type { QueryModalType } from "metabase/query_builder/constants";
@@ -25,6 +27,8 @@ import { QuestionNotebookButton } from "../QuestionNotebookButton";
import { QuestionSummarizeWidget } from "../QuestionSummarizeWidget";
import { ToggleNativeQueryPreview } from "../ToggleNativeQueryPreview";
import S from "./ViewTitleHeaderRightSide.module.css";
interface ViewTitleHeaderRightSideProps {
question: Question;
result: Dataset;
@@ -143,6 +147,12 @@ export function ViewTitleHeaderRightSide({
className={ViewTitleHeaderS.ViewHeaderActionPanel}
data-testid="qb-header-action-panel"
>
{isSaved && (
<Link
className={S.visualizerLink}
to={Urls.visualizer("card", question.id())}
>{t`Visualize with…`}</Link>
)}
{FilterHeaderButton.shouldRender({
question,
queryBuilderMode,

View File

@@ -10,6 +10,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";
@@ -25,4 +26,5 @@ export const mainReducers = {
setup,
admin,
plugins: combineReducers(PLUGIN_REDUCERS),
visualizer,
};

View File

@@ -68,7 +68,8 @@ const errorPage = handleActions(
// regexr.com/7r89i
// A word boundary is added to /model so it doesn't match /browse/models
const PATH_WITH_COLLAPSED_NAVBAR = /\/(model\b|question|dashboard|metabot).*/;
const PATH_WITH_COLLAPSED_NAVBAR =
/\/(model\b|question|dashboard|metabot|visualizer).*/;
export function isNavbarOpenForPathname(pathname: string, prevState: boolean) {
return (

View File

@@ -55,6 +55,7 @@ import SegmentRevisionsContainer from "metabase/reference/segments/SegmentRevisi
import SearchApp from "metabase/search/containers/SearchApp";
import { Setup } from "metabase/setup/components/Setup";
import getCollectionTimelineRoutes from "metabase/timelines/collections/routes";
import { VisualizerPage } from "metabase/visualizer";
import {
CanAccessOnboarding,
@@ -181,6 +182,11 @@ export const getRoutes = store => {
<Route path=":slug/:objectId" component={QueryBuilder} />
</Route>
<Route path="visualizer">
<IndexRoute component={VisualizerPage} />
<Route path=":slug" component={VisualizerPage} />
</Route>
{/* MODELS */}
{getModelRoutes()}

View File

@@ -15,10 +15,9 @@ export function SmartScalar({
renderingContext,
}: StaticChartProps) {
const { fontFamily, getColor } = renderingContext;
const [{ card, data }] = rawSeries;
const { insights } = data;
const [{ card }] = rawSeries;
const { trend, error } = computeTrend(rawSeries, insights, settings, {
const { trend, error } = computeTrend(rawSeries, settings, {
getColor,
});

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

@@ -391,7 +391,7 @@ class Visualization extends PureComponent {
/>
);
} else if (e instanceof MinRowsError) {
noResults = true;
// noResults = true;
}
}
}

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

@@ -132,6 +132,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

@@ -520,7 +520,8 @@ export const tableColumnSettings = {
section: t`Columns`,
// title: t`Columns`,
widget: ChartSettingTableColumns,
getHidden: (series, vizSettings) => vizSettings["table.pivot"],
getHidden: (series, vizSettings) =>
vizSettings["table.pivot"] || vizSettings["table.transpose"],
getValue: ([{ data }], vizSettings) => {
const { cols } = data;
const settings = vizSettings["table.columns"] ?? [];

View File

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

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

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

@@ -95,6 +95,7 @@ export function addMissingCardBreakouts(
export function isColumnValid(col: DatasetColumn) {
return (
col.source === "artificial" ||
col.source === "aggregation" ||
col.source === "breakout" ||
isPivotGroupColumn(col)

View File

@@ -19,7 +19,6 @@ import ScalarValue, {
ScalarWrapper,
} from "metabase/visualizations/components/ScalarValue";
import { ScalarTitleContainer } from "metabase/visualizations/components/ScalarValue/ScalarValue.styled";
import { NoBreakoutError } from "metabase/visualizations/lib/errors";
import { compactifyValue } from "metabase/visualizations/lib/scalar_utils";
import { columnSettings } from "metabase/visualizations/lib/settings/column";
import { fieldSetting } from "metabase/visualizations/lib/settings/utils";
@@ -72,14 +71,13 @@ export function SmartScalar({
}) {
const scalarRef = useRef(null);
const insights = rawSeries?.[0].data?.insights;
const { trend, error } = useMemo(
() =>
computeTrend(series, insights, settings, {
computeTrend(series, settings, {
formatValue,
getColor: color,
}),
[series, insights, settings],
[series, settings],
);
useEffect(() => {
@@ -432,20 +430,4 @@ Object.assign(SmartScalar, {
isSensible({ insights }) {
return insights && insights.length > 0;
},
// Smart scalars need to have a breakout
checkRenderable(
[
{
data: { insights },
},
],
settings,
) {
if (!insights || insights.length === 0) {
throw new NoBreakoutError(
t`Group only by a time field to see how this has changed over time`,
);
}
},
});

View File

@@ -11,12 +11,11 @@ import { formatChange } from "metabase/visualizations/visualizations/SmartScalar
import * as Lib from "metabase-lib";
import { isDate } from "metabase-lib/v1/types/utils/isa";
export function computeTrend(series, insights, settings, { getColor }) {
export function computeTrend(series, settings, { getColor }) {
try {
const comparisons = settings["scalar.comparisons"] || [];
const currentMetricData = getCurrentMetricData({
series,
insights,
settings,
});
@@ -142,12 +141,9 @@ function computeComparison({ comparison, currentMetricData, series }) {
throw Error("Invalid comparison type specified.");
}
function getCurrentMetricData({ series, insights, settings }) {
function getCurrentMetricData({ series, settings }) {
const [
{
card: {
dataset_query: { type: queryType },
},
data: { rows, cols },
},
] = series;
@@ -183,18 +179,14 @@ function getCurrentMetricData({ series, insights, settings }) {
// get metric column metadata
const metricColumn = cols[metricColIndex];
const metricInsight = insights?.find(
insight => insight.col === metricColumn.name,
);
const dateUnit = metricInsight?.unit;
const dateColumn = cols[dimensionColIndex];
const dateUnit = dateColumn?.unit;
const dateColumnSettings = settings?.column?.(dateColumn) ?? {};
const dateUnitSettings = {
dateColumn,
dateColumnSettings,
dateUnit,
queryType,
};
const formatOptions = {
@@ -509,10 +501,9 @@ function computeComparisonStrPreviousValue({
}
function formatDateStr({ date, dateUnitSettings, options }) {
const { dateColumn, dateColumnSettings, dateUnit, queryType } =
dateUnitSettings;
const { dateColumn, dateColumnSettings, dateUnit } = dateUnitSettings;
if (isEmpty(dateUnit) || queryType === "native") {
if (isEmpty(dateUnit)) {
return formatValue(date, {
...dateColumnSettings,
column: dateColumn,

View File

@@ -110,12 +110,12 @@ export function getDefaultComparison(
): SmartScalarComparison[] {
const [
{
data: { insights },
data: { cols },
},
] = series;
const dateUnit = insights?.find(
insight => insight.col === settings["scalar.field"],
const dateUnit = cols.find(
col => col.name === settings["scalar.field"],
)?.unit;
if (!dateUnit) {
@@ -158,8 +158,6 @@ export function getComparisonOptions(
},
] = series;
const insights = series[0].data.insights ?? [];
const options: ComparisonMenuOption[] = [
createComparisonMenuOption({ type: COMPARISON_TYPES.PREVIOUS_VALUE }),
];
@@ -175,8 +173,8 @@ export function getComparisonOptions(
createComparisonMenuOption({ type: COMPARISON_TYPES.STATIC_NUMBER }),
);
const dateUnit = insights.find(
insight => insight.col === settings["scalar.field"],
const dateUnit = cols.find(
col => col.name === settings["scalar.field"],
)?.unit;
if (!dateUnit) {
@@ -214,7 +212,7 @@ export function isComparisonValid(
) {
const [
{
data: { cols, insights },
data: { cols },
},
] = series;
@@ -244,8 +242,8 @@ export function isComparisonValid(
return !isEmpty(comparison?.value) && !isEmpty(comparison?.label);
}
const dateUnit = insights?.find(
insight => insight.col === settings["scalar.field"],
const dateUnit = cols.find(
col => col.name === settings["scalar.field"],
)?.unit;
if (!dateUnit) {

View File

@@ -87,20 +87,39 @@ class Table extends Component<TableProps, TableState> {
title: t`Pivot table`,
widget: "toggle",
inline: true,
getHidden: ([{ data }]: Series) => data && data.cols.length !== 3,
getDefault: ([{ card, data }]: Series) => {
getHidden: ([{ data }]: Series, settings: VisualizationSettings) => {
return (data && data.cols.length !== 3) || settings["table.transpose"];
},
getDefault: (
[{ card, data }]: Series,
settings: VisualizationSettings,
) => {
if (
!data ||
data.cols.length !== 3 ||
isNative(card) ||
data.cols.filter(isMetric).length !== 1 ||
data.cols.filter(isDimension).length !== 2
data.cols.filter(isDimension).length !== 2 ||
settings["table.transpose"]
) {
return false;
}
return getDefaultPivotColumn(data.cols, data.rows) != null;
},
getValue: (series: Series, settings: VisualizationSettings) => {
if (settings["table.transpose"]) {
return false;
}
return Table.isPivoted(series, settings);
},
readDependencies: ["table.transpose"],
},
"table.transpose": {
section: t`Columns`,
title: t`Transpose`,
widget: "toggle",
inline: true,
},
"table.pivot_column": {
section: t`Columns`,
@@ -340,6 +359,8 @@ class Table extends Component<TableProps, TableState> {
data: DataGrid.pivot(data, normalIndex, pivotIndex, cellIndex),
question,
});
} else if (settings["table.transpose"]) {
this.setState({ data: DataGrid.transpose(data), question });
} else {
const { cols, rows, results_timezone } = data;
const columnSettings = settings["table.columns"] ?? [];

View File

@@ -0,0 +1,96 @@
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, Icon, 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`}
icon={<Icon name="search" />}
/>
</Box>
<Flex
direction="column"
pt="sm"
px="sm"
style={{
overflowY: "auto",
}}
>
{showRecents ? (
<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,160 @@
import { useDraggable } from "@dnd-kit/core";
import { t } from "ttag";
import { isPivotGroupColumn } from "metabase/lib/data_grid";
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
.filter(column => !isPivotGroupColumn(column))
.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,59 @@
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
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;
}
function DragOverlayWrapper({ style, ...props }: BoxProps) {
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,61 @@
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 { getIsDirty } from "metabase/visualizer/selectors";
import {
toggleFullscreenMode,
toggleVizSettingsSidebar,
} from "metabase/visualizer/visualizer.slice";
export function Header() {
const { canUndo, canRedo, undo, redo } = useVisualizerHistory();
const isDirty = useSelector(getIsDirty);
const dispatch = useDispatch();
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>
<Button compact disabled mx="xs">
Persist me
</Button>
<Tooltip label={t`Fullscreen`}>
<ActionIcon
disabled={!isDirty}
onClick={() => dispatch(toggleFullscreenMode())}
>
<Icon name="expand" />
</ActionIcon>
</Tooltip>
</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,114 @@
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,
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,150 @@
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,
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,61 @@
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
pos="absolute"
top="1rem"
right="1rem"
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,82 @@
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`Pivot table`,
value: "pivot",
icon: "pivot_table",
},
{
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,150 @@
import { useDroppable } from "@dnd-kit/core";
import { type ReactNode, forwardRef, 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";
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>
);
}
interface WellBoxProps {
isHighlighted: boolean;
isOver: boolean;
children: ReactNode;
}
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,204 @@
import { type Active, useDraggable, useDroppable } from "@dnd-kit/core";
import { type ReactNode, forwardRef, useMemo } from "react";
import _ from "underscore";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { isNotNull } from "metabase/lib/types";
import { type BoxProps, Flex, Stack, Text } from "metabase/ui";
import { DRAGGABLE_ID, DROPPABLE_ID } from "metabase/visualizer/constants";
import {
getVisualizerComputedSettings,
getVisualizerRawSeries,
} from "metabase/visualizer/selectors";
import {
isDraggedColumnItem,
isDraggedWellItem,
} from "metabase/visualizer/utils";
import { removeColumn } from "metabase/visualizer/visualizer.slice";
import type { DatasetColumn, RawSeries } from "metabase-types/api";
import { WellItem } from "../WellItem";
export function PivotVerticalWell() {
const settings = useSelector(getVisualizerComputedSettings);
const series = useSelector(getVisualizerRawSeries);
const dispatch = useDispatch();
const droppableColumnsWell = useDroppable({
id: DROPPABLE_ID.PIVOT_COLUMNS_WELL,
});
const droppableValuesWell = useDroppable({
id: DROPPABLE_ID.PIVOT_VALUES_WELL,
});
const droppableRowsWell = useDroppable({ id: DROPPABLE_ID.PIVOT_ROWS_WELL });
const { cols, rows, values } = useMemo(() => {
const { columns, rows, values } =
settings["pivot_table.column_split"] ?? {};
return {
cols: findPivotColumns(series, columns),
rows: findPivotColumns(series, rows),
values: findPivotColumns(series, values),
};
}, [series, settings]);
const handleRemoveColumn = (column: DatasetColumn) => {
dispatch(removeColumn({ name: column.name }));
};
if (!series[0]?.data) {
return null;
}
return (
<Stack mr="lg">
<WellBox
isHighlighted={isWellBoxHighlighted(droppableRowsWell)}
ref={droppableRowsWell.setNodeRef}
>
<Text>Rows</Text>
{rows.map(rowCol => (
<DraggableWellItem
key={rowCol.name}
column={rowCol}
wellId={DROPPABLE_ID.PIVOT_ROWS_WELL}
onRemove={() => handleRemoveColumn(rowCol)}
>
<Text truncate>{rowCol.display_name}</Text>
</DraggableWellItem>
))}
</WellBox>
<WellBox
isHighlighted={isWellBoxHighlighted(droppableColumnsWell)}
ref={droppableColumnsWell.setNodeRef}
>
<Text>Columns</Text>
{cols.map(col => (
<DraggableWellItem
key={col.name}
column={col}
wellId={DROPPABLE_ID.PIVOT_COLUMNS_WELL}
onRemove={() => handleRemoveColumn(col)}
>
<Text key={col.name} truncate>
{col.display_name}
</Text>
</DraggableWellItem>
))}
</WellBox>
<WellBox
isHighlighted={isWellBoxHighlighted(droppableValuesWell)}
ref={droppableValuesWell.setNodeRef}
>
<Text>Measures</Text>
{values.map(valueCol => (
<DraggableWellItem
key={valueCol.name}
column={valueCol}
wellId={DROPPABLE_ID.PIVOT_VALUES_WELL}
onRemove={() => handleRemoveColumn(valueCol)}
>
<Text truncate>{valueCol.display_name}</Text>
</DraggableWellItem>
))}
</WellBox>
</Stack>
);
}
interface WellBoxProps {
isHighlighted?: boolean;
children: ReactNode;
}
const WellBox = forwardRef<HTMLDivElement, WellBoxProps>(function WellBox(
{ isHighlighted, children },
ref,
) {
const borderColor = isHighlighted
? "var(--mb-color-brand)"
: "var(--mb-color-border)";
return (
<Flex
direction="column"
bg={
isHighlighted
? "var(--mb-color-brand-light)"
: "var(--mb-color-bg-light)"
}
p="md"
gap="sm"
wrap="nowrap"
w="190px"
mih="90px"
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",
}}
ref={ref}
>
{children}
</Flex>
);
});
interface DraggableWellItemProps extends BoxProps {
column: DatasetColumn;
wellId: string;
onRemove: () => void;
}
function DraggableWellItem({
column,
wellId,
...props
}: DraggableWellItemProps) {
const { attributes, listeners, isDragging, setNodeRef } = useDraggable({
id: `${wellId}:${DRAGGABLE_ID.WELL_ITEM}:${column.name}`,
data: {
type: DRAGGABLE_ID.WELL_ITEM,
wellId,
column,
},
});
return (
<WellItem
{...props}
{...attributes}
{...listeners}
style={{
cursor: "grab",
visibility: isDragging ? "hidden" : "visible",
}}
ref={setNodeRef}
/>
);
}
function findPivotColumns(series: RawSeries, columnNames: string[] = []) {
const [{ data }] = series ?? [];
if (!data) {
return [];
}
return columnNames
.map(name => data.cols.find(col => col.name === name))
.filter(isNotNull);
}
function isWellBoxHighlighted({
active,
isOver,
}: {
isOver: boolean;
active?: Active | null;
}) {
return Boolean(
isOver &&
active &&
(isDraggedColumnItem(active) || isDraggedWellItem(active)),
);
}

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,27 @@
import { isCartesianChart } from "metabase/visualizations";
import type { VisualizationDisplay } from "metabase-types/api";
import { CartesianVerticalWell } from "./CartesianVerticalWell";
import { FunnelVerticalWell } from "./FunnelVerticalWell";
import { PieVerticalWell } from "./PieVerticalWell";
import { PivotVerticalWell } from "./PivotVerticalWell";
interface VerticalWellProps {
display: VisualizationDisplay;
}
export function VerticalWell({ display }: VerticalWellProps) {
if (isCartesianChart(display)) {
return <CartesianVerticalWell />;
}
if (display === "funnel") {
return <FunnelVerticalWell />;
}
if (display === "pie") {
return <PieVerticalWell />;
}
if (display === "pivot") {
return <PivotVerticalWell />;
}
return null;
}

View File

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

View File

@@ -0,0 +1,93 @@
import { useDroppable } from "@dnd-kit/core";
import { useState } from "react";
import { t } from "ttag";
import { useSelector } from "metabase/lib/redux";
import {
ActionIcon,
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";
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 (
<>
<Flex w="100%" h="100%" pos="relative" ref={setNodeRef}>
<VerticalWell display={display} />
<Flex direction="column" style={{ flex: 1 }}>
<Visualization
rawSeries={rawSeries}
// TableInteractive crashes when trying to use metabase-lib
isDashboard={display === "table"}
/>
<HorizontalWell
display={display}
w="90%"
style={{ alignSelf: "center" }}
/>
</Flex>
{display === "scatter" && <ScatterFloatingWell />}
<Tooltip label={t`View as table`}>
<ActionIcon
pos="absolute"
right={0}
bottom={0}
onClick={() => setTabularPreviewOpen(true)}
>
<Icon name="table" />
</ActionIcon>
</Tooltip>
</Flex>
<TabularPreviewModal
opened={isTabularPreviewOpen}
onClose={() => setTabularPreviewOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,41 @@
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"
{...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>{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,177 @@
import {
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
} from "@dnd-kit/core";
import { useCallback, useEffect } from "react";
import type { WithRouterProps } from "react-router";
import { useKeyPressEvent, usePrevious, useUnmount } from "react-use";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { Box, Flex } from "metabase/ui";
import { useVisualizerHistory } from "metabase/visualizer/hooks/use-visualizer-history";
import { useVisualizerUrlSync } from "metabase/visualizer/hooks/use-visualizer-url-sync";
import {
getDatasets,
getDraggedItem,
getIsDirty,
getIsFullscreenModeEnabled,
getIsVizSettingsSidebarOpen,
getVisualizationType,
} from "metabase/visualizer/selectors";
import { isValidDraggedItem } from "metabase/visualizer/utils";
import {
closeVizSettingsSidebar,
handleDrop,
resetVisualizer,
setDisplay,
setDraggedItem,
turnOffFullscreenMode,
} from "metabase/visualizer/visualizer.slice";
import type { VisualizationDisplay } from "metabase-types/api";
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";
export const VisualizerPage = ({ location, router }: WithRouterProps) => {
const { canUndo, canRedo, undo, redo } = useVisualizerHistory();
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 },
});
useVisualizerUrlSync(location, router);
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 handleChangeDisplay = useCallback(
(nextDisplay: string) => {
dispatch(setDisplay(nextDisplay as VisualizationDisplay));
},
[dispatch],
);
return (
<DndContext
sensors={[canvasSensor]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Flex direction="column" w="100%" h="100%">
<Header />
<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
component="main"
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">
<p>Name your visualization</p>
<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 "./VisualizerPage";

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,20 @@
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",
PIVOT_COLUMNS_WELL: "PIVOT_COLUMNS_WELL",
PIVOT_ROWS_WELL: "PIVOT_ROWS_WELL",
PIVOT_VALUES_WELL: "PIVOT_VALUES_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,80 @@
import type { Location } from "history";
import { useEffect } from "react";
import type { InjectedRouter } from "react-router";
import { push } from "react-router-redux";
import { usePrevious } from "react-use";
import { useDispatch, useSelector } from "metabase/lib/redux";
import {
getFutureVisualizerUrlHashes,
getPastVisualizerUrlHashes,
getVisualizerUrlHash,
} from "../selectors";
import {
initializeVisualizer,
redo,
resetVisualizer,
undo,
} from "../visualizer.slice";
export function useVisualizerUrlSync(
location: Location,
router: InjectedRouter,
) {
const stateHash = useSelector(getVisualizerUrlHash);
const pastStateHashes = useSelector(getPastVisualizerUrlHashes);
const futureStateHashes = useSelector(getFutureVisualizerUrlHashes);
const previousStateHash = usePrevious(stateHash);
const previousLocation = usePrevious(location);
const dispatch = useDispatch();
useEffect(() => {
// @ts-expect-error missing type declaration
const unsubscribe = router.listen(nextLocation => {
const nextHash = cleanHash(nextLocation?.hash);
const didGoBack = pastStateHashes.includes(nextHash);
const didGoForward = futureStateHashes.includes(nextHash);
if (didGoBack) {
dispatch(undo());
} else if (didGoForward) {
dispatch(redo());
}
});
return () => unsubscribe();
}, [pastStateHashes, futureStateHashes, router, dispatch]);
useEffect(() => {
const hash = cleanHash(location?.hash);
const previousHash = cleanHash(previousLocation?.hash);
const isUrlHashChanged =
hash && hash !== previousHash && hash !== stateHash;
const isUrlHashRemoved = previousHash && !hash;
const isStateChanged =
stateHash && stateHash !== hash && stateHash !== previousStateHash;
if (isStateChanged) {
dispatch(push({ ...location, hash: `#${stateHash}` }));
} else if (isUrlHashChanged) {
dispatch(initializeVisualizer(hash));
} else if (isUrlHashRemoved) {
dispatch(resetVisualizer());
}
}, [
stateHash,
previousStateHash,
pastStateHashes,
futureStateHashes,
location,
previousLocation,
dispatch,
]);
}
function cleanHash(hash?: string) {
return hash?.startsWith("#") ? hash.slice(1) : hash;
}

View File

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

View File

@@ -0,0 +1,262 @@
import { createSelector } from "@reduxjs/toolkit";
import _ from "underscore";
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, RowValues } from "metabase-types/api";
import type {
VisualizerHistoryItem,
VisualizerState,
} from "metabase-types/store/visualizer";
import {
createDataSource,
extractReferencedColumns,
getDataSourceIdFromNameRef,
isDataSourceNameRef,
} 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 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,
getReferencedColumns,
getVisualizationColumns,
getVisualizerColumnValuesMapping,
],
(
usedDataSources,
datasets,
referencedColumns,
cols,
columnValuesMapping,
): DatasetData => {
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 hasPivotGrouping = cols.some(col => col.name === "pivot-grouping");
if (hasPivotGrouping) {
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,
);
}
const unzippedRows = cols.map(column =>
(columnValuesMapping[column.name] ?? [])
.map(valueSource => {
if (isDataSourceNameRef(valueSource)) {
const id = getDataSourceIdFromNameRef(valueSource);
const dataSource = usedDataSources.find(source => source.id === id);
return dataSource?.name ? [dataSource.name] : [];
}
const values = referencedColumnValuesMap[valueSource.name];
if (!values) {
return [];
}
return values;
})
.flat(),
);
return {
cols,
rows: _.zip(...unzippedRows),
results_metadata: { columns: cols },
};
},
);
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 getIsDirty = createSelector([getCurrentHistoryItem], state =>
checkIfStateDirty(state),
);
export const getVisualizerUrlHash = createSelector(
[getCurrentHistoryItem],
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,264 @@
import type { Active } from "@dnd-kit/core";
import { isPivotGroupColumn } from "metabase/lib/data_grid";
import { isDate, isNumeric } from "metabase-lib/v1/types/utils/isa";
import type {
Card,
DatasetColumn,
VisualizationDisplay,
VisualizationSettings,
} from "metabase-types/api";
import type {
DraggedColumn,
DraggedItem,
DraggedWellItem,
VisualizerColumnReference,
VisualizerColumnValueSource,
VisualizerDataSource,
VisualizerDataSourceId,
VisualizerDataSourceNameReference,
VisualizerDataSourceType,
} from "metabase-types/store/visualizer";
import { DRAGGABLE_ID } from "./constants";
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 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;
}
if (isPivotGroupColumn(column)) {
return {
sourceId: dataSource.id,
originalName: column.name,
name: column.name,
};
}
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 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;
}
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);
}
export function copyColumn(name: string, column: DatasetColumn): DatasetColumn {
const copy: DatasetColumn = {
...column,
name,
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["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"];
}
}
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))
);
}

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,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,176 @@
import type { DragEndEvent } from "@dnd-kit/core";
import { DROPPABLE_ID } from "metabase/visualizer/constants";
import {
createVisualizerColumnReference,
extractReferencedColumns,
isDraggedColumnItem,
isDraggedWellItem,
} from "metabase/visualizer/utils";
import type { VisualizationSettings } from "metabase-types/api";
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
export const pivotDropHandler = (
state: VisualizerHistoryItem,
{ active, over }: DragEndEvent,
) => {
if (over && isDraggedColumnItem(active)) {
const { column, dataSource } = active.data.current;
const columnRef = createVisualizerColumnReference(
dataSource,
column,
extractReferencedColumns(state.columnValuesMapping),
);
const columnType = getColumnTypeFromWellId(over.id);
if (columnType) {
const nextSettings = addColumnToVizSettings(
state.settings,
columnRef.name,
columnType,
);
if (nextSettings) {
state.settings = nextSettings;
state.columns.push({
...column,
name: columnRef.name,
source: column.source === "breakout" ? "breakout" : "artificial",
});
state.columnValuesMapping[columnRef.name] = [columnRef];
}
}
}
if (over && isDraggedWellItem(active) && isPivotWell(over.id)) {
const { column, wellId } = active.data.current;
const previousColumnType = getColumnTypeFromWellId(wellId);
const nextColumnType = getColumnTypeFromWellId(over.id);
if (previousColumnType && nextColumnType) {
const nextSettings = addColumnToVizSettings(
removeColumnFromVizSettings(
state.settings,
column.name,
previousColumnType,
),
column.name,
nextColumnType,
);
if (nextSettings) {
state.settings = nextSettings;
}
}
}
if (
isDraggedWellItem(active) &&
(!over || over.id === DROPPABLE_ID.CANVAS_MAIN)
) {
const { column, wellId } = active.data.current;
const columnType = getColumnTypeFromWellId(wellId);
removeColumnFromPivotTable(state, column.name, columnType);
}
};
export function removeColumnFromPivotTable(
state: VisualizerHistoryItem,
columnName: string,
columnType?: "columns" | "rows" | "values" | null,
) {
state.columns = state.columns.filter(col => col.name !== columnName);
delete state.columnValuesMapping[columnName];
if (columnType) {
state.settings = removeColumnFromVizSettings(
state.settings,
columnName,
columnType,
);
} else {
state.settings = removeColumnFromVizSettings(
state.settings,
columnName,
"columns",
);
state.settings = removeColumnFromVizSettings(
state.settings,
columnName,
"rows",
);
state.settings = removeColumnFromVizSettings(
state.settings,
columnName,
"values",
);
}
}
function addColumnToVizSettings(
settings: VisualizationSettings,
columnName: string,
columnType: "columns" | "rows" | "values",
): VisualizationSettings | null {
const columnSplit = settings["pivot_table.column_split"] ?? {
columns: [],
rows: [],
values: [],
};
const list = columnSplit[columnType];
if (list.includes(columnName)) {
return null;
}
return {
...settings,
"pivot_table.column_split": {
...columnSplit,
[columnType]: [...list, columnName],
},
};
}
function removeColumnFromVizSettings(
settings: VisualizationSettings,
columnName: string,
columnType: "columns" | "rows" | "values",
) {
const columnSplit = settings["pivot_table.column_split"] ?? {
columns: [],
rows: [],
values: [],
};
const list = columnSplit[columnType];
return {
...settings,
"pivot_table.column_split": {
...columnSplit,
[columnType]: list.filter(col => col !== columnName),
},
};
}
function getColumnTypeFromWellId(wellId: string | number) {
if (wellId === DROPPABLE_ID.PIVOT_COLUMNS_WELL) {
return "columns";
}
if (wellId === DROPPABLE_ID.PIVOT_ROWS_WELL) {
return "rows";
}
if (wellId === DROPPABLE_ID.PIVOT_VALUES_WELL) {
return "values";
}
return null;
}
function isPivotWell(wellId: string | number) {
return (
typeof wellId === "string" &&
[
DROPPABLE_ID.PIVOT_COLUMNS_WELL,
DROPPABLE_ID.PIVOT_ROWS_WELL,
DROPPABLE_ID.PIVOT_VALUES_WELL,
].includes(wellId)
);
}

View File

@@ -0,0 +1,569 @@
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 { b64hash_to_utf8 } from "metabase/lib/encoding";
import { createAsyncThunk } from "metabase/lib/redux";
import { isNotNull } from "metabase/lib/types";
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,
parseDataSourceId,
} from "./utils";
import {
addMetricColumnToCartesianChart,
cartesianDropHandler,
removeColumnFromCartesianChart,
} from "./visualizations/cartesian";
import {
addScalarToFunnel,
canCombineCardWithFunnel,
funnelDropHandler,
removeColumnFromFunnel,
} from "./visualizations/funnel";
import { pieDropHandler, removeColumnFromPieChart } from "./visualizations/pie";
import {
pivotDropHandler,
removeColumnFromPivotTable,
} from "./visualizations/pivot";
const initialCommonState: VisualizerCommonState = {
cards: [],
datasets: {},
loadingDataSources: {},
loadingDatasets: {},
expandedDataSources: {},
isFullscreen: false,
isVizSettingsSidebarOpen: false,
error: null,
draggedItem: null,
};
const initialVisualizerHistoryItem: VisualizerHistoryItem = {
display: null,
columns: [],
columnValuesMapping: {},
settings: {},
};
const initialState: VisualizerState = {
...initialCommonState,
past: [],
present: initialVisualizerHistoryItem,
future: [],
};
export const initializeVisualizer = createAsyncThunk(
"visualizer/initializeVisualizer",
async (urlHash: string, { dispatch }) => {
try {
const urlData = JSON.parse(b64hash_to_utf8(urlHash));
const columnRefs = extractReferencedColumns(urlData.columnValuesMapping);
const dataSourceIds = Array.from(
new Set(columnRefs.map(ref => ref.sourceId)),
);
await Promise.all(
dataSourceIds
.map(sourceId => {
const [, cardId] = sourceId.split(":");
return [
dispatch(fetchCard(Number(cardId))),
dispatch(fetchCardQuery(Number(cardId))),
];
})
.flat(),
);
return urlData;
} catch (err) {
console.error("Error parsing visualizer URL hash", err);
}
},
);
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: initialVisualizerHistoryItem,
reducers: {
setDisplay: (state, action: PayloadAction<VisualizationDisplay | null>) => {
const previousDisplay = state.display;
const display = action.payload;
state.display = display;
if (
display === "pivot" &&
!state.columns.some(col => col.name === "pivot-grouping")
) {
state.columns.push({
name: "pivot-grouping",
display_name: "pivot-grouping",
expression_name: "pivot-grouping",
field_ref: ["expression", "pivot-grouping"],
base_type: "type/Integer",
effective_type: "type/Integer",
source: "artificial",
});
}
if (previousDisplay === "pivot" && display !== "pivot") {
state.columns = state.columns.filter(
col => col.name !== "pivot-grouping",
);
}
},
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 === "pivot") {
removeColumnFromPivotTable(state, name);
}
},
},
extraReducers: builder => {
builder
.addCase(initializeVisualizer.fulfilled, (state, action) => {
const urlState = action.payload;
if (urlState) {
Object.assign(state, urlState);
}
})
.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 === "pivot") {
pivotDropHandler(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,
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, initialState);
} else {
state.past = [];
state.future = [...state.future, state.present];
state.present = initialVisualizerHistoryItem;
}
},
},
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 = initialVisualizerHistoryItem;
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)
) {
state.display = card.display;
state.columns = [];
state.columnValuesMapping = {};
dataset.data.cols.forEach(column => {
const columnRef = createVisualizerColumnReference(
source,
column,
extractReferencedColumns(state.columnValuesMapping),
);
state.columns.push(copyColumn(columnRef.name, column));
state.columnValuesMapping[columnRef.name] = [columnRef];
});
const entries = getColumnVizSettings(state.display)
.map(setting => {
const originalValue = card.visualization_settings[setting];
if (!originalValue) {
return null;
}
if (Array.isArray(originalValue)) {
return [
setting,
originalValue.map(originalColumnName => {
const index = dataset.data.cols.findIndex(
col => col.name === originalColumnName,
);
return state.columns[index].name;
}),
];
} else {
const index = dataset.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;
}
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 { 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

@@ -248,6 +248,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]
@@ -299,6 +301,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)