Compare commits

...

76 Commits

Author SHA1 Message Date
Anton Kulyk
15ec4c486c Update visualizer search component 2024-12-06 14:25:13 +00:00
Adam James
2139b62fd5 Proj-Visualizer Temporarily add :visualization_settings and :result_metadata
Add some additional keys to search results for the purposes of validating the proj-visualizer design.

This is a temporary measure and changes should be reverted in favour of a solution that won't require all search
results everywhere to 'carry around' data that's only needed in the visualizer context.

Tests are left failing as a reminder that this is temporary!
2024-12-06 13:20:38 +00:00
Anton Kulyk
f667d79bb6 Show root collection name 2024-12-06 13:19:22 +00:00
Anton Kulyk
6eaf130e33 Improve sorting + dim non-combinable cards 2024-12-06 13:19:22 +00:00
Anton Kulyk
2e6b38f35c Add data type stack component 2024-12-06 13:19:22 +00:00
Anton Kulyk
d77ce41fd7 Prototype basic items ordering in data importer 2024-12-06 13:19:21 +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
82 changed files with 4217 additions and 83 deletions

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

@@ -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 {
SaveButton,
ViewHeaderActionPanel,
@@ -29,6 +31,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;
@@ -148,6 +152,12 @@ export function ViewTitleHeaderRightSide({
return (
<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

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

@@ -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 { QuestionList } from "./QuestionList";
// 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 ? (
<QuestionList
dataSourceIds={dataSourceIds}
onSelect={handleDataSourceSelect}
/>
) : (
<SearchResultsList
search={debouncedSearch}
onSelect={handleDataSourceSelect}
dataSourceIds={dataSourceIds}
/>
)}
</Flex>
</Flex>
);
};

View File

@@ -0,0 +1,54 @@
.root {
position: relative;
height: 2rem;
min-width: 4rem;
.iconContainer:nth-child(1) {
right: 0;
}
.iconContainer:nth-child(2) {
right: 4px;
}
.iconContainer:nth-child(3) {
right: 8px;
}
&:hover {
.iconContainer {
transform: scale(1.1);
}
.iconContainer:nth-child(1) {
right: 0;
}
.iconContainer:nth-child(2) {
right: 2.25rem;
}
.iconContainer:nth-child(3) {
right: 4.5rem;
}
}
}
.iconContainer {
position: absolute;
transition: all 0.2s ease;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background-color: var(--mb-color-bg-medium);
border: 1px solid var(--mb-color-bg-light);
border-radius: var(--default-border-radius);
}
.popoverListItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
}

View File

@@ -0,0 +1,70 @@
import { useDisclosure } from "@mantine/hooks";
import { useMemo } from "react";
import { Flex, Icon, type IconName, Popover, Text } from "metabase/ui";
import {
isCoordinate,
isCurrency,
isDate,
isLocation,
} from "metabase-lib/v1/types/utils/isa";
import type { Field } from "metabase-types/api";
import S from "./DataTypeStack.module.css";
interface DataTypeStackProps {
columns: Field[];
}
export function DataTypeStack({ columns }: DataTypeStackProps) {
const [isOpen, { open, close }] = useDisclosure();
const filteredColumns = useMemo(
() =>
columns.filter(
col =>
isCoordinate(col) ||
isCurrency(col) ||
isDate(col) ||
isLocation(col),
),
[columns],
);
return (
<Popover opened={isOpen} position="right-start">
<Popover.Target>
<Flex className={S.root} onMouseEnter={open} onMouseLeave={close}>
{filteredColumns.slice(0, 3).map(column => (
<Flex key={column.name} className={S.iconContainer}>
<Icon name={getIcon(column)} />
</Flex>
))}
</Flex>
</Popover.Target>
<Popover.Dropdown>
<ul>
{filteredColumns.map(column => (
<li key={column.name} className={S.popoverListItem}>
<Icon name={getIcon(column)} />
<Text>{column.display_name}</Text>
</li>
))}
</ul>
</Popover.Dropdown>
</Popover>
);
}
function getIcon(column: Field): IconName {
if (isDate(column)) {
return "calendar";
}
if (isLocation(column)) {
return "pinmap";
}
if (isCurrency(column)) {
return "int";
}
return "list";
}

View File

@@ -0,0 +1,51 @@
import { t } from "ttag";
import { Box, Flex, Stack, Text } from "metabase/ui";
import type { Card } from "metabase-types/api";
import { DataTypeStack } from "./DataTypeStack";
type ListItemProps = {
card: Card;
isSelected: boolean;
isMuted?: boolean;
onSelect: () => void;
};
export function ListItem({
card,
isSelected,
isMuted = false,
onSelect,
}: ListItemProps) {
return (
<Box
component="li"
px={14}
py={10}
mb={4}
style={{
border: "1px solid var(--mb-color-border)",
borderRadius: 5,
cursor: "pointer",
backgroundColor: isSelected
? "var(--mb-color-bg-medium)"
: "transparent",
opacity: isMuted ? 0.5 : 1,
}}
onClick={onSelect}
>
<Flex direction="row" align="center" justify="space-between" w="100%">
<Stack spacing="xs" maw="75%">
<Text truncate fw="bold">
{card.name}
</Text>
<Text truncate c="text-medium" size="sm">
{card.collection?.name ?? t`Our analytics`}
</Text>
</Stack>
<DataTypeStack columns={card.result_metadata} />
</Flex>
</Box>
);
}

View File

@@ -0,0 +1,74 @@
import { useMemo } from "react";
import _ from "underscore";
import { useListCardsQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import { Box } from "metabase/ui";
import {
getVisualizationType,
getVisualizerComputedSettings,
getVisualizerDatasetColumns,
} from "metabase/visualizer/selectors";
import { canCombineCard, createDataSource } from "metabase/visualizer/utils";
import type {
VisualizerDataSource,
VisualizerDataSourceId,
} from "metabase-types/store/visualizer";
import { ListItem } from "./ListItem";
import { getScoreFn } from "./utils";
interface QuestionListProps {
dataSourceIds: Set<VisualizerDataSourceId>;
onSelect: (item: VisualizerDataSource) => void;
}
export function QuestionList({ dataSourceIds, onSelect }: QuestionListProps) {
const { data: cards = [], isLoading } = useListCardsQuery({ f: "all" });
const display = useSelector(getVisualizationType);
const columns = useSelector(getVisualizerDatasetColumns);
const settings = useSelector(getVisualizerComputedSettings);
const items = useMemo(() => {
const isEmpty = columns.length === 0;
const calcBaseScore = getScoreFn(display);
return _.chain(cards)
.map(card => {
const dataSource = createDataSource("card", card.id, card.name);
const isSelected = dataSourceIds.has(dataSource.id);
const canCombine =
isEmpty ||
Boolean(
display &&
["line", "area", "bar"].includes(display) &&
canCombineCard(display, columns, settings, card),
);
let score = calcBaseScore(card);
if (canCombine || isSelected) {
score += 1000;
}
return { card, dataSource, score: -score, canCombine, isSelected };
})
.sortBy("score")
.value();
}, [cards, columns, dataSourceIds, display, settings]);
if (isLoading) {
return null;
}
return (
<Box component="ul">
{items.map(({ card, dataSource, canCombine, isSelected }) => (
<ListItem
key={card.id}
card={card}
isMuted={!canCombine && !isSelected}
isSelected={isSelected}
onSelect={() => onSelect(dataSource)}
/>
))}
</Box>
);
}

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,107 @@
import { useMemo } from "react";
import _ from "underscore";
import { skipToken, useSearchQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import { Box, Loader } from "metabase/ui";
import {
getVisualizationType,
getVisualizerComputedSettings,
getVisualizerDatasetColumns,
} from "metabase/visualizer/selectors";
import { canCombineCard, createDataSource } from "metabase/visualizer/utils";
import type { VisualizerDataSourceId } from "metabase-types/store/visualizer";
import { ListItem } from "./ListItem";
import type { ResultsListProps } from "./ResultsList";
import { getScoreFn } from "./utils";
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", "dataset", "metric"],
}
: skipToken,
{
refetchOnMountOrArgChange: true,
},
);
const display = useSelector(getVisualizationType);
const columns = useSelector(getVisualizerDatasetColumns);
const settings = useSelector(getVisualizerComputedSettings);
const items = useMemo(() => {
if (!Array.isArray(result.data) || result.data.length === 0) {
return [];
}
const isEmpty = columns.length === 0;
const calcBaseScore = getScoreFn(display);
return _.chain(result.data)
.map(item => {
const cardLike = {
id: item.id,
name: item.name,
dataset_query: item.dataset_query,
display: item.display,
result_metadata: item.result_metadata,
visualization_settings: item.visualization_settings,
type: item.model,
colleciton: item.collection,
};
const dataSource = createDataSource("card", cardLike.id, cardLike.name);
const isSelected = dataSourceIds.has(dataSource.id);
const canCombine =
isEmpty ||
Boolean(
display &&
["line", "area", "bar"].includes(display) &&
canCombineCard(display, columns, settings, cardLike),
);
let score = calcBaseScore(cardLike);
if (canCombine || isSelected) {
score += 1000;
}
return {
card: cardLike,
dataSource,
score: -score,
canCombine,
isSelected,
};
})
.sortBy("score")
.value();
}, [columns, dataSourceIds, display, result, settings]);
if (items.length === 0) {
return <Loader />;
}
return (
<Box component="ul">
{items.map(({ card, dataSource, canCombine, isSelected }) => (
<ListItem
key={card.id}
card={card}
isMuted={!canCombine && !isSelected}
isSelected={isSelected}
onSelect={() => onSelect(dataSource)}
/>
))}
</Box>
);
}

View File

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

View File

@@ -0,0 +1,177 @@
import { isCategory, isDate, isNumeric } from "metabase-lib/v1/types/utils/isa";
import type { Card } from "metabase-types/api";
type CardLike = Pick<
Card,
| "dataset_query"
| "display"
| "result_metadata"
| "visualization_settings"
| "type"
>;
export function getScoreFn(display?: string | null) {
switch (display) {
case "line":
case "area":
case "combo":
return lineAreaCombo;
case "bar":
case "row":
case "waterfall":
return barRowWaterfallCombo;
case "pie":
case "funnel":
return pieFunnel;
case "table":
return table;
case "pivot":
return pivotTable;
case "scatter":
return scatter;
default:
return () => 100;
}
}
function lineAreaCombo(card: CardLike) {
const isNative = card.dataset_query.type === "native";
const isUnaggregated =
"query" in card.dataset_query && !card.dataset_query.query.aggregation;
const datetimeColumns = card.result_metadata.filter(column => isDate(column));
const categoryColumns = card.result_metadata.filter(column =>
isCategory(column),
);
const numericColumns = card.result_metadata.filter(column =>
isNumeric(column),
);
if (card.type === "metric") {
if (datetimeColumns.length > 0) {
return 710;
}
if (categoryColumns.length > 0) {
return 700;
}
}
if (
card.display === "area" ||
card.display === "line" ||
card.display === "combo"
) {
return 600;
}
if (card.result_metadata.length === 2 && numericColumns.length > 0) {
return 500;
}
if (
(isNative || isUnaggregated) &&
numericColumns.length > 1 &&
datetimeColumns.length > 1
) {
return 400;
}
return card.type === "model" ? 300 : 200;
}
function barRowWaterfallCombo(card: CardLike) {
const datetimeColumns = card.result_metadata.filter(column => isDate(column));
const categoryColumns = card.result_metadata.filter(column =>
isCategory(column),
);
const numericColumns = card.result_metadata.filter(column =>
isNumeric(column),
);
if (card.type === "metric") {
if (categoryColumns.length > 0) {
return 700;
}
if (datetimeColumns.length > 0) {
return 600;
}
}
if (
card.display === "bar" ||
card.display === "row" ||
card.display === "waterfall" ||
card.display === "combo"
) {
return 500;
}
if (card.result_metadata.length === 2 && numericColumns.length > 0) {
return 400;
}
if (card.result_metadata.length === 3 && numericColumns.length > 0) {
return 300;
}
return card.type === "model" ? 300 : 200;
}
function pieFunnel(card: CardLike) {
const datetimeColumns = card.result_metadata.filter(column => isDate(column));
const categoryColumns = card.result_metadata.filter(column =>
isCategory(column),
);
const numericColumns = card.result_metadata.filter(column =>
isNumeric(column),
);
if (card.type === "metric") {
if (categoryColumns.length > 0) {
return 700;
}
if (datetimeColumns.length > 0) {
return 600;
}
}
if (card.display === "pie" || card.display === "funnel") {
return 500;
}
if (card.result_metadata.length === 2 && numericColumns.length > 0) {
return 400;
}
return card.type === "model" ? 300 : 200;
}
function table(card: CardLike) {
if (card.type === "model") {
return 700;
}
if (card.display === "table") {
return 600;
}
return card.type === "metric" ? 500 : 400;
}
function pivotTable(card: CardLike) {
if (card.type === "metric") {
return 700;
}
if (card.display === "pivot") {
return 600;
}
return card.type === "model" ? 500 : 400;
}
function scatter(card: CardLike) {
if (card.type === "model") {
return 700;
}
if (card.type === "metric") {
return 600;
}
return card.display === "scatter" ? 500 : 400;
}

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,31 @@
import { forwardRef } from "react";
import { displayNameForColumn } from "metabase/lib/formatting";
import { 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;
}
export const ColumnListItem = forwardRef<HTMLDivElement, ColumnListItemProps>(
function ColumnListItem({ column, ...props }, ref) {
return (
<Flex
{...props}
px={8}
py={4}
align="center"
ref={ref}
className={S.parent}
>
<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>
);
},
);

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,13 @@
.parent {
border: 1px solid transparent;
border-radius: var(--default-border-radius);
cursor: pointer;
}
.close {
visibility: hidden;
}
.parent:hover .close {
visibility: visible;
}

View File

@@ -0,0 +1,123 @@
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, Text } from "metabase/ui";
import { DRAGGABLE_ID } from "metabase/visualizer/constants";
import {
getDataSources,
getDatasets,
getExpandedDataSources,
getReferencedColumns,
} from "metabase/visualizer/selectors";
import { isReferenceToColumn } from "metabase/visualizer/utils";
import {
removeDataSource,
toggleDataSourceExpanded,
} from "metabase/visualizer/visualizer.slice";
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 expandedDataSources = useSelector(getExpandedDataSources);
const referencedColumns = useSelector(getReferencedColumns);
const dispatch = useDispatch();
return (
<Flex
px={12}
direction="column"
style={{
overflowY: "auto",
}}
>
{dataSources.map(source => {
const dataset = datasets[source.id];
const isExpanded = expandedDataSources[source.id];
return (
<Box key={source.id} mb={4}>
<Flex align="center" px={8} py={4} className={S.parent}>
<Icon
style={{ flexShrink: 0 }}
name={isExpanded ? "chevronup" : "chevrondown"}
aria-label={t`Expand`}
size={12}
mr={6}
onClick={() => dispatch(toggleDataSourceExpanded(source.id))}
cursor="pointer"
/>
<Text truncate mr={4}>
{source.name}
</Text>
<Icon
style={{ flexShrink: 0 }}
className={S.close}
name="close"
ml="auto"
size={12}
aria-label={t`Remove the dataset ${source.name} from the list`}
onClick={() => dispatch(removeDataSource(source))}
cursor="pointer"
/>
</Flex>
{isExpanded && dataset && dataset.data.cols && (
<Box ml={12} mt={2}>
{dataset.data.cols
.filter(column => !isPivotGroupColumn(column))
.map(column => (
<DraggableColumnListItem
key={column.name}
column={column}
dataSource={source}
isSelected={referencedColumns.some(ref =>
isReferenceToColumn(column, source.id, ref),
)}
/>
))}
</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,41 @@
import { t } from "ttag";
import { ActionIcon, Button, Flex, Icon, Tooltip } from "metabase/ui";
import { useVisualizerHistory } from "metabase/visualizer/hooks/use-visualizer-history";
export function Header() {
const { canUndo, canRedo, undo, redo } = useVisualizerHistory();
return (
<Flex p="md" pb="sm">
<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 ml="auto" align="center">
<Tooltip label={t`Share`}>
<ActionIcon>
<Icon name="share" />
</ActionIcon>
</Tooltip>
<Button compact mx="md" disabled>
Persist me
</Button>
<Tooltip label={t`Fullscreen`}>
<ActionIcon>
<Icon name="expand" />
</ActionIcon>
</Tooltip>
</Flex>
</Flex>
);
}

View File

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

View File

@@ -0,0 +1,100 @@
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 { DRAGGABLE_ID, DROPPABLE_ID } from "metabase/visualizer/constants";
import {
getVisualizerComputedSettings,
getVisualizerDatasetColumns,
} from "metabase/visualizer/selectors";
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 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 handleRemoveDimension = (dimension: DatasetColumn) => {
dispatch(
removeColumn({
name: dimension.name,
wellId: DROPPABLE_ID.X_AXIS_WELL,
}),
);
};
return (
<Flex
{...props}
bg={active ? "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 ${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",
}}
maw="80%"
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,156 @@
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"],
wellId: DROPPABLE_ID.X_AXIS_WELL,
}),
);
};
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",
}}
maw="80%"
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,47 @@
import { useDroppable } from "@dnd-kit/core";
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 { WellItem } from "../WellItem";
export function ScatterFloatingWell() {
const columns = useSelector(getVisualizerDatasetColumns);
const settings = useSelector(getVisualizerComputedSettings);
const { isOver, setNodeRef } = useDroppable({
id: DROPPABLE_ID.SCATTER_BUBBLE_SIZE_WELL,
});
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 ? "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,87 @@
import { useDraggable } 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 { DRAGGABLE_ID, DROPPABLE_ID } from "metabase/visualizer/constants";
import {
getVisualizerComputedSettings,
getVisualizerDatasetColumns,
} from "metabase/visualizer/selectors";
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 settings = useSelector(getVisualizerComputedSettings);
const columns = useSelector(getVisualizerDatasetColumns);
const dispatch = useDispatch();
const metrics = useMemo(() => {
const metricNames = settings["graph.metrics"] ?? [];
return metricNames
.map(name => columns.find(column => column.name === name))
.filter(isNotNull);
}, [columns, settings]);
const handleRemoveMetric = (metric: DatasetColumn) => {
dispatch(
removeColumn({
name: metric.name,
wellId: DROPPABLE_ID.Y_AXIS_WELL,
}),
);
};
return (
<SimpleVerticalWell hasValues={metrics.length > 1}>
<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,43 @@
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 { removeColumn } from "metabase/visualizer/visualizer.slice";
import { WellItem } from "../WellItem";
import { SimpleVerticalWell } from "./SimpleVerticalWell";
export function FunnelVerticalWell() {
const settings = useSelector(getVisualizerComputedSettings);
const columns = useSelector(getVisualizerDatasetColumns);
const dispatch = useDispatch();
const metric = columns.find(
column => column.name === settings["funnel.metric"],
);
const handleRemoveMetric = () => {
if (metric) {
dispatch(
removeColumn({ name: metric.name, wellId: DROPPABLE_ID.Y_AXIS_WELL }),
);
}
};
return (
<SimpleVerticalWell hasValues={!!metric}>
{!!metric && (
<WellItem
onRemove={handleRemoveMetric}
style={{ position: "absolute", transform: "rotate(-90deg)" }}
>
<Text truncate>{metric.display_name}</Text>
</WellItem>
)}
</SimpleVerticalWell>
);
}

View File

@@ -0,0 +1,138 @@
import { useDroppable } from "@dnd-kit/core";
import { type ReactNode, forwardRef } 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 { removeColumn } from "metabase/visualizer/visualizer.slice";
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 { isOver, setNodeRef, active } = useDroppable({
id: DROPPABLE_ID.PIE_METRIC,
});
const metric = columns.find(col => col.name === settings["pie.metric"]);
const handleRemoveMetric = () => {
if (metric) {
dispatch(
removeColumn({ name: metric.name, wellId: DROPPABLE_ID.PIE_METRIC }),
);
}
};
return (
<Box mt="lg">
<Text>{t`Pie chart metric`}</Text>
<WellBox isHighlighted={!!active} 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 dimensions = columns.filter(col =>
(settings["pie.dimension"] ?? []).includes(col.name),
);
const handleRemoveDimension = (dimension: DatasetColumn) => {
dispatch(
removeColumn({
name: dimension.name,
wellId: DROPPABLE_ID.PIE_DIMENSION,
}),
);
};
return (
<Box mt="lg">
<Text>{t`Pie chart dimensions`}</Text>
<WellBox isHighlighted={!!active} 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 ? "1px solid var(--mb-color-brand)" : "none",
}}
ref={ref}
>
{children}
</Box>
);
});

View File

@@ -0,0 +1,210 @@
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, wellId: string) => {
dispatch(removeColumn({ name: column.name, wellId }));
};
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, DROPPABLE_ID.PIVOT_ROWS_WELL)
}
>
<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, DROPPABLE_ID.PIVOT_COLUMNS_WELL)
}
>
<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, DROPPABLE_ID.PIVOT_VALUES_WELL)
}
>
<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,45 @@
import { useDroppable } from "@dnd-kit/core";
import type { ReactNode } from "react";
import { Flex } from "metabase/ui";
import { DROPPABLE_ID } from "metabase/visualizer/constants";
interface SimpleVerticalWellProps {
hasValues: boolean;
children: ReactNode;
}
export function SimpleVerticalWell({
hasValues,
children,
}: SimpleVerticalWellProps) {
const { active, isOver, setNodeRef } = useDroppable({
id: DROPPABLE_ID.Y_AXIS_WELL,
});
const borderStyle = hasValues ? "solid" : "dashed";
const borderColor = active ? "var(--mb-color-brand)" : "var(--border-color)";
return (
<Flex
h="100%"
w="42px"
align="center"
justify="center"
bg={active ? "var(--mb-color-brand-light)" : "bg-light"}
p="md"
wrap="nowrap"
style={{
borderRadius: "var(--border-radius-xl)",
border: `1px ${borderStyle} ${borderColor}`,
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}
>
{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,64 @@
import { useDroppable } from "@dnd-kit/core";
import { t } from "ttag";
import { useSelector } from "metabase/lib/redux";
import { Center, Flex, Loader, Text } 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 { HorizontalWell } from "./HorizontalWell";
import { ScatterFloatingWell } from "./ScatterFloatingWell";
import { StartFromViz } from "./StartFromViz";
import { VerticalWell } from "./VerticalWell";
export function VisualizationCanvas() {
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="95%"
style={{ alignSelf: "center" }}
/>
</Flex>
{display === "scatter" && <ScatterFloatingWell />}
</Flex>
);
}

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,57 @@
import { useMemo } from "react";
import IconButtonWrapper from "metabase/components/IconButtonWrapper";
import { Flex, Icon, type IconName, Menu, Text } from "metabase/ui";
import visualizations from "metabase/visualizations";
import type { DatasetData, VisualizationDisplay } from "metabase-types/api";
interface VisualizationPickerProps {
value: VisualizationDisplay | null;
data?: DatasetData;
onChange: (vizType: string) => void;
}
export function VisualizationPicker({
value,
data,
onChange,
}: VisualizationPickerProps) {
const vizOptions = useMemo(() => {
return Array.from(visualizations)
.filter(([, viz]) => {
if (viz.hidden) {
return false;
}
if (data && typeof viz.isSensible === "function") {
return viz.isSensible(data);
}
return true;
})
.map(([vizType, viz]) => ({
label: viz.uiName,
value: vizType,
icon: viz.iconName,
}));
}, [data]);
return (
<Menu>
<Menu.Target>
<IconButtonWrapper style={{ marginRight: "4px" }}>
<Icon name={(value ?? "area") as IconName} />
<Icon name="chevrondown" size={8} />
</IconButtonWrapper>
</Menu.Target>
<Menu.Dropdown>
{vizOptions.map(({ label, value, icon }) => (
<Menu.Item key={value} onClick={() => onChange(value)}>
<Flex align="center">
<Icon name={icon} />
<Text ml="sm">{label}</Text>
</Flex>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

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

View File

@@ -0,0 +1,151 @@
import {
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
} from "@dnd-kit/core";
import { useCallback } from "react";
import type { WithRouterProps } from "react-router";
import { useKeyPressEvent, 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,
getVisualizationType,
} from "metabase/visualizer/selectors";
import { isValidDraggedItem } from "metabase/visualizer/utils";
import {
handleDrop,
resetVisualizer,
setDisplay,
setDraggedItem,
} 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";
export const VisualizerPage = ({ location, router }: WithRouterProps) => {
const { canUndo, canRedo, undo, redo } = useVisualizerHistory();
const display = useSelector(getVisualizationType);
const draggedItem = useSelector(getDraggedItem);
const dispatch = useDispatch();
const datasets = useSelector(getDatasets);
const hasDatasets = Object.values(datasets).length > 0;
const canvasSensor = useSensor(PointerSensor, {
activationConstraint: { distance: 10 },
});
useVisualizerUrlSync(location, router);
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 }}>
<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>
</Flex>
<DragOverlay dropAnimation={null}>
{draggedItem && <VisualizerDragOverlay item={draggedItem} />}
</DragOverlay>
</Flex>
</DndContext>
);
};

View File

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

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,227 @@
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 getExpandedDataSources = (state: State) =>
state.visualizer.expandedDataSources;
export const getIsLoading = createSelector(
[
state => state.visualizer.loadingDataSources,
state => state.visualizer.loadingDatasets,
],
(loadingDataSources, loadingDatasets) => {
return (
Object.values(loadingDataSources).includes(true) ||
Object.values(loadingDatasets).includes(true)
);
},
);
export const getDraggedItem = (state: State) => state.visualizer.draggedItem;
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 => {
return rawSeries.length > 0 ? getComputedSettingsForSeries(rawSeries) : {};
},
);
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) {
const isDirty =
!!state.display ||
state.columns.length > 0 ||
Object.keys(state.settings).length > 0 ||
Object.keys(state.columnValuesMapping).length > 0;
return isDirty ? utf8_to_b64(JSON.stringify(state)) : "";
}

View File

@@ -0,0 +1,267 @@
import type { Active } from "@dnd-kit/core";
import { isPivotGroupColumn } from "metabase/lib/data_grid";
import { isCategory, 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"];
type CardLike = Pick<
Card,
"display" | "result_metadata" | "visualization_settings"
>;
export function canCombineCard(
display: VisualizationDisplay,
columns: DatasetColumn[],
settings: VisualizationSettings,
card: CardLike,
) {
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: CardLike,
) {
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;
if (!primaryNewDimension || !primaryInitialDimension) {
return false;
}
return (
(isDate(primaryInitialDimension) && isDate(primaryNewDimension)) ||
(isCategory(primaryInitialDimension) && isCategory(primaryNewDimension))
);
}

View File

@@ -0,0 +1,147 @@
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,
wellId: string,
) {
if (
wellId === DROPPABLE_ID.X_AXIS_WELL &&
state.settings["graph.dimensions"]
) {
const dimensions = state.settings["graph.dimensions"];
state.settings["graph.dimensions"] = dimensions.filter(
dimension => dimension !== columnName,
);
}
if (wellId === DROPPABLE_ID.Y_AXIS_WELL && state.settings["graph.metrics"]) {
const metrics = state.settings["graph.metrics"];
state.settings["graph.metrics"] = metrics.filter(
metric => metric !== columnName,
);
}
if (wellId === DROPPABLE_ID.SCATTER_BUBBLE_SIZE_WELL) {
delete state.settings["scatter.bubble"];
}
}

View File

@@ -0,0 +1,149 @@
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,
name: string,
wellId: string,
) {
if (wellId === DROPPABLE_ID.X_AXIS_WELL) {
delete state.settings["funnel.dimension"];
}
if (wellId === DROPPABLE_ID.Y_AXIS_WELL) {
delete state.settings["funnel.metric"];
}
}

View File

@@ -0,0 +1,88 @@
import type { DragEndEvent } from "@dnd-kit/core";
import { DROPPABLE_ID } from "metabase/visualizer/constants";
import {
addColumnMapping,
copyColumn,
createVisualizerColumnReference,
extractReferencedColumns,
isDraggedColumnItem,
} from "metabase/visualizer/utils";
import { isNumeric } from "metabase-lib/v1/types/utils/isa";
import type { VisualizerHistoryItem } from "metabase-types/store/visualizer";
export const 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,
wellId: string,
) {
if (
wellId === DROPPABLE_ID.PIE_DIMENSION &&
state.settings["pie.dimension"]
) {
let dimensions = state.settings["pie.dimension"];
if (!Array.isArray(dimensions)) {
dimensions = [dimensions];
}
state.settings["pie.dimension"] = dimensions.filter(
dimension => dimension !== columnName,
);
}
if (wellId === DROPPABLE_ID.PIE_METRIC) {
delete state.settings["pie.metric"];
}
}

View File

@@ -0,0 +1,159 @@
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;
removeColumnFromPivotTable(state, column.name, wellId);
}
};
export function removeColumnFromPivotTable(
state: VisualizerHistoryItem,
columnName: string,
wellId: string,
) {
const columnType = getColumnTypeFromWellId(wellId);
if (columnType) {
state.settings = removeColumnFromVizSettings(
state.settings,
columnName,
columnType,
);
state.columns = state.columns.filter(col => col.name !== columnName);
delete state.columnValuesMapping[columnName];
}
}
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,530 @@
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,
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: {},
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 display = action.payload;
if (
display &&
state.display &&
isCartesianChart(display) &&
isCartesianChart(state.display)
) {
state.display = display;
return;
}
state.display = display;
state.settings = {};
state.columns = [];
state.columnValuesMapping = {};
if (display === "pivot") {
state.columns = [
{
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",
},
];
}
},
updateSettings: (state, action: PayloadAction<VisualizationSettings>) => {
state.settings = {
...state.settings,
...action.payload,
};
},
removeColumn: (
state,
action: PayloadAction<{ name: string; wellId: string }>,
) => {
const { name, wellId } = 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, wellId);
} else if (state.display === "funnel") {
removeColumnFromFunnel(state, name, wellId);
} else if (state.display === "pie") {
removeColumnFromPieChart(state, name, wellId);
} else if (state.display === "pivot") {
removeColumnFromPivotTable(state, name, wellId);
}
},
},
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];
},
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 = [];
} 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, removeColumn } =
visualizerHistoryItemSlice.actions;
export const {
setDraggedItem,
toggleDataSourceExpanded,
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
[:card.collection_id :entity-coll-id]
[:mr.status :moderated-status]
[:collection.id :collection_id]
@@ -291,6 +293,8 @@
:database_id (:database_id card)
:description (:description card)
:display (some-> card :display name)
:result_metadata (:result_metadata card)
:visualization_settings (:visualization_settings card)
:model :card
:can_write (mi/can-write? card)
:timestamp (str timestamp)

View File

@@ -183,6 +183,8 @@
(when collection_effective_ancestors
{:effective_ancestors collection_effective_ancestors})))
:scores (remove-thunks all-scores))
(update :visualization_settings #(some-> % json/decode))
(update :result_metadata #(some-> % json/decode))
(update :dataset_query (fn [dataset-query]
(when-let [query (some-> dataset-query json/decode)]
(if (get query "type")

View File

@@ -107,7 +107,10 @@
:pk_ref :text
:model_index_id :integer
;; returned for Card and Action
:dataset_query :text))
:dataset_query :text
;; returned on card, dataset, and metric for the proj-visualizer prototype
:result_metadata :text
:visualization_settings :text))
(mu/defn- canonical-columns :- [:sequential HoneySQLColumn]
"Returns a seq of lists of canonical columns for the search query with the given `model` Will return column names
@@ -339,6 +342,8 @@
(defmethod columns-for-model "card"
[_]
(conj default-columns :collection_id :archived_directly :collection_position :dataset_query :display :creator_id
;; added just to help with the proj-visualizer prototype
:result_metadata :visualization_settings
[:collection.name :collection_name]
[:collection.type :collection_type]
[:collection.location :collection_location]
@@ -385,7 +390,10 @@
(defmethod columns-for-model "metric"
[_]
(concat default-columns table-columns [:creator_id]))
(concat default-columns table-columns
[:creator_id
;; added just to help with the proj-visualizer prototype
:result_metadata :visualization_settings]))
(defmethod columns-for-model "table"
[_]