Compare commits
76 Commits
python-run
...
visualizer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ec4c486c | ||
|
|
2139b62fd5 | ||
|
|
f667d79bb6 | ||
|
|
6eaf130e33 | ||
|
|
2e6b38f35c | ||
|
|
d77ce41fd7 | ||
|
|
dce52eb1cf | ||
|
|
53537a094e | ||
|
|
4ea9070da0 | ||
|
|
4347f96db1 | ||
|
|
e90952591b | ||
|
|
017bd49c2c | ||
|
|
dce32407d4 | ||
|
|
a05e6805a2 | ||
|
|
100c4013d2 | ||
|
|
b6e1c61fb3 | ||
|
|
ffafee1ee9 | ||
|
|
9f84294476 | ||
|
|
aaae363ce3 | ||
|
|
672e6d8862 | ||
|
|
302beef76f | ||
|
|
81d4aff6c7 | ||
|
|
b9530848c8 | ||
|
|
302ff52159 | ||
|
|
6ec3813e01 | ||
|
|
1f74334c0d | ||
|
|
ddfcb2ac4a | ||
|
|
cc32ab99ae | ||
|
|
9b07d9812c | ||
|
|
f8ec344ee8 | ||
|
|
1228d5cd53 | ||
|
|
73e7055e81 | ||
|
|
52e07386cb | ||
|
|
a01beddd2f | ||
|
|
b0d64b4956 | ||
|
|
108915f231 | ||
|
|
811bc5871c | ||
|
|
0bcac59912 | ||
|
|
f7678aff45 | ||
|
|
2ae5dc679e | ||
|
|
223620ab93 | ||
|
|
93f22e28dd | ||
|
|
04f3242041 | ||
|
|
7bb3dce3cc | ||
|
|
f3c579d42c | ||
|
|
969e109476 | ||
|
|
9e348794e8 | ||
|
|
187334284d | ||
|
|
93614feb66 | ||
|
|
28c69f7f9f | ||
|
|
4c4fdc5277 | ||
|
|
0894fc05b4 | ||
|
|
2b5051d96b | ||
|
|
b26c3fd9e9 | ||
|
|
7d9ec78335 | ||
|
|
6f6a8cb957 | ||
|
|
fd4681bde8 | ||
|
|
cf2494685c | ||
|
|
54077021b7 | ||
|
|
fad96e4275 | ||
|
|
aba9a695f9 | ||
|
|
62f1abb393 | ||
|
|
0dd03cbca4 | ||
|
|
97698a36f7 | ||
|
|
93398edfb4 | ||
|
|
b7d0cb0658 | ||
|
|
c162ae1a2c | ||
|
|
ce8c928661 | ||
|
|
a1f4e00d83 | ||
|
|
dbaf0c2f8d | ||
|
|
ea56baeaad | ||
|
|
3a65db46a7 | ||
|
|
b06b8261d6 | ||
|
|
af7cba10cc | ||
|
|
28af981218 | ||
|
|
2327da2dc8 |
@@ -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>;
|
||||
|
||||
75
frontend/src/metabase-types/store/visualizer.ts
Normal file
75
frontend/src/metabase-types/store/visualizer.ts
Normal 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[];
|
||||
}
|
||||
@@ -132,6 +132,12 @@ const NewItemMenu = ({
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: t`Visualization`,
|
||||
icon: "lineandbar",
|
||||
link: `/visualizer`,
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [
|
||||
hasDataAccess,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
:root {
|
||||
--default-border-radius: 8px;
|
||||
--border-radius-xl: 99px;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./pulses";
|
||||
export * from "./questions";
|
||||
export * from "./timelines";
|
||||
export * from "./utils";
|
||||
export * from "./visualizer";
|
||||
|
||||
8
frontend/src/metabase/lib/urls/visualizer.ts
Normal file
8
frontend/src/metabase/lib/urls/visualizer.ts
Normal 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}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.visualizerLink {
|
||||
margin-right: 0.5rem;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -391,7 +391,7 @@ class Visualization extends PureComponent {
|
||||
/>
|
||||
);
|
||||
} else if (e instanceof MinRowsError) {
|
||||
noResults = true;
|
||||
// noResults = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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?`,
|
||||
|
||||
@@ -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"]) &&
|
||||
|
||||
@@ -95,6 +95,7 @@ export function addMissingCardBreakouts(
|
||||
|
||||
export function isColumnValid(col: DatasetColumn) {
|
||||
return (
|
||||
col.source === "artificial" ||
|
||||
col.source === "aggregation" ||
|
||||
col.source === "breakout" ||
|
||||
isPivotGroupColumn(col)
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useListRecentsQuery } from "metabase/api";
|
||||
import { Loader } from "metabase/ui";
|
||||
import { createDataSource } from "metabase/visualizer/utils";
|
||||
import type { VisualizerDataSourceId } from "metabase-types/store/visualizer";
|
||||
|
||||
import { ResultsList, type ResultsListProps } from "./ResultsList";
|
||||
|
||||
interface RecentsListProps {
|
||||
onSelect: ResultsListProps["onSelect"];
|
||||
dataSourceIds: Set<VisualizerDataSourceId>;
|
||||
}
|
||||
|
||||
export function RecentsList({ onSelect, dataSourceIds }: RecentsListProps) {
|
||||
const { data: allRecents = [] } = useListRecentsQuery(undefined, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
return allRecents
|
||||
.filter(maybeCard =>
|
||||
["card", "dataset", "metric"].includes(maybeCard.model),
|
||||
)
|
||||
.map(card => createDataSource("card", card.id, card.name));
|
||||
}, [allRecents]);
|
||||
|
||||
if (!items) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResultsList
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
dataSourceIds={dataSourceIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Box, Text } from "metabase/ui";
|
||||
import type {
|
||||
VisualizerDataSource,
|
||||
VisualizerDataSourceId,
|
||||
} from "metabase-types/store/visualizer";
|
||||
|
||||
export interface ResultsListProps {
|
||||
items: VisualizerDataSource[];
|
||||
onSelect: (item: VisualizerDataSource) => void;
|
||||
dataSourceIds: Set<VisualizerDataSourceId>;
|
||||
}
|
||||
|
||||
export const ResultsList = ({
|
||||
items,
|
||||
onSelect,
|
||||
dataSourceIds,
|
||||
}: ResultsListProps) => {
|
||||
return (
|
||||
<Box component="ul">
|
||||
{items.map((item, index) => (
|
||||
<Box
|
||||
style={{
|
||||
border: "1px solid var(--mb-color-border)",
|
||||
borderRadius: 5,
|
||||
cursor: "pointer",
|
||||
backgroundColor: dataSourceIds.has(item.id)
|
||||
? "var(--mb-color-bg-light)"
|
||||
: "transparent",
|
||||
}}
|
||||
key={index}
|
||||
component="li"
|
||||
px={14}
|
||||
py={10}
|
||||
mb={4}
|
||||
onClick={() => onSelect?.(item)}
|
||||
>
|
||||
<Text truncate>{item.name}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DataImporter";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.grabber {
|
||||
visibility: hidden;
|
||||
color: var(--mb-color-text-medium);
|
||||
}
|
||||
|
||||
.parent {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--default-border-radius);
|
||||
}
|
||||
|
||||
.parent:hover .grabber {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.parent:hover {
|
||||
background-color: var(--mb-color-bg-light);
|
||||
cursor: grab;
|
||||
border-color: var(--mb-color-border);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { Box, Center, Flex, Text } from "metabase/ui";
|
||||
import { getDataSources } from "metabase/visualizer/selectors";
|
||||
|
||||
import { DatasetList } from "./DatasetList";
|
||||
|
||||
export const DataManager = () => {
|
||||
const dataSources = useSelector(getDataSources);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="column"
|
||||
bg="white"
|
||||
style={{
|
||||
borderRadius: "var(--default-border-radius)",
|
||||
height: "100%",
|
||||
border: `1px solid var(--mb-color-border)`,
|
||||
}}
|
||||
>
|
||||
<Box px={12} py={8}>
|
||||
<Text fw="bold" mb={3}>
|
||||
{t`Data`}
|
||||
</Text>
|
||||
</Box>
|
||||
{dataSources.length > 0 ? (
|
||||
<DatasetList />
|
||||
) : (
|
||||
<Center h="100%" w="100%" mx="auto">
|
||||
<Text>{t`Pick a dataset first`}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
.parent {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--default-border-radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.parent:hover .close {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./ColumnListItem";
|
||||
export * from "./DataManager";
|
||||
@@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DragOverlay";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Header";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FlexProps } from "metabase/ui";
|
||||
import { isCartesianChart } from "metabase/visualizations";
|
||||
import type { VisualizationDisplay } from "metabase-types/api";
|
||||
|
||||
import { CartesianHorizontalWell } from "./CartesianHorizontallWell";
|
||||
import { FunnelHorizontalWell } from "./FunnelHorizontalWell";
|
||||
|
||||
interface HorizontalWellProps extends FlexProps {
|
||||
display: VisualizationDisplay;
|
||||
}
|
||||
|
||||
export function HorizontalWell({ display, ...props }: HorizontalWellProps) {
|
||||
if (isCartesianChart(display)) {
|
||||
return <CartesianHorizontalWell {...props} />;
|
||||
}
|
||||
if (display === "funnel") {
|
||||
return <FunnelHorizontalWell {...props} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./HorizontalWell";
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ScatterFloatingWell";
|
||||
@@ -0,0 +1,20 @@
|
||||
.card {
|
||||
border: 2px solid var(--mb-color-border);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--default-border-radius);
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--mb-color-brand);
|
||||
box-shadow: 0 1px 8px 2px var(--mb-color-shadow);
|
||||
transform: scale(1.025);
|
||||
transition:
|
||||
transform 300ms ease-in-out,
|
||||
box-shadow 300ms ease-in-out;
|
||||
color: var(--mb-color-brand);
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./StartFromViz";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VerticalWell";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VisualizationCanvas";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VisualizationPicker";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./VisualizerPage";
|
||||
20
frontend/src/metabase/visualizer/constants.ts
Normal file
20
frontend/src/metabase/visualizer/constants.ts
Normal 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",
|
||||
};
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1
frontend/src/metabase/visualizer/index.ts
Normal file
1
frontend/src/metabase/visualizer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./components/VisualizerPage";
|
||||
227
frontend/src/metabase/visualizer/selectors.ts
Normal file
227
frontend/src/metabase/visualizer/selectors.ts
Normal 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)) : "";
|
||||
}
|
||||
267
frontend/src/metabase/visualizer/utils.ts
Normal file
267
frontend/src/metabase/visualizer/utils.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
147
frontend/src/metabase/visualizer/visualizations/cartesian.ts
Normal file
147
frontend/src/metabase/visualizer/visualizations/cartesian.ts
Normal 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"];
|
||||
}
|
||||
}
|
||||
149
frontend/src/metabase/visualizer/visualizations/funnel.ts
Normal file
149
frontend/src/metabase/visualizer/visualizations/funnel.ts
Normal 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"];
|
||||
}
|
||||
}
|
||||
88
frontend/src/metabase/visualizer/visualizations/pie.ts
Normal file
88
frontend/src/metabase/visualizer/visualizations/pie.ts
Normal 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"];
|
||||
}
|
||||
}
|
||||
159
frontend/src/metabase/visualizer/visualizations/pivot.ts
Normal file
159
frontend/src/metabase/visualizer/visualizations/pivot.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
530
frontend/src/metabase/visualizer/visualizer.slice.ts
Normal file
530
frontend/src/metabase/visualizer/visualizer.slice.ts
Normal 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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
[_]
|
||||
|
||||
Reference in New Issue
Block a user