Compare commits

...

81 Commits

Author SHA1 Message Date
Anton Kulyk
d35471660d Merge branch 'master' into visualizer 2024-08-13 13:57:42 +01:00
Anton Kulyk
63f016e9ff Merge branch 'master' into visualizer 2024-07-23 12:23:58 +01:00
Anton Kulyk
5ceda5fa54 Add trend line 2024-07-23 12:23:39 +01:00
Anton Kulyk
98362f9580 Clean up, update mouse enter/leave logic 2024-07-23 12:23:39 +01:00
Anton Kulyk
a0b2e14abf Basic goal line controls 2024-07-23 12:23:39 +01:00
Anton Kulyk
d596a5427b Basic y-axis panel layout 2024-07-23 12:23:39 +01:00
Anton Kulyk
7590869bc5 Fix series name change reverts other changes 2024-07-22 11:42:53 +01:00
Anton Kulyk
3010c074b0 Update series names from the legend 2024-07-22 11:42:53 +01:00
Anton Kulyk
979b022f09 Use double click editable text in visualizer axis components 2024-07-22 11:42:53 +01:00
Anton Kulyk
a23e583975 Add DoubleClickEditableText 2024-07-22 11:42:53 +01:00
Anton Kulyk
4683e2b9aa Switch components to use EditableMarkdownText 2024-07-22 11:42:53 +01:00
Anton Kulyk
ee7748a1e4 Extract EditableMarkdownText from EditableText 2024-07-22 11:42:53 +01:00
Anton Kulyk
3a27ad93d9 Remove unused EditableText prop 2024-07-22 11:42:53 +01:00
Anton Kulyk
9650fd1508 Extract useEditableText hook 2024-07-22 11:42:53 +01:00
Anton Kulyk
c551f39547 Add inline axis label editing for one metric/dimension 2024-07-22 11:42:53 +01:00
Anton Kulyk
244b4c2b8e Merge branch 'master' into visualizer 2024-07-22 11:42:25 +01:00
Anton Kulyk
17fa6a528a Merge branch 'master' into visualizer 2024-07-19 14:47:18 +01:00
Anton Kulyk
63d0d3e856 Merge branch 'master' into visualizer 2024-07-18 11:42:23 +01:00
Anton Kulyk
db017505bb Remove visualizer urls test file 2024-07-17 16:56:27 +01:00
Anton Kulyk
77ce9a2060 Fix outdated import 2024-07-17 16:56:16 +01:00
Anton Kulyk
45152a5f9f Fix links in QB's compatible series menu 2024-07-17 13:05:56 +01:00
Anton Kulyk
ed66d69aec Rename scalar transform 2024-07-17 12:58:54 +01:00
Anton Kulyk
4b21681971 Wire up viz settings sidebar 2024-07-17 12:58:54 +01:00
Anton Kulyk
6b2c9751da Extend supported scalar transforms 2024-07-17 12:58:54 +01:00
Anton Kulyk
6a0a2a90b5 Add scalar to funnel transform 2024-07-17 12:58:54 +01:00
Anton Kulyk
6c4585cc71 Merge branch 'master' into visualizer 2024-07-17 12:15:59 +01:00
Anton Kulyk
b9f538898d Merge branch 'master' into visualizer 2024-07-16 14:04:02 +01:00
Anton Kulyk
e92d152e33 Merge branch 'master' into visualizer 2024-07-15 12:29:01 +01:00
Anton Kulyk
37c68376e8 Extract VizTypePicker 2024-07-10 17:14:12 +01:00
Anton Kulyk
0bb3544dc2 Minor clean up 2024-07-10 17:07:06 +01:00
Anton Kulyk
983d9b8aab Update axis 2024-07-10 17:04:31 +01:00
Anton Kulyk
48386755be Show '+' only next to compatible series 2024-07-10 15:34:08 +01:00
Anton Kulyk
2a6d7f3121 Remove "Metrics" and "Models" tabs 2024-07-10 14:46:21 +01:00
Anton Kulyk
5dbf99326f Add "Compatible" tab to visualizer menu 2024-07-10 14:43:34 +01:00
Anton Kulyk
db418b4b03 Refactor areSeriesCompatible into areCardsCompatible 2024-07-10 14:32:40 +01:00
Anton Kulyk
5eb62543f0 Make Recents tab a default 2024-07-10 14:00:19 +01:00
Anton Kulyk
a0fcddb209 Fix current metrics/dimensions getter 2024-07-10 13:56:05 +01:00
Kyle Doherty
f82eb4a346 title logic fix 2024-07-09 15:21:09 -04:00
Kyle Doherty
780d631184 fix positioning when title shrinks 2024-07-09 15:09:28 -04:00
Kyle Doherty
1bcfe4fcd1 try out truncate text 2024-07-09 14:47:59 -04:00
Kyle Doherty
a941267df0 header shouldn't yell so loud 2024-07-09 14:38:55 -04:00
Anton Kulyk
7137d65cd6 Basic URL state 2024-07-09 17:06:14 +01:00
Anton Kulyk
ff48495c9c Close navbar when opening visualizer 2024-07-09 15:08:24 +01:00
Anton Kulyk
2695ef3eeb Enable table interactive 2024-07-09 15:01:25 +01:00
Anton Kulyk
936bfa45d8 Add basic drill-through support 2024-07-09 14:58:15 +01:00
Anton Kulyk
a796c8326d Merge branch 'master' into visualizer 2024-07-09 14:33:21 +01:00
Anton Kulyk
54346c789e Rename modal 2024-07-09 14:33:06 +01:00
Anton Kulyk
ba7e57df9d Add basic native query editing 2024-07-09 14:33:06 +01:00
Anton Kulyk
ef2b1687ef Add modal notebook editor 2024-07-09 14:33:06 +01:00
Anton Kulyk
07a1804748 Fetch card metadata 2024-07-09 14:33:06 +01:00
Anton Kulyk
e9152b1420 Switch useVisualizerSeries to series indexes 2024-07-09 14:33:06 +01:00
Kyle Doherty
5a66f56931 axis pill styling 2024-07-08 11:45:36 -04:00
Anton Kulyk
9a32c06892 Merge branch 'master' into visualizer 2024-07-08 12:14:29 +01:00
Anton Kulyk
e6988714c8 Setup DND + remove metrics/dimensions with DND 2024-07-05 16:39:33 +01:00
Anton Kulyk
9964bc85ac Extract ResizeHandle 2024-07-05 13:51:55 +01:00
Anton Kulyk
eb86a7dea9 Remove visualizer redux state 2024-07-05 13:45:56 +01:00
Anton Kulyk
e52e0f801b Extract useVisualizerSeries hook 2024-07-05 13:43:15 +01:00
Anton Kulyk
46f54d5034 Add viz settings sidebar 2024-07-05 12:57:12 +01:00
Anton Kulyk
2451e54af8 Implement add column feature 2024-07-05 12:50:29 +01:00
Anton Kulyk
bc07f5394a Fix rendering for non-cartesian charts 2024-07-05 12:50:29 +01:00
Anton Kulyk
a590706a95 Add basic axis editor 2024-07-05 12:50:29 +01:00
Anton Kulyk
823e88bf1a Wire up viz picker 2024-07-03 18:23:42 +01:00
Anton Kulyk
3175e91b2c Add remove and refresh 2024-07-03 18:10:31 +01:00
Anton Kulyk
cfa5df5472 Add question links 2024-07-03 18:10:14 +01:00
Kyle Doherty
e979ba8661 add skeleton content for being used section items 2024-07-02 13:18:03 -04:00
Anton Kulyk
b644520a94 Add compatible series picker to QB chill mode 2024-07-02 15:01:58 +01:00
Anton Kulyk
41c6daf77f Add card ID URL query parameters to visualizer 2024-07-02 15:01:39 +01:00
Anton Kulyk
3ffb5b09f0 Distinct add/replace actions 2024-07-02 14:21:05 +01:00
Anton Kulyk
ac9535bf02 Add multi-series charts support 2024-07-02 14:20:19 +01:00
Kyle Doherty
87f4af0a67 add ui redux stuff 2024-06-25 16:34:47 -04:00
Kyle Doherty
04026ce60d add ability for visualizer url to accept a definition + tests 2024-06-25 13:26:11 -04:00
Kyle Doherty
e01cbebcd9 title and settings trigger 2024-06-24 10:27:28 -04:00
Kyle Doherty
138df1c4f0 default to metrics tab selected 2024-06-18 11:33:21 -04:00
Kyle Doherty
80152f8e0f hacky viz loading 2024-06-14 17:13:49 -04:00
Kyle Doherty
5d6eabe776 hide nav when in the visualizer 2024-06-14 16:16:43 -04:00
Kyle Doherty
4709826b00 layout via resizable panels 2024-06-14 16:14:17 -04:00
Kyle Doherty
55378ea0c8 menu item styling 2024-06-14 15:40:22 -04:00
Kyle Doherty
aab9fde68b use menu item on search results 2024-06-14 15:26:10 -04:00
Kyle Doherty
256668b4d4 add to being used 2024-06-14 15:17:18 -04:00
Kyle Doherty
adabe0edf0 visualizer menu list hookups 2024-06-14 14:31:18 -04:00
Kyle Doherty
aaec2732ef add visualizer route and route-like things 2024-06-14 12:20:40 -04:00
58 changed files with 2509 additions and 198 deletions

View File

@@ -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;
};
}

View File

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

View File

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

View File

@@ -69,7 +69,6 @@ export const CollectionCaption = ({
isDisabled={!isEditable}
isOptional
isMultiline
isMarkdown
onChange={handleChangeDescription}
/>
)}

View File

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

View File

@@ -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>
);
},
);

View File

@@ -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,
});

View File

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

View File

@@ -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);

View File

@@ -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>
);
});

View File

@@ -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", () => {

View File

@@ -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";

View 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;
}

View File

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

View File

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

View File

@@ -132,7 +132,6 @@ const DashboardInfoSidebarBody = ({
onBlur={handleDescriptionBlur}
isOptional
isMultiline
isMarkdown
hasError={!!descriptionError}
placeholder={t`Add description`}
key={`dashboard-description-${dashboard.description}`}

View File

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

View File

@@ -0,0 +1,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`;
}
}

View File

@@ -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}
`;

View File

@@ -40,7 +40,6 @@ function ModelInfoSidePanel({ model, mainTable, onChangeDescription }: Props) {
}
isOptional
isMultiline
isMarkdown
isDisabled={!canWrite}
aria-label={t`Description`}
onChange={onChangeDescription}

View File

@@ -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);
};

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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}
/>

View File

@@ -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 (

View File

@@ -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

View File

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

View File

@@ -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}

View File

@@ -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
}

View File

@@ -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>

View File

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

View File

@@ -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}

View File

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

View File

@@ -89,7 +89,7 @@ export function getStoredSettingsForSeries(series) {
}
export function getComputedSettingsForSeries(series) {
if (!series) {
if (!series || series.length === 0) {
return {};
}
const settingsDefs = getSettingDefintionsForSeries(series);

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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}
/>
);
}

View File

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

View File

@@ -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 {};
}

View File

@@ -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;
};

View 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>
);
}

View 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>
);
}

View File

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

View File

@@ -0,0 +1,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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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,
})),
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"];

View File

@@ -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>
);
}

View 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;
}

View 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,
};
}

View 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);
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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"