Compare commits

...

23 Commits

Author SHA1 Message Date
Anton Kulyk
121c7d5b3b Implement add column feature 2024-07-03 18:24:01 +01:00
Anton Kulyk
1e94cec71b Fix rendering for non-cartesian charts 2024-07-03 18:24:00 +01:00
Anton Kulyk
e124cf3847 Add basic axis editor 2024-07-03 18:24:00 +01:00
Anton Kulyk
823e88bf1a Wire up viz picker 2024-07-03 18:23:42 +01:00
Anton Kulyk
3175e91b2c Add remove and refresh 2024-07-03 18:10:31 +01:00
Anton Kulyk
cfa5df5472 Add question links 2024-07-03 18:10:14 +01:00
Kyle Doherty
e979ba8661 add skeleton content for being used section items 2024-07-02 13:18:03 -04:00
Anton Kulyk
b644520a94 Add compatible series picker to QB chill mode 2024-07-02 15:01:58 +01:00
Anton Kulyk
41c6daf77f Add card ID URL query parameters to visualizer 2024-07-02 15:01:39 +01:00
Anton Kulyk
3ffb5b09f0 Distinct add/replace actions 2024-07-02 14:21:05 +01:00
Anton Kulyk
ac9535bf02 Add multi-series charts support 2024-07-02 14:20:19 +01:00
Kyle Doherty
87f4af0a67 add ui redux stuff 2024-06-25 16:34:47 -04:00
Kyle Doherty
04026ce60d add ability for visualizer url to accept a definition + tests 2024-06-25 13:26:11 -04:00
Kyle Doherty
e01cbebcd9 title and settings trigger 2024-06-24 10:27:28 -04:00
Kyle Doherty
138df1c4f0 default to metrics tab selected 2024-06-18 11:33:21 -04:00
Kyle Doherty
80152f8e0f hacky viz loading 2024-06-14 17:13:49 -04:00
Kyle Doherty
5d6eabe776 hide nav when in the visualizer 2024-06-14 16:16:43 -04:00
Kyle Doherty
4709826b00 layout via resizable panels 2024-06-14 16:14:17 -04:00
Kyle Doherty
55378ea0c8 menu item styling 2024-06-14 15:40:22 -04:00
Kyle Doherty
aab9fde68b use menu item on search results 2024-06-14 15:26:10 -04:00
Kyle Doherty
256668b4d4 add to being used 2024-06-14 15:17:18 -04:00
Kyle Doherty
adabe0edf0 visualizer menu list hookups 2024-06-14 14:31:18 -04:00
Kyle Doherty
aaec2732ef add visualizer route and route-like things 2024-06-14 12:20:40 -04:00
29 changed files with 1124 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ import type { SettingsState } from "./settings";
import type { SetupState } from "./setup";
import type { UndoState } from "./undo";
import type { FileUploadState } from "./upload";
import type { VisualizerState } from "./visualizer";
type ModalName = null | "collection" | "dashboard" | "action";
@@ -37,6 +38,7 @@ export interface State {
upload: FileUploadState;
modal: ModalName;
undo: UndoState;
visualizer: VisualizerState;
}
export type Dispatch<T = any> = (action: T) => unknown | Promise<unknown>;

View File

@@ -0,0 +1,6 @@
export interface VisualizerState {
ui: {
vizSettings: boolean;
data: boolean;
};
}

View File

@@ -3,6 +3,7 @@ import type {
CardId,
CardQueryMetadata,
CreateCardRequest,
Dataset,
GetCardRequest,
ListCardsRequest,
UpdateCardRequest,
@@ -28,6 +29,12 @@ export const cardApi = Api.injectEndpoints({
}),
providesTags: (cards = []) => provideCardListTags(cards),
}),
listCompatibleCards: builder.query<Card[], { id: CardId }>({
query: ({ id }) => ({
method: "GET",
url: `/api/card/${id}/series`,
}),
}),
getCard: builder.query<Card, GetCardRequest>({
query: ({ id, ignore_error, ...params }) => ({
method: "GET",
@@ -97,11 +104,18 @@ export const cardApi = Api.injectEndpoints({
listTag("persisted-info"),
]),
}),
cardQuery: builder.query<Dataset, CardId>({
query: id => ({
method: "POST",
url: `/api/card/${id}/query`,
}),
}),
}),
});
export const {
useListCardsQuery,
useListCompatibleCardsQuery,
useGetCardQuery,
useGetCardQueryMetadataQuery,
useCreateCardMutation,
@@ -109,4 +123,5 @@ export const {
useDeleteCardMutation,
useCopyCardMutation,
useRefreshModelCacheMutation,
useCardQueryQuery,
} = cardApi;

View File

@@ -96,6 +96,11 @@ const NewItemMenu = ({
icon: "folder",
action: () => dispatch(setOpenModal("collection")),
},
{
title: t`Visualization`,
icon: "bar",
link: Urls.visualizer(),
},
);
if (hasNativeWrite) {

View File

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

View File

@@ -0,0 +1,7 @@
export function visualizer(setupDefinition?: string) {
if (setupDefinition) {
return `v/${setupDefinition}`;
} else {
return `v`;
}
}

View File

@@ -0,0 +1,25 @@
import { createMockCard } from "metabase-types/api/mocks";
import { visualizer } from "./visualizer";
describe("visualizer urls", () => {
it("returns the base url if no setupDefinition is provided", () => {
expect(visualizer()).toBe("v");
});
it("returns a visualizer url with setupDefinition if provided", () => {
const setupDefinition = JSON.stringify({
queries: {
1: createMockCard({ id: 1 }),
2: createMockCard({ id: 2 }),
},
visualization: {
1: {
type: "line",
},
2: undefined,
},
});
expect(visualizer(setupDefinition)).toBe(`v/${setupDefinition}`);
});
});

View File

@@ -49,6 +49,7 @@ import {
QuestionDescription,
QuestionSummarizeWidget,
} from "./components";
import { CompatibleSeriesButton } from "./components/CompatibleSeriesButton";
import { canExploreResults } from "./utils";
const viewTitleHeaderPropTypes = {
@@ -523,6 +524,7 @@ function ViewTitleHeaderRightSide(props) {
/>
</ViewHeaderIconButtonContainer>
)}
{isSaved && <CompatibleSeriesButton question={question} />}
{isSaved && (
<QuestionActions
isShowingQuestionInfoSidebar={isShowingQuestionInfoSidebar}

View File

@@ -0,0 +1,39 @@
import { t } from "ttag";
import { useListCompatibleCardsQuery } from "metabase/api";
import Button from "metabase/core/components/Button";
import Link from "metabase/core/components/Link";
import { color } from "metabase/lib/colors";
import { Menu, Text, Tooltip } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
export function CompatibleSeriesButton({ question }: { question: Question }) {
const mainCardId = question.id();
const { status, data: compatibleCards = [] } = useListCompatibleCardsQuery({
id: mainCardId,
});
if (status !== "fulfilled" || compatibleCards.length === 0) {
return null;
}
return (
<Menu>
<Menu.Target>
<Tooltip label={t`See this alongside…`}>
<Button onlyIcon icon="add" iconSize={16} color={color("brand")} />
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
{compatibleCards.map(card => (
<Menu.Item key={card.id}>
<Link to={`/v?c1=${mainCardId}&c2=${card.id}`}>
<Text>{card.name}</Text>
</Link>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -11,6 +11,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 { visualizer } from "metabase/visualizer/store";
import { commonReducers } from "./reducers-common";
@@ -26,5 +27,6 @@ export const mainReducers = {
revisions,
setup,
admin,
visualizer,
plugins: combineReducers(PLUGIN_REDUCERS),
};

View File

@@ -61,7 +61,7 @@ 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|v).*/;
export function isNavbarOpenForPathname(pathname: string, prevState: boolean) {
return (

View File

@@ -0,0 +1,10 @@
import { createAction, createReducer } from "@reduxjs/toolkit";
const initialState = {
ui: {
vizSettings: false,
data: true,
},
};
export const visualizer = createReducer(initialState, builder => {});

View File

@@ -52,6 +52,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 { Visualizer } from "metabase/visualizer/containers/Visualizer";
import { BrowseDatabases } from "./browse/components/BrowseDatabases";
import { BrowseModels } from "./browse/components/BrowseModels";
@@ -232,6 +233,12 @@ export const getRoutes = store => {
/>
</Route>
{/* VISUALIZER */}
<Route path="v">
<IndexRoute component={Visualizer} />
<Route path=":slug" component={Visualizer} />
</Route>
{/* INDIVIDUAL DASHBOARDS */}
<Route

View File

@@ -137,6 +137,16 @@ export function hasGraphDataSettings(display: string) {
);
}
export function hasAxes(display: string) {
const visualization = visualizations.get(display);
const settingDefinitions = visualization?.settings ?? {};
const settingNames = Object.keys(settingDefinitions);
return (
settingNames.some(name => name.startsWith("graph.x_axis.")) &&
settingNames.some(name => name.startsWith("graph.y_axis."))
);
}
// removes columns with `remapped_from` property and adds a `remapping` to the appropriate column
export const extractRemappedColumns = (data: DatasetData) => {
const cols: RemappingHydratedDatasetColumn[] = data.cols.map(col => ({

View File

@@ -0,0 +1,156 @@
// eslint-disable-next-line no-restricted-imports
import { ActionIcon } from "@mantine/core";
import { Flex, Icon, Menu, Text } from "metabase/ui";
interface VisualizerAxisProps {
direction?: "horizontal" | "vertical";
columns: string[];
columnOptions: Array<{ label: string; value: string }>;
onColumnsChange: (columns: string[]) => void;
}
export function VisualizerAxis({
direction = "horizontal",
columns,
columnOptions,
onColumnsChange,
}: VisualizerAxisProps) {
const handleColumnChange = (column: string, nextColumn: string) => {
const nextColumns = columns.map(c => (c === column ? nextColumn : c));
onColumnsChange(nextColumns);
};
const handleColumnAdd = (column: string) => {
const nextColumns = [...columns, column];
onColumnsChange(nextColumns);
};
const flexProps =
direction === "vertical"
? {
w: "16px",
h: "100%",
}
: { w: "100%" };
return (
<Flex
{...flexProps}
pos="relative"
justify="center"
align="center"
gap="md"
bg="bg-light"
>
{columns.map(column => (
<ColumnPicker
key={column}
column={column}
columnOptions={columnOptions}
direction={direction}
onChange={nextColumn => handleColumnChange(column, nextColumn)}
/>
))}
<AddColumnButton
direction={direction}
columns={columns}
columnOptions={columnOptions}
onAddColumn={handleColumnAdd}
/>
</Flex>
);
}
interface ColumnPickerProps {
direction?: "horizontal" | "vertical";
column: string;
columnOptions: Array<{ label: string; value: string }>;
onChange: (column: string) => void;
}
function ColumnPicker({
column,
columnOptions,
direction = "horizontal",
onChange,
}: ColumnPickerProps) {
const option = columnOptions.find(option => option.value === column);
const containerStyle =
direction === "vertical"
? {
position: "absolute",
width: "200px",
height: "14px",
textAlign: "center",
transform: "rotate(-90deg)",
cursor: "pointer",
}
: { cursor: "pointer" };
return (
<Menu>
<Menu.Target>
<Flex align="center" gap="4px" style={containerStyle}>
<Text color="text-medium" fw="bold">
{option?.label ?? column}
</Text>
<Icon name="chevrondown" size={12} />
</Flex>
</Menu.Target>
<Menu.Dropdown>
{columnOptions.map(option => (
<Menu.Item
key={option.value}
value={option.value}
onClick={() => onChange(option.value)}
>
{option.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}
interface AddColumnButtonProps {
direction?: "horizontal" | "vertical";
columns: string[];
columnOptions: Array<{ label: string; value: string }>;
onAddColumn: (column: string) => void;
}
function AddColumnButton({
direction = "horizontal",
columns,
columnOptions,
onAddColumn,
}: AddColumnButtonProps) {
const positionAttr = direction === "vertical" ? "top" : "right";
const filteredOptions = columnOptions.filter(
option => !columns.includes(option.value),
);
return (
<Menu>
<Menu.Target>
<ActionIcon pos="absolute" style={{ [positionAttr]: 0 }}>
<Icon name="add" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{filteredOptions.map(option => (
<Menu.Item
key={option.value}
value={option.value}
onClick={() => onAddColumn(option.value)}
>
{option.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -0,0 +1,60 @@
/* eslint-disable*/
/* Hacking in some styling, TODO - remove this */
import { ActionIcon } from "@mantine/core";
import { color } from "metabase/lib/colors";
import { Box, Flex, Icon } from "metabase/ui";
export function VisualizerMenuItem({
item,
onAdd,
onReplace,
isAddable = true,
}: {
item: any;
onAdd: (item: any) => void;
onReplace: (item: any) => void;
isAddable?: boolean;
}) {
return (
<Flex
align="center"
sx={{
".Icon": {
opacity: "0",
},
":hover": {
backgroundColor: color("brand-lighter"),
color: color("brand"),
borderRadius: "4px",
cursor: "pointer",
".Icon": {
opacity: "1",
},
},
}}
onClick={() => onReplace(item)}
>
<Box py="sm" px="md">
{item.displayName || item.name}
</Box>
{/* TODO - Only show this if the item is addable */}
{isAddable && (
<ActionIcon
ml="auto"
onClick={event => {
event.stopPropagation();
onAdd(item);
}}
>
<Icon
name="add"
style={{ borderLeft: `1px solid ${color("brand-lighter")}` }}
/>
</ActionIcon>
)}
</Flex>
);
}

View File

@@ -0,0 +1,202 @@
import { useState } from "react";
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
import type { WithRouterProps } from "react-router";
import { useMount } from "react-use";
import _ from "underscore";
import { cardApi } from "metabase/api";
import { useDispatch } from "metabase/lib/redux";
import type { CardId, RecentItem, Series } from "metabase-types/api";
import { useVizSettings } from "../useVizSettings";
import { VisualizerCanvas } from "./VisualizerCanvas";
import { VisualizerMenu } from "./VisualizerMenu/VisualizerMenu";
import { VisualizerUsed } from "./VisualizerUsed";
import { areSeriesCompatible } from "./utils";
export function Visualizer({ location }: WithRouterProps) {
const [series, setSeries] = useState<Series>([]);
const dispatch = useDispatch();
const { isVizSettingsOpen, closeVizSettings } = useVizSettings();
const _fetchCardAndData = async (
cardId: CardId,
{ forceRefetch = false } = {},
) => {
const { data: card } = await dispatch(
cardApi.endpoints.getCard.initiate(
{ id: cardId },
{
forceRefetch,
},
),
);
if (!card) {
return;
}
const { data: dataset } = await dispatch(
cardApi.endpoints.cardQuery.initiate(cardId, { forceRefetch }),
);
if (!dataset) {
return;
}
return { card, data: dataset.data };
};
const handleAdd = async (item: RecentItem) => {
const newSeries = await _fetchCardAndData(item.id);
if (!newSeries) {
return;
}
const [mainSeries] = series;
const mainCard = mainSeries?.card;
if (!mainSeries) {
setSeries([newSeries]);
return;
}
if (mainCard) {
const canMerge = areSeriesCompatible(mainSeries, newSeries);
if (canMerge) {
setSeries([...series, newSeries]);
} else {
setSeries([newSeries]);
}
} else {
setSeries([newSeries]);
}
};
const handleReplace = async (item: RecentItem) => {
const newSeries = await _fetchCardAndData(item.id);
if (newSeries) {
setSeries([newSeries]);
}
};
const handleChangeVizType = (cardId: CardId, vizType: string) => {
const nextSeries = series.map(series =>
series.card.id === cardId
? { ...series, card: { ...series.card, display: vizType } }
: series,
);
setSeries(nextSeries);
};
const handleRefresh = async (cardId: CardId) => {
const newSeries = await _fetchCardAndData(cardId, { forceRefetch: true });
if (newSeries) {
const nextSeries = series.map(series =>
series.card.id === cardId ? newSeries : series,
);
setSeries(nextSeries);
}
};
const handleRemove = (cardId: CardId) => {
const nextSeries = series.filter(series => series.card.id !== cardId);
setSeries(nextSeries);
};
useMount(() => {
async function init() {
const firstCardId = Number(location.query?.c1);
const secondCardId = Number(location.query?.c2);
if (
Number.isSafeInteger(firstCardId) &&
Number.isSafeInteger(secondCardId)
) {
const [series1, series2] = await Promise.all([
_fetchCardAndData(firstCardId),
_fetchCardAndData(secondCardId),
]);
if (series1 && series2 && areSeriesCompatible(series1, series2)) {
setSeries([series1, series2]);
}
}
}
init();
});
const hasInitialCardsSelected =
"c1" in location.query && "c2" in location.query;
return (
<PanelGroup direction="horizontal" style={{ padding: 20 }}>
{!isVizSettingsOpen && (
<Panel defaultSize={25} minSize={15}>
<PanelGroup direction="vertical">
<Panel defaultSize={70} minSize={20} maxSize={80}>
<VisualizerMenu
defaultTab={hasInitialCardsSelected ? "recents" : "metrics"}
onAdd={handleAdd}
onReplace={handleReplace}
/>
</Panel>
<PanelResizeHandle
style={{
margin: 4,
display: "flex",
}}
>
<span
style={{
width: 20,
height: 4,
backgroundColor: "#ddd",
borderRadius: 99,
margin: "0 auto",
}}
></span>
</PanelResizeHandle>
<Panel defaultSize={30}>
<VisualizerUsed
series={series}
onVizTypeChange={handleChangeVizType}
onRefreshCard={handleRefresh}
onRemoveCard={handleRemove}
/>
</Panel>
</PanelGroup>
</Panel>
)}
<PanelResizeHandle
style={{
display: "flex",
margin: 4,
}}
>
<span
style={{
width: 4,
height: 20,
backgroundColor: "#ddd",
borderRadius: 99,
margin: "auto 0",
}}
></span>
</PanelResizeHandle>
<Panel defaultSize={75} minSize={60}>
<VisualizerCanvas series={series} onChange={setSeries} />
</Panel>
{isVizSettingsOpen && (
<Panel minSize={40}>
<>
<h1>SETTINGS</h1>
<button onClick={() => closeVizSettings()}>Close</button>
</>
</Panel>
)}
</PanelGroup>
);
}

View File

@@ -0,0 +1,170 @@
import { ActionIcon } from "@mantine/core";
import { useMemo } from "react";
import { useSelector } from "metabase/lib/redux";
import { getMetadata } from "metabase/selectors/metadata";
import { Card, Flex, Group, Icon, Title, Stack } from "metabase/ui";
import { hasAxes as checkHasAxes } from "metabase/visualizations";
import BaseVisualization from "metabase/visualizations/components/Visualization";
import { isDimension, isMetric } from "metabase-lib/v1/types/utils/isa";
import type { Series } from "metabase-types/api";
import { VisualizerAxis } from "../components/VisualizerAxis";
import { useVizSettings } from "../useVizSettings";
interface VisualizerCanvasProps {
series: Series;
onChange: (series: Series) => void;
}
export function VisualizerCanvas({ series, onChange }: VisualizerCanvasProps) {
const { openVizSettings } = useVizSettings();
const metadata = useSelector(getMetadata);
const { metrics, dimensions } = useMemo(
() => getMetricAndDimensionOptions(series),
[series],
);
const currentMetrics = getCurrentMetrics(series);
const currentDimensions = getCurrentDimensions(series);
const hasAxes = useMemo(() => {
if (series.length === 0) {
return false;
}
const mainSeries = { ...series[0] };
const mainCard = mainSeries.card;
return checkHasAxes(mainCard.display);
}, [series]);
const displaySeries = useMemo(() => {
if (hasAxes) {
const mainSeries = { ...series[0] };
mainSeries.card = {
...mainSeries.card,
visualization_settings: {
...mainSeries.card.visualization_settings,
"graph.x_axis.labels_enabled": false,
"graph.y_axis.labels_enabled": false,
},
};
return [mainSeries, ...series.slice(1)];
} else {
return series;
}
}, [series, hasAxes]);
const handleMetricsChange = (metrics: string[]) => {
const mainSeries = { ...series[0] };
mainSeries.card = {
...mainSeries.card,
visualization_settings: {
...mainSeries.card.visualization_settings,
"graph.metrics": metrics,
},
};
onChange([mainSeries, ...series.slice(1)]);
};
const handleDimensionsChange = (dimensions: string[]) => {
const mainSeries = { ...series[0] };
mainSeries.card = {
...mainSeries.card,
visualization_settings: {
...mainSeries.card.visualization_settings,
"graph.dimensions": dimensions,
},
};
onChange([mainSeries, ...series.slice(1)]);
};
return (
<Card w="100%" h="100%">
{displaySeries.length > 0 && (
<>
<Flex mx="xs" mb="md">
<Title>{displaySeries[0].card.name}</Title>
<ActionIcon ml="auto" onClick={() => openVizSettings()}>
<Icon name="gear" />
</ActionIcon>
</Flex>
{hasAxes ? (
<Group w="100%" h="90%">
<VisualizerAxis
direction="vertical"
columns={currentMetrics}
columnOptions={metrics}
onColumnsChange={handleMetricsChange}
/>
<Stack w="90%" h="100%">
<BaseVisualization
rawSeries={displaySeries}
metadata={metadata}
/>
<VisualizerAxis
columns={currentDimensions}
columnOptions={dimensions}
onColumnsChange={handleDimensionsChange}
/>
</Stack>
</Group>
) : (
<div style={{ width: "100%", height: "90%" }}>
<BaseVisualization
rawSeries={displaySeries}
metadata={metadata}
// isDashboard flag makes it use the TableSimple component
// TableInteractive does a lot of work with Question and Metadata
// and it's currently crashing
isDashboard={series[0].card.display === "table"}
/>
</div>
)}
</>
)}
</Card>
);
}
function getCurrentMetrics(series: Series) {
if (series.length === 0) {
return [];
}
const [{ card }] = series;
return card.visualization_settings["graph.metrics"] ?? [];
}
function getCurrentDimensions(series: Series) {
if (series.length === 0) {
return [];
}
const [{ card }] = series;
return card.visualization_settings["graph.dimensions"] ?? [];
}
function getMetricAndDimensionOptions(series: Series) {
if (series.length === 0) {
return { metrics: [], dimensions: [] };
}
const [{ data }] = series;
const metrics = data.cols.filter(col => isMetric(col));
const dimensions = data.cols.filter(
col => isDimension(col) && !isMetric(col),
);
return {
metrics: metrics.map(col => ({
value: col.name,
label: col.display_name,
})),
dimensions: dimensions.map(col => ({
value: col.name,
label: col.display_name,
})),
};
}

View File

@@ -0,0 +1,69 @@
import { useState } from "react";
import { useSearchQuery } from "metabase/api";
import { Card, Input, Tabs } from "metabase/ui";
import { VisualizerMenuItem } from "metabase/visualizer/components/VisualizerMenuItem";
import type { RecentItem } from "metabase-types/api";
import { VisualizerMetricsList } from "./VisualizerMetricsList";
import { VisualizerModelsList } from "./VisualizerModelsList";
import { VisualizerRecentsList } from "./VisualizerRecentsList";
export function VisualizerMenu({
defaultTab = "metrics",
onAdd,
onReplace,
}: {
onAdd: (item: RecentItem) => void;
onReplace: (item: RecentItem) => void;
defaultTab?: "metrics" | "models" | "recents";
}) {
const [searchQuery, setSearchQuery] = useState<string>();
const { data: searchResults } = useSearchQuery({
q: searchQuery,
models: ["card", "dataset", "metric"],
});
return (
<Card h="100%">
<Input
placeholder="Search"
value={searchQuery}
onChange={ev => setSearchQuery(ev.target.value)}
/>
{searchQuery ? (
<div>
{searchResults?.data.map((item, index) => (
<VisualizerMenuItem
item={item}
key={index}
onAdd={onAdd}
onReplace={onReplace}
isAddable={item.model === "card"}
/>
))}
</div>
) : (
<>
<Tabs defaultValue={defaultTab}>
<Tabs.List>
<Tabs.Tab value="metrics">Metrics</Tabs.Tab>
<Tabs.Tab value="models">Models</Tabs.Tab>
<Tabs.Tab value="recents">Recents</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="metrics">
<VisualizerMetricsList />
</Tabs.Panel>
<Tabs.Panel value="models">
<VisualizerModelsList onReplace={onReplace} />
</Tabs.Panel>
<Tabs.Panel value="recents">
<VisualizerRecentsList onAdd={onAdd} onReplace={onReplace} />
</Tabs.Panel>
</Tabs>
</>
)}
</Card>
);
}

View File

@@ -0,0 +1,3 @@
export function VisualizerMetricsList() {
return <div>Metrics</div>;
}

View File

@@ -0,0 +1,26 @@
import type { ModelResult } from "metabase/browse/types";
import { useFetchModels } from "metabase/common/hooks/use-fetch-models";
import { VisualizerMenuItem } from "metabase/visualizer/components/VisualizerMenuItem";
export function VisualizerModelsList({
onReplace,
}: {
onReplace: (item: any) => void;
}) {
const modelsResult = useFetchModels();
const models = modelsResult.data?.data as ModelResult[] | undefined;
return (
<div>
{models?.map(model => (
<VisualizerMenuItem
key={model.id}
item={model}
isAddable={false}
onAdd={onReplace}
onReplace={onReplace}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useListRecentsQuery } from "metabase/api";
import { VisualizerMenuItem } from "metabase/visualizer/components/VisualizerMenuItem";
export function VisualizerRecentsList({
onAdd,
onReplace,
}: {
onAdd: (item: any) => void;
onReplace: (item: any) => void;
}) {
const { data: recents = [] } = useListRecentsQuery();
const cards = recents.filter(recent =>
RECENT_ITEM_MODELS.includes(recent.model),
);
return (
<div>
{cards.map((card: any) => (
<VisualizerMenuItem
key={card.id}
item={card}
onAdd={onAdd}
onReplace={onReplace}
/>
))}
</div>
);
}
const RECENT_ITEM_MODELS = ["card", "dataset", "metric"];

View File

@@ -0,0 +1,113 @@
/* eslint-disable */
/* Use ActionIcon */
import { t } from "ttag";
import { Menu, Flex, Card, Title, Icon, IconName, Text } from "metabase/ui";
import type { CardId, Series, SingleSeries } from "metabase-types/api";
import { ActionIcon } from "@mantine/core";
import Link from "metabase/core/components/Link";
import * as Urls from "metabase/lib/urls";
import visualizations from "metabase/visualizations";
interface VisualizerUsedProps {
series: Series;
onVizTypeChange: (card: CardId, vizType: string) => void;
onRefreshCard: (card: CardId) => void;
onRemoveCard: (card: CardId) => void;
}
export function VisualizerUsed({
series,
onVizTypeChange,
onRefreshCard,
onRemoveCard,
}: VisualizerUsedProps) {
return (
<Card h="100%">
<Title order={4}>{t`Being used`}</Title>
{series.map(({ card, data }, index) => {
return (
<Flex key={index} py="sm" align="center">
{/* TODO - create a dark variant */}
<VisualizationPicker
series={{ card, data }}
onChange={vizType => onVizTypeChange(card.id, vizType)}
/>
<div>{card.name}</div>
<Flex ml="auto">
<Link to={Urls.question(card)} style={{ marginRight: "6px" }}>
<Icon
name={
card.dataset_query.type === "native" ? "sql" : "notebook"
}
style={{ marginTop: "6px" }}
/>
</Link>
<Menu>
<Menu.Target>
<ActionIcon>
<Icon name="ellipsis" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => onRefreshCard(card.id)}
>{t`Refresh`}</Menu.Item>
<Menu.Item
onClick={() => onRemoveCard(card.id)}
>{t`Remove`}</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
</Flex>
);
})}
</Card>
);
}
interface VisualizationPickerProps {
series: SingleSeries;
onChange: (vizType: string) => void;
}
function VisualizationPicker({ series, onChange }: VisualizationPickerProps) {
const { card, data } = series;
const vizOptions = Array.from(visualizations)
.filter(([, viz]) => {
if (viz.hidden) {
return false;
}
if (typeof viz.isSensible === "function") {
return viz.isSensible(data);
}
return true;
})
.map(([vizType, viz]) => ({
label: viz.uiName,
value: vizType,
icon: viz.iconName,
}));
return (
<Menu>
<Menu.Target>
<ActionIcon mr="sm">
<Icon name={card.display as IconName} />
<Icon name="chevrondown" size={8} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown bg="black.0">
{vizOptions.map(({ label, value, icon }) => (
<Menu.Item key={value} onClick={() => onChange(value)}>
<Flex align="center" color="white">
<Icon name={icon} />
<Text ml="sm">{label}</Text>
</Flex>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -0,0 +1,68 @@
import {
isNumeric,
isDate,
isDateWithoutTime,
isTime,
} from "metabase-lib/v1/types/utils/isa";
import type { DatasetColumn, SingleSeries } from "metabase-types/api";
const areBarLine = ["area", "bar", "line"];
export function areSeriesCompatible(s1: SingleSeries, s2: SingleSeries) {
if (
areBarLine.includes(s1.card.display) &&
areBarLine.includes(s2.card.display)
) {
return areAreaBarLineSeriesCompatible(s1, s2);
}
if (s1.card.display === "scalar" && s2.card.display === "scalar") {
return (
s1.card.result_metadata.length === 1 &&
s2.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(s1: SingleSeries, s2: SingleSeries) {
const card1 = s1.card;
const card2 = s2.card;
const initialDimensions = (
card1.visualization_settings["graph.dimensions"] ?? []
).map(col => s1.data.cols.find(c => c.name === col));
const newDimensions = (
card2.visualization_settings["graph.dimensions"] ?? []
).map(col => s2.data.cols.find(c => c.name === col));
const newMetrics = (card2.visualization_settings["graph.metrics"] ?? []).map(
col => s2.data.cols.find(c => c.name === col),
);
if (
newDimensions.length === 0 ||
newMetrics.length === 0 ||
!newMetrics.every(isNumeric)
) {
return false;
}
const primaryInitialDimension = initialDimensions[0] as DatasetColumn;
const primaryNewDimension = newDimensions[0] as DatasetColumn;
// both or neither primary dimension must be dates
// both or neither primary dimension must be numeric
// TODO handle 👇
// a timestamp field is both date and number so don't enforce the condition if both fields are dates; see #2811
return (
isTemporal(primaryInitialDimension) === isTemporal(primaryNewDimension) ||
isNumeric(primaryInitialDimension) !== isNumeric(primaryNewDimension)
);
}
function isTemporal(col: DatasetColumn) {
return isDate(col) || isDateWithoutTime(col) || isTime(col);
}

View File

@@ -0,0 +1,47 @@
import { createAction, createReducer, createSelector } from "@reduxjs/toolkit";
import { VisualizerState } from "metabase-types/store/visualizer";
const OPEN_VIZ_SETTINGS = "metabase/visualizer/OPEN_VIZ_SETTINGS";
const CLOSE_VIZ_SETTINGS = "metabase/visualizer/CLOSE_VIZ_SETTINGS";
const TOGGLE_VIZ_SETTINGS = "metabase/visualizer/TOGGLE_VIZ_SETTINGS";
export const openVizSettings = createAction(OPEN_VIZ_SETTINGS);
export const closeVizSettings = createAction(CLOSE_VIZ_SETTINGS);
const toggleVizSettings = createAction(TOGGLE_VIZ_SETTINGS);
const initialState: VisualizerState = {
ui: {
vizSettings: false,
data: true,
},
};
const getVisualizerState = (state: any) => state.visualizer;
export const getVizSettingsDisplay = createSelector(
getVisualizerState,
state => {
return state.ui.vizSettings;
},
);
export const visualizer = createReducer(initialState, builder => {
builder.addCase(openVizSettings, state => {
return {
...state,
ui: { vizSettings: true, data: false },
};
});
builder.addCase(closeVizSettings, state => {
return {
...state,
ui: { vizSettings: false, data: true },
};
});
builder.addCase(toggleVizSettings, state => {
return {
...state,
ui: { vizSettings: !state.ui.vizSettings, data: !state.ui.data },
};
});
});

View File

@@ -0,0 +1,21 @@
import { visualizer } from "./store";
import { openVizSettings } from "./store";
describe("visualizer state", () => {
describe("ui state", () => {
it("should open viz settings and close the data menu", () => {
const state = visualizer(
{
ui: {
data: true,
vizSettings: false,
},
},
openVizSettings,
);
expect(state.ui.data).toBe(false);
expect(state.ui.vizSettings).toBe(true);
});
xit("should close the viz settings menu without changing the data menu state", () => {});
});
});

View File

@@ -0,0 +1,20 @@
import { useDispatch, useSelector } from "metabase/lib/redux";
import {
getVizSettingsDisplay,
openVizSettings,
closeVizSettings,
} from "./store";
export function useVizSettings() {
const isVizSettingsOpen = useSelector(getVizSettingsDisplay);
const dispatch = useDispatch();
dispatch(openVizSettings);
return {
isVizSettingsOpen,
openVizSettings: () => dispatch(openVizSettings()),
closeVizSettings: () => dispatch(closeVizSettings()),
};
}

View File

@@ -94,6 +94,7 @@
"react-motion": "0.5.2",
"react-redux": "^8.0.5",
"react-resizable": "^3.0.5",
"react-resizable-panels": "^2.0.19",
"react-router": "3",
"react-router-redux": "^4.0.8",
"react-textarea-autosize": "^5.2.1",

View File

@@ -19548,6 +19548,11 @@ react-remove-scroll@^2.5.5:
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
react-resizable-panels@^2.0.19:
version "2.0.19"
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.0.19.tgz#df259898c682cb774af65c3bc38c1b29c855b99b"
integrity sha512-v3E41kfKSuCPIvJVb4nL4mIZjjKIn/gh6YqZF/gDfQDolv/8XnhJBek4EiV2gOr3hhc5A3kOGOayk3DhanpaQw==
react-resizable@^3.0.1, react-resizable@^3.0.5:
version "3.0.5"
resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-3.0.5.tgz#362721f2efbd094976f1780ae13f1ad7739786c1"