Compare commits
81 Commits
python-run
...
visualizer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35471660d | ||
|
|
63f016e9ff | ||
|
|
5ceda5fa54 | ||
|
|
98362f9580 | ||
|
|
a0b2e14abf | ||
|
|
d596a5427b | ||
|
|
7590869bc5 | ||
|
|
3010c074b0 | ||
|
|
979b022f09 | ||
|
|
a23e583975 | ||
|
|
4683e2b9aa | ||
|
|
ee7748a1e4 | ||
|
|
3a27ad93d9 | ||
|
|
9650fd1508 | ||
|
|
c551f39547 | ||
|
|
244b4c2b8e | ||
|
|
17fa6a528a | ||
|
|
63d0d3e856 | ||
|
|
db017505bb | ||
|
|
77ce9a2060 | ||
|
|
45152a5f9f | ||
|
|
ed66d69aec | ||
|
|
4b21681971 | ||
|
|
6b2c9751da | ||
|
|
6a0a2a90b5 | ||
|
|
6c4585cc71 | ||
|
|
b9f538898d | ||
|
|
e92d152e33 | ||
|
|
37c68376e8 | ||
|
|
0bb3544dc2 | ||
|
|
983d9b8aab | ||
|
|
48386755be | ||
|
|
2a6d7f3121 | ||
|
|
5dbf99326f | ||
|
|
db418b4b03 | ||
|
|
5eb62543f0 | ||
|
|
a0fcddb209 | ||
|
|
f82eb4a346 | ||
|
|
780d631184 | ||
|
|
1bcfe4fcd1 | ||
|
|
a941267df0 | ||
|
|
7137d65cd6 | ||
|
|
ff48495c9c | ||
|
|
2695ef3eeb | ||
|
|
936bfa45d8 | ||
|
|
a796c8326d | ||
|
|
54346c789e | ||
|
|
ba7e57df9d | ||
|
|
ef2b1687ef | ||
|
|
07a1804748 | ||
|
|
e9152b1420 | ||
|
|
5a66f56931 | ||
|
|
9a32c06892 | ||
|
|
e6988714c8 | ||
|
|
9964bc85ac | ||
|
|
eb86a7dea9 | ||
|
|
e52e0f801b | ||
|
|
46f54d5034 | ||
|
|
2451e54af8 | ||
|
|
bc07f5394a | ||
|
|
a590706a95 | ||
|
|
823e88bf1a | ||
|
|
3175e91b2c | ||
|
|
cfa5df5472 | ||
|
|
e979ba8661 | ||
|
|
b644520a94 | ||
|
|
41c6daf77f | ||
|
|
3ffb5b09f0 | ||
|
|
ac9535bf02 | ||
|
|
87f4af0a67 | ||
|
|
04026ce60d | ||
|
|
e01cbebcd9 | ||
|
|
138df1c4f0 | ||
|
|
80152f8e0f | ||
|
|
5d6eabe776 | ||
|
|
4709826b00 | ||
|
|
55378ea0c8 | ||
|
|
aab9fde68b | ||
|
|
256668b4d4 | ||
|
|
adabe0edf0 | ||
|
|
aaec2732ef |
@@ -54,12 +54,12 @@ export interface DatasetData {
|
||||
rows: RowValues[];
|
||||
cols: DatasetColumn[];
|
||||
insights?: Insight[] | null;
|
||||
results_metadata: ResultsMetadata;
|
||||
rows_truncated: number;
|
||||
results_metadata?: ResultsMetadata;
|
||||
rows_truncated?: number;
|
||||
requested_timezone?: string;
|
||||
results_timezone?: string;
|
||||
download_perms?: DownloadPermission;
|
||||
native_form: {
|
||||
native_form?: {
|
||||
query: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import EditableText from "metabase/core/components/EditableText";
|
||||
import EditableText, {
|
||||
EditableMarkdownText,
|
||||
} from "metabase/core/components/EditableText";
|
||||
|
||||
export const CaptionTitleContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -17,7 +19,9 @@ export interface CaptionDescriptionProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export const CaptionDescription = styled(EditableText)<CaptionDescriptionProps>`
|
||||
export const CaptionDescription = styled(
|
||||
EditableMarkdownText,
|
||||
)<CaptionDescriptionProps>`
|
||||
opacity: ${props => (props.isVisible ? 1 : 0)};
|
||||
max-width: 25rem;
|
||||
transition: opacity 400ms ease 1s;
|
||||
|
||||
@@ -69,7 +69,6 @@ export const CollectionCaption = ({
|
||||
isDisabled={!isEditable}
|
||||
isOptional
|
||||
isMultiline
|
||||
isMarkdown
|
||||
onChange={handleChangeDescription}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -96,6 +96,12 @@ const NewItemMenu = ({
|
||||
icon: "folder",
|
||||
action: () => dispatch(setOpenModal("collection")),
|
||||
},
|
||||
{
|
||||
title: t`Visualization`,
|
||||
icon: "bar",
|
||||
link: Urls.visualizer(),
|
||||
onClose: onCloseNavbar,
|
||||
},
|
||||
);
|
||||
|
||||
if (hasNativeWrite) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
|
||||
import { EditableTextArea, EditableTextRoot } from "./EditableText.styled";
|
||||
import type { EditableTextProps } from "./types";
|
||||
import { useEditableText } from "./useEditableText";
|
||||
|
||||
export const DoubleClickEditableText = forwardRef(
|
||||
function DoubleClickEditableText(
|
||||
{
|
||||
initialValue,
|
||||
placeholder,
|
||||
isOptional = false,
|
||||
isMultiline = false,
|
||||
isDisabled = false,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
"data-testid": dataTestId,
|
||||
...props
|
||||
}: EditableTextProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const {
|
||||
inputValue,
|
||||
isInFocus,
|
||||
setIsInFocus,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
} = useEditableText({
|
||||
initialValue,
|
||||
isOptional,
|
||||
isMultiline,
|
||||
onChange,
|
||||
onBlur,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isInFocus) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isInFocus]);
|
||||
|
||||
const handleRootElementDoubleClick = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof HTMLAnchorElement)) {
|
||||
setIsInFocus(true);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = inputValue ? inputValue : placeholder;
|
||||
|
||||
return (
|
||||
<EditableTextRoot
|
||||
{...props}
|
||||
isDisabled={isDisabled}
|
||||
isInFocus={Boolean(!isInFocus && inputValue)}
|
||||
onDoubleClick={handleRootElementDoubleClick}
|
||||
hasHoverBorder={false}
|
||||
data-value={`${displayValue}\u00A0`}
|
||||
data-testid="editable-text"
|
||||
ref={ref}
|
||||
>
|
||||
{isInFocus && inputValue ? (
|
||||
<EditableTextArea
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
disabled={isDisabled}
|
||||
data-testid={dataTestId}
|
||||
onFocus={onFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
) : (
|
||||
<span data-testid={dataTestId}>{displayValue}</span>
|
||||
)}
|
||||
</EditableTextRoot>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
|
||||
import Markdown from "metabase/core/components/Markdown";
|
||||
|
||||
import { EditableTextArea, EditableTextRoot } from "./EditableText.styled";
|
||||
import type { EditableTextProps } from "./types";
|
||||
import { useEditableText } from "./useEditableText";
|
||||
|
||||
const _EditableMarkdownText = forwardRef(function EditableMarkdownText(
|
||||
{
|
||||
initialValue,
|
||||
placeholder,
|
||||
isOptional = false,
|
||||
isMultiline = false,
|
||||
isDisabled = false,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
"data-testid": dataTestId,
|
||||
...props
|
||||
}: EditableTextProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) {
|
||||
const {
|
||||
inputValue,
|
||||
isInFocus,
|
||||
setIsInFocus,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
} = useEditableText({
|
||||
initialValue,
|
||||
isOptional,
|
||||
isMultiline,
|
||||
onChange,
|
||||
onBlur,
|
||||
});
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInFocus) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isInFocus]);
|
||||
|
||||
const handleRootElementClick = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof HTMLAnchorElement)) {
|
||||
setIsInFocus(true);
|
||||
}
|
||||
};
|
||||
|
||||
const displayValue = inputValue ? inputValue : placeholder;
|
||||
const shouldShowMarkdown = Boolean(!isInFocus && inputValue);
|
||||
|
||||
return (
|
||||
<EditableTextRoot
|
||||
{...props}
|
||||
isDisabled={isDisabled}
|
||||
isInFocus={shouldShowMarkdown}
|
||||
onClick={handleRootElementClick}
|
||||
data-value={`${displayValue}\u00A0`}
|
||||
data-testid="editable-text"
|
||||
ref={ref}
|
||||
>
|
||||
{shouldShowMarkdown ? (
|
||||
<Markdown>{inputValue}</Markdown>
|
||||
) : (
|
||||
<EditableTextArea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
disabled={isDisabled}
|
||||
data-testid={dataTestId}
|
||||
onFocus={onFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
</EditableTextRoot>
|
||||
);
|
||||
});
|
||||
|
||||
export const EditableMarkdownText = Object.assign(_EditableMarkdownText, {
|
||||
Root: EditableTextRoot,
|
||||
});
|
||||
@@ -30,15 +30,3 @@ WithMaxWidth.args = {
|
||||
placeholder: "Enter title",
|
||||
style: { maxWidth: 500 },
|
||||
};
|
||||
|
||||
export const WithMarkdown = Template.bind({});
|
||||
WithMarkdown.args = {
|
||||
initialValue: `**bold** text
|
||||
|
||||
*multiline*
|
||||
|
||||
and [link](https://metabase.com)`,
|
||||
placeholder: "Enter description",
|
||||
isMultiline: true,
|
||||
isMarkdown: true,
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import { css } from "@emotion/react";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
export interface EditableTextRootProps {
|
||||
isEditing?: boolean;
|
||||
isInFocus: boolean; // TODO Rename prop
|
||||
isDisabled: boolean;
|
||||
isEditingMarkdown?: boolean;
|
||||
hasHoverBorder?: boolean;
|
||||
}
|
||||
|
||||
export const EditableTextRoot = styled.div<EditableTextRootProps>`
|
||||
@@ -15,20 +15,22 @@ export const EditableTextRoot = styled.div<EditableTextRootProps>`
|
||||
border-radius: 4px;
|
||||
word-wrap: break-word;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: ${props => !props.isDisabled && "var(--mb-color-border)"};
|
||||
}
|
||||
|
||||
${props =>
|
||||
props.isEditing &&
|
||||
!props.isDisabled &&
|
||||
${({ isDisabled, isInFocus, hasHoverBorder = true }) =>
|
||||
hasHoverBorder &&
|
||||
css`
|
||||
border-color: var(--mb-color-border);
|
||||
border-color: ${isInFocus && "var(--mb-color-border)"};
|
||||
|
||||
&:hover {
|
||||
border-color: ${!isDisabled && "var(--mb-color-border)"};
|
||||
}
|
||||
`}
|
||||
|
||||
${({ isEditingMarkdown }) =>
|
||||
isEditingMarkdown &&
|
||||
${({ isInFocus }) =>
|
||||
!isInFocus &&
|
||||
css`
|
||||
&:after {
|
||||
content: attr(data-value);
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
import type {
|
||||
ChangeEvent,
|
||||
KeyboardEvent,
|
||||
HTMLAttributes,
|
||||
Ref,
|
||||
MouseEvent,
|
||||
FocusEventHandler,
|
||||
FocusEvent,
|
||||
} from "react";
|
||||
import { forwardRef, useCallback, useEffect, useState, useRef } from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
|
||||
import Markdown from "metabase/core/components/Markdown";
|
||||
import { forwardRef, useRef, type Ref } from "react";
|
||||
|
||||
import { EditableTextArea, EditableTextRoot } from "./EditableText.styled";
|
||||
|
||||
export type EditableTextAttributes = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange" | "onFocus" | "onBlur"
|
||||
>;
|
||||
|
||||
export interface EditableTextProps extends EditableTextAttributes {
|
||||
initialValue?: string | null;
|
||||
placeholder?: string;
|
||||
isEditing?: boolean;
|
||||
isOptional?: boolean;
|
||||
isMultiline?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isMarkdown?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: FocusEventHandler<HTMLTextAreaElement>;
|
||||
onBlur?: FocusEventHandler<HTMLTextAreaElement>;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
import type { EditableTextProps } from "./types";
|
||||
import { useEditableText } from "./useEditableText";
|
||||
|
||||
const EditableText = forwardRef(function EditableText(
|
||||
{
|
||||
initialValue,
|
||||
placeholder,
|
||||
isEditing = false,
|
||||
isOptional = false,
|
||||
isMultiline = false,
|
||||
isDisabled = false,
|
||||
isMarkdown = false,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
@@ -50,103 +19,39 @@ const EditableText = forwardRef(function EditableText(
|
||||
}: EditableTextProps,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) {
|
||||
const [inputValue, setInputValue] = useState(initialValue ?? "");
|
||||
const [submitValue, setSubmitValue] = useState(initialValue ?? "");
|
||||
const [isInFocus, setIsInFocus] = useState(isEditing);
|
||||
const displayValue = inputValue ? inputValue : placeholder;
|
||||
const submitOnBlur = useRef(true);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const previousInitialValue = usePrevious(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValue && initialValue !== previousInitialValue) {
|
||||
setInputValue(initialValue);
|
||||
}
|
||||
}, [initialValue, previousInitialValue]);
|
||||
const { inputValue, isInFocus, handleChange, handleBlur, handleKeyDown } =
|
||||
useEditableText({
|
||||
initialValue,
|
||||
isOptional,
|
||||
isMultiline,
|
||||
onChange,
|
||||
onBlur,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMarkdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInFocus) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isInFocus, isMarkdown]);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: FocusEvent<HTMLTextAreaElement>) => {
|
||||
setIsInFocus(false);
|
||||
|
||||
if (!isOptional && !inputValue) {
|
||||
setInputValue(submitValue);
|
||||
} else if (inputValue !== submitValue && submitOnBlur.current) {
|
||||
setSubmitValue(inputValue);
|
||||
onChange?.(inputValue);
|
||||
}
|
||||
|
||||
onBlur?.(event);
|
||||
},
|
||||
[inputValue, submitValue, isOptional, onChange, onBlur, setIsInFocus],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInputValue(event.currentTarget.value);
|
||||
submitOnBlur.current = true;
|
||||
},
|
||||
[submitOnBlur],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
setInputValue(submitValue);
|
||||
submitOnBlur.current = false;
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Enter" && !isMultiline) {
|
||||
event.preventDefault();
|
||||
submitOnBlur.current = true;
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
},
|
||||
[submitValue, isMultiline],
|
||||
);
|
||||
|
||||
const handleRootElementClick = (event: MouseEvent) => {
|
||||
if (!(event.target instanceof HTMLAnchorElement)) {
|
||||
setIsInFocus(true);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldShowMarkdown = isMarkdown && !isInFocus && inputValue;
|
||||
const displayValue = inputValue ? inputValue : placeholder;
|
||||
|
||||
return (
|
||||
<EditableTextRoot
|
||||
onClick={isMarkdown ? handleRootElementClick : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
isEditing={isEditing}
|
||||
isDisabled={isDisabled}
|
||||
isEditingMarkdown={!shouldShowMarkdown}
|
||||
isInFocus={isInFocus}
|
||||
data-value={`${displayValue}\u00A0`}
|
||||
data-testid="editable-text"
|
||||
ref={ref}
|
||||
>
|
||||
{shouldShowMarkdown ? (
|
||||
<Markdown>{inputValue}</Markdown>
|
||||
) : (
|
||||
<EditableTextArea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
disabled={isDisabled}
|
||||
data-testid={dataTestId}
|
||||
onFocus={onFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
)}
|
||||
<EditableTextArea
|
||||
value={inputValue}
|
||||
placeholder={placeholder}
|
||||
disabled={isDisabled}
|
||||
data-testid={dataTestId}
|
||||
onFocus={onFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={inputRef}
|
||||
/>
|
||||
</EditableTextRoot>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,16 @@ import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { screen, render, waitFor } from "__support__/ui";
|
||||
|
||||
import type { EditableTextProps } from "./EditableText";
|
||||
import { EditableMarkdownText } from "./EditableMarkdownText";
|
||||
import EditableText from "./EditableText";
|
||||
import type { EditableTextProps } from "./types";
|
||||
|
||||
const setup = (props?: Partial<EditableTextProps>) => {
|
||||
render(<EditableText {...props} />);
|
||||
const setup = ({
|
||||
isMarkdown = false,
|
||||
...props
|
||||
}: Partial<EditableTextProps> & { isMarkdown?: boolean } = {}) => {
|
||||
const Component = isMarkdown ? EditableMarkdownText : EditableText;
|
||||
render(<Component {...props} />);
|
||||
};
|
||||
|
||||
describe("EditableText", () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export { default } from "./EditableText";
|
||||
export type { EditableTextProps } from "./EditableText";
|
||||
export * from "./DoubleClickEditableText";
|
||||
export * from "./EditableMarkdownText";
|
||||
export * from "./types";
|
||||
|
||||
18
frontend/src/metabase/core/components/EditableText/types.ts
Normal file
18
frontend/src/metabase/core/components/EditableText/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FocusEventHandler, HTMLAttributes } from "react";
|
||||
|
||||
type EditableTextAttributes = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange" | "onFocus" | "onBlur"
|
||||
>;
|
||||
|
||||
export interface EditableTextProps extends EditableTextAttributes {
|
||||
initialValue?: string | null;
|
||||
placeholder?: string;
|
||||
isOptional?: boolean;
|
||||
isMultiline?: boolean;
|
||||
isDisabled?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: FocusEventHandler<HTMLTextAreaElement>;
|
||||
onBlur?: FocusEventHandler<HTMLTextAreaElement>;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type FocusEventHandler,
|
||||
type KeyboardEvent,
|
||||
type FocusEvent,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { usePrevious } from "react-use";
|
||||
|
||||
type UseEditableTextOptions = {
|
||||
initialValue?: string | null;
|
||||
isOptional?: boolean;
|
||||
isMultiline?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: FocusEventHandler<HTMLTextAreaElement>;
|
||||
};
|
||||
|
||||
export function useEditableText({
|
||||
initialValue,
|
||||
isOptional = false,
|
||||
isMultiline = false,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: UseEditableTextOptions) {
|
||||
const [inputValue, setInputValue] = useState(initialValue ?? "");
|
||||
const [submitValue, setSubmitValue] = useState(initialValue ?? "");
|
||||
const [isInFocus, setIsInFocus] = useState(false);
|
||||
const submitOnBlur = useRef(true);
|
||||
const previousInitialValue = usePrevious(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValue && initialValue !== previousInitialValue) {
|
||||
setInputValue(initialValue);
|
||||
}
|
||||
}, [initialValue, previousInitialValue]);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(event: FocusEvent<HTMLTextAreaElement>) => {
|
||||
setIsInFocus(false);
|
||||
|
||||
if (!isOptional && !inputValue) {
|
||||
setInputValue(submitValue);
|
||||
} else if (inputValue !== submitValue && submitOnBlur.current) {
|
||||
setSubmitValue(inputValue);
|
||||
onChange?.(inputValue);
|
||||
}
|
||||
|
||||
onBlur?.(event);
|
||||
},
|
||||
[inputValue, submitValue, isOptional, onChange, onBlur, setIsInFocus],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInputValue(event.currentTarget.value);
|
||||
submitOnBlur.current = true;
|
||||
},
|
||||
[submitOnBlur],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
setInputValue(submitValue);
|
||||
submitOnBlur.current = false;
|
||||
event.currentTarget.blur();
|
||||
} else if (event.key === "Enter" && !isMultiline) {
|
||||
event.preventDefault();
|
||||
submitOnBlur.current = true;
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
},
|
||||
[submitValue, isMultiline],
|
||||
);
|
||||
|
||||
return {
|
||||
inputValue,
|
||||
isInFocus,
|
||||
setIsInFocus,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { css } from "@emotion/react";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import EditableText from "metabase/core/components/EditableText";
|
||||
import EditableText, {
|
||||
EditableMarkdownText,
|
||||
} from "metabase/core/components/EditableText";
|
||||
import FormField from "metabase/core/components/FormField/FormField";
|
||||
import { breakpointMaxSmall } from "metabase/styled-components/theme";
|
||||
|
||||
@@ -61,7 +63,9 @@ export const DescriptionHeader = styled.h3`
|
||||
margin-bottom: 0.5rem;
|
||||
`;
|
||||
|
||||
export const EditableDescription = styled(EditableText)<{ hasError?: boolean }>`
|
||||
export const EditableDescription = styled(EditableMarkdownText)<{
|
||||
hasError?: boolean;
|
||||
}>`
|
||||
${props =>
|
||||
props.hasError &&
|
||||
css`
|
||||
|
||||
@@ -132,7 +132,6 @@ const DashboardInfoSidebarBody = ({
|
||||
onBlur={handleDescriptionBlur}
|
||||
isOptional
|
||||
isMultiline
|
||||
isMarkdown
|
||||
hasError={!!descriptionError}
|
||||
placeholder={t`Add description`}
|
||||
key={`dashboard-description-${dashboard.description}`}
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./pulses";
|
||||
export * from "./questions";
|
||||
export * from "./timelines";
|
||||
export * from "./utils";
|
||||
export * from "./visualizer";
|
||||
|
||||
18
frontend/src/metabase/lib/urls/visualizer.ts
Normal file
18
frontend/src/metabase/lib/urls/visualizer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { utf8_to_b64url, b64hash_to_utf8 } from "metabase/lib/encoding";
|
||||
import type { Card } from "metabase-types/api";
|
||||
|
||||
export function encodeVisualizerState(cards: Card[] = []) {
|
||||
return cards.length > 0 ? utf8_to_b64url(JSON.stringify(cards)) : "";
|
||||
}
|
||||
|
||||
export function decodeVisualizerState(hash: string): Card[] {
|
||||
return JSON.parse(b64hash_to_utf8(hash));
|
||||
}
|
||||
|
||||
export function visualizer(cards: Card[] = []) {
|
||||
if (cards.length > 0) {
|
||||
return `v#${encodeVisualizerState(cards)}`;
|
||||
} else {
|
||||
return `v`;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { css } from "@emotion/react";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import EditableText from "metabase/core/components/EditableText";
|
||||
import { EditableMarkdownText } from "metabase/core/components/EditableText";
|
||||
import Link from "metabase/core/components/Link";
|
||||
|
||||
export const ModelInfoSection = styled.div``;
|
||||
@@ -38,7 +38,7 @@ export const ModelInfoText = styled.span`
|
||||
${commonInfoTextStyle}
|
||||
`;
|
||||
|
||||
export const ModelDescription = styled(EditableText)`
|
||||
export const ModelDescription = styled(EditableMarkdownText)`
|
||||
${commonInfoTextStyle}
|
||||
`;
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ function ModelInfoSidePanel({ model, mainTable, onChangeDescription }: Props) {
|
||||
}
|
||||
isOptional
|
||||
isMultiline
|
||||
isMarkdown
|
||||
isDisabled={!canWrite}
|
||||
aria-label={t`Description`}
|
||||
onChange={onChangeDescription}
|
||||
|
||||
@@ -15,9 +15,9 @@ export type NotebookProps = {
|
||||
isResultDirty: boolean;
|
||||
reportTimezone: string;
|
||||
hasVisualizeButton?: boolean;
|
||||
updateQuestion: (question: Question) => Promise<void>;
|
||||
updateQuestion: (question: Question) => void | Promise<void>;
|
||||
runQuestionQuery: () => void;
|
||||
setQueryBuilderMode: (mode: string) => void;
|
||||
setQueryBuilderMode?: (mode: string) => void;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -56,13 +56,13 @@ const Notebook = ({
|
||||
cleanupQuestion();
|
||||
}
|
||||
// switch mode before running otherwise URL update may cause it to switch back to notebook mode
|
||||
await setQueryBuilderMode("view");
|
||||
setQueryBuilderMode?.("view");
|
||||
if (isResultDirty) {
|
||||
await runQuestionQuery();
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateQuestion = (question: Question): Promise<void> => {
|
||||
const handleUpdateQuestion = (question: Question) => {
|
||||
dispatch(setUIControls({ isModifiedFromNotebook: true }));
|
||||
return updateQuestion(question);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ interface NotebookStepsProps {
|
||||
question: Question;
|
||||
sourceQuestion?: Question;
|
||||
reportTimezone: string;
|
||||
updateQuestion: (question: Question) => Promise<void>;
|
||||
updateQuestion: (question: Question) => void | Promise<void>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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 * as Urls from "metabase/lib/urls";
|
||||
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={Urls.visualizer([question.card(), card])}>
|
||||
<Text>{card.name}</Text>
|
||||
</Link>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
QuestionSummarizeWidget,
|
||||
ToggleNativeQueryPreview,
|
||||
} from "metabase/query_builder/components/view/ViewHeader/components";
|
||||
import { CompatibleSeriesButton } from "metabase/query_builder/components/view/ViewHeader/components/CompatibleSeriesButton";
|
||||
import { canExploreResults } from "metabase/query_builder/components/view/ViewHeader/utils";
|
||||
import type { QueryModalType } from "metabase/query_builder/constants";
|
||||
import { MODAL_TYPES } from "metabase/query_builder/constants";
|
||||
@@ -211,6 +212,7 @@ export function ViewTitleHeaderRightSide({
|
||||
/>
|
||||
</ViewHeaderIconButtonContainer>
|
||||
)}
|
||||
{isSaved && <CompatibleSeriesButton question={question} />}
|
||||
{isSaved && (
|
||||
<QuestionActions
|
||||
question={question}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import EditableText from "metabase/core/components/EditableText";
|
||||
import { EditableMarkdownText } from "metabase/core/components/EditableText";
|
||||
import Link from "metabase/core/components/Link";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { PLUGIN_CACHING, PLUGIN_MODERATION } from "metabase/plugins";
|
||||
@@ -55,7 +55,7 @@ export const QuestionInfoSidebar = ({
|
||||
>{t`Model details`}</Link>
|
||||
)}
|
||||
</HeaderContainer>
|
||||
<EditableText
|
||||
<EditableMarkdownText
|
||||
initialValue={description}
|
||||
placeholder={
|
||||
!description && !canWrite
|
||||
@@ -64,7 +64,6 @@ export const QuestionInfoSidebar = ({
|
||||
}
|
||||
isOptional
|
||||
isMultiline
|
||||
isMarkdown
|
||||
isDisabled={!canWrite}
|
||||
onChange={handleSave}
|
||||
/>
|
||||
|
||||
@@ -58,7 +58,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 (
|
||||
|
||||
@@ -50,6 +50,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";
|
||||
@@ -222,6 +223,12 @@ export const getRoutes = store => {
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* VISUALIZER */}
|
||||
<Route path="v">
|
||||
<IndexRoute component={Visualizer} />
|
||||
<Route path=":slug" component={Visualizer} />
|
||||
</Route>
|
||||
|
||||
{/* INDIVIDUAL DASHBOARDS */}
|
||||
|
||||
<Route
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ComboChart } from "metabase/static-viz/components/ComboChart";
|
||||
import type { StaticChartProps } from "metabase/static-viz/components/StaticVisualization";
|
||||
import { scalarToBarTransform } from "metabase/visualizations/visualizations/Scalar/scalars-bar-transform";
|
||||
import { scalarToCartesianTransform } from "metabase/visualizations/visualizations/Scalar/scalars-cartesian-transform";
|
||||
|
||||
export const ScalarChart = (props: StaticChartProps) => {
|
||||
return (
|
||||
<ComboChart
|
||||
{...props}
|
||||
rawSeries={scalarToBarTransform(
|
||||
rawSeries={scalarToCartesianTransform(
|
||||
props.rawSeries,
|
||||
{},
|
||||
props.renderingContext,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type React from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import { getVisualizationRaw } from "metabase/visualizations";
|
||||
import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
|
||||
import type {
|
||||
ComputedVisualizationSettings,
|
||||
@@ -14,19 +15,19 @@ import type { RawSeries } from "metabase-types/api";
|
||||
export type TransformSeries = (
|
||||
rawSeries: RawSeries,
|
||||
settings: ComputedVisualizationSettings,
|
||||
renderingContext: RenderingContext,
|
||||
renderingContext?: RenderingContext,
|
||||
) => RawSeries;
|
||||
|
||||
export interface TransformedVisualizationProps {
|
||||
transformSeries: TransformSeries;
|
||||
originalProps: VisualizationProps;
|
||||
VisualizationComponent: React.FC<VisualizationProps>;
|
||||
VisualizationComponent?: React.FC<VisualizationProps>;
|
||||
renderingContext: RenderingContext;
|
||||
}
|
||||
|
||||
export const TransformedVisualization = ({
|
||||
originalProps,
|
||||
VisualizationComponent,
|
||||
VisualizationComponent: _VisualizationComponent,
|
||||
transformSeries,
|
||||
renderingContext,
|
||||
}: TransformedVisualizationProps) => {
|
||||
@@ -41,6 +42,9 @@ export const TransformedVisualization = ({
|
||||
return getComputedSettingsForSeries(transformedSeries);
|
||||
}, [transformedSeries]);
|
||||
|
||||
const VisualizationComponent =
|
||||
_VisualizationComponent || getVisualizationRaw(transformedSeries);
|
||||
|
||||
const handleChangeCardCandRun: OnChangeCardAndRun = useCallback(
|
||||
(options: OnChangeCardAndRunOpts) => {
|
||||
const cards = rawSeries.map(series => series.card);
|
||||
@@ -72,6 +76,7 @@ export const TransformedVisualization = ({
|
||||
return (
|
||||
<VisualizationComponent
|
||||
{...restProps}
|
||||
series={transformedSeries}
|
||||
rawSeries={transformedSeries}
|
||||
settings={transformedSettings}
|
||||
onChangeCardAndRun={handleChangeCardCandRun}
|
||||
|
||||
@@ -26,6 +26,7 @@ const propTypes = {
|
||||
isVertical: PropTypes.bool,
|
||||
onHoverChange: PropTypes.func,
|
||||
onSelectSeries: PropTypes.func,
|
||||
onChangeSeriesName: PropTypes.func,
|
||||
onRemoveSeries: PropTypes.func,
|
||||
isReversed: PropTypes.bool,
|
||||
canRemoveSeries: PropTypes.func,
|
||||
@@ -42,6 +43,7 @@ const Legend = ({
|
||||
isVertical,
|
||||
onHoverChange,
|
||||
onSelectSeries,
|
||||
onChangeSeriesName,
|
||||
onRemoveSeries,
|
||||
isReversed,
|
||||
canRemoveSeries = alwaysTrue,
|
||||
@@ -88,6 +90,7 @@ const Legend = ({
|
||||
isReversed={isReversed}
|
||||
onHoverChange={onHoverChange}
|
||||
onSelectSeries={onSelectSeries}
|
||||
onChangeSeriesName={onChangeSeriesName}
|
||||
onRemoveSeries={
|
||||
canRemoveSeries(itemIndex) ? onRemoveSeries : undefined
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import cx from "classnames";
|
||||
import PropTypes from "prop-types";
|
||||
import { memo } from "react";
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { Ellipsified } from "metabase/core/components/Ellipsified";
|
||||
import DashboardS from "metabase/css/dashboard.module.css";
|
||||
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
|
||||
|
||||
import {
|
||||
EditableLegendItemTitle,
|
||||
LegendItemDot,
|
||||
LegendItemLabel,
|
||||
LegendItemRemoveIcon,
|
||||
@@ -22,6 +23,7 @@ const propTypes = {
|
||||
isReversed: PropTypes.bool,
|
||||
onHoverChange: PropTypes.func,
|
||||
onSelectSeries: PropTypes.func,
|
||||
onChangeSeriesName: PropTypes.func,
|
||||
onRemoveSeries: PropTypes.func,
|
||||
};
|
||||
|
||||
@@ -33,8 +35,11 @@ const LegendItem = ({
|
||||
isReversed,
|
||||
onHoverChange,
|
||||
onSelectSeries,
|
||||
onChangeSeriesName,
|
||||
onRemoveSeries,
|
||||
}) => {
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
|
||||
const handleItemClick = event => {
|
||||
onSelectSeries && onSelectSeries(event, index, isReversed);
|
||||
};
|
||||
@@ -51,24 +56,34 @@ const LegendItem = ({
|
||||
onRemoveSeries && onRemoveSeries(event, index);
|
||||
};
|
||||
|
||||
const legendItemTitleClassNames = cx(
|
||||
DashboardS.fullscreenNormalText,
|
||||
DashboardS.fullscreenNightText,
|
||||
EmbedFrameS.fullscreenNightText,
|
||||
);
|
||||
|
||||
return (
|
||||
<LegendItemRoot isVertical={isVertical} data-testid="legend-item">
|
||||
<LegendItemLabel
|
||||
isMuted={isMuted}
|
||||
onClick={onSelectSeries && handleItemClick}
|
||||
onClick={onSelectSeries && !isEditingName && handleItemClick}
|
||||
onMouseEnter={onHoverChange && handleItemMouseEnter}
|
||||
onMouseLeave={onHoverChange && handleItemMouseLeave}
|
||||
>
|
||||
<LegendItemDot color={item.color} />
|
||||
<LegendItemTitle
|
||||
className={cx(
|
||||
DashboardS.fullscreenNormalText,
|
||||
DashboardS.fullscreenNightText,
|
||||
EmbedFrameS.fullscreenNightText,
|
||||
)}
|
||||
>
|
||||
<Ellipsified>{item.name}</Ellipsified>
|
||||
</LegendItemTitle>
|
||||
{onChangeSeriesName ? (
|
||||
<EditableLegendItemTitle
|
||||
className={legendItemTitleClassNames}
|
||||
initialValue={item.name}
|
||||
onFocus={() => setIsEditingName(true)}
|
||||
onBlur={() => setIsEditingName(false)}
|
||||
onChange={name => onChangeSeriesName(index, name)}
|
||||
/>
|
||||
) : (
|
||||
<LegendItemTitle className={legendItemTitleClassNames}>
|
||||
<Ellipsified>{item.name}</Ellipsified>
|
||||
</LegendItemTitle>
|
||||
)}
|
||||
</LegendItemLabel>
|
||||
{onRemoveSeries && <LegendItemRemoveIcon onClick={handleRemoveClick} />}
|
||||
</LegendItemRoot>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { css } from "@emotion/react";
|
||||
import styled from "@emotion/styled";
|
||||
|
||||
import { DoubleClickEditableText } from "metabase/core/components/EditableText";
|
||||
import { Icon } from "metabase/ui";
|
||||
|
||||
export const LegendItemRoot = styled.div<{ isVertical: boolean }>`
|
||||
@@ -36,7 +38,7 @@ export const LegendItemDot = styled.div`
|
||||
color-adjust: exact;
|
||||
`;
|
||||
|
||||
export const LegendItemTitle = styled.div`
|
||||
const legendItemStyle = css`
|
||||
color: var(--mb-color-text-primary);
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
@@ -44,6 +46,14 @@ export const LegendItemTitle = styled.div`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export const EditableLegendItemTitle = styled(DoubleClickEditableText)`
|
||||
${legendItemStyle}
|
||||
`;
|
||||
|
||||
export const LegendItemTitle = styled.div`
|
||||
${legendItemStyle}
|
||||
`;
|
||||
|
||||
export const LegendItemRemoveIcon = styled(Icon)`
|
||||
color: var(--mb-color-text-light);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -28,6 +28,7 @@ const propTypes = {
|
||||
children: PropTypes.node,
|
||||
onHoverChange: PropTypes.func,
|
||||
onSelectSeries: PropTypes.func,
|
||||
onChangeSeriesName: PropTypes.func,
|
||||
onRemoveSeries: PropTypes.func,
|
||||
isReversed: PropTypes.bool,
|
||||
canRemoveSeries: PropTypes.func,
|
||||
@@ -46,6 +47,7 @@ export const LegendLayout = ({
|
||||
children,
|
||||
onHoverChange,
|
||||
onSelectSeries,
|
||||
onChangeSeriesName,
|
||||
onRemoveSeries,
|
||||
isReversed,
|
||||
canRemoveSeries,
|
||||
@@ -76,6 +78,7 @@ export const LegendLayout = ({
|
||||
isVertical={isVertical}
|
||||
onHoverChange={onHoverChange}
|
||||
onSelectSeries={onSelectSeries}
|
||||
onChangeSeriesName={onChangeSeriesName}
|
||||
onRemoveSeries={onRemoveSeries}
|
||||
isReversed={isReversed}
|
||||
canRemoveSeries={canRemoveSeries}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DatasetData,
|
||||
Series,
|
||||
TransformedSeries,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { RemappingHydratedDatasetColumn } from "./types";
|
||||
@@ -58,10 +59,20 @@ export function registerVisualization(visualization: Visualization) {
|
||||
}
|
||||
}
|
||||
|
||||
type SeriesLike = Array<{ card: { display: string } }>;
|
||||
type SeriesLike = Array<{
|
||||
card: { display: string; visualization_settings?: VisualizationSettings };
|
||||
}>;
|
||||
|
||||
export function getVisualizationRaw(series: SeriesLike) {
|
||||
return visualizations.get(series[0].card.display);
|
||||
const [{ card }] = series;
|
||||
let display = card.display;
|
||||
if (
|
||||
card.display === "scalar" &&
|
||||
card.visualization_settings?.["scalar.multiseries.display"]
|
||||
) {
|
||||
display = card.visualization_settings?.["scalar.multiseries.display"];
|
||||
}
|
||||
return visualizations.get(display);
|
||||
}
|
||||
|
||||
export function getVisualizationTransformed(series: TransformedSeries) {
|
||||
@@ -128,6 +139,15 @@ export function getDefaultSize(display: string) {
|
||||
return visualization?.defaultSize;
|
||||
}
|
||||
|
||||
export function hasGraphDataSettings(display: string) {
|
||||
const visualization = visualizations.get(display);
|
||||
const settingDefinitions = visualization?.settings ?? {};
|
||||
return (
|
||||
"graph.dimensions" in settingDefinitions &&
|
||||
"graph.metrics" in settingDefinitions
|
||||
);
|
||||
}
|
||||
|
||||
// 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 => ({
|
||||
@@ -169,5 +189,12 @@ export const extractRemappedColumns = (data: DatasetData) => {
|
||||
};
|
||||
};
|
||||
|
||||
// TODO switch to visualizations map
|
||||
export function isCartesianViz(display: string) {
|
||||
return ["area", "bar", "line", "combo", "scatter", "waterfall"].includes(
|
||||
display,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default visualizations;
|
||||
|
||||
@@ -89,7 +89,7 @@ export function getStoredSettingsForSeries(series) {
|
||||
}
|
||||
|
||||
export function getComputedSettingsForSeries(series) {
|
||||
if (!series) {
|
||||
if (!series || series.length === 0) {
|
||||
return {};
|
||||
}
|
||||
const settingsDefs = getSettingDefintionsForSeries(series);
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface VisualizationProps {
|
||||
"graph.metrics"?: string[];
|
||||
|
||||
canRemoveSeries?: (seriesIndex: number) => boolean;
|
||||
onChangeSeriesName?: (seriesIndex: number, name: string) => void;
|
||||
onRemoveSeries?: (event: React.MouseEvent, seriesIndex: number) => void;
|
||||
onUpdateWarnings?: any;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ function _CartesianChart(props: VisualizationProps) {
|
||||
onChangeCardAndRun,
|
||||
onHoverChange,
|
||||
canRemoveSeries,
|
||||
onChangeSeriesName,
|
||||
onRemoveSeries,
|
||||
} = props;
|
||||
|
||||
@@ -113,6 +114,7 @@ function _CartesianChart(props: VisualizationProps) {
|
||||
isFullscreen={isFullscreen}
|
||||
isQueryBuilder={isQueryBuilder}
|
||||
onSelectSeries={onSelectSeries}
|
||||
onChangeSeriesName={onChangeSeriesName}
|
||||
canRemoveSeries={canRemoveSeries}
|
||||
onRemoveSeries={onRemoveSeries}
|
||||
onHoverChange={onHoverChange}
|
||||
|
||||
@@ -7,6 +7,7 @@ import _ from "underscore";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import DashboardS from "metabase/css/dashboard.module.css";
|
||||
import EmbedFrameS from "metabase/public/components/EmbedFrame/EmbedFrame.module.css";
|
||||
import { isCartesianViz } from "metabase/visualizations";
|
||||
import ScalarValue, {
|
||||
ScalarWrapper,
|
||||
ScalarTitle,
|
||||
@@ -19,11 +20,11 @@ import {
|
||||
getDefaultSize,
|
||||
getMinSize,
|
||||
} from "metabase/visualizations/shared/utils/sizes";
|
||||
import { BarChart } from "metabase/visualizations/visualizations/BarChart";
|
||||
|
||||
import { ScalarContainer, LabelIcon } from "./Scalar.styled";
|
||||
import { TITLE_ICON_SIZE } from "./constants";
|
||||
import { scalarToBarTransform } from "./scalars-bar-transform";
|
||||
import { scalarToCartesianTransform } from "./scalars-cartesian-transform";
|
||||
import { scalarToSingleSeriesTransform } from "./scalars-single-series-transform";
|
||||
import { getTitleLinesCount, getValueHeight, getValueWidth } from "./utils";
|
||||
|
||||
// convert legacy `scalar.*` visualization settings to format options
|
||||
@@ -89,6 +90,10 @@ export class Scalar extends Component {
|
||||
],
|
||||
readDependencies: ["scalar.field"],
|
||||
}),
|
||||
"scalar.multiseries.display": {
|
||||
default: "bar",
|
||||
hidden: true,
|
||||
},
|
||||
// LEGACY scalar settings, now handled by column level settings
|
||||
"scalar.locale": {
|
||||
// title: t`Separator style`,
|
||||
@@ -153,11 +158,17 @@ export class Scalar extends Component {
|
||||
} = this.props;
|
||||
|
||||
if (rawSeries.length > 1) {
|
||||
const isCartesian = isCartesianViz(
|
||||
settings["scalar.multiseries.display"],
|
||||
);
|
||||
return (
|
||||
<TransformedVisualization
|
||||
transformSeries={scalarToBarTransform}
|
||||
transformSeries={
|
||||
isCartesian
|
||||
? scalarToCartesianTransform
|
||||
: scalarToSingleSeriesTransform
|
||||
}
|
||||
originalProps={this.props}
|
||||
VisualizationComponent={BarChart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,14 +4,11 @@ import type { TransformSeries } from "metabase/visualizations/components/Transfo
|
||||
import { TYPE } from "metabase-lib/v1/types/constants";
|
||||
import type { RawSeries } from "metabase-types/api";
|
||||
|
||||
export const scalarToBarTransform: TransformSeries = rawSeries => {
|
||||
return rawSeries.map(({ card, data }) => {
|
||||
let metricColumnIndex = data.cols.findIndex(
|
||||
col => col.name === card.visualization_settings["scalar.field"],
|
||||
);
|
||||
import { findScalarMetricColumnIndex } from "./utils";
|
||||
|
||||
// If not found, set default
|
||||
metricColumnIndex = metricColumnIndex === -1 ? 0 : metricColumnIndex;
|
||||
export const scalarToCartesianTransform: TransformSeries = rawSeries => {
|
||||
return rawSeries.map(({ card, data }) => {
|
||||
const metricColumnIndex = findScalarMetricColumnIndex({ card, data });
|
||||
|
||||
const transformedDataset = {
|
||||
...data,
|
||||
@@ -33,7 +30,8 @@ export const scalarToBarTransform: TransformSeries = rawSeries => {
|
||||
|
||||
const transformedCard = {
|
||||
...card,
|
||||
display: "bar",
|
||||
display:
|
||||
card?.visualization_settings?.["scalar.multiseries.display"] ?? "bar",
|
||||
visualization_settings: {
|
||||
"graph.tooltip_type": "default",
|
||||
"graph.x_axis.labels_enabled": false,
|
||||
@@ -41,6 +39,7 @@ export const scalarToBarTransform: TransformSeries = rawSeries => {
|
||||
"graph.dimensions": [transformedDataset.cols[0].name],
|
||||
"graph.metrics": [card.name],
|
||||
"legend.is_reversed": false,
|
||||
...card?.visualization_settings,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { TransformSeries } from "metabase/visualizations/components/TransformedVisualization";
|
||||
import { TYPE } from "metabase-lib/v1/types/constants";
|
||||
import type { DatasetColumn, SingleSeries } from "metabase-types/api";
|
||||
|
||||
import { findScalarMetricColumnIndex } from "./utils";
|
||||
|
||||
export const scalarToSingleSeriesTransform: TransformSeries = rawSeries => {
|
||||
const [firstSeries] = rawSeries;
|
||||
|
||||
const firstSeriesMetricColumnIndex = findScalarMetricColumnIndex(firstSeries);
|
||||
const firstSeriesMetricColumn =
|
||||
firstSeries.data.cols[firstSeriesMetricColumnIndex];
|
||||
|
||||
const cols = [
|
||||
{
|
||||
base_type: TYPE.Text,
|
||||
display_name: t`Name`,
|
||||
name: "name",
|
||||
source: "query-transform",
|
||||
},
|
||||
{
|
||||
...firstSeriesMetricColumn,
|
||||
name: firstSeries.card.name,
|
||||
semantic_type: "type/Number",
|
||||
},
|
||||
];
|
||||
|
||||
const display =
|
||||
firstSeries.card?.visualization_settings?.["scalar.multiseries.display"] ??
|
||||
"table";
|
||||
|
||||
const transformedCard = {
|
||||
...firstSeries.card,
|
||||
display,
|
||||
visualization_settings: {
|
||||
...getTransformedSettings(display, cols),
|
||||
...firstSeries.card?.visualization_settings,
|
||||
},
|
||||
};
|
||||
|
||||
const transformedSeries: SingleSeries = {
|
||||
card: transformedCard,
|
||||
data: {
|
||||
cols,
|
||||
rows: [],
|
||||
},
|
||||
};
|
||||
|
||||
rawSeries.forEach(({ card, data }) => {
|
||||
const metricColumnIndex = findScalarMetricColumnIndex({ card, data });
|
||||
const metricValue = data.rows[0][metricColumnIndex];
|
||||
transformedSeries.data.rows.push([card.name, metricValue]);
|
||||
});
|
||||
|
||||
return [transformedSeries];
|
||||
};
|
||||
|
||||
function getTransformedSettings(display: string, cols: DatasetColumn[]) {
|
||||
if (display === "funnel") {
|
||||
return {
|
||||
"funnel.type": "funnel",
|
||||
"funnel.dimensions": [cols[0].name],
|
||||
"funnel.metrics": [cols[1].name],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SingleSeries } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
PADDING,
|
||||
SCALAR_TITLE_LINE_HEIGHT,
|
||||
@@ -51,3 +53,10 @@ const getTitleHeight = ({
|
||||
|
||||
return titleLinesCount * SCALAR_TITLE_LINE_HEIGHT;
|
||||
};
|
||||
|
||||
export const findScalarMetricColumnIndex = ({ card, data }: SingleSeries) => {
|
||||
const metricColumnIndex = data.cols.findIndex(
|
||||
col => col.name === card.visualization_settings["scalar.field"],
|
||||
);
|
||||
return metricColumnIndex === -1 ? 0 : metricColumnIndex;
|
||||
};
|
||||
|
||||
117
frontend/src/metabase/visualizer/components/QueryEditorModal.tsx
Normal file
117
frontend/src/metabase/visualizer/components/QueryEditorModal.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
|
||||
import Notebook from "metabase/query_builder/components/notebook/Notebook";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { runQuestionQuery } from "metabase/services";
|
||||
import { Button, Flex, Modal } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type NativeQuery from "metabase-lib/v1/queries/NativeQuery";
|
||||
|
||||
interface QueryEditorModalProps {
|
||||
question?: Question | null;
|
||||
onChange: (question: Question) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function QueryEditorModal({
|
||||
question: _question,
|
||||
onChange,
|
||||
onClose,
|
||||
}: QueryEditorModalProps) {
|
||||
const [question, setQuestion] = useState(_question);
|
||||
|
||||
const reportTimezone = useSelector(state =>
|
||||
getSetting(state, "report-timezone-long"),
|
||||
);
|
||||
|
||||
const isOpen = Boolean(question);
|
||||
const isDirty =
|
||||
question && _question && question.isDirtyComparedTo(_question);
|
||||
const isNative = Boolean(
|
||||
question && Lib.queryDisplayInfo(question.query()).isNative,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setQuestion(_question);
|
||||
}, [_question]);
|
||||
|
||||
const handleRunQuery = () => {
|
||||
return runQuestionQuery(question);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (question) {
|
||||
onChange(question);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleChangeNativeQuery = (query: NativeQuery) => {
|
||||
if (question) {
|
||||
const nextQuestion = question.setDatasetQuery(query.datasetQuery());
|
||||
setQuestion(nextQuestion);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEditorBody = () => {
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
if (isNative) {
|
||||
return (
|
||||
<NativeQueryEditor
|
||||
question={question}
|
||||
query={question.legacyQuery()}
|
||||
viewHeight="full"
|
||||
enableRun={false}
|
||||
isNativeEditorOpen
|
||||
hasParametersList={false}
|
||||
hasEditingSidebar={false}
|
||||
resizable={false}
|
||||
readOnly={false}
|
||||
setDatasetQuery={handleChangeNativeQuery}
|
||||
runQuestionQuery={handleRunQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Notebook
|
||||
question={question}
|
||||
isRunnable
|
||||
isDirty={false}
|
||||
isResultDirty={false}
|
||||
hasVisualizeButton={false}
|
||||
reportTimezone={reportTimezone}
|
||||
runQuestionQuery={handleRunQuery}
|
||||
updateQuestion={setQuestion}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal.Root opened={isOpen} size="70rem" onClose={onClose}>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content>
|
||||
<Modal.Header>
|
||||
<Modal.Title>{question?.displayName?.() ?? t`Editor`}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{renderEditorBody()}
|
||||
<Flex direction="row" justify="flex-end" gap="sm" p="md">
|
||||
<Button onClick={onClose}>{t`Cancel`}</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
disabled={!isDirty}
|
||||
onClick={handleSave}
|
||||
>{t`Save`}</Button>
|
||||
</Flex>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
248
frontend/src/metabase/visualizer/components/VisualizerAxis.tsx
Normal file
248
frontend/src/metabase/visualizer/components/VisualizerAxis.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { DoubleClickEditableText } from "metabase/core/components/EditableText";
|
||||
import { color } from "metabase/lib/colors";
|
||||
import { Flex, Icon, Menu } from "metabase/ui";
|
||||
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
|
||||
|
||||
import {
|
||||
DRAGGABLE_ACTIVE_DIMENSION_TYPE,
|
||||
DRAGGABLE_ACTIVE_METRIC_TYPE,
|
||||
} from "../dnd";
|
||||
|
||||
interface VisualizerAxisProps {
|
||||
direction?: "horizontal" | "vertical";
|
||||
columns: string[];
|
||||
columnOptions: Array<{ label: string; value: string }>;
|
||||
settings: ComputedVisualizationSettings;
|
||||
onColumnsChange: (columns: string[]) => void;
|
||||
onLabelChange: (column: string, label: string) => void;
|
||||
onMouseEnter?: () => void;
|
||||
}
|
||||
|
||||
export function VisualizerAxis({
|
||||
direction = "horizontal",
|
||||
columns,
|
||||
columnOptions,
|
||||
settings,
|
||||
onColumnsChange,
|
||||
onLabelChange,
|
||||
onMouseEnter,
|
||||
}: 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%" };
|
||||
|
||||
const axisLabelSettingKey =
|
||||
direction === "vertical" // 🥴
|
||||
? "graph.y_axis.title_text"
|
||||
: "graph.x_axis.title_text";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
{...flexProps}
|
||||
pos="relative"
|
||||
justify="center"
|
||||
align="center"
|
||||
gap="md"
|
||||
bg="bg-light"
|
||||
py="sm"
|
||||
px="md"
|
||||
onMouseEnter={onMouseEnter}
|
||||
style={{ borderRadius: 99 }}
|
||||
>
|
||||
{columns.map(column => (
|
||||
<ColumnPicker
|
||||
key={column}
|
||||
column={column}
|
||||
label={
|
||||
columns.length === 1 ? settings[axisLabelSettingKey] : undefined
|
||||
}
|
||||
columns={columns}
|
||||
columnOptions={columnOptions}
|
||||
direction={direction}
|
||||
onChangeColumn={nextColumn => handleColumnChange(column, nextColumn)}
|
||||
onChangeLabel={label => {
|
||||
onLabelChange(column, label);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<AddColumnButton
|
||||
direction={direction}
|
||||
columns={columns}
|
||||
columnOptions={columnOptions}
|
||||
onAddColumn={handleColumnAdd}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
interface ColumnPickerProps {
|
||||
direction?: "horizontal" | "vertical";
|
||||
column: string;
|
||||
label?: string;
|
||||
columns: string[];
|
||||
columnOptions: Array<{ label: string; value: string }>;
|
||||
onChangeColumn: (column: string) => void;
|
||||
onChangeLabel: (label: string) => void;
|
||||
}
|
||||
|
||||
function ColumnPicker({
|
||||
column,
|
||||
label,
|
||||
columns,
|
||||
columnOptions,
|
||||
direction = "horizontal",
|
||||
onChangeColumn,
|
||||
onChangeLabel,
|
||||
}: ColumnPickerProps) {
|
||||
const type =
|
||||
direction === "horizontal" // 🥴
|
||||
? DRAGGABLE_ACTIVE_DIMENSION_TYPE
|
||||
: DRAGGABLE_ACTIVE_METRIC_TYPE;
|
||||
|
||||
const { attributes, listeners, transform, setNodeRef } = useDraggable({
|
||||
id: column,
|
||||
data: { type, column },
|
||||
});
|
||||
|
||||
const option = columnOptions.find(option => option.value === column);
|
||||
|
||||
const filteredOptions = columnOptions.filter(
|
||||
option => !columns.includes(option.value) && option.value !== column,
|
||||
);
|
||||
|
||||
const hasOptions = filteredOptions.length > 0;
|
||||
|
||||
const containerStyle: CSSProperties =
|
||||
direction === "vertical"
|
||||
? {
|
||||
position: "absolute",
|
||||
width: "200px",
|
||||
height: "14px",
|
||||
textAlign: "center",
|
||||
transform: transform
|
||||
? CSS.Translate.toString(transform) + " rotate(-90deg)"
|
||||
: "rotate(-90deg)",
|
||||
}
|
||||
: { transform: CSS.Translate.toString(transform) };
|
||||
|
||||
if (hasOptions) {
|
||||
containerStyle.cursor = "pointer";
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu disabled={!hasOptions}>
|
||||
<Flex
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
align="center"
|
||||
style={containerStyle}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
style={{
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: 99,
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
bg="white"
|
||||
px="sm"
|
||||
gap="4px"
|
||||
>
|
||||
<Icon name="grabber" size={12} />
|
||||
<DoubleClickEditableText
|
||||
initialValue={label ?? option?.label ?? column}
|
||||
onChange={label => {
|
||||
if (label.length > 0) {
|
||||
onChangeLabel(label);
|
||||
}
|
||||
}}
|
||||
style={{ color: color("text-medium"), fontWeight: "bold" }}
|
||||
/>
|
||||
{hasOptions && (
|
||||
<Menu.Target>
|
||||
<Icon name="chevrondown" size={12} />
|
||||
</Menu.Target>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Menu.Dropdown>
|
||||
{columnOptions.map(option => (
|
||||
<Menu.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onClick={() => onChangeColumn(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),
|
||||
);
|
||||
|
||||
if (filteredOptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
|
||||
import { Menu, Flex, Icon, type IconName, Text } from "metabase/ui";
|
||||
import visualizations from "metabase/visualizations";
|
||||
import type { DatasetData } from "metabase-types/api";
|
||||
|
||||
interface VizTypePickerProps {
|
||||
value: IconName;
|
||||
data?: DatasetData;
|
||||
onChange: (vizType: string) => void;
|
||||
}
|
||||
|
||||
export function VizTypePicker({ value, data, onChange }: VizTypePickerProps) {
|
||||
const vizOptions = 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,
|
||||
}));
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon mr="sm">
|
||||
<Icon name={value} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
230
frontend/src/metabase/visualizer/components/YAxisPanel.tsx
Normal file
230
frontend/src/metabase/visualizer/components/YAxisPanel.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Chip } from "@mantine/core";
|
||||
import { useRef, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Icon,
|
||||
Stack,
|
||||
Popover,
|
||||
NumberInput,
|
||||
TextInput,
|
||||
type ButtonProps,
|
||||
} from "metabase/ui";
|
||||
import type { Series, VisualizationSettings } from "metabase-types/api";
|
||||
|
||||
interface YAxisPanelProps {
|
||||
isVisible: boolean;
|
||||
columns: string[];
|
||||
columnOptions: Array<{ label: string; value: string }>;
|
||||
series: Series;
|
||||
settings: VisualizationSettings;
|
||||
onColumnsChange: (columns: string[]) => void;
|
||||
onSettingsChange: (settings: VisualizationSettings) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function YAxisPanel({
|
||||
isVisible,
|
||||
columns,
|
||||
columnOptions,
|
||||
series,
|
||||
settings,
|
||||
onColumnsChange,
|
||||
onSettingsChange,
|
||||
onClose,
|
||||
}: YAxisPanelProps) {
|
||||
const [isGoalLinePopoverOpen, setGoalLinePopoverOpen] = useState(false);
|
||||
const goalLinePopoverTimeout = useRef<any>(null);
|
||||
const goalLine = useGoalLine(settings, onSettingsChange);
|
||||
|
||||
const canHaveTrendLine =
|
||||
Array.isArray(series?.[0]?.data?.insights) &&
|
||||
series[0].data.insights.length > 0;
|
||||
|
||||
const handleToggleColumn = (column: string) => {
|
||||
if (columns.includes(column)) {
|
||||
onColumnsChange(columns.filter(c => c !== column));
|
||||
} else {
|
||||
onColumnsChange([...columns, column]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddGoalLine = () => {
|
||||
goalLine.add();
|
||||
setGoalLinePopoverOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setGoalLinePopoverOpen(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
pos="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
h="100%"
|
||||
w="16rem"
|
||||
bg="bg-medium"
|
||||
shadow="md"
|
||||
onMouseLeave={handleClose}
|
||||
style={{
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "translateX(0)" : "translateX(-16rem)",
|
||||
transition: "transform 0.3s ease-out, opacity 0.3s ease-out",
|
||||
}}
|
||||
>
|
||||
<Stack spacing="xs" p="md">
|
||||
{columnOptions.map(option => (
|
||||
<Chip
|
||||
key={option.value}
|
||||
checked={columns.includes(option.value)}
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
onChange={() => handleToggleColumn(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Chip>
|
||||
))}
|
||||
{canHaveTrendLine && (
|
||||
<Chip
|
||||
checked={settings["graph.show_trendline"]}
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
onChange={() =>
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
"graph.show_trendline": !settings["graph.show_trendline"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{t`Trend`}
|
||||
</Chip>
|
||||
)}
|
||||
<Popover opened={isGoalLinePopoverOpen} position="right" offset={10}>
|
||||
<Popover.Target>
|
||||
{goalLine.isEnabled ? (
|
||||
<div
|
||||
onMouseEnter={() => {
|
||||
clearTimeout(goalLinePopoverTimeout.current);
|
||||
setGoalLinePopoverOpen(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
goalLinePopoverTimeout.current = setTimeout(() => {
|
||||
setGoalLinePopoverOpen(false);
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
checked={goalLine.isVisible}
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
onChange={goalLine.toggle}
|
||||
>
|
||||
{goalLine.label}
|
||||
</Chip>
|
||||
</div>
|
||||
) : (
|
||||
<AddButton
|
||||
mt="lg"
|
||||
onClick={handleAddGoalLine}
|
||||
>{t`Add a goal`}</AddButton>
|
||||
)}
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
p="md"
|
||||
onMouseEnter={() => clearTimeout(goalLinePopoverTimeout.current)}
|
||||
onMouseLeave={() => setGoalLinePopoverOpen(false)}
|
||||
>
|
||||
<TextInput
|
||||
label={t`Label`}
|
||||
value={goalLine.label}
|
||||
onChange={e => goalLine.setLabel(e.target.value)}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t`Value`}
|
||||
value={goalLine.value}
|
||||
mt="sm"
|
||||
onChange={value => goalLine.setValue(value || 0)}
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGoalLine(
|
||||
settings: VisualizationSettings,
|
||||
setSettings: (settings: VisualizationSettings) => void,
|
||||
) {
|
||||
// TODO Use getDefault functions from viz settings?
|
||||
const isEnabled = typeof settings["graph.show_goal"] === "boolean";
|
||||
const isVisible = settings["graph.show_goal"] ?? false;
|
||||
const label = settings["graph.goal_label"] ?? t`Goal`;
|
||||
const value = settings["graph.goal_value"] ?? 0;
|
||||
|
||||
const add = () => {
|
||||
setSettings({
|
||||
...settings,
|
||||
"graph.show_goal": true,
|
||||
});
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
setSettings({
|
||||
...settings,
|
||||
"graph.show_goal": true,
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
setSettings({
|
||||
...settings,
|
||||
"graph.show_goal": !isVisible,
|
||||
});
|
||||
};
|
||||
|
||||
const setLabel = (label: string) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
"graph.goal_label": label,
|
||||
});
|
||||
};
|
||||
|
||||
const setValue = (value: number) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
"graph.goal_value": value || 0,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
isVisible,
|
||||
label,
|
||||
value,
|
||||
add,
|
||||
remove,
|
||||
toggle,
|
||||
setLabel,
|
||||
setValue,
|
||||
};
|
||||
}
|
||||
|
||||
function AddButton(props: ButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
color="text-medium"
|
||||
compact
|
||||
leftIcon={<Icon name="add" />}
|
||||
w="8rem"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
234
frontend/src/metabase/visualizer/containers/Visualizer.tsx
Normal file
234
frontend/src/metabase/visualizer/containers/Visualizer.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
MouseSensor,
|
||||
useSensor,
|
||||
} from "@dnd-kit/core";
|
||||
import type { Location } from "history";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import type { WithRouterProps } from "react-router";
|
||||
import { useMount, usePrevious } from "react-use";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import ChartSettings from "metabase/visualizations/components/ChartSettings";
|
||||
import type { OnChangeCardAndRun } from "metabase/visualizations/types";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
|
||||
import { QueryEditorModal } from "../components/QueryEditorModal";
|
||||
import { handleDragEnd } from "../dnd";
|
||||
import { useVisualizerSeries } from "../hooks/useVisualizerSeries";
|
||||
|
||||
import { VisualizerCanvas } from "./VisualizerCanvas";
|
||||
import { VisualizerMenu } from "./VisualizerMenu/VisualizerMenu";
|
||||
import { VisualizerUsed } from "./VisualizerUsed";
|
||||
|
||||
export function Visualizer({ router, location }: WithRouterProps) {
|
||||
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [vizType, _setVizType] = useState("bar");
|
||||
const [isVizSettingsOpen, setVizSettingsOpen] = useState(false);
|
||||
|
||||
const metadata = useSelector(getMetadata);
|
||||
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const {
|
||||
series,
|
||||
transformedSeries,
|
||||
settings,
|
||||
question,
|
||||
addCardSeries,
|
||||
replaceSeries,
|
||||
replaceAllWithCardSeries,
|
||||
refreshSeriesData,
|
||||
removeSeries,
|
||||
setVizSettings,
|
||||
updateSeriesCard,
|
||||
updateSeriesQuery,
|
||||
} = useVisualizerSeries(getInitialCards(location), {
|
||||
onSeriesChange: nextSeries => {
|
||||
const cards = nextSeries.map(({ card }) => card);
|
||||
const hash = Urls.encodeVisualizerState(cards);
|
||||
router.push({ ...location, hash: hash ? `#${hash}` : undefined });
|
||||
},
|
||||
});
|
||||
|
||||
useMount(() => {
|
||||
const [card] = getInitialCards(location);
|
||||
if (card) {
|
||||
if (card.display === "scalar") {
|
||||
_setVizType(
|
||||
card.visualization_settings?.["scalar.multiseries.display"] ??
|
||||
"scalar",
|
||||
);
|
||||
} else {
|
||||
_setVizType(card.display);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (previousLocation && location.hash !== previousLocation.hash) {
|
||||
const seriesCards = series.map(({ card }) => card);
|
||||
const hashCards = getInitialCards(location);
|
||||
if (!_.isEqual(seriesCards, hashCards)) {
|
||||
replaceSeries(hashCards);
|
||||
}
|
||||
}
|
||||
}, [series, location, previousLocation, replaceSeries, router]);
|
||||
|
||||
const focusedQuestion = useMemo(() => {
|
||||
if (focusedSeriesIndex === null || !series[focusedSeriesIndex]) {
|
||||
return null;
|
||||
}
|
||||
const { card } = series[focusedSeriesIndex];
|
||||
return new Question(card, metadata);
|
||||
}, [series, focusedSeriesIndex, metadata]);
|
||||
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 10 },
|
||||
});
|
||||
|
||||
const handleChangeVizType = (display: string) => {
|
||||
_setVizType(display);
|
||||
if (series.length === 0) {
|
||||
return;
|
||||
}
|
||||
const [{ card }] = series;
|
||||
if (card.display === "scalar") {
|
||||
updateSeriesCard(0, {
|
||||
visualization_settings: {
|
||||
...card.visualization_settings,
|
||||
"scalar.multiseries.display": display,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateSeriesCard(0, { display });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeQuery = (question: Question) => {
|
||||
if (typeof focusedSeriesIndex === "number") {
|
||||
updateSeriesQuery(focusedSeriesIndex, question.datasetQuery());
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeCardAndRun: OnChangeCardAndRun = ({ nextCard }) => {
|
||||
updateSeriesCard(0, nextCard, { makeAdHoc: true, runQuery: true });
|
||||
};
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
if (question) {
|
||||
const nextVizSettings = handleDragEnd(event, question.settings());
|
||||
setVizSettings(nextVizSettings);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext sensors={[mouseSensor]} onDragEnd={onDragEnd}>
|
||||
<PanelGroup direction="horizontal" style={{ padding: 20 }}>
|
||||
{!isVizSettingsOpen && (
|
||||
<Panel defaultSize={25} minSize={15}>
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel defaultSize={70} minSize={20} maxSize={80}>
|
||||
<VisualizerMenu
|
||||
series={series}
|
||||
onAdd={item => addCardSeries(item.id)}
|
||||
onReplace={item => replaceAllWithCardSeries(item.id)}
|
||||
/>
|
||||
</Panel>
|
||||
<ResizeHandle direction="horizontal" />
|
||||
<Panel defaultSize={30}>
|
||||
<VisualizerUsed
|
||||
series={series}
|
||||
onFocusSeries={setFocusedSeriesIndex}
|
||||
onVizTypeChange={(index, display) => {
|
||||
updateSeriesCard(index, { display });
|
||||
}}
|
||||
onRefreshData={refreshSeriesData}
|
||||
onRemoveSeries={removeSeries}
|
||||
/>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
)}
|
||||
<ResizeHandle direction="vertical" />
|
||||
<Panel defaultSize={75} minSize={60}>
|
||||
<VisualizerCanvas
|
||||
series={series}
|
||||
transformedSeries={transformedSeries}
|
||||
settings={settings}
|
||||
vizType={vizType}
|
||||
onToggleVizSettings={() => setVizSettingsOpen(isOpen => !isOpen)}
|
||||
onVizTypeChange={handleChangeVizType}
|
||||
onChange={setVizSettings}
|
||||
onChangeCardAndRun={handleChangeCardAndRun}
|
||||
/>
|
||||
</Panel>
|
||||
{isVizSettingsOpen && (
|
||||
<Panel defaultSize={20} minSize={20}>
|
||||
<ChartSettings
|
||||
question={question}
|
||||
series={series}
|
||||
computedSettings={settings}
|
||||
noPreview
|
||||
onChange={setVizSettings}
|
||||
onClose={() => setVizSettingsOpen(false)}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</PanelGroup>
|
||||
</DndContext>
|
||||
<QueryEditorModal
|
||||
question={focusedQuestion}
|
||||
onChange={handleChangeQuery}
|
||||
onClose={() => setFocusedSeriesIndex(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialCards(location: Location) {
|
||||
if (!location.hash) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const result = Urls.decodeVisualizerState(location.hash);
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (e) {
|
||||
// pass
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function ResizeHandle({ direction }: { direction: "horizontal" | "vertical" }) {
|
||||
const style =
|
||||
direction === "horizontal"
|
||||
? { width: 20, height: 4, margin: "0 auto" }
|
||||
: { width: 4, height: 20, margin: "auto 0" };
|
||||
|
||||
return (
|
||||
<PanelResizeHandle
|
||||
style={{
|
||||
display: "flex",
|
||||
margin: 4,
|
||||
cursor: direction === "horizontal" ? "row-resize" : "col-resize",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: "#ddd",
|
||||
borderRadius: 99,
|
||||
}}
|
||||
></span>
|
||||
</PanelResizeHandle>
|
||||
);
|
||||
}
|
||||
252
frontend/src/metabase/visualizer/containers/VisualizerCanvas.tsx
Normal file
252
frontend/src/metabase/visualizer/containers/VisualizerCanvas.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { assocIn } from "icepick";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import {
|
||||
Card,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Title,
|
||||
Stack,
|
||||
type IconName,
|
||||
} from "metabase/ui";
|
||||
import BaseVisualization from "metabase/visualizations/components/Visualization";
|
||||
import { keyForSingleSeries } from "metabase/visualizations/lib/settings/series";
|
||||
import type {
|
||||
ComputedVisualizationSettings,
|
||||
OnChangeCardAndRun,
|
||||
} from "metabase/visualizations/types";
|
||||
import { isDimension, isMetric } from "metabase-lib/v1/types/utils/isa";
|
||||
import type { Series, VisualizationSettings } from "metabase-types/api";
|
||||
|
||||
import { VisualizerAxis } from "../components/VisualizerAxis";
|
||||
import { VizTypePicker } from "../components/VizTypePicker";
|
||||
import { YAxisPanel } from "../components/YAxisPanel";
|
||||
import { DROPPABLE_CANVAS_ID } from "../dnd";
|
||||
import { isCartesianSeries } from "../utils";
|
||||
|
||||
interface VisualizerCanvasProps {
|
||||
series: Series;
|
||||
transformedSeries: Series; // TODO Rename into series + rawSeries?
|
||||
settings: ComputedVisualizationSettings;
|
||||
vizType: string;
|
||||
onToggleVizSettings: () => void;
|
||||
onVizTypeChange: (vizType: string) => void;
|
||||
onChange: (settings: VisualizationSettings) => void;
|
||||
onChangeCardAndRun: OnChangeCardAndRun;
|
||||
}
|
||||
|
||||
export function VisualizerCanvas({
|
||||
series,
|
||||
transformedSeries,
|
||||
settings,
|
||||
vizType,
|
||||
onToggleVizSettings,
|
||||
onVizTypeChange,
|
||||
onChange,
|
||||
onChangeCardAndRun,
|
||||
}: VisualizerCanvasProps) {
|
||||
const [isYAxisPanelVisible, setYAxisPanelVisible] = useState(false);
|
||||
const metadata = useSelector(getMetadata);
|
||||
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: DROPPABLE_CANVAS_ID,
|
||||
});
|
||||
|
||||
const { metrics, dimensions } = useMemo(
|
||||
() => getMetricAndDimensionOptions(series),
|
||||
[series],
|
||||
);
|
||||
|
||||
const hasData = series.length > 0;
|
||||
|
||||
const currentMetrics = settings?.["graph.metrics"] ?? [];
|
||||
const currentDimensions = settings?.["graph.dimensions"] ?? [];
|
||||
|
||||
const hasAxes = isCartesianSeries(series, settings);
|
||||
|
||||
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 [{ card: mainCard }] = series;
|
||||
onChange({
|
||||
...mainCard.visualization_settings,
|
||||
"graph.metrics": metrics,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMetricLabelChange = (metricColumn: string, label: string) => {
|
||||
const [{ card: mainCard }] = series;
|
||||
if (currentMetrics.length === 1) {
|
||||
onChange({
|
||||
...mainCard.visualization_settings,
|
||||
"graph.y_axis.title_text": label,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeriesNameChange = (seriesIndex: number, name: string) => {
|
||||
const [{ card: mainCard }] = series;
|
||||
const editedSeries = transformedSeries[seriesIndex];
|
||||
const seriesKey = keyForSingleSeries(editedSeries);
|
||||
if (seriesKey && name.length > 0) {
|
||||
const nextSettings = assocIn(
|
||||
mainCard.visualization_settings,
|
||||
["series_settings", seriesKey, "title"],
|
||||
name,
|
||||
);
|
||||
onChange(nextSettings);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionsChange = (dimensions: string[]) => {
|
||||
const [{ card: mainCard }] = series;
|
||||
onChange({
|
||||
...mainCard.visualization_settings,
|
||||
"graph.dimensions": dimensions,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDimensionLabelChange = (
|
||||
dimensionColumn: string,
|
||||
label: string,
|
||||
) => {
|
||||
const [{ card: mainCard }] = series;
|
||||
if (currentDimensions.length === 1) {
|
||||
onChange({
|
||||
...mainCard.visualization_settings,
|
||||
"graph.x_axis.title_text": label,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const title = displaySeries.length > 0 ? displaySeries[0].card.name : "";
|
||||
|
||||
const renderChart = () => {
|
||||
if (hasAxes) {
|
||||
return (
|
||||
<Group pos="relative" w="100%" h="90%">
|
||||
<VisualizerAxis
|
||||
direction="vertical"
|
||||
columns={currentMetrics}
|
||||
columnOptions={metrics}
|
||||
settings={settings}
|
||||
onColumnsChange={handleMetricsChange}
|
||||
onLabelChange={handleMetricLabelChange}
|
||||
onMouseEnter={() => setYAxisPanelVisible(true)}
|
||||
/>
|
||||
<Stack w="90%" h="100%">
|
||||
<BaseVisualization
|
||||
rawSeries={displaySeries}
|
||||
metadata={metadata}
|
||||
onChangeSeriesName={handleSeriesNameChange}
|
||||
onChangeCardAndRun={onChangeCardAndRun}
|
||||
/>
|
||||
<VisualizerAxis
|
||||
columns={currentDimensions}
|
||||
columnOptions={dimensions}
|
||||
settings={settings}
|
||||
onColumnsChange={handleDimensionsChange}
|
||||
onLabelChange={handleDimensionLabelChange}
|
||||
/>
|
||||
</Stack>
|
||||
<YAxisPanel
|
||||
isVisible={isYAxisPanelVisible}
|
||||
columns={currentMetrics}
|
||||
columnOptions={metrics}
|
||||
series={series}
|
||||
settings={series[0].card.visualization_settings}
|
||||
onColumnsChange={handleMetricsChange}
|
||||
onSettingsChange={onChange}
|
||||
onClose={() => {
|
||||
setYAxisPanelVisible(false);
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ width: "100%", height: "90%" }}>
|
||||
<BaseVisualization
|
||||
rawSeries={displaySeries}
|
||||
metadata={metadata}
|
||||
onChangeCardAndRun={onChangeCardAndRun}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card w="100%" h="100%" ref={setNodeRef}>
|
||||
<Flex mx="xs" mb="md" align="center">
|
||||
<Flex gap="xs">
|
||||
{hasData && (
|
||||
<VizTypePicker
|
||||
value={vizType as IconName}
|
||||
onChange={onVizTypeChange}
|
||||
/>
|
||||
)}
|
||||
{hasData && (
|
||||
<Title size="h2" truncate title={title}>
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
</Flex>
|
||||
{hasData && (
|
||||
<Flex miw={200} ml="auto" align="center">
|
||||
<ActionIcon ml="auto" onClick={onToggleVizSettings}>
|
||||
<Icon name="gear" />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
{hasData && renderChart()}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useListCompatibleCardsQuery } from "metabase/api";
|
||||
import { VisualizerMenuItem } from "metabase/visualizer/components/VisualizerMenuItem";
|
||||
import type { Card, Series } from "metabase-types/api";
|
||||
|
||||
interface VisualizerCompatibleCardsListProps {
|
||||
series: Series;
|
||||
onAdd: (item: Card) => void;
|
||||
onReplace: (item: Card) => void;
|
||||
}
|
||||
|
||||
export function VisualizerCompatibleCardsList({
|
||||
series,
|
||||
onAdd,
|
||||
onReplace,
|
||||
}: VisualizerCompatibleCardsListProps) {
|
||||
const [{ card: mainCard }] = series;
|
||||
|
||||
const { data: cards = [] } = useListCompatibleCardsQuery({ id: mainCard.id });
|
||||
|
||||
const notUsedCards = useMemo(() => {
|
||||
return cards.filter(card => !series.some(s => s.card.id === card.id));
|
||||
}, [cards, series]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{notUsedCards.map(card => (
|
||||
<VisualizerMenuItem
|
||||
key={card.id}
|
||||
item={card}
|
||||
isAddable
|
||||
onAdd={onAdd}
|
||||
onReplace={onReplace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useSearchQuery } from "metabase/api";
|
||||
import { Card, Input, Tabs } from "metabase/ui";
|
||||
import { VisualizerMenuItem } from "metabase/visualizer/components/VisualizerMenuItem";
|
||||
import { canCombineCardWithOthers } from "metabase/visualizer/utils";
|
||||
import type { Card as ICard, RecentItem, Series } from "metabase-types/api";
|
||||
|
||||
import { VisualizerCompatibleCardsList } from "./VisualizerCompatibleCardsList";
|
||||
import { VisualizerRecentsList } from "./VisualizerRecentsList";
|
||||
|
||||
interface VisualizerMenuProps {
|
||||
series: Series;
|
||||
onAdd: (item: ICard | RecentItem) => void;
|
||||
onReplace: (item: ICard | RecentItem) => void;
|
||||
}
|
||||
|
||||
export function VisualizerMenu({
|
||||
series,
|
||||
onAdd,
|
||||
onReplace,
|
||||
}: VisualizerMenuProps) {
|
||||
const [searchQuery, setSearchQuery] = useState<string>();
|
||||
const { data: searchResults } = useSearchQuery({
|
||||
q: searchQuery,
|
||||
models: ["card", "dataset", "metric"],
|
||||
});
|
||||
|
||||
const hasCompatibleTab =
|
||||
series.length > 0 && canCombineCardWithOthers(series[0].card);
|
||||
|
||||
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="recents">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="recents">Recents</Tabs.Tab>
|
||||
{hasCompatibleTab && (
|
||||
<Tabs.Tab value="compatible">Compatible</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="recents">
|
||||
<VisualizerRecentsList
|
||||
series={series}
|
||||
onAdd={onAdd}
|
||||
onReplace={onReplace}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
{hasCompatibleTab && (
|
||||
<Tabs.Panel value="compatible">
|
||||
<VisualizerCompatibleCardsList
|
||||
series={series}
|
||||
onAdd={onAdd}
|
||||
onReplace={onReplace}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useListRecentsQuery } from "metabase/api";
|
||||
import { VisualizerMenuItem } from "metabase/visualizer/components/VisualizerMenuItem";
|
||||
import { areCardsCompatible } from "metabase/visualizer/utils";
|
||||
import type { RecentItem, Series } from "metabase-types/api";
|
||||
|
||||
export function VisualizerRecentsList({
|
||||
series,
|
||||
onAdd,
|
||||
onReplace,
|
||||
}: {
|
||||
series: Series;
|
||||
onAdd: (item: RecentItem) => void;
|
||||
onReplace: (item: RecentItem) => void;
|
||||
}) {
|
||||
const { data: recents = [] } = useListRecentsQuery();
|
||||
|
||||
const mainCard = series[0]?.card;
|
||||
|
||||
const cards = recents.filter(recent =>
|
||||
RECENT_ITEM_MODELS.includes(recent.model),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{cards.map((card: any) => {
|
||||
const isUsed = series.some(series => series.card.id === card.id);
|
||||
return (
|
||||
<VisualizerMenuItem
|
||||
key={card.id}
|
||||
item={card}
|
||||
isAddable={
|
||||
!isUsed && Boolean(mainCard && areCardsCompatible(mainCard, card))
|
||||
}
|
||||
onAdd={onAdd}
|
||||
onReplace={onReplace}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RECENT_ITEM_MODELS = ["card", "dataset", "metric"];
|
||||
@@ -0,0 +1,77 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
Menu,
|
||||
Flex,
|
||||
Card,
|
||||
Title,
|
||||
Icon,
|
||||
type IconName,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import type { Series } from "metabase-types/api";
|
||||
|
||||
import { VizTypePicker } from "../components/VizTypePicker";
|
||||
|
||||
interface VisualizerUsedProps {
|
||||
series: Series;
|
||||
onFocusSeries: (index: number) => void;
|
||||
onVizTypeChange: (index: number, vizType: string) => void;
|
||||
onRefreshData: (index: number) => void;
|
||||
onRemoveSeries: (index: number) => void;
|
||||
}
|
||||
|
||||
export function VisualizerUsed({
|
||||
series,
|
||||
onFocusSeries,
|
||||
onVizTypeChange,
|
||||
onRefreshData,
|
||||
onRemoveSeries,
|
||||
}: 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 */}
|
||||
<VizTypePicker
|
||||
value={card.display as IconName}
|
||||
data={data}
|
||||
onChange={vizType => onVizTypeChange(index, vizType)}
|
||||
/>
|
||||
<Text truncate title={card.name}>
|
||||
{card.name}
|
||||
</Text>
|
||||
<Flex ml="auto">
|
||||
<ActionIcon onClick={() => onFocusSeries(index)}>
|
||||
<Icon
|
||||
name={
|
||||
card.dataset_query.type === "native" ? "sql" : "notebook"
|
||||
}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon>
|
||||
<Icon name="ellipsis" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() => onRefreshData(index)}
|
||||
>{t`Refresh`}</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => onRemoveSeries(index)}
|
||||
>{t`Remove`}</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
43
frontend/src/metabase/visualizer/dnd.ts
Normal file
43
frontend/src/metabase/visualizer/dnd.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
|
||||
import type { VisualizationSettings } from "metabase-types/api";
|
||||
|
||||
export const DROPPABLE_CANVAS_ID = "visualizer-canvas";
|
||||
export const DRAGGABLE_ACTIVE_METRIC_TYPE = "active-metric";
|
||||
export const DRAGGABLE_ACTIVE_DIMENSION_TYPE = "active-dimension";
|
||||
|
||||
export function handleDragEnd(
|
||||
{ active, over }: DragEndEvent,
|
||||
settings: VisualizationSettings,
|
||||
): VisualizationSettings {
|
||||
const isDraggingActiveMetric =
|
||||
active?.data?.current?.type === DRAGGABLE_ACTIVE_METRIC_TYPE;
|
||||
const isDraggingActiveDimension =
|
||||
active?.data?.current?.type === DRAGGABLE_ACTIVE_DIMENSION_TYPE;
|
||||
const isOverCanvas = over?.id === DROPPABLE_CANVAS_ID;
|
||||
|
||||
if (isOverCanvas) {
|
||||
const metrics = settings["graph.metrics"] || [];
|
||||
const dimensions = settings["graph.dimensions"] || [];
|
||||
|
||||
if (isDraggingActiveMetric && metrics.length > 1) {
|
||||
const metricName = active.data?.current?.column;
|
||||
return {
|
||||
...settings,
|
||||
"graph.metrics": metrics.filter(metric => metric !== metricName),
|
||||
};
|
||||
}
|
||||
|
||||
if (isDraggingActiveDimension && dimensions.length > 1) {
|
||||
const dimensionName = active.data?.current?.column;
|
||||
return {
|
||||
...settings,
|
||||
"graph.dimensions": dimensions.filter(
|
||||
dimension => dimension !== dimensionName,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
301
frontend/src/metabase/visualizer/hooks/useVisualizerSeries.ts
Normal file
301
frontend/src/metabase/visualizer/hooks/useVisualizerSeries.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { assocIn } from "icepick";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMount } from "react-use";
|
||||
import _ from "underscore";
|
||||
|
||||
import { cardApi } from "metabase/api";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { isNotNull } from "metabase/lib/types";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { MetabaseApi } from "metabase/services";
|
||||
import {
|
||||
extractRemappings,
|
||||
getVisualizationTransformed,
|
||||
isCartesianViz,
|
||||
} from "metabase/visualizations";
|
||||
import { getComputedSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
|
||||
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
|
||||
import { scalarToCartesianTransform } from "metabase/visualizations/visualizations/Scalar/scalars-cartesian-transform";
|
||||
import { scalarToSingleSeriesTransform } from "metabase/visualizations/visualizations/Scalar/scalars-single-series-transform";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type {
|
||||
Card,
|
||||
CardId,
|
||||
DatasetQuery,
|
||||
RawSeries,
|
||||
VisualizationSettings,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { areCardsCompatible } from "../utils";
|
||||
|
||||
const MAIN_SERIES_INDEX = 0;
|
||||
|
||||
export function useVisualizerSeries(
|
||||
initialCards: Card[],
|
||||
{ onSeriesChange }: { onSeriesChange: (series: RawSeries) => void },
|
||||
) {
|
||||
const [rawSeries, _setRawSeries] = useState<RawSeries>([]);
|
||||
const metadata = useSelector(getMetadata);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const setRawSeries = (series: RawSeries) => {
|
||||
_setRawSeries(series);
|
||||
onSeriesChange(series);
|
||||
};
|
||||
|
||||
const transformedSeries = useMemo(() => {
|
||||
if (rawSeries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
let series = [...rawSeries];
|
||||
|
||||
const transformed = getVisualizationTransformed(
|
||||
extractRemappings(rawSeries),
|
||||
);
|
||||
series = transformed.series;
|
||||
|
||||
const [{ card }] = series;
|
||||
const isScalar = card.display === "scalar";
|
||||
if (isScalar && series.length > 0) {
|
||||
const computedSettings = getComputedSettingsForSeries(
|
||||
series,
|
||||
) as ComputedVisualizationSettings;
|
||||
const display = computedSettings["scalar.multiseries.display"] ?? "bar";
|
||||
const transform = isCartesianViz(display)
|
||||
? scalarToCartesianTransform
|
||||
: scalarToSingleSeriesTransform;
|
||||
series = transform(series, computedSettings);
|
||||
}
|
||||
|
||||
return series;
|
||||
}, [rawSeries]);
|
||||
|
||||
const mainQuestion = useMemo(() => {
|
||||
if (rawSeries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [{ card }] = rawSeries;
|
||||
return new Question(card, metadata);
|
||||
}, [rawSeries, metadata]);
|
||||
|
||||
const computedSettings = useMemo(
|
||||
() => getComputedSettingsForSeries(transformedSeries),
|
||||
[transformedSeries],
|
||||
);
|
||||
|
||||
const _fetchCardAndData = async (
|
||||
cardId: CardId,
|
||||
{
|
||||
cardAttrs,
|
||||
forceRefetch = false,
|
||||
}: { cardAttrs?: Partial<Card>; forceRefetch?: boolean } = {},
|
||||
) => {
|
||||
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;
|
||||
}
|
||||
|
||||
await dispatch(loadMetadataForCard(card));
|
||||
|
||||
return { card: { ...card, ...cardAttrs }, data: dataset.data };
|
||||
};
|
||||
|
||||
const _fetchAdHocCardData = async (card: Card) => {
|
||||
const result = await MetabaseApi.dataset(card.dataset_query);
|
||||
if (result.data) {
|
||||
await dispatch(loadMetadataForCard(card));
|
||||
return { card, data: result.data };
|
||||
}
|
||||
};
|
||||
|
||||
const updateSeriesCard = async (
|
||||
index: number,
|
||||
attrs: Partial<Card>,
|
||||
{ makeAdHoc = false, runQuery = false } = {},
|
||||
) => {
|
||||
if (!rawSeries[index]?.card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { card } = rawSeries[index];
|
||||
const wasSaved = "id" in card;
|
||||
|
||||
let nextQuestion = new Question({ ...card, ...attrs }, metadata);
|
||||
if (makeAdHoc) {
|
||||
nextQuestion = nextQuestion.withoutNameAndId();
|
||||
|
||||
let nextName = nextQuestion.generateQueryDescription();
|
||||
if (!nextName && wasSaved) {
|
||||
nextName = card.name + " — Modified";
|
||||
}
|
||||
|
||||
nextQuestion = nextQuestion.setDisplayName(nextName);
|
||||
}
|
||||
|
||||
const nextCard = nextQuestion.card();
|
||||
|
||||
if (runQuery) {
|
||||
const nextSeries = await _fetchAdHocCardData(nextCard);
|
||||
if (nextSeries) {
|
||||
setRawSeries(assocIn(rawSeries, [index], nextSeries));
|
||||
}
|
||||
} else {
|
||||
const nextSeries = assocIn(rawSeries, [index, "card"], nextCard);
|
||||
setRawSeries(nextSeries);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSeriesQuery = async (index: number, query: DatasetQuery) => {
|
||||
const previousCard = rawSeries[index]?.card;
|
||||
if (!previousCard) {
|
||||
return;
|
||||
}
|
||||
const previousQuestion = new Question(previousCard, metadata);
|
||||
const wasSaved = previousQuestion.isSaved();
|
||||
let nextQuestion = previousQuestion
|
||||
.setDatasetQuery(query)
|
||||
.withoutNameAndId();
|
||||
|
||||
let nextName = nextQuestion.generateQueryDescription();
|
||||
if (!nextName && wasSaved) {
|
||||
const previousName = previousQuestion.displayName();
|
||||
nextName = previousName + " — Modified";
|
||||
}
|
||||
|
||||
nextQuestion = nextQuestion.setDisplayName(nextName);
|
||||
|
||||
const nextSeries = await _fetchAdHocCardData(nextQuestion.card());
|
||||
|
||||
if (nextSeries) {
|
||||
setRawSeries(assocIn(rawSeries, [index], nextSeries));
|
||||
}
|
||||
};
|
||||
|
||||
const addCardSeries = async (cardId: CardId) => {
|
||||
const newSeries = await _fetchCardAndData(cardId);
|
||||
if (!newSeries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [mainSeries] = rawSeries;
|
||||
const mainCard = mainSeries?.card;
|
||||
|
||||
if (!mainSeries) {
|
||||
setRawSeries([newSeries]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainCard) {
|
||||
const canMerge = areCardsCompatible(mainSeries.card, newSeries.card);
|
||||
if (canMerge) {
|
||||
setRawSeries([...rawSeries, newSeries]);
|
||||
} else {
|
||||
setRawSeries([newSeries]);
|
||||
}
|
||||
} else {
|
||||
setRawSeries([newSeries]);
|
||||
}
|
||||
};
|
||||
|
||||
const replaceSeries = async (cards: Card[]) => {
|
||||
const data = await Promise.all(
|
||||
cards.map(card =>
|
||||
card.id
|
||||
? _fetchCardAndData(card.id, { cardAttrs: card })
|
||||
: _fetchAdHocCardData(card),
|
||||
),
|
||||
);
|
||||
const newSeries = data.filter(Boolean);
|
||||
_setRawSeries(newSeries);
|
||||
};
|
||||
|
||||
const replaceAllWithCardSeries = async (cardId: CardId) => {
|
||||
const newSeries = await _fetchCardAndData(cardId);
|
||||
if (newSeries) {
|
||||
setRawSeries([newSeries]);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSeriesData = async (index: number) => {
|
||||
const series = rawSeries[index];
|
||||
|
||||
const newSeries = series.card.id
|
||||
? await _fetchCardAndData(series.card.id, {
|
||||
cardAttrs: series.card,
|
||||
forceRefetch: true,
|
||||
})
|
||||
: await _fetchAdHocCardData(series.card);
|
||||
|
||||
if (newSeries) {
|
||||
const nextSeries = assocIn(rawSeries, [index], newSeries);
|
||||
setRawSeries(nextSeries);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSeries = (index: number) => {
|
||||
const nextSeries = rawSeries.filter((_, i) => i !== index);
|
||||
setRawSeries(nextSeries);
|
||||
};
|
||||
|
||||
const setVizSettings = (settings: VisualizationSettings) => {
|
||||
updateSeriesCard(MAIN_SERIES_INDEX, { visualization_settings: settings });
|
||||
};
|
||||
|
||||
useMount(() => {
|
||||
async function init() {
|
||||
const allSeries = await Promise.all(
|
||||
initialCards.map(card =>
|
||||
card.id
|
||||
? _fetchCardAndData(card.id, { cardAttrs: card })
|
||||
: _fetchAdHocCardData(card),
|
||||
),
|
||||
);
|
||||
const nonEmptySeries = allSeries.filter(isNotNull);
|
||||
const compatibleSeries = allSeries.filter((series, index) => {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
series?.card &&
|
||||
areCardsCompatible(nonEmptySeries[0].card, series.card)
|
||||
);
|
||||
});
|
||||
_setRawSeries(compatibleSeries);
|
||||
}
|
||||
if (initialCards.length > 0) {
|
||||
init();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
series: rawSeries,
|
||||
transformedSeries,
|
||||
settings: computedSettings,
|
||||
question: mainQuestion,
|
||||
addCardSeries,
|
||||
updateSeriesCard,
|
||||
updateSeriesQuery,
|
||||
replaceSeries,
|
||||
replaceAllWithCardSeries,
|
||||
refreshSeriesData,
|
||||
removeSeries,
|
||||
setVizSettings,
|
||||
};
|
||||
}
|
||||
89
frontend/src/metabase/visualizer/utils.ts
Normal file
89
frontend/src/metabase/visualizer/utils.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { isCartesianViz } from "metabase/visualizations";
|
||||
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
|
||||
import {
|
||||
isNumeric,
|
||||
isDate,
|
||||
isDateWithoutTime,
|
||||
isTime,
|
||||
} from "metabase-lib/v1/types/utils/isa";
|
||||
import type { Card, Field, Series } from "metabase-types/api";
|
||||
|
||||
const areaBarLine = ["area", "bar", "line"];
|
||||
|
||||
export function canCombineCardWithOthers(card: Card) {
|
||||
return card.display === "scalar" || areaBarLine.includes(card.display);
|
||||
}
|
||||
|
||||
export function areCardsCompatible(card1: Card, card2: Card) {
|
||||
if (
|
||||
areaBarLine.includes(card1.display) &&
|
||||
areaBarLine.includes(card2.display)
|
||||
) {
|
||||
return areAreaBarLineSeriesCompatible(card1, card2);
|
||||
}
|
||||
|
||||
if (card1.display === "scalar" && card2.display === "scalar") {
|
||||
return (
|
||||
card1.result_metadata.length === 1 && card2.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(card1: Card, card2: Card) {
|
||||
const initialDimensions = (
|
||||
card1.visualization_settings["graph.dimensions"] ?? []
|
||||
).map(col => card1.result_metadata.find(c => c.name === col));
|
||||
const newDimensions = (
|
||||
card2.visualization_settings["graph.dimensions"] ?? []
|
||||
).map(col => card2.result_metadata.find(c => c.name === col));
|
||||
const newMetrics = (card2.visualization_settings["graph.metrics"] ?? []).map(
|
||||
col => card2.result_metadata.find(c => c.name === col),
|
||||
);
|
||||
|
||||
if (
|
||||
newDimensions.length === 0 ||
|
||||
newMetrics.length === 0 ||
|
||||
!newMetrics.every(isNumeric)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primaryInitialDimension = initialDimensions[0] as Field;
|
||||
const primaryNewDimension = newDimensions[0] as Field;
|
||||
|
||||
// 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: Field) {
|
||||
return isDate(col) || isDateWithoutTime(col) || isTime(col);
|
||||
}
|
||||
|
||||
export function isCartesianSeries(
|
||||
series: Series,
|
||||
settings?: ComputedVisualizationSettings,
|
||||
) {
|
||||
if (series.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const [{ card }] = series;
|
||||
|
||||
if (card.display === "scalar" && series.length > 1) {
|
||||
const display =
|
||||
settings?.["scalar.multiseries.display"] ??
|
||||
card?.visualization_settings["scalar.multiseries.display"];
|
||||
return isCartesianViz(display);
|
||||
}
|
||||
|
||||
return isCartesianViz(card.display);
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-resizable-panels": "^2.0.19",
|
||||
"react-router": "3",
|
||||
"react-router-redux": "^4.0.8",
|
||||
"react-transition-group": "^4.4.5",
|
||||
|
||||
@@ -239,6 +239,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]
|
||||
@@ -282,6 +284,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)
|
||||
|
||||
@@ -19785,6 +19785,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"
|
||||
|
||||
Reference in New Issue
Block a user