Compare commits

...

50 Commits

Author SHA1 Message Date
Kyle Doherty
bfca8bacc9 wip 2025-09-29 14:31:04 -04:00
Kyle Doherty
0ce67c84ba Cleanup panel 2025-09-26 13:18:47 -04:00
Kyle Doherty
f0873a8ed5 update spec on entity lists 2025-09-26 13:11:13 -04:00
Kyle Doherty
9419600308 clean up new metric page 2025-09-26 13:00:31 -04:00
Kyle Doherty
ea0bcc1648 fix double border 2025-09-26 12:55:48 -04:00
Kyle Doherty
9d5cf66bc4 structure sidebar a bit 2025-09-26 12:51:42 -04:00
Kyle Doherty
25c217346b move sidebar toggle 2025-09-26 11:25:30 -04:00
Kyle Doherty
b69d600c4b tweak nav structure 2025-09-26 11:24:01 -04:00
Kyle Doherty
86d571fb4b basic overview page 2025-09-25 15:15:23 -04:00
Kyle Doherty
6e1d196f24 update spec and add ability to scoot around 2025-09-25 13:59:10 -04:00
Kyle Doherty
b2da483689 basics of segment view 2025-09-25 13:40:57 -04:00
Kyle Doherty
ae294bfdea add sidebar open / close 2025-09-25 13:08:37 -04:00
Kyle Doherty
24d33159ba improve content area styling 2025-09-25 13:02:10 -04:00
Kyle Doherty
ff11a7981d fixup toolbar and sidebar 2025-09-25 12:56:39 -04:00
Kyle Doherty
9eb6888b24 add basic layout diagram to spec 2025-09-25 12:52:47 -04:00
Kyle Doherty
b3ded09ade add toolbar 2025-09-25 12:51:43 -04:00
Kyle Doherty
267a049bf7 clean up use of navlink 2025-09-25 12:35:55 -04:00
Kyle Doherty
aa194d23d7 standardize on navlink 2025-09-25 12:31:29 -04:00
Kyle Doherty
97937432f8 add a line about using nav-link 2025-09-25 12:26:07 -04:00
Kyle Doherty
0fce1ac6f0 add border, update spec 2025-09-25 12:20:36 -04:00
Kyle Doherty
9b37c7cd65 remove use of dimmed text 2025-09-25 12:18:23 -04:00
Kyle Doherty
c6b86ce8f2 fix sidebar nav 2025-09-25 12:09:08 -04:00
Kyle Doherty
382ba6cd95 remove dark mode 2025-09-25 12:06:37 -04:00
Kyle Doherty
4530844329 specify dark mode 2025-09-25 11:55:14 -04:00
Kyle Doherty
34b5f79c2e update spec 2025-09-25 11:53:42 -04:00
Kyle Doherty
bf6761c5bb working notebook editor 2025-09-25 11:51:31 -04:00
Kyle Doherty
0c83c166ca fixup spec 2025-09-25 11:47:25 -04:00
Kyle Doherty
315c91903f new metric 2025-09-25 11:45:27 -04:00
Kyle Doherty
a15e0fea05 pre next step cleanup 2025-09-25 11:15:33 -04:00
Kyle Doherty
e2be93a3ff add spec details on loading things into the notebook 2025-09-24 16:38:42 -04:00
Kyle Doherty
eb4d848d6a models section + spec 2025-09-24 16:21:09 -04:00
Kyle Doherty
ff3e32c6d0 update spec 2025-09-24 15:30:51 -04:00
Kyle Doherty
522ec39542 basics of metrics section 2025-09-24 15:29:59 -04:00
Kyle Doherty
537ccb3e1e add initial spec 2025-09-24 15:20:59 -04:00
Kyle Doherty
d39ba9a140 basics of models / metrics in bench 2025-09-23 15:09:56 -04:00
Kyle Doherty
d0db9a9564 fixup segments 2025-09-23 13:50:15 -04:00
Kyle Doherty
385362f276 metadata 2025-09-23 13:23:16 -04:00
Kyle Doherty
b37818fbb1 basic datamodel integration 2025-09-23 13:16:25 -04:00
Kyle Doherty
d166a3de9c link from top 2025-09-23 11:21:23 -04:00
Kyle Doherty
8db485228f linked data goes to query 2025-09-23 11:18:20 -04:00
Kyle Doherty
e66a9be848 round trip of exposure 2025-09-23 11:10:38 -04:00
Kyle Doherty
a06af99414 run box 2025-09-23 10:07:05 -04:00
Kyle Doherty
dbb56a4d2d spot for the bot 2025-09-23 10:00:32 -04:00
Kyle Doherty
0074ffdb9c dark mode stuff 2025-09-23 09:53:29 -04:00
Kyle Doherty
7c84626e83 basic dark mode 2025-09-23 09:33:05 -04:00
Kyle Doherty
df3ffbfaf8 basic previewing 2025-09-23 09:25:40 -04:00
Kyle Doherty
697f4c5b79 remove dimmed text 2025-09-23 09:20:07 -04:00
Kyle Doherty
e07ea0463d basic data round trip 2025-09-23 09:17:10 -04:00
Kyle Doherty
7e622d9c93 resizable panels 2025-09-23 09:04:48 -04:00
Kyle Doherty
9fc6a6f23a init bench 2025-09-23 08:50:09 -04:00
298 changed files with 21081 additions and 2 deletions

View File

@@ -0,0 +1,132 @@
/* CodeMirror theme overrides for the bench */
.benchEditor {
/* Container for the editor */
}
/* CSS custom properties and text color inheritance */
.light {
--text-primary: #000000;
--text-secondary: #666666;
--text-dimmed: #999999;
}
.dark {
--text-primary: #ffffff;
--text-secondary: #e0e0e0;
--text-dimmed: #b0b0b0;
}
/* Global text color inheritance for Mantine components */
.light :global(.mantine-Text-root) {
color: var(--text-primary) !important;
}
.light :global(.mantine-Text-root[data-variant="dimmed"]) {
color: var(--text-dimmed) !important;
}
.dark :global(.mantine-Text-root) {
color: var(--text-primary) !important;
}
.dark :global(.mantine-Text-root[data-variant="dimmed"]) {
color: var(--text-dimmed) !important;
}
/* Override for specific color props */
.dark :global(.mantine-Text-root[data-color="dimmed"]) {
color: var(--text-secondary) !important;
}
/* Light theme overrides */
.light :global(.cm-lineNumbers) {
color: #999999 !important;
background-color: #f8f9fa !important;
}
.light :global(.cm-lineNumbers .cm-gutterElement) {
color: #999999 !important;
}
.light :global(.cm-activeLineGutter) {
background-color: #e9ecef !important;
}
.light :global(.cm-gutters) {
background-color: #f8f9fa !important;
border-right: 1px solid #dee2e6 !important;
}
/* Dark theme overrides */
.dark :global(.cm-lineNumbers) {
color: #6c757d !important;
background-color: #2c2e33 !important;
}
.dark :global(.cm-lineNumbers .cm-gutterElement) {
color: #6c757d !important;
}
.dark :global(.cm-activeLineGutter) {
background-color: #373a40 !important;
}
.dark :global(.cm-gutters) {
background-color: #2c2e33 !important;
border-right: 1px solid #495057 !important;
}
.dark :global(.cm-editor) {
background-color: #25262b !important;
color: #c1c2c5 !important;
}
.dark :global(.cm-content) {
background-color: #25262b !important;
color: #c1c2c5 !important;
}
.dark :global(.cm-focused) {
outline: none !important;
}
/* Selection colors for dark mode */
.dark :global(.cm-selectionBackground) {
background-color: #495057 !important;
}
.dark :global(.cm-editor.cm-focused .cm-selectionBackground) {
background-color: #495057 !important;
}
/* Cursor color for dark mode */
.dark :global(.cm-cursor) {
border-left-color: #c1c2c5 !important;
}
/* Search highlight colors for dark mode */
.dark :global(.cm-searchMatch) {
background-color: rgba(255, 255, 0, 0.2) !important;
}
.dark :global(.cm-searchMatch.cm-searchMatch-selected) {
background-color: rgba(255, 255, 0, 0.4) !important;
}
/* Ensure proper line number spacing and visibility */
.light :global(.cm-lineNumbers),
.dark :global(.cm-lineNumbers) {
min-width: 3em !important;
padding-right: 8px !important;
text-align: right !important;
user-select: none !important;
}
/* Active line highlighting */
.light :global(.cm-activeLine) {
background-color: rgba(0, 0, 0, 0.02) !important;
}
.dark :global(.cm-activeLine) {
background-color: rgba(255, 255, 255, 0.03) !important;
}

View File

@@ -0,0 +1,256 @@
import { useEffect, useRef, useState } from "react";
import { keymap } from "@codemirror/view";
import { push } from "react-router-redux";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { skipToken } from "metabase/api";
import { CodeMirror } from "metabase/common/components/CodeMirror";
import { color } from "metabase/lib/colors";
import { useDispatch } from "metabase/lib/redux";
import { language } from "metabase/query_builder/components/NativeQueryEditor/CodeMirrorEditor/language";
import { Box, Text } from "metabase/ui";
import { useGetTransformQuery } from "metabase-enterprise/api";
import { t } from "ttag";
import {
BenchPanel,
TransformEntitiesList,
TransformDetails,
QueryPreview,
TransformExecutionCard,
CollectionsCard,
} from "./components";
import styles from "./BenchApp.module.css";
import type { Transform } from "metabase-types/api";
interface BenchAppProps {
params?: {
transformId?: string;
};
}
export function BenchApp({ params }: BenchAppProps) {
const dispatch = useDispatch();
const queryPreviewRef = useRef<{ runQuery: () => void }>(null);
const transformId = params?.transformId
? parseInt(params.transformId, 10)
: undefined;
const { data: selectedTransform } = useGetTransformQuery(
transformId ?? skipToken,
);
const [code, setCode] =
useState(`-- ${t`Welcome to the Metabase Bench`} // eslint-disable-line no-literal-metabase-strings -- This string only shows for admins.
-- ${t`This is your SQL editor for transform development`}
SELECT
customer_id,
COUNT(*) as order_count,
SUM(total_amount) as total_spent
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY customer_id
ORDER BY total_spent DESC;`);
// Update editor content when transform is loaded
useEffect(() => {
if (
selectedTransform?.source?.query &&
selectedTransform.source.query.type === "native" &&
"native" in selectedTransform.source.query &&
selectedTransform.source.query.native?.query
) {
setCode(selectedTransform.source.query.native.query);
} else if (!transformId) {
// Reset to welcome message when no transform is selected
setCode(`-- ${t`Welcome to the Metabase Bench`} // eslint-disable-line no-literal-metabase-strings -- This string only shows for admins.
-- ${t`This is your SQL editor for transform development`}
SELECT
customer_id,
COUNT(*) as order_count,
SUM(total_amount) as total_spent
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY customer_id
ORDER BY total_spent DESC;`);
}
}, [selectedTransform, transformId]);
const handleTransformClick = (transform: Transform) => {
dispatch(push(`/bench/transform/${transform.id}`));
};
const handleRunQuery = () => {
queryPreviewRef.current?.runQuery();
};
return (
<Box h="100vh" style={{ overflow: "hidden" }}>
<PanelGroup direction="horizontal">
{/* Left Panel - Transform Entities */}
<Panel defaultSize={33} minSize={25} maxSize={45}>
<BenchPanel
title="Transforms"
height="100%"
createNewSegmented={{
defaultPath: "/bench/transforms/new/notebook",
options: [
{ label: "Notebook", path: "/bench/transforms/new/notebook" },
{ label: "Python", path: "/bench/transforms/new/python" },
{
label: "A saved question",
path: "/bench/transforms/new/saved-question",
},
],
}}
>
<TransformEntitiesList
selectedTransformId={transformId}
onTransformClick={handleTransformClick}
/>
</BenchPanel>
</Panel>
<PanelResizeHandle
style={{
width: "4px",
cursor: "col-resize",
borderRadius: "2px",
margin: "0 2px",
}}
/>
{/* Center Panel - Code Editor with nested Preview */}
<Panel defaultSize={60} minSize={30}>
<Box h="100%" p="md">
<Box
h="100%"
p="md"
style={{
backgroundColor: "var(--mb-color-bg-white)",
border: "1px solid var(--mb-color-border)",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
}}
>
<Box mb="md">
<Text fw={500}>
{selectedTransform ? selectedTransform.name : t`SQL Editor`}
</Text>
{selectedTransform && (
<Text size="sm">
{t`Target`}:{" "}
{selectedTransform.target.schema
? `${selectedTransform.target.schema}.`
: ""}
{selectedTransform.target.name}
</Text>
)}
</Box>
<Box
style={{ flex: 1, position: "relative" }}
className={styles.benchEditor}
>
<CodeMirror
value={code}
onChange={(value) => setCode(value)}
height="100%"
style={{
height: "100%",
fontSize: "14px",
}}
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
}}
extensions={[
...language({ engine: "postgres" }),
keymap.of([
{
key: "Mod-Enter",
run: () => {
handleRunQuery();
return true;
},
},
]),
]}
placeholder="Enter your SQL code here..."
/>
</Box>
{/* Nested Query Preview Card */}
<Box
mt="md"
style={{
backgroundColor: "var(--mb-color-bg-white)",
border: "1px solid var(--mb-color-border)",
borderRadius: "8px",
boxShadow: `0 2px 8px ${color("shadow")}`,
maxHeight: "40vh",
minHeight: "200px",
}}
>
<QueryPreview
ref={queryPreviewRef}
query={code}
databaseId={selectedTransform?.source?.query?.database}
/>
</Box>
</Box>
</Box>
</Panel>
<PanelResizeHandle
style={{
width: "4px",
cursor: "col-resize",
borderRadius: "2px",
margin: "0 2px",
}}
/>
{/* Right Panel - Properties/Tools */}
<Panel defaultSize={20} minSize={15} maxSize={35}>
<Box h="100%" style={{ display: "flex", flexDirection: "column" }}>
<Box
style={{
height: "100%",
padding: "16px",
overflow: "auto",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
<TransformDetails transformId={transformId} />
<TransformExecutionCard transform={selectedTransform} />
<CollectionsCard transform={selectedTransform} />
<Box
p="sm"
style={{
borderRadius: "4px",
}}
>
<Text size="xs">
{t`Lines`}: {code.split("\n").length}
</Text>
<Text size="xs">
{t`Characters`}: {code.length}
</Text>
</Box>
</Box>
</Box>
</Panel>
</PanelGroup>
</Box>
);
}

View File

@@ -0,0 +1,98 @@
import type { ReactNode } from "react";
import { useState } from "react";
import { withRouter } from "react-router";
import { Box, Flex, ThemeProvider } from "metabase/ui";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { BenchSidebar } from "./components";
import { BenchToolbar } from "./components/BenchToolbar";
import { BenchMetabot } from "./components/BenchMetabot/BenchMetabot";
interface BenchLayoutProps {
children: ReactNode;
}
function BenchLayoutComponent({ children }: BenchLayoutProps) {
const [isMetabotOpen, setIsMetabotOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const handleMetabotToggle = () => {
setIsMetabotOpen(!isMetabotOpen);
};
const handleSidebarToggle = () => {
setIsSidebarOpen(!isSidebarOpen);
};
return (
<ThemeProvider>
<Box
style={{
height: "100vh",
overflow: "hidden",
display: "flex",
}}
>
{/* Main nav on the far left - conditionally rendered */}
{isSidebarOpen && <BenchSidebar />}
{/* Main content area taking up the full rest of the view */}
<Box
style={{
flex: 1,
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{/* Toolbar on top */}
<BenchToolbar
isMetabotOpen={isMetabotOpen}
onMetabotToggle={handleMetabotToggle}
isSidebarOpen={isSidebarOpen}
onSidebarToggle={handleSidebarToggle}
/>
{/* Horizontal series of panels below toolbar */}
<Box style={{ flex: 1, overflow: "hidden" }}>
<PanelGroup direction="horizontal">
{/* Main content panel */}
<Panel defaultSize={isMetabotOpen ? 75 : 100} minSize={50}>
<Box h="100%" style={{ overflow: "hidden" }}>
{children}
</Box>
</Panel>
{/* Metabot panel - conditionally rendered */}
{isMetabotOpen && (
<>
<PanelResizeHandle
style={{
width: "4px",
cursor: "col-resize",
borderRadius: "2px",
margin: "0 2px",
}}
/>
<Panel defaultSize={25} minSize={15} maxSize={40}>
<Box
h="100%"
style={{
borderLeft: "1px solid var(--mb-color-border)",
}}
>
<BenchMetabot />
</Box>
</Panel>
</>
)}
</PanelGroup>
</Box>
</Box>
</Box>
</ThemeProvider>
);
}
export const BenchLayout = withRouter(BenchLayoutComponent);

View File

@@ -0,0 +1,108 @@
import { Box, Text, Card, Group, Button, Icon, Stack } from "metabase/ui";
import { Link } from "react-router";
import { t } from "ttag";
import { useSelector } from "metabase/lib/redux";
import { getUser } from "metabase/selectors/user";
import _ from "underscore";
export function BenchOverview() {
const user = useSelector(getUser);
const greeting = "Hello"
const toolCards = [
{
title: t`Transforms`,
description: t`Create and manage data transformation pipelines`,
icon: <Icon name="sql" size={24} />,
path: "/bench/transforms",
color: "blue",
},
{
title: t`Table Metadata`,
description: t`Explore and manage database schemas and table structures`,
icon: <Icon name="database" size={24} />,
path: "/bench/metadata",
color: "green",
},
{
title: t`Segments`,
description: t`Define and manage user segments for targeted analysis`,
icon: <Icon name="filter" size={24} />,
path: "/bench/segments",
color: "purple",
},
{
title: t`Metrics`,
description: t`Create and manage business metrics and KPIs`,
icon: <Icon name="metric" size={24} />,
path: "/bench/metrics",
color: "orange",
},
{
title: t`Models`,
description: t`Build and manage data models for consistent analysis`,
icon: <Icon name="model" size={24} />,
path: "/bench/models",
color: "teal",
},
];
return (
<Box
style={{
height: "100vh",
overflow: "auto",
padding: "32px",
backgroundColor: "var(--mb-color-bg-light)",
}}
>
<Stack gap="xl">
{/* Welcome Section */}
<Box>
<Text size="xl" fw="bold" mb="sm">
{greeting}
</Text>
</Box>
{/* Tool Cards Grid */}
<Box>
<Text size="lg" fw="bold" mb="md">
{t`Available Tools`}
</Text>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
gap: "20px",
}}
>
{toolCards.map((tool) => (
<Card
key={tool.path}
component={Link}
to={tool.path}
p="xl"
>
<Group mb="md">
<Box
style={{
color: `var(--mb-color-${tool.color}-6)`,
}}
>
{tool.icon}
</Box>
<Text size="lg" fw="bold">
{tool.title}
</Text>
</Group>
<Text size="sm">
{tool.description}
</Text>
</Card>
))}
</Box>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,66 @@
import { Box } from "metabase/ui";
import { useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { BenchPanel } from "./components/Panel/Panel";
import { MetricsEntitiesList } from "./components/MetricsEntitiesList/MetricsEntitiesList";
import { MetricsDetails } from "./components/MetricsDetails/MetricsDetails";
import type { Card } from "metabase-types/api";
import styles from "./BenchApp.module.css";
interface MetricsAppProps {
children?: React.ReactNode;
}
export function MetricsApp({ children }: MetricsAppProps) {
const [selectedMetric, setSelectedMetric] = useState<Card | undefined>();
const handleMetricClick = (metric: Card) => {
setSelectedMetric(metric);
};
return (
<Box h="100vh" style={{ overflow: "hidden" }}>
<PanelGroup direction="horizontal">
{/* Left Panel - Metrics List */}
<Panel defaultSize={33} minSize={25} maxSize={45}>
<BenchPanel
title="Metrics"
height="100%"
createNewPath="/bench/metrics/new"
>
<MetricsEntitiesList
selectedMetricId={selectedMetric?.id}
onMetricClick={handleMetricClick}
/>
</BenchPanel>
</Panel>
<PanelResizeHandle
style={{
width: "4px",
cursor: "col-resize",
borderRadius: "2px",
margin: "0 2px",
}}
/>
{/* Main Panel - Metric Details and Results */}
<Panel defaultSize={67} minSize={55}>
<Box h="100%" p="md">
<Box
h="100%"
p="lg"
style={{
backgroundColor: "var(--mb-color-bg-white)",
border: "1px solid var(--mb-color-border)",
borderRadius: "8px",
}}
>
{children}
</Box>
</Box>
</Panel>
</PanelGroup>
</Box>
);
}

View File

@@ -0,0 +1,67 @@
import { Box } from "metabase/ui";
import { useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { BenchPanel } from "./components/Panel/Panel";
import { ModelsEntitiesList } from "./components/ModelsEntitiesList/ModelsEntitiesList";
import { ModelsDetails } from "./components/ModelsDetails/ModelsDetails";
import type { Card } from "metabase-types/api";
interface ModelsAppProps {
params?: {
modelId?: string;
};
}
export function ModelsApp({ params }: ModelsAppProps) {
const [selectedModel, setSelectedModel] = useState<Card | undefined>();
const handleModelClick = (model: Card) => {
setSelectedModel(model);
};
return (
<Box h="100vh" style={{ overflow: "hidden" }}>
<PanelGroup direction="horizontal">
{/* Left Panel - Models List */}
<Panel defaultSize={33} minSize={25} maxSize={45}>
<BenchPanel
title="Models"
height="100%"
createNewPath="/bench/models/new"
>
<ModelsEntitiesList
selectedModelId={selectedModel?.id}
onModelClick={handleModelClick}
/>
</BenchPanel>
</Panel>
<PanelResizeHandle
style={{
width: "4px",
cursor: "col-resize",
borderRadius: "2px",
margin: "0 2px",
}}
/>
{/* Main Panel - Model Details and Results */}
<Panel defaultSize={67} minSize={55}>
<Box h="100%" p="md">
<Box
h="100%"
p="lg"
style={{
backgroundColor: "var(--mb-color-bg-white)",
border: "1px solid var(--mb-color-border)",
borderRadius: "8px",
}}
>
<ModelsDetails model={selectedModel} />
</Box>
</Box>
</Panel>
</PanelGroup>
</Box>
);
}

View File

@@ -0,0 +1,53 @@
import { Box } from "metabase/ui";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { BenchPanel } from "./components/Panel/Panel";
import { SegmentsEntitiesList } from "./components/SegmentsEntitiesList/SegmentsEntitiesList";
interface SegmentsAppProps {
children?: React.ReactNode;
}
export function SegmentsApp({ children }: SegmentsAppProps) {
return (
<Box h="100vh" style={{ overflow: "hidden" }}>
<PanelGroup direction="horizontal">
{/* Left Panel - Segments List */}
<Panel defaultSize={33} minSize={25} maxSize={45}>
<BenchPanel
title="Segments"
height="100%"
createNewPath="/bench/segments/new"
>
<SegmentsEntitiesList />
</BenchPanel>
</Panel>
<PanelResizeHandle
style={{
width: "4px",
cursor: "col-resize",
borderRadius: "2px",
margin: "0 2px",
}}
/>
{/* Main Panel - Segment Details and Results */}
<Panel defaultSize={67} minSize={55}>
<Box h="100%" p="md">
<Box
h="100%"
p="lg"
style={{
backgroundColor: "var(--mb-color-bg-white)",
border: "1px solid var(--mb-color-border)",
borderRadius: "8px",
}}
>
{children}
</Box>
</Box>
</Panel>
</PanelGroup>
</Box>
);
}

View File

@@ -0,0 +1,164 @@
import { useMemo, useEffect } from "react";
import { t } from "ttag";
import {
ActionIcon,
Box,
Button,
Flex,
Icon,
Paper,
Stack,
Text,
Textarea,
Tooltip,
} from "metabase/ui";
import { useGetSuggestedMetabotPromptsQuery } from "metabase-enterprise/api";
import {
useMetabotAgent,
useMetabotChatHandlers,
} from "metabase-enterprise/metabot/hooks";
import { Messages } from "metabase-enterprise/metabot/components/MetabotChat/MetabotChatMessage";
import { MetabotThinking } from "metabase-enterprise/metabot/components/MetabotChat/MetabotThinking";
import ErrorBoundary from "metabase/ErrorBoundary";
export const BenchMetabot = () => {
const metabot = useMetabotAgent();
const { handleSubmitInput, handleRetryMessage, handleResetInput } =
useMetabotChatHandlers();
// Force the metabot to be visible for our bench tab
useEffect(() => {
if (!metabot.visible) {
metabot.setVisible(true);
}
}, [metabot]);
const hasMessages =
metabot.messages.length > 0 || metabot.errorMessages.length > 0;
const suggestedPromptsReq = useGetSuggestedMetabotPromptsQuery({
metabot_id: metabot.metabotId,
limit: 3,
sample: true,
});
const suggestedPrompts = useMemo(() => {
return suggestedPromptsReq.currentData?.prompts ?? [];
}, [suggestedPromptsReq.currentData?.prompts]);
return (
<ErrorBoundary errorComponent={() => null}>
<Box
style={{
height: "100%",
display: "flex",
flexDirection: "column",
padding: "16px",
}}
>
{/* Header */}
<Box style={{ marginBottom: "16px" }}>
<Flex justify="space-between" align="center">
<Text size="sm">
{t`Metabot isn't perfect. Double-check results.`}
</Text>
<Tooltip label={t`Clear conversation`} position="bottom">
<ActionIcon
onClick={() => metabot.resetConversation()}
data-testid="metabot-reset-chat"
>
<Icon name="revert" />
</ActionIcon>
</Tooltip>
</Flex>
</Box>
{/* Messages Area */}
<Box
style={{
flex: 1,
overflow: "auto",
marginBottom: "16px",
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: "8px",
padding: "8px",
}}
>
{!hasMessages && !metabot.isDoingScience && (
<Flex
h="100%"
gap="md"
direction="column"
align="center"
justify="center"
>
<Text size="lg" fw="bold" c="text-primary">
{t`Ask Metabot anything`}
</Text>
<Text size="sm" c="text-secondary" ta="center">
{t`Get help with SQL, data analysis, or transform development`}
</Text>
{suggestedPrompts.length > 0 && (
<Stack gap="xs" style={{ marginTop: "16px" }}>
<Text size="sm" c="text-secondary">
{t`Suggested prompts:`}
</Text>
{suggestedPrompts.map((prompt, index) => (
<Button
key={index}
variant="subtle"
size="xs"
onClick={() => handleSubmitInput(prompt)}
>
{prompt}
</Button>
))}
</Stack>
)}
</Flex>
)}
{hasMessages && (
<Messages
messages={metabot.messages}
errorMessages={metabot.errorMessages}
onRetryMessage={handleRetryMessage}
/>
)}
{metabot.isDoingScience && <MetabotThinking />}
</Box>
{/* Input Area */}
<Box>
<Flex gap="sm">
<Textarea
placeholder={t`Ask Metabot anything...`}
value={metabot.prompt || ""}
onChange={(e) => metabot.setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmitInput(metabot.prompt || "");
}
}}
style={{ flex: 1 }}
minRows={2}
maxRows={4}
/>
<ActionIcon
size="lg"
onClick={() => handleSubmitInput(metabot.prompt || "")}
disabled={
!(metabot.prompt || "").trim() || metabot.isDoingScience
}
>
<Icon name="send" />
</ActionIcon>
</Flex>
</Box>
</Box>
</ErrorBoundary>
);
};

View File

@@ -0,0 +1 @@
export { BenchMetabot } from "./BenchMetabot";

View File

@@ -0,0 +1,258 @@
import {
Box,
NavLink,
Stack,
Text,
Divider,
Icon,
Menu,
Group,
} from "metabase/ui";
import { withRouter } from "react-router";
import { t } from "ttag";
import { Link } from "react-router";
import { useState } from "react";
import { useKeyboardShortcut } from "metabase/common/hooks/use-keyboard-shortcut";
interface BenchSidebarProps {
location?: {
pathname: string;
};
}
function BenchSidebarComponent({ location }: BenchSidebarProps) {
const currentPath = location?.pathname || "";
const [isMenuOpen, setIsMenuOpen] = useState(false);
const isActive = (path: string) => {
if (path === "/bench/overview") {
return currentPath === "/bench" || currentPath === "/bench/overview";
}
if (path === "/bench/transforms") {
return currentPath.startsWith("/bench/transform");
}
return currentPath.startsWith(path);
};
const handleMenuToggle = () => {
setIsMenuOpen(!isMenuOpen);
};
const handleExploreClick = () => {
// Navigate to main app explore section
window.location.href = "/";
};
const handleWorkbenchClick = () => {
// Already in workbench, just close menu
setIsMenuOpen(false);
};
const handleDocsClick = () => {
// Open docs in new tab
window.open("https://www.metabase.com/docs", "_blank");
};
const handleAdminClick = () => {
// Navigate to admin section
window.location.href = "/admin";
};
const handleAccountClick = () => {
// Navigate to account settings
window.location.href = "/admin/people";
};
// Keyboard shortcuts
useKeyboardShortcut("1", (e) => {
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleExploreClick();
}
});
useKeyboardShortcut("2", (e) => {
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleWorkbenchClick();
}
});
useKeyboardShortcut("3", (e) => {
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
handleAdminClick();
}
});
return (
<Box
style={{
width: "280px",
height: "100%",
padding: "16px 0",
display: "flex",
flexDirection: "column",
borderRight: "1px solid var(--mb-color-border)",
}}
>
<Box p="lg">
<Menu
opened={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
position="bottom-start"
shadow="md"
width={200}
>
<Menu.Target>
<Group
style={{
cursor: "pointer",
userSelect: "none",
}}
onClick={handleMenuToggle}
>
<Text size="lg" fw="bold">
{t`Workbench`}
</Text>
<Icon
name="chevrondown"
size={12}
style={{
transform: isMenuOpen ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
/>
</Group>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Icon name="rocket" size={16} />}
rightSection={
<Text size="xs" c="dimmed">
⌘1
</Text>
}
onClick={handleExploreClick}
>
{t`Explore`}
</Menu.Item>
<Menu.Item
leftSection={<Icon name="notebook" size={16} />}
rightSection={
<Text size="xs" c="dimmed">
⌘2
</Text>
}
onClick={handleWorkbenchClick}
>
{t`Workbench`}
</Menu.Item>
<Menu.Item
leftSection={<Icon name="gear" size={16} />}
rightSection={
<Text size="xs" c="dimmed">
⌘3
</Text>
}
onClick={handleAdminClick}
>
{t`Admin`}
</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={handleDocsClick}>
<Text size="sm">{t`Docs`}</Text>
</Menu.Item>
<Menu.Item onClick={handleAccountClick}>
<Text size="sm">{t`Metabase Account`}</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Box>
<Stack gap={4} px="md" style={{ flex: 1 }}>
<Box>
<NavLink
component={Link}
to="/bench/overview"
label={t`Overview`}
leftSection={<Icon name="home" size={16} />}
active={isActive("/bench/overview")}
px="lg"
py="md"
/>
</Box>
<Divider />
<Box>
<NavLink
component={Link}
to="/bench/metadata"
label={t`Metadata`}
leftSection={<Icon name="database" size={16} />}
active={isActive("/bench/metadata")}
px="lg"
py="md"
/>
</Box>
<Divider />
<Box>
<NavLink
component={Link}
to="/bench/transforms"
label={t`Transforms`}
leftSection={<Icon name="sql" size={16} />}
active={isActive("/bench/transforms")}
px="lg"
py="md"
/>
</Box>
<Box>
<NavLink
component={Link}
to="/bench/segments"
label={t`Segments`}
leftSection={<Icon name="filter" size={16} />}
active={isActive("/bench/segments")}
px="lg"
py="md"
/>
</Box>
<Box>
<NavLink
component={Link}
to="/bench/models"
label={t`Models`}
leftSection={<Icon name="model" size={16} />}
active={isActive("/bench/models")}
px="lg"
py="md"
/>
</Box>
<Divider />
<Box>
<NavLink
component={Link}
to="/bench/metrics"
label={t`Metrics`}
leftSection={<Icon name="metric" size={16} />}
active={isActive("/bench/metrics")}
px="lg"
py="md"
/>
</Box>
</Stack>
<Box px="md" mt="auto" pt="lg">
<Divider mb="md" />
<Text size="xs">{t`Version 0.1.0`}</Text>
</Box>
</Box>
);
}
export const BenchSidebar = withRouter(BenchSidebarComponent);

View File

@@ -0,0 +1 @@
export { BenchSidebar } from "./BenchSidebar";

View File

@@ -0,0 +1,62 @@
import { ActionIcon, Box, Group, TextInput } from "metabase/ui";
import { Icon } from "metabase/ui";
interface BenchToolbarProps {
onMetabotToggle: () => void;
isMetabotOpen: boolean;
onSidebarToggle: () => void;
isSidebarOpen: boolean;
}
export function BenchToolbar({
onMetabotToggle,
isMetabotOpen,
onSidebarToggle,
isSidebarOpen,
}: BenchToolbarProps) {
return (
<Box
style={{
height: "48px",
borderBottom: "1px solid var(--mb-color-border)",
padding: "0 16px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
{/* Left navigation buttons */}
<Group gap="xs">
<ActionIcon onClick={onSidebarToggle}>
<Icon name={isSidebarOpen ? "sidebar_open" : "sidebar_closed"} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm">
<Icon name="chevronleft" />
</ActionIcon>
<ActionIcon variant="subtle" size="sm">
<Icon name="chevronright" />
</ActionIcon>
</Group>
{/* Search input - centered with max-width */}
<TextInput
placeholder="Search..."
leftSection={<Icon name="search" size={16} />}
style={{
flex: 1,
maxWidth: "600px",
margin: "0 16px",
}}
/>
<Group gap="xs">
<ActionIcon
variant={isMetabotOpen ? "filled" : "subtle"}
onClick={onMetabotToggle}
>
<Icon name="insight" />
</ActionIcon>
</Group>
</Box>
);
}

View File

@@ -0,0 +1 @@
export { BenchToolbar } from "./BenchToolbar";

View File

@@ -0,0 +1,131 @@
import { useEffect, useState } from "react";
import { useListCollectionsTreeQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { Box, Card, Checkbox, Stack, Text, Title } from "metabase/ui";
import type { Collection } from "metabase-types/api/collection";
import type { Transform } from "metabase-types/api/transform";
interface CollectionsCardProps {
transform?: Transform;
}
const LOCAL_STORAGE_KEY_PREFIX = "mb-transform-collection-link-";
export function CollectionsCard({ transform }: CollectionsCardProps) {
const [selectedCollectionIds, setSelectedCollectionIds] = useState<
Record<number, boolean>
>({});
const { data: collections, isLoading } = useListCollectionsTreeQuery({
"exclude-other-user-collections": true,
"exclude-archived": true,
});
// Load saved collection links from localStorage
useEffect(() => {
if (transform?.id && transform.table?.id) {
try {
const savedLinks = localStorage.getItem(
`${LOCAL_STORAGE_KEY_PREFIX}${transform.table.id}`,
);
if (savedLinks) {
const parsedLinks = JSON.parse(savedLinks);
setSelectedCollectionIds(parsedLinks);
} else {
setSelectedCollectionIds({});
}
} catch (e) {
console.error("Error loading collection links from localStorage", e);
setSelectedCollectionIds({});
}
} else {
setSelectedCollectionIds({});
}
}, [transform?.id, transform?.table?.id]);
const handleToggleCollection = (collectionId: number) => {
if (!transform?.id || !transform.table?.id) {
return;
}
const newSelectedCollections = {
...selectedCollectionIds,
[collectionId]: !selectedCollectionIds[collectionId],
};
// If the value is false, remove it from the object to keep it clean
if (!newSelectedCollections[collectionId]) {
delete newSelectedCollections[collectionId];
}
setSelectedCollectionIds(newSelectedCollections);
// Save to localStorage
try {
localStorage.setItem(
`${LOCAL_STORAGE_KEY_PREFIX}${transform.table.id}`,
JSON.stringify(newSelectedCollections),
);
} catch (e) {
console.error("Error saving collection links to localStorage", e);
}
};
const renderCollections = (collections: Collection[], level = 0) => {
return collections.map((collection) => (
<Box key={collection.id} pl={level * 16} mb="xs">
<Checkbox
label={collection.name}
checked={
!!selectedCollectionIds[
typeof collection.id === "number"
? collection.id
: parseInt(String(collection.id), 10)
]
}
onChange={() =>
handleToggleCollection(
typeof collection.id === "number"
? collection.id
: parseInt(String(collection.id), 10),
)
}
disabled={!transform?.table?.id}
/>
{collection.children && collection.children.length > 0 && (
<Box mt="xs">{renderCollections(collection.children, level + 1)}</Box>
)}
</Box>
));
};
if (isLoading) {
return <LoadingAndErrorWrapper loading />;
}
return (
<Card withBorder p="md">
<Stack gap="md">
<Title order={5}>Link to Collections</Title>
<Text>Select collections to show this transform output</Text>
{(!transform || !transform.table?.id) && (
<Text c="red">
The transform needs a target table to be linked with collections
</Text>
)}
<Box style={{ maxHeight: 300, overflow: "auto" }}>
<Stack gap="sm">
{collections && renderCollections(collections)}
</Stack>
</Box>
<Text size="xs">
Selected collections will display this transform output
</Text>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1 @@
export { CollectionsCard } from "./CollectionsCard";

View File

@@ -0,0 +1,148 @@
import { Box, Stack, Text, Loader } from "metabase/ui";
import { CodeMirror } from "metabase/common/components/CodeMirror";
import { Table } from "@mantine/core";
import { useGetCardQuery, useGetCardQueryQuery } from "metabase/api";
import { skipToken } from "metabase/api";
import type { Card, Dataset } from "metabase-types/api";
interface MetricsDetailsProps {
params: {
metricId: string;
};
}
export function MetricsDetails({ params }: MetricsDetailsProps) {
const metricId = parseInt(params.metricId, 10);
// Load the metric definition
const { data: metricData, isLoading: isLoadingMetric } = useGetCardQuery({
id: metricId,
});
// Run the metric query to get results
const { data: queryResults, isLoading: isLoadingResults } =
useGetCardQueryQuery({
cardId: metricId,
parameters: [],
ignore_cache: false,
});
if (isLoadingMetric) {
return (
<Box
h="100%"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text size="lg">Loading metric...</Text>
</Box>
);
}
return (
<Box h="100%" style={{ overflow: "hidden" }}>
<Stack h="100%" gap="md">
{/* Metric Definition - Notebook Mode */}
<Box
style={{
flex: 1,
minHeight: 0,
borderRadius: "8px",
overflow: "hidden",
}}
>
<Box p="md">
<Text size="lg" fw="bold">
{metricData?.name}
</Text>
<Text size="sm">Metric Definition</Text>
</Box>
<Box style={{ flex: 1, height: "400px" }}>
<CodeMirror
value={
metricData?.dataset_query
? JSON.stringify(metricData.dataset_query, null, 2)
: "No query definition available"
}
options={{
readOnly: true,
mode: "application/json",
lineNumbers: true,
}}
/>
</Box>
</Box>
{/* Query Results */}
<Box
style={{
height: "300px",
borderRadius: "8px",
overflow: "hidden",
}}
>
<Box p="md">
<Text size="lg" fw="bold">
Query Results
</Text>
<Text size="sm">Metric execution output</Text>
</Box>
<Box style={{ height: "calc(100% - 60px)", overflow: "auto" }}>
{isLoadingResults ? (
<Box p="md" ta="center">
<Loader size="sm" />
<Text mt="sm">Running query...</Text>
</Box>
) : queryResults ? (
<Box p="md">
<Text size="sm" mb="sm">
{queryResults.data?.rows?.length || 0} rows returned
</Text>
{queryResults.data?.rows &&
queryResults.data.rows.length > 0 ? (
<Table>
<Table.Thead>
<Table.Tr>
{queryResults.data.cols.map((col, index) => (
<Table.Th key={index}>{col.display_name}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{queryResults.data.rows
.slice(0, 100)
.map((row, rowIndex) => (
<Table.Tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Td key={cellIndex}>
{cell === null ? (
<Text fs="italic">null</Text>
) : (
String(cell)
)}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text>No data returned</Text>
)}
</Box>
) : (
<Box p="md" ta="center">
<Text>No results available</Text>
</Box>
)}
</Box>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import { Button, NavLink, Stack, Text } from "metabase/ui";
import { Icon } from "metabase/ui";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useFetchMetrics } from "metabase/common/hooks/use-fetch-metrics";
import type { Card } from "metabase-types/api";
import Link from "metabase/common/components/Link";
interface MetricsEntitiesListProps {
selectedMetricId?: number;
onMetricClick?: (metric: Card) => void;
}
export function MetricsEntitiesList({
selectedMetricId,
onMetricClick,
}: MetricsEntitiesListProps) {
const { data: searchResponse, isLoading, error } = useFetchMetrics();
if (isLoading) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
if (error) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
const metrics = searchResponse?.data || [];
return (
<Stack spacing="md">
{metrics.length === 0 ? (
<Text size="sm" ta="center" py="md">
No metrics found
</Text>
) : (
metrics.map((metric) => (
<NavLink
key={metric.id}
component={Link}
to={`/bench/metrics/${metric.id}`}
label={metric.name}
leftSection={<Icon name="metric" size={16} />}
active={selectedMetricId === metric.id}
onClick={() => onMetricClick?.(metric)}
px="lg"
py="md"
/>
))
)}
</Stack>
);
}

View File

@@ -0,0 +1,182 @@
import { Box, Stack, Text, Loader } from "metabase/ui";
import { CodeMirror } from "metabase/common/components/CodeMirror";
import { Table } from "@mantine/core";
import { useGetCardQuery, useGetCardQueryQuery } from "metabase/api";
import { skipToken } from "metabase/api";
import type { Card, Dataset } from "metabase-types/api";
interface ModelsDetailsProps {
model?: Card;
}
export function ModelsDetails({ model }: ModelsDetailsProps) {
// Load the model definition
const { data: modelData, isLoading: isLoadingModel } = useGetCardQuery(
model?.id ? { id: model.id } : skipToken,
);
// Run the model query to get results
const { data: queryResults, isLoading: isLoadingResults } =
useGetCardQueryQuery(
model?.id
? {
cardId: model.id,
parameters: [],
ignore_cache: false,
}
: skipToken,
);
if (!model) {
return (
<Box
h="100%"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text size="lg">Select a model to view its definition and results</Text>
</Box>
);
}
if (isLoadingModel) {
return (
<Box
h="100%"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text>Loading model...</Text>
</Box>
);
}
return (
<Box h="100%" style={{ overflow: "hidden" }}>
<Stack h="100%" gap="md">
{/* Model Definition - Notebook Mode */}
<Box
style={{
flex: 1,
minHeight: 0,
borderRadius: "8px",
overflow: "hidden",
}}
>
<Box
p="md"
style={{
borderBottom: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3]}`,
backgroundColor: isDark
? theme.colors.dark[6]
: theme.colors.gray[0],
}}
>
<Text size="lg" fw="bold">
{model.name}
</Text>
<Text size="sm">Model Definition</Text>
</Box>
<Box style={{ flex: 1, height: "400px" }}>
<CodeMirror
value={
modelData?.dataset_query
? JSON.stringify(modelData.dataset_query, null, 2)
: "No query definition available"
}
options={{
readOnly: true,
mode: "application/json",
lineNumbers: true,
theme: isDark ? "material-darker" : "default",
}}
/>
</Box>
</Box>
{/* Query Results */}
<Box
style={{
height: "300px",
border: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3]}`,
borderRadius: "8px",
overflow: "hidden",
}}
>
<Box
p="md"
style={{
borderBottom: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[3]}`,
backgroundColor: isDark
? theme.colors.dark[6]
: theme.colors.gray[0],
}}
>
<Text size="lg" fw="bold">
Query Results
</Text>
<Text size="sm">Model execution output</Text>
</Box>
<Box style={{ height: "calc(100% - 60px)", overflow: "auto" }}>
{isLoadingResults ? (
<Box p="md" ta="center">
<Loader size="sm" />
<Text mt="sm">Running query...</Text>
</Box>
) : queryResults ? (
<Box p="md">
<Text size="sm" mb="sm">
{queryResults.data?.rows?.length || 0} rows returned
</Text>
{queryResults.data?.rows &&
queryResults.data.rows.length > 0 ? (
<Table>
<Table.Thead>
<Table.Tr>
{queryResults.data.cols.map((col, index) => (
<Table.Th key={index}>{col.display_name}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{queryResults.data.rows
.slice(0, 100)
.map((row, rowIndex) => (
<Table.Tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<Table.Td key={cellIndex}>
{cell === null ? (
<Text fs="italic">null</Text>
) : (
String(cell)
)}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text>No data returned</Text>
)}
</Box>
) : (
<Box p="md" ta="center">
<Text>No results available</Text>
</Box>
)}
</Box>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import { NavLink, Text } from "metabase/ui";
import { Icon } from "metabase/ui";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useFetchModels } from "metabase/common/hooks/use-fetch-models";
import type { Card } from "metabase-types/api";
interface ModelsEntitiesListProps {
selectedModelId?: number;
onModelClick?: (model: Card) => void;
}
export function ModelsEntitiesList({
selectedModelId,
onModelClick,
}: ModelsEntitiesListProps) {
const { data: searchResponse, isLoading, error } = useFetchModels();
if (isLoading) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
if (error) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
const models = searchResponse?.data || [];
if (models.length === 0) {
return (
<Text size="sm" ta="center" py="md">
No models found
</Text>
);
}
return (
<>
{models.map((model) => (
<NavLink
key={model.id}
label={model.name}
leftSection={<Icon name="model" size={16} />}
active={selectedModelId === model.id}
onClick={() => onModelClick?.(model)}
px="lg"
py="md"
/>
))}
</>
);
}

View File

@@ -0,0 +1,130 @@
import { Box, Stack, Text, Button, Group } from "metabase/ui";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useSelector, useDispatch } from "metabase/lib/redux";
import { Notebook } from "metabase/querying/notebook/components/Notebook";
import { getMetadata } from "metabase/selectors/metadata";
import { useSetting } from "metabase/common/hooks";
import { Icon } from "metabase/ui";
import Question from "metabase-lib/v1/Question";
import * as Lib from "metabase-lib";
import type { DatasetQuery } from "metabase-types/api";
export function NewMetricPage() {
const metadata = useSelector(getMetadata);
const reportTimezone = useSetting("report-timezone-long");
const [question, setQuestion] = useState<Question | null>(null);
const [isRunnable, setIsRunnable] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [isResultDirty, setIsResultDirty] = useState(false);
// Create Question when metadata is available
useEffect(() => {
if (metadata && metadata.databasesList().length > 0) {
// Get the first available database
const firstDatabase = metadata.databasesList()[0];
// Create a new Question with the first available database
const newQuestion = Question.create({
databaseId: firstDatabase.id,
metadata,
});
setQuestion(newQuestion);
}
}, [metadata]);
const handleUpdateQuestion = useCallback(async (newQuestion: Question) => {
setQuestion(newQuestion);
setIsDirty(true);
setIsResultDirty(true);
// Check if the question is runnable (has a source table selected)
const queryInfo = Lib.queryDisplayInfo(newQuestion.query());
setIsRunnable(!!queryInfo.isEditable);
}, []);
const handleRunQuery = useCallback(async () => {
// This would normally run the query and show results
console.log("Running query for metric:", question.query());
}, [question]);
const handleSaveMetric = useCallback(() => {
// This would normally save the metric
console.log("Saving metric:", question);
// For now, just navigate back to metrics list
}, [question]);
const handleCancel = useCallback(() => {
alert("Cancel");
}, []);
return (
<Box h="100%" style={{ overflow: "hidden" }}>
<Stack h="100%" gap="md">
{/* Header with title and actions */}
<Box>
<Group position="apart" style={{ borderBottom: "1px solid var(--mb-color-border)" }}>
<Box>
New metric
</Box>
<Group ml="auto">
<Button variant="subtle" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button
variant="filled"
leftIcon={<Icon name="check" />}
onClick={handleSaveMetric}
disabled={!isRunnable}
>
Save
</Button>
</Group>
</Group>
</Box>
{/* Notebook Editor */}
<Box
style={{
flex: 1,
minHeight: 0,
borderRadius: "8px",
overflow: "hidden",
}}
>
<Box
style={{ flex: 1, height: "calc(100% - 60px)", overflow: "auto" }}
>
{question ? (
<Notebook
question={question}
updateQuestion={handleUpdateQuestion}
runQuestionQuery={handleRunQuery}
isDirty={isDirty}
isRunnable={isRunnable}
isResultDirty={isResultDirty}
reportTimezone={reportTimezone}
hasVisualizeButton={true}
readOnly={false}
/>
) : (
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
}}
>
<Text>Loading notebook editor...</Text>
</Box>
)}
</Box>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,48 @@
import { Box, Text, Group, Button } from "metabase/ui";
import { Link } from "react-router";
import { t } from "ttag";
export function NewSegmentPage() {
return (
<Box>
{/* New Segment Toolbar */}
<Group justify="space-between" mb="md">
<Box>
<Text size="xl" fw="bold" mb="xs">
{t`Create New Segment`}
</Text>
<Text size="sm" c="dimmed">
{t`Define a new segment for your data`}
</Text>
</Box>
<Group>
<Button
component={Link}
to="/bench/segments"
variant="outline"
>
{t`Cancel`}
</Button>
</Group>
</Group>
{/* New Segment Form */}
<Box
style={{
backgroundColor: "var(--mb-color-bg-white)",
border: "1px solid var(--mb-color-border)",
borderRadius: "8px",
padding: "24px",
}}
>
<Text size="sm" c="dimmed" mb="md">
{t`Segment creation form`}
</Text>
{/* TODO: Integrate existing segment form component here */}
<Text size="sm" c="dimmed">
{t`Segment form integration coming soon...`}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,161 @@
import {
Stack,
Title,
Box,
Button,
Group,
Menu,
Icon,
Flex,
} from "metabase/ui";
import { Link } from "react-router";
import { t } from "ttag";
import type { ReactNode } from "react";
interface CreateNewOption {
label: string;
path: string;
}
interface CreateNewSegmented {
defaultPath: string;
options: CreateNewOption[];
}
interface BenchPanelProps {
title: string;
children: ReactNode;
height?: string | number;
createNewPath?: string;
createNewSegmented?: CreateNewSegmented;
}
export function BenchPanel({
title,
children,
height = "100%",
createNewPath,
createNewSegmented,
}: BenchPanelProps) {
return (
<Box
style={{
width: "100%",
height,
display: "flex",
flexDirection: "column",
backgroundColor: "var(--mb-color-bg-secondary)",
borderRight: "1px solid var(--mb-color-border)",
}}
>
<Box
p="md"
style={{
borderBottom: "1px solid var(--mb-color-border)",
backgroundColor: "var(--mb-color-bg-secondary)",
}}
>
<Flex>
<Group gap="xs" justify="space-between" style={{ width: "100%" }}>
<Menu position="bottom-start" shadow="md">
<Menu.Target>
<Button
variant="subtle"
size="xs"
rightSection={<Icon name="chevrondown" size={12} />}
>
{t`All ${title}`}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item disabled>{t`Filters coming soon`}</Menu.Item>
</Menu.Dropdown>
</Menu>
<Group gap="xs">
<Menu position="bottom-start" shadow="md">
<Menu.Target>
<Button
variant="subtle"
size="xs"
rightSection={<Icon name="chevrondown" size={12} />}
>
<Icon name="sort" size={12} />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item>{t`Alphabetical`}</Menu.Item>
<Menu.Item>{t`Most recent`}</Menu.Item>
<Menu.Item>{t`Least recent`}</Menu.Item>
</Menu.Dropdown>
</Menu>
{createNewSegmented ? (
<Group gap={0}>
<Button
component={Link}
to={createNewSegmented.defaultPath}
variant="light"
size="xs"
style={{
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderRight: "1px solid var(--mb-color-border)",
paddingLeft: "8px",
paddingRight: "8px",
}}
>
<Icon name="add" size={12} />
</Button>
<Menu position="bottom-end" shadow="md">
<Menu.Target>
<Button
variant="light"
size="xs"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
paddingLeft: "8px",
paddingRight: "8px",
}}
>
<Icon name="chevrondown" size={12} />
</Button>
</Menu.Target>
<Menu.Dropdown>
{createNewSegmented.options.map((option) => (
<Menu.Item
key={option.path}
component={Link}
to={option.path}
>
{option.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
</Group>
) : createNewPath ? (
<Button
component={Link}
to={createNewPath}
variant="light"
size="xs"
style={{
paddingLeft: "6px",
paddingRight: "6px",
}}
>
<Icon name="add" size={12} />
</Button>
) : null}
</Group>
</Group>
</Flex>
</Box>
<Box p="md" style={{ flex: 1, overflow: "auto" }}>
<Stack gap="sm">{children}</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,284 @@
import {
Box,
Button,
Group,
Loader,
Stack,
Text,
} from "metabase/ui";
import { Icon } from "metabase/ui";
import { useState, forwardRef, useImperativeHandle } from "react";
import { useGetAdhocQueryQuery } from "metabase/api";
import { skipToken } from "metabase/api";
import type { DatasetQuery, Dataset } from "metabase-types/api";
interface QueryPreviewProps {
query: string;
databaseId?: number | null;
onRunQuery?: () => void;
}
interface QueryPreviewRef {
runQuery: () => void;
}
export const QueryPreview = forwardRef<QueryPreviewRef, QueryPreviewProps>(
function QueryPreview({ query, databaseId, onRunQuery }, ref) {
const [shouldExecute, setShouldExecute] = useState(false);
const datasetQuery: DatasetQuery | typeof skipToken =
databaseId && shouldExecute
? {
type: "native",
native: {
query: query.trim(),
},
database: databaseId,
}
: skipToken;
const {
data: dataset,
isLoading,
error,
refetch,
} = useGetAdhocQueryQuery(datasetQuery, {
skip: !shouldExecute || !databaseId || !query.trim(),
});
const handleRunQuery = () => {
if (databaseId && query.trim()) {
setShouldExecute(true);
onRunQuery?.();
if (shouldExecute) {
refetch();
}
}
};
const handleClearResults = () => {
setShouldExecute(false);
};
useImperativeHandle(ref, () => ({
runQuery: handleRunQuery,
}));
if (!databaseId) {
return (
<Box p="md" style={{ textAlign: "center" }}>
<Text size="sm">Select a transform to preview queries</Text>
</Box>
);
}
return (
<Stack gap="md" h="100%">
<Group
justify="space-between"
p="md"
style={{
borderBottom: `1px solid var(--mb-color-border)`,
backgroundColor: "var(--mb-color-bg-light)",
}}
>
<Group gap="sm">
<Text fw={500} size="sm">
Query Preview
</Text>
{isLoading && <Loader size="xs" />}
</Group>
<Group gap="sm">
<Button
size="xs"
variant="filled"
leftSection={<Icon name="play" size={12} />}
onClick={handleRunQuery}
disabled={!query.trim() || isLoading}
>
Run (⌘↵)
</Button>
{shouldExecute && (
<Button
size="xs"
variant="subtle"
leftSection={<Icon name="close" size={12} />}
onClick={handleClearResults}
>
Clear
</Button>
)}
</Group>
</Group>
<Box style={{ flex: 1, overflow: "auto" }} p="md">
{error && (
<Box
p="md"
style={{
backgroundColor: "var(--mb-color-bg-error)",
border: "1px solid var(--mb-color-border-error)",
borderRadius: "4px",
}}
>
<Group gap="sm" align="flex-start">
<Icon
name="warning"
size={16}
color="var(--mb-color-text-error)"
/>
<Stack gap="xs">
<Text fw={500} size="sm" c="error">
Query Error
</Text>
<Text size="sm" c="error">
{error && typeof error === "object" && "data" in error
? (error.data as any)?.message || "Unknown error occurred"
: String(error)}
</Text>
</Stack>
</Group>
</Box>
)}
{isLoading && (
<Box style={{ textAlign: "center" }} py="xl">
<Loader size="md" />
<Text size="sm" mt="md">
Executing query...
</Text>
</Box>
)}
{dataset && !isLoading && !error && (
<Box>
<Group mb="md" gap="md">
<Text size="sm" fw={500}>
Results: {dataset.data.rows.length} rows
</Text>
<Text size="sm">{dataset.data.cols.length} columns</Text>
{dataset.running_time && (
<Text size="sm">{dataset.running_time}ms</Text>
)}
</Group>
{dataset.data.rows.length > 0 ? (
<Box
style={{
overflowX: "auto",
border: "1px solid var(--mb-color-border)",
borderRadius: "4px",
}}
>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr
style={{
backgroundColor: "var(--mb-color-bg-light)",
}}
>
{dataset.data.cols.map((col, index) => (
<th
key={index}
style={{
padding: "8px 12px",
textAlign: "left",
borderRight:
index < dataset.data.cols.length - 1
? "1px solid var(--mb-color-border)"
: "none",
fontSize: "12px",
fontWeight: 500,
}}
>
{col.display_name || col.name}
</th>
))}
</tr>
</thead>
<tbody>
{dataset.data.rows.slice(0, 100).map((row, rowIndex) => (
<tr
key={rowIndex}
style={{
borderBottom:
rowIndex <
Math.min(dataset.data.rows.length, 100) - 1
? "1px solid var(--mb-color-border)"
: "none",
}}
>
{row.map((cell, cellIndex) => (
<td
key={cellIndex}
style={{
padding: "8px 12px",
borderRight:
cellIndex < row.length - 1
? "1px solid var(--mb-color-border)"
: "none",
fontSize: "12px",
maxWidth: "200px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{cell === null ? (
<Text size="xs" fs="italic">
null
</Text>
) : (
String(cell)
)}
</td>
))}
</tr>
))}
</tbody>
</table>
{dataset.data.rows.length > 100 && (
<Box
p="md"
style={{
textAlign: "center",
backgroundColor: "var(--mb-color-bg-light)",
}}
>
<Text size="sm">
Showing first 100 of {dataset.data.rows.length} rows
</Text>
</Box>
)}
</Box>
) : (
<Box
p="md"
style={{
textAlign: "center",
backgroundColor: "var(--mb-color-bg-light)",
border: "1px solid var(--mb-color-border)",
borderRadius: "4px",
}}
>
<Text size="sm">
Query executed successfully but returned no rows
</Text>
</Box>
)}
</Box>
)}
{!shouldExecute && (
<Box style={{ textAlign: "center" }} py="xl">
<Icon name="play" size={32} color="var(--mb-color-text-light)" />
<Text size="sm" mt="md">
Press ⌘↵ or click Run to execute the query
</Text>
</Box>
)}
</Box>
</Stack>
);
},
);

View File

@@ -0,0 +1,107 @@
import { Box, Text, Group, Button, Divider } from "metabase/ui";
import { useGetSegmentQuery } from "metabase/api/segment";
import { t } from "ttag";
import { Link } from "react-router";
interface SegmentsDetailsProps {
params: {
segmentId: string;
};
}
export function SegmentsDetails({ params }: SegmentsDetailsProps) {
const segmentId = parseInt(params.segmentId, 10);
const { data: segment, isLoading, error } = useGetSegmentQuery(segmentId);
if (isLoading) {
return (
<Box>
<Text size="sm">{t`Loading segment...`}</Text>
</Box>
);
}
if (error) {
return (
<Box>
<Text size="sm" c="error">{t`Error loading segment`}</Text>
</Box>
);
}
if (!segment) {
return (
<Box>
<Text size="sm">{t`Segment not found`}</Text>
</Box>
);
}
return (
<Box>
{/* Segment Toolbar */}
<Group justify="space-between" mb="md">
<Box>
<Text size="xl" fw="bold" mb="xs">
{segment.name}
</Text>
<Text size="sm" c="dimmed">
{t`Segment definition`}
</Text>
</Box>
<Group>
<Button
component={Link}
to={`/bench/segments/${segment.id}/edit`}
variant="outline"
>
{t`Edit`}
</Button>
</Group>
</Group>
<Divider mb="lg" />
{/* Segment Content */}
<Box>
<Text size="lg" fw="bold" mb="md">
{t`Definition`}
</Text>
{segment.description && (
<Box mb="md">
<Text size="sm" mb="xs">
{t`Description`}
</Text>
<Text>{segment.description}</Text>
</Box>
)}
<Box mb="md">
<Text size="sm" mb="xs">
{t`Table`}
</Text>
<Text>{segment.table?.display_name || segment.table?.name}</Text>
</Box>
{/* Query Builder Component - Keep existing implementation */}
<Box
style={{
backgroundColor: "var(--mb-color-bg-white)",
border: "1px solid var(--mb-color-border)",
borderRadius: "8px",
padding: "16px",
}}
>
<Text size="sm" mb="md">
{t`Segment filter definition`}
</Text>
{/* TODO: Integrate existing query builder component here */}
<Text size="sm">
{t`Query builder integration coming soon...`}
</Text>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import { Box, NavLink, Text } from "metabase/ui";
import { Link } from "react-router";
import { useListSegmentsQuery } from "metabase/api/segment";
import { t } from "ttag";
interface SegmentsEntitiesListProps {
selectedSegmentId?: number;
onSegmentClick?: (segment: any) => void;
}
export function SegmentsEntitiesList({
selectedSegmentId,
onSegmentClick
}: SegmentsEntitiesListProps) {
const { data: segments = [], isLoading, error } = useListSegmentsQuery();
if (isLoading) {
return (
<Box p="lg">
<Text size="sm" c="dimmed">{t`Loading segments...`}</Text>
</Box>
);
}
if (error) {
return (
<Box p="lg">
<Text size="sm" c="error">{t`Error loading segments`}</Text>
</Box>
);
}
if (segments.length === 0) {
return (
<Box p="lg">
<Text size="sm" c="dimmed">
{t`No segments found. Create your first segment to get started.`}
</Text>
</Box>
);
}
return (
<Box>
{segments.map((segment) => (
<NavLink
key={segment.id}
component={Link}
to={`/bench/segments/${segment.id}`}
label={segment.name}
active={selectedSegmentId === segment.id}
px="lg"
py="md"
onClick={() => onSegmentClick?.(segment)}
/>
))}
</Box>
);
}

View File

@@ -0,0 +1,130 @@
import { Badge, Box, Group, Stack, Text } from "metabase/ui";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useGetTransformQuery } from "metabase-enterprise/api";
import { skipToken } from "metabase/api";
import type { TransformId } from "metabase-types/api";
interface TransformDetailsProps {
transformId?: TransformId;
}
export function TransformDetails({ transformId }: TransformDetailsProps) {
const {
data: transform,
isLoading,
error,
} = useGetTransformQuery(transformId ?? skipToken);
if (!transformId) {
return (
<Text size="sm" ta="center" py="md">
Select a transform to view details
</Text>
);
}
if (isLoading || error) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
if (!transform) {
return (
<Text size="sm" ta="center" py="md">
Transform not found
</Text>
);
}
const getStatusColor = (status?: string) => {
switch (status) {
case "succeeded":
return "green";
case "failed":
return "red";
case "started":
case "canceling":
return "yellow";
case "canceled":
return "gray";
default:
return "gray";
}
};
return (
<Stack gap="md">
<Box>
<Text size="xs" fw={500} mb="xs">
TRANSFORM NAME
</Text>
<Text size="sm" fw={500}>
{transform.name}
</Text>
</Box>
{transform.description && (
<Box>
<Text size="xs" fw={500} mb="xs">
DESCRIPTION
</Text>
<Text size="sm">{transform.description}</Text>
</Box>
)}
<Box>
<Text size="xs" fw={500} mb="xs">
TARGET TABLE
</Text>
<Text size="sm">
{transform.target.schema ? `${transform.target.schema}.` : ""}
{transform.target.name}
</Text>
</Box>
{transform.last_run && (
<Box>
<Text size="xs" fw={500} mb="xs">
LAST RUN STATUS
</Text>
<Group gap="xs">
<Badge
size="sm"
color={getStatusColor(transform.last_run.status)}
variant="light"
>
{transform.last_run.status}
</Badge>
{transform.last_run.end_time && (
<Text size="xs">
{new Date(transform.last_run.end_time).toLocaleString()}
</Text>
)}
</Group>
{transform.last_run.message && (
<Text size="xs" mt="xs">
{transform.last_run.message}
</Text>
)}
</Box>
)}
<Box>
<Text size="xs" fw={500} mb="xs">
CREATED
</Text>
<Text size="sm">
{new Date(transform.created_at).toLocaleDateString()}
</Text>
</Box>
<Box>
<Text size="xs" fw={500} mb="xs">
LAST UPDATED
</Text>
<Text size="sm">
{new Date(transform.updated_at).toLocaleDateString()}
</Text>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,45 @@
import { NavLink, Text } from "metabase/ui";
import { Icon } from "metabase/ui";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useListTransformsQuery } from "metabase-enterprise/api";
import type { Transform } from "metabase-types/api";
interface TransformEntitiesListProps {
selectedTransformId?: number;
onTransformClick?: (transform: Transform) => void;
}
export function TransformEntitiesList({
selectedTransformId,
onTransformClick,
}: TransformEntitiesListProps) {
const { data: transforms = [], isLoading, error } = useListTransformsQuery();
if (isLoading || error) {
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
}
if (transforms.length === 0) {
return (
<Text size="sm" ta="center" py="md">
No transforms found
</Text>
);
}
return (
<>
{transforms.map((transform) => (
<NavLink
key={transform.id}
label={transform.name}
leftSection={<Icon name="model" size={16} />}
active={selectedTransformId === transform.id}
onClick={() => onTransformClick?.(transform)}
px="lg"
py="md"
/>
))}
</>
);
}

View File

@@ -0,0 +1,294 @@
import { Link } from "react-router";
import { t } from "ttag";
import { useSetting } from "metabase/common/hooks";
import { useMetadataToasts } from "metabase/metadata/hooks";
import {
Box,
Card,
Group,
Icon,
Stack,
Text,
Button,
Loader,
Tooltip,
} from "metabase/ui";
import {
useRunTransformMutation,
useCancelCurrentTransformRunMutation,
} from "metabase-enterprise/api";
import { trackTransformTriggerManualRun } from "metabase-enterprise/transforms/analytics";
import { parseTimestampWithTimezone } from "metabase-enterprise/transforms/utils";
import type { Transform, TransformRun } from "metabase-types/api";
import { useState, useEffect } from "react";
interface TransformExecutionCardProps {
transform?: Transform;
}
export function TransformExecutionCard({
transform,
}: TransformExecutionCardProps) {
const systemTimezone = useSetting("system-timezone");
const [runTransform] = useRunTransformMutation();
const [cancelTransform] = useCancelCurrentTransformRunMutation();
const { sendErrorToast } = useMetadataToasts();
const [isRecentRun, setIsRecentRun] = useState(false);
const lastRun = transform?.last_run;
// Reset recent run indicator after 5 seconds
useEffect(() => {
if (lastRun?.status) {
setIsRecentRun(true);
const timeout = setTimeout(() => setIsRecentRun(false), 5000);
return () => clearTimeout(timeout);
}
}, [lastRun?.status, lastRun?.end_time]);
const handleRun = async () => {
if (!transform) return;
trackTransformTriggerManualRun({
transformId: transform.id,
triggeredFrom: "bench-workbench",
});
const { error } = await runTransform(transform.id);
if (error) {
sendErrorToast(t`Failed to run transform`);
}
};
const handleCancel = async () => {
if (!transform) return;
const { error } = await cancelTransform(transform.id);
if (error) {
sendErrorToast(t`Failed to cancel transform`);
}
};
if (!transform) {
return (
<Card withBorder p="md">
<Text size="sm">{t`Select a transform to see execution details`}</Text>
</Card>
);
}
return (
<Card withBorder p="md">
<Stack gap="md">
<Text fw="bold" size="sm">
{t`Transform Execution`}
</Text>
<ExecutionStatus
lastRun={lastRun}
systemTimezone={systemTimezone}
transformId={transform.id}
/>
<RunButton
lastRun={lastRun}
isRecentRun={isRecentRun}
onRun={handleRun}
onCancel={handleCancel}
/>
</Stack>
</Card>
);
}
interface ExecutionStatusProps {
lastRun?: TransformRun | null;
systemTimezone: string;
transformId: number;
}
function ExecutionStatus({
lastRun,
systemTimezone,
transformId,
}: ExecutionStatusProps) {
if (!lastRun) {
return (
<Group gap="sm">
<Icon c="text-secondary" name="calendar" size="sm" />
<Text size="sm">{t`This transform hasn't been run before.`}</Text>
</Group>
);
}
const { status, end_time, message } = lastRun;
const endTime = end_time
? parseTimestampWithTimezone(end_time, systemTimezone)
: null;
const endTimeText = endTime?.fromNow();
switch (status) {
case "started":
return (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">{t`Run in progress…`}</Text>
</Group>
);
case "succeeded":
return (
<Group gap="sm">
<Icon c="success" name="check_filled" size="sm" />
<Text size="sm">
{endTimeText
? t`Last ran ${endTimeText} successfully.`
: t`Last ran successfully.`}
</Text>
</Group>
);
case "failed":
return (
<Stack gap="xs">
<Group gap="sm">
<Icon c="error" name="warning" size="sm" />
<Text size="sm">
{endTimeText
? t`Last run failed ${endTimeText}.`
: t`Last run failed.`}
</Text>
</Group>
{message && (
<Text size="xs" c="error" style={{ marginLeft: "20px" }}>
{message}
</Text>
)}
</Stack>
);
case "timeout":
return (
<Stack gap="xs">
<Group gap="sm">
<Icon c="error" name="warning" size="sm" />
<Text size="sm">
{endTimeText
? t`Last run timed out ${endTimeText}.`
: t`Last run timed out.`}
</Text>
</Group>
{message && (
<Text size="xs" c="error" style={{ marginLeft: "20px" }}>
{message}
</Text>
)}
</Stack>
);
case "canceling":
return (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">{t`Canceling…`}</Text>
</Group>
);
case "canceled":
return (
<Group gap="sm">
<Icon c="text-secondary" name="close" size="sm" />
<Text size="sm">
{endTimeText
? t`Last run was canceled ${endTimeText}.`
: t`Last run was canceled.`}
</Text>
</Group>
);
default:
return null;
}
}
interface RunButtonProps {
lastRun?: TransformRun | null;
isRecentRun: boolean;
onRun: () => void;
onCancel: () => void;
}
function RunButton({ lastRun, isRecentRun, onRun, onCancel }: RunButtonProps) {
const isRunning = lastRun?.status === "started";
const isCanceling = lastRun?.status === "canceling";
if (isRunning) {
return (
<Group>
<Button variant="filled" leftSection={<Loader size="sm" />} disabled>
{t`Running now…`}
</Button>
<Tooltip label={t`Cancel`}>
<Button
variant="outline"
onClick={onCancel}
leftSection={<Icon name="close" />}
>
{t`Cancel`}
</Button>
</Tooltip>
</Group>
);
}
if (isCanceling) {
return (
<Button variant="filled" leftSection={<Loader size="sm" />} disabled>
{t`Canceling…`}
</Button>
);
}
if (isRecentRun && lastRun) {
let buttonProps = {};
switch (lastRun.status) {
case "succeeded":
buttonProps = {
color: "green",
leftSection: <Icon name="check" />,
children: t`Ran successfully`,
};
break;
case "failed":
case "timeout":
buttonProps = {
color: "red",
leftSection: <Icon name="warning" />,
children: t`Run failed`,
};
break;
case "canceled":
buttonProps = {
variant: "outline",
leftSection: <Icon name="close" />,
children: t`Canceled`,
};
break;
default:
buttonProps = {
leftSection: <Icon name="play_outlined" />,
children: t`Run now`,
};
}
return <Button {...buttonProps} onClick={onRun} />;
}
return (
<Button leftSection={<Icon name="play_outlined" />} onClick={onRun}>
{t`Run now`}
</Button>
);
}

View File

@@ -0,0 +1 @@
export { TransformExecutionCard } from "./TransformExecutionCard";

View File

@@ -0,0 +1,11 @@
export { BenchPanel } from "./Panel/Panel";
export { TransformEntitiesList } from "./TransformEntitiesList/TransformEntitiesList";
export { MetricsEntitiesList } from "./MetricsEntitiesList/MetricsEntitiesList";
export { TransformDetails } from "./TransformDetails/TransformDetails";
export { MetricsDetails } from "./MetricsDetails/MetricsDetails";
export { QueryPreview } from "./QueryPreview/QueryPreview";
export { BenchMetabot } from "./BenchMetabot/BenchMetabot";
export { TransformExecutionCard } from "./TransformExecutionCard/TransformExecutionCard";
export { CollectionsCard } from "./CollectionsCard";
export { BenchSidebar } from "./BenchSidebar";
export { BenchToolbar } from "./BenchToolbar";

View File

@@ -0,0 +1,17 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
interface FormInputRootProps {
touched?: boolean;
error?: string | boolean;
}
export const FormInputRoot = styled.input<FormInputRootProps>`
width: 100%;
&:not(:focus) {
border-color: ${(props) => props.touched && props.error && color("error")};
}
`;

View File

@@ -0,0 +1,32 @@
import cx from "classnames";
import type { InputHTMLAttributes, Ref } from "react";
import { forwardRef } from "react";
import CS from "metabase/css/core/index.css";
import { FormInputRoot } from "./FormInput.styled";
interface FormInputProps extends InputHTMLAttributes<HTMLInputElement> {
touched?: boolean;
error?: string | boolean;
}
const FormInput = forwardRef(function FormInput(
{ className, touched, error, ...props }: FormInputProps,
ref: Ref<HTMLInputElement>,
) {
return (
<FormInputRoot
{...props}
value={props.value ?? ""}
ref={ref}
className={cx(CS.input, className)}
type="text"
touched={touched}
error={error}
/>
);
});
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default FormInput;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./FormInput";

View File

@@ -0,0 +1,22 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
export const FormLabelRoot = styled.div`
margin-bottom: 2rem;
`;
export const FormLabelContent = styled.div`
max-width: 36rem;
`;
export const FormLabelTitle = styled.label`
font-size: 0.72rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.06em;
`;
export const FormLabelDescription = styled.p`
margin-top: 0.5rem;
margin-bottom: 1rem;
`;

View File

@@ -0,0 +1,41 @@
import type {
HTMLAttributes,
LabelHTMLAttributes,
ReactNode,
Ref,
} from "react";
import { forwardRef } from "react";
import {
FormLabelContent,
FormLabelDescription,
FormLabelRoot,
FormLabelTitle,
} from "./FormLabel.styled";
interface FormLabelProps extends HTMLAttributes<HTMLDivElement> {
htmlFor?: LabelHTMLAttributes<HTMLLabelElement>["htmlFor"];
title?: string;
description?: string;
children?: ReactNode;
}
const FormLabel = forwardRef(function FormLabel(
{ htmlFor, title, description, children, ...props }: FormLabelProps,
ref: Ref<HTMLDivElement>,
) {
return (
<FormLabelRoot {...props} ref={ref}>
<FormLabelContent>
{title && <FormLabelTitle htmlFor={htmlFor}>{title}</FormLabelTitle>}
{description && (
<FormLabelDescription>{description}</FormLabelDescription>
)}
</FormLabelContent>
{children}
</FormLabelRoot>
);
});
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default FormLabel;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./FormLabel";

View File

@@ -0,0 +1,17 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { color } from "metabase/lib/colors";
interface FormTextAreaRootProps {
touched?: boolean;
error?: string | boolean;
}
export const FormTextAreaRoot = styled.textarea<FormTextAreaRootProps>`
width: 100%;
&:not(:focus) {
border-color: ${(props) => props.touched && props.error && color("error")};
}
`;

View File

@@ -0,0 +1,31 @@
import cx from "classnames";
import type { Ref, TextareaHTMLAttributes } from "react";
import { forwardRef } from "react";
import CS from "metabase/css/core/index.css";
import { FormTextAreaRoot } from "./FormTextArea.styled";
interface FormTextAreaProps
extends TextareaHTMLAttributes<HTMLTextAreaElement> {
touched?: boolean;
error?: string | boolean;
}
const FormTextArea = forwardRef(function FormTextArea(
{ className, touched, error, ...props }: FormTextAreaProps,
ref: Ref<HTMLTextAreaElement>,
) {
return (
<FormTextAreaRoot
{...props}
ref={ref}
className={cx(CS.input, className)}
touched={touched}
error={error}
/>
);
});
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default FormTextArea;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./FormTextArea";

View File

@@ -0,0 +1,39 @@
import { getSegmentQuery } from "metabase/admin/datamodel/utils/segments";
import { useSelector } from "metabase/lib/redux";
import { FilterPill } from "metabase/querying/filters/components/FilterPanel/FilterPill";
import { getMetadata } from "metabase/selectors/metadata";
import { Flex } from "metabase/ui";
import * as Lib from "metabase-lib";
import type { StructuredQuery, TableId } from "metabase-types/api";
const STAGE_INDEX = -1;
type QueryDefinitionProps = {
className?: string;
definition: StructuredQuery;
tableId: TableId;
};
export function QueryDefinition({
className,
tableId,
definition,
}: QueryDefinitionProps) {
const metadata = useSelector(getMetadata);
const query = getSegmentQuery(definition, tableId, metadata);
if (!query) {
return null;
}
const filters = Lib.filters(query, STAGE_INDEX);
return (
<Flex className={className} gap="md" wrap="wrap">
{filters.map((filter, filterIndex) => (
<FilterPill key={filterIndex}>
{Lib.displayInfo(query, STAGE_INDEX, filter).displayName}
</FilterPill>
))}
</Flex>
);
}

View File

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

View File

@@ -0,0 +1,81 @@
import cx from "classnames";
import PropTypes from "prop-types";
import { Component, createRef } from "react";
import { t } from "ttag";
import ModalWithTrigger from "metabase/common/components/ModalWithTrigger";
import PopoverWithTrigger from "metabase/common/components/PopoverWithTrigger";
import AdminS from "metabase/css/admin.module.css";
import CS from "metabase/css/core/index.css";
import { Icon } from "metabase/ui";
import { ActionLink, TriggerIconContainer } from "./SegmentActionSelect.styled";
import SegmentRetireModal from "./SegmentRetireModal";
export default class SegmentActionSelect extends Component {
constructor(props) {
super(props);
this.retireModal = createRef();
}
static propTypes = {
object: PropTypes.object.isRequired,
onRetire: PropTypes.func.isRequired,
};
async onRetire(object) {
await this.props.onRetire(object);
this.retireModal.current.close();
}
render() {
const { object } = this.props;
return (
<div>
<PopoverWithTrigger
triggerElement={
<TriggerIconContainer>
<Icon name="ellipsis" />
</TriggerIconContainer>
}
>
<ul className={AdminS.UserActionsSelect}>
<li>
<ActionLink to={`/admin/datamodel/segment/${object.id}`}>
{t`Edit Segment`}
</ActionLink>
</li>
<li>
<ActionLink
to={`/admin/datamodel/segment/${object.id}/revisions`}
>
{t`Revision History`}
</ActionLink>
</li>
<li className={cx(CS.mt1, CS.borderTop)}>
<ModalWithTrigger
ref={this.retireModal}
triggerElement={t`Retire Segment`}
triggerClasses={cx(
CS.block,
CS.p2,
CS.bgErrorHover,
CS.textError,
CS.textWhiteHover,
CS.cursorPointer,
)}
>
<SegmentRetireModal
object={object}
onRetire={this.onRetire.bind(this)}
onClose={() => this.retireModal.current.close()}
/>
</ModalWithTrigger>
</li>
</ul>
</PopoverWithTrigger>
</div>
);
}
}

View File

@@ -0,0 +1,23 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { Link } from "react-router";
export const ActionLink = styled(Link)`
display: block;
padding: 0.5rem 1rem;
cursor: pointer;
text-decoration: none;
&:hover {
color: var(--mb-color-text-white);
background-color: var(--mb-color-brand);
}
`;
export const TriggerIconContainer = styled.span`
color: var(--mb-color-text-light);
&:hover {
color: var(--mb-color-brand);
}
`;

View File

@@ -0,0 +1,54 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import Button from "metabase/common/components/Button";
import {
breakpointMinMedium,
breakpointMinSmall,
} from "metabase/styled-components/theme";
export const FormRoot = styled.form`
width: 100%;
height: 100%;
overflow: auto;
background-color: var(--mb-color-bg-white);
`;
export const FormSection = styled.div`
margin: 0 auto;
padding: 0 1em;
${breakpointMinSmall} {
padding-left: 1.75rem;
padding-right: 1.75rem;
}
${breakpointMinMedium} {
padding-left: 2.625rem;
padding-right: 2.625rem;
}
`;
export const FormBody = styled(FormSection)`
padding-top: 2rem;
padding-bottom: 2rem;
`;
export const FormBodyContent = styled.div`
max-width: 36rem;
`;
export const FormFooter = styled.div`
padding-top: 2rem;
padding-bottom: 2rem;
border-top: 1px solid var(--mb-color-border);
`;
export const FormFooterContent = styled.div`
display: flex;
align-items: center;
`;
export const FormSubmitButton = styled(Button)`
margin-right: 1rem;
`;

View File

@@ -0,0 +1,206 @@
import type { FieldInputProps } from "formik";
import { useFormik } from "formik";
import { useEffect } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import {
getSegmentQuery,
getSegmentQueryDefinition,
} from "metabase/admin/datamodel/utils/segments";
import Button from "metabase/common/components/Button/Button";
import { FieldSet } from "metabase/common/components/FieldSet";
import { useSelector } from "metabase/lib/redux";
import { SegmentEditor } from "metabase/querying/segments/components/SegmentEditor";
import { getMetadata } from "metabase/selectors/metadata";
import * as Lib from "metabase-lib";
import type Metadata from "metabase-lib/v1/metadata/Metadata";
import type { Segment, StructuredQuery, TableId } from "metabase-types/api";
import FormInput from "../FormInput";
import FormLabel from "../FormLabel";
import FormTextArea from "../FormTextArea";
import {
FormBody,
FormBodyContent,
FormFooter,
FormFooterContent,
FormRoot,
FormSection,
FormSubmitButton,
} from "./SegmentForm.styled";
export interface SegmentFormProps {
segment?: Segment;
previewSummary?: string;
updatePreviewSummary: (previewSummary: string) => void;
onIsDirtyChange: (isDirty: boolean) => void;
onSubmit: (values: Partial<Segment>) => void;
}
const SegmentForm = ({
segment,
onIsDirtyChange,
onSubmit,
}: SegmentFormProps): JSX.Element => {
const isNew = segment == null;
const metadata = useSelector(getMetadata);
const { isValid, getFieldProps, getFieldMeta, handleSubmit, dirty } =
useFormik({
initialValues: segment ?? {},
isInitialValid: false,
validate: (values) => getFormErrors(values, metadata),
onSubmit,
});
useEffect(() => {
onIsDirtyChange(dirty);
}, [dirty, onIsDirtyChange]);
return (
<FormRoot onSubmit={handleSubmit}>
<FormBody>
<FormLabel
title={isNew ? t`Create Your Segment` : t`Edit Your Segment`}
description={
isNew
? t`Select and add filters to create your new segment.`
: t`Make changes to your segment and leave an explanatory note.`
}
>
<SegmentEditor
{...getSegmentEditorProps(
getFieldProps("definition"),
getFieldProps("table_id"),
metadata,
)}
isNew={isNew}
/>
</FormLabel>
<FormBodyContent>
<FormLabel
htmlFor="name"
title={t`Name Your Segment`}
description={t`Give your segment a name to help others find it.`}
>
<FormInput
{...getFieldProps("name")}
{...getFieldMeta("name")}
id="name"
placeholder={t`Something descriptive but not too long`}
/>
</FormLabel>
<FormLabel
htmlFor="description"
title={t`Describe Your Segment`}
description={t`Give your segment a description to help others understand what it's about.`}
>
<FormTextArea
{...getFieldProps("description")}
{...getFieldMeta("description")}
id="description"
placeholder={t`This is a good place to be more specific about less obvious segment rules`}
/>
</FormLabel>
{!isNew && (
<FieldSet legend={t`Reason For Changes`} noPadding={false}>
<FormLabel
htmlFor="revision_message"
description={t`Leave a note to explain what changes you made and why they were required.`}
>
<FormTextArea
{...getFieldProps("revision_message")}
{...getFieldMeta("revision_message")}
id="revision_message"
placeholder={t`This will show up in the revision history for this segment to help everyone remember why things changed`}
/>
</FormLabel>
<FormFooterContent>
<SegmentFormActions isValid={isValid} />
</FormFooterContent>
</FieldSet>
)}
</FormBodyContent>
</FormBody>
{isNew && (
<FormFooter>
<FormSection>
<SegmentFormActions isValid={isValid} />
</FormSection>
</FormFooter>
)}
</FormRoot>
);
};
interface SegmentFormActionsProps {
isValid: boolean;
}
const SegmentFormActions = ({
isValid,
}: SegmentFormActionsProps): JSX.Element => {
return (
<div>
<FormSubmitButton type="submit" primary={isValid} disabled={!isValid}>
{t`Save changes`}
</FormSubmitButton>
<Button as={Link} to="/admin/datamodel/segments">
{t`Cancel`}
</Button>
</div>
);
};
const getFormErrors = (values: Partial<Segment>, metadata: Metadata) => {
const errors: Record<string, string> = {};
if (!values.name) {
errors.name = t`Name is required`;
}
if (!values.description) {
errors.description = t`Description is required`;
}
if (values.id != null && !values.revision_message) {
errors.revision_message = t`Revision message is required`;
}
const query = getSegmentQuery(values.definition, values.table_id, metadata);
const filters = query ? Lib.filters(query, -1) : [];
if (filters.length === 0) {
errors.definition = t`At least one filter is required`;
}
return errors;
};
function getSegmentEditorProps(
definitionProps: FieldInputProps<StructuredQuery | undefined>,
tableIdProps: FieldInputProps<TableId | undefined>,
metadata: Metadata,
) {
return {
query: getSegmentQuery(definitionProps.value, tableIdProps.value, metadata),
onChange: (query: Lib.Query) => {
definitionProps.onChange({
target: {
name: definitionProps.name,
value: getSegmentQueryDefinition(query),
},
});
tableIdProps.onChange({
target: {
name: tableIdProps.name,
value: Lib.sourceTableOrCardId(query),
},
});
},
};
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default SegmentForm;

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/no-default-export -- deprecated usage
export { default } from "./SegmentForm";

View File

@@ -0,0 +1,3 @@
.cell {
overflow-wrap: break-word;
}

View File

@@ -0,0 +1,49 @@
import { Link } from "react-router";
import { TableBreadcrumbs } from "metabase/metadata/components";
import { Box, Flex, Group, Icon } from "metabase/ui";
import type { Segment } from "metabase-types/api";
import SegmentActionSelect from "./SegmentActionSelect";
import S from "./SegmentItem.module.css";
interface Props {
segment: Segment;
onRetire: () => void;
}
export const SegmentItem = ({ segment, onRetire }: Props) => {
return (
<tr>
<Box component="td" className={S.cell} p="sm">
<Link to={`/admin/datamodel/segment/${segment.id}`}>
<Group display="inline-flex" gap="sm" wrap="nowrap">
<Box
color="text-medium"
component={Icon}
flex="0 0 auto"
name="segment"
/>
<Box c="text-dark" fw="bold">
{segment.name}
</Box>
</Group>
</Link>
</Box>
<Box component="td" className={S.cell} maw={500} p="sm">
<TableBreadcrumbs tableId={segment.table_id} />
</Box>
<Box component="td" className={S.cell} p="sm">
{segment.definition_description}
</Box>
<Box component="td" className={S.cell} p="sm">
<Flex justify="center">
<SegmentActionSelect object={segment} onRetire={onRetire} />
</Flex>
</Box>
</tr>
);
};

View File

@@ -0,0 +1,75 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { Component, createRef } from "react";
import { t } from "ttag";
import ActionButton from "metabase/common/components/ActionButton";
import ModalContent from "metabase/common/components/ModalContent";
import ButtonsS from "metabase/css/components/buttons.module.css";
import CS from "metabase/css/core/index.css";
export default class SegmentRetireModal extends Component {
constructor(props, context) {
super(props, context);
this.state = {
valid: false,
};
this.revisionMessage = createRef();
}
async handleSubmit() {
const payload = {
id: this.props.object.id,
revision_message: this.revisionMessage.current.value,
};
await this.props.onRetire(payload);
this.props.onClose();
}
render() {
const { valid } = this.state;
return (
<ModalContent
title={t`Retire this segment?`}
onClose={this.props.onClose}
>
<form className={cx(CS.flex, CS.flexColumn, CS.flexFull)}>
<div className={cx("Form-inputs", CS.pb4)}>
<p
className={CS.textParagraph}
>{t`Saved questions and other things that depend on this segment will continue to work, but it will no longer be selectable from the query builder.`}</p>
<p className={CS.textParagraph}>
{t`If you're sure you want to retire this segment, please write a quick explanation of why it's being retired`}
:
</p>
<textarea
ref={this.revisionMessage}
className={cx(CS.input, CS.full)}
placeholder={t`This will show up in the activity feed and in an email that will be sent to anyone on your team who created something that uses this segment.`}
onChange={(e) => this.setState({ valid: !!e.target.value })}
/>
</div>
<div className={cx("Form-actions", CS.mlAuto)}>
<a className={ButtonsS.Button} onClick={this.props.onClose}>
{t`Cancel`}
</a>
<ActionButton
actionFn={this.handleSubmit.bind(this)}
className={cx(ButtonsS.Button, CS.ml2, {
[ButtonsS.ButtonDanger]: valid,
[CS.disabled]: !valid,
})}
normalText={t`Retire`}
activeText={t`Retiring…`}
failedText={t`Failed`}
successText={t`Success`}
/>
</div>
</form>
</ModalContent>
);
}
}

View File

@@ -0,0 +1,27 @@
import PropTypes from "prop-types";
import { Component } from "react";
import { QueryDefinition } from "metabase/admin/datamodel/components/QueryDefinition";
import CS from "metabase/css/core/index.css";
export default class QueryDiff extends Component {
static propTypes = {
diff: PropTypes.object.isRequired,
tableId: PropTypes.number.isRequired,
};
render() {
const {
diff: { before, after },
tableId,
} = this.props;
const definition = after || before;
return (
<QueryDefinition
className={CS.my1}
definition={definition}
tableId={tableId}
/>
);
}
}

View File

@@ -0,0 +1,100 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import dayjs from "dayjs";
import PropTypes from "prop-types";
import { Component } from "react";
import { t } from "ttag";
import UserAvatar from "metabase/common/components/UserAvatar";
import CS from "metabase/css/core/index.css";
import RevisionDiff from "./RevisionDiff";
export default class Revision extends Component {
static propTypes = {
objectName: PropTypes.string.isRequired,
revision: PropTypes.object.isRequired,
currentUser: PropTypes.object.isRequired,
tableId: PropTypes.number.isRequired,
};
getAction() {
const { revision, objectName } = this.props;
if (revision.is_creation) {
return t`created` + ' "' + revision.diff.name.after + '"';
}
if (revision.is_reversion) {
return t`reverted to a previous version`;
}
const changedKeys = Object.keys(revision.diff || {});
if (changedKeys.length === 1) {
switch (changedKeys[0]) {
case "name":
return t`edited the title`;
case "description":
return t`edited the description`;
case "definition":
return t`edited the ` + objectName;
}
}
return t`made some changes`;
}
getName() {
const {
revision: { user },
currentUser,
} = this.props;
if (user.id === currentUser.id) {
return t`You`;
} else {
return user.common_name;
}
}
render() {
const { revision, userColor, tableId } = this.props;
let message = revision.message;
let diffKeys = Object.keys(revision.diff || {});
if (revision.is_creation) {
// these are included in the
message = revision.diff.description.after;
diffKeys = diffKeys.filter((k) => k !== "name" && k !== "description");
}
return (
<li className={cx(CS.flex, CS.flexRow)}>
<div className={cx(CS.flex, CS.flexColumn, CS.alignCenter, CS.mr2)}>
<div className={CS.textWhite}>
<UserAvatar user={revision.user} bg={userColor} />
</div>
<div
className={cx(CS.flexFull, CS.my1, CS.borderLeft)}
style={{ borderWidth: 2 }}
/>
</div>
<div className={cx(CS.flex1, CS.mt1, CS.mb4)}>
<div className={cx(CS.flex, CS.mb1, CS.textMedium)}>
<span>
<strong>{this.getName()}</strong> {this.getAction()}
</span>
<span className={cx(CS.flexAlignRight, CS.h5)}>
{dayjs(revision.timestamp).format("MMMM DD, YYYY")}
</span>
</div>
{message && <p>&quot;{message}&quot;</p>}
{diffKeys.map((key) => (
<RevisionDiff
key={key}
property={key}
diff={revision.diff[key]}
tableId={tableId}
/>
))}
</div>
</li>
);
}
}

View File

@@ -0,0 +1,54 @@
import cx from "classnames";
import PropTypes from "prop-types";
import { Component } from "react";
import CS from "metabase/css/core/index.css";
import QueryDiff from "./QueryDiff";
import { EditIcon, ErrorIcon, SuccessIcon } from "./RevisionDiff.styled";
import TextDiff from "./TextDiff";
export default class RevisionDiff extends Component {
static propTypes = {
property: PropTypes.string.isRequired,
diff: PropTypes.object.isRequired,
tableId: PropTypes.number.isRequired,
};
render() {
const {
diff: { before, after },
tableId,
} = this.props;
let icon;
if (before != null && after != null) {
icon = <EditIcon name="pencil" size={16} />;
} else if (before != null) {
icon = <ErrorIcon name="add" size={16} />;
} else {
// TODO: "minus" icon
icon = <SuccessIcon name="add" size={16} />;
}
return (
<div
className={cx(CS.bordered, CS.rounded, CS.my2)}
style={{ borderWidth: 2, overflow: "hidden", maxWidth: 860 }}
>
<div className={cx(CS.flex, CS.alignCenter, CS.scrollX, CS.scrollShow)}>
<div className={CS.m3} style={{ lineHeight: 0 }}>
{icon}
</div>
<div>
{this.props.property === "definition" ? (
<QueryDiff diff={this.props.diff} tableId={tableId} />
) : (
<TextDiff diff={this.props.diff} />
)}
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,16 @@
// eslint-disable-next-line no-restricted-imports
import styled from "@emotion/styled";
import { Icon } from "metabase/ui";
export const EditIcon = styled(Icon)`
color: var(--mb-color-brand);
`;
export const ErrorIcon = styled(Icon)`
color: var(--mb-color-error);
`;
export const SuccessIcon = styled(Icon)`
color: var(--mb-color-summarize);
`;

View File

@@ -0,0 +1,74 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import PropTypes from "prop-types";
import { Component } from "react";
import { t } from "ttag";
import Breadcrumbs from "metabase/common/components/Breadcrumbs";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import CS from "metabase/css/core/index.css";
import { assignUserColors } from "metabase/lib/formatting";
import Revision from "./Revision";
export default class RevisionHistory extends Component {
static propTypes = {
segment: PropTypes.object,
revisions: PropTypes.array,
};
render() {
const { segment, revisions, user } = this.props;
let userColorAssignments = {};
if (revisions) {
userColorAssignments = assignUserColors(
revisions.map((r) => r.user.id),
user.id,
);
}
return (
<LoadingAndErrorWrapper
loading={!segment || !revisions}
className={cx(CS.wrapper, CS.scrollY, CS.bgWhite)}
>
{() => (
<>
<Breadcrumbs
className={CS.py4}
crumbs={[
[
t`Segments`,
`/admin/datamodel/segments?table=${segment.table_id}`,
],
[t`Segment History`],
]}
/>
<div
className={cx(CS.wrapper, CS.py4)}
style={{ maxWidth: 950 }}
data-testid="segment-revisions"
>
<h2 className={CS.mb4}>
{t`Revision History for`} &quot;{segment.name}&quot;
</h2>
<ol>
{revisions.map((revision) => (
<Revision
key={revision.id}
revision={revision}
tableId={segment.table_id}
objectName={segment.name}
currentUser={user}
userColor={userColorAssignments[revision.user.id]}
/>
))}
</ol>
</div>
</>
)}
</LoadingAndErrorWrapper>
);
}
}

View File

@@ -0,0 +1,42 @@
/*eslint-env node */
import { diffWords } from "diff";
import PropTypes from "prop-types";
import { Component } from "react";
export default class TextDiff extends Component {
static propTypes = {
diff: PropTypes.object.isRequired,
};
render() {
const {
diff: { before, after },
} = this.props;
return (
<div>
&quot;
{before != null && after != null ? (
diffWords(before, after).map((section, index) => (
<span key={index}>
{section.added ? (
<strong>{section.value}</strong>
) : section.removed ? (
<span style={{ textDecoration: "line-through" }}>
{section.value}
</span>
) : (
<span>{section.value}</span>
)}{" "}
</span>
))
) : before != null ? (
<span style={{ textDecoration: "line-through" }}>{before}</span>
) : (
<strong>{after}</strong>
)}
&quot;
</div>
);
}
}

View File

@@ -0,0 +1,48 @@
/* eslint-disable react/prop-types */
import { Component } from "react";
import _ from "underscore";
import Segments from "metabase/entities/segments";
import Tables from "metabase/entities/tables";
import { connect } from "metabase/lib/redux";
import RevisionHistory from "../components/revisions/RevisionHistory";
import { fetchSegmentRevisions } from "../datamodel";
import { getCurrentUser, getRevisions } from "../selectors";
const mapStateToProps = (state, props) => ({
id: props.params.id,
user: getCurrentUser(state),
revisions: getRevisions(state),
});
const mapDispatchToProps = { fetchSegmentRevisions };
class RevisionHistoryApp extends Component {
componentDidMount() {
const { id } = this.props;
this.props.fetchSegmentRevisions(id);
}
render() {
return <SegmentRevisionHistory {...this.props} />;
}
}
export default connect(mapStateToProps, mapDispatchToProps)(RevisionHistoryApp);
class SegmentRevisionHistoryInner extends Component {
render() {
const { segment, ...props } = this.props;
return <RevisionHistory segment={segment} {...props} />;
}
}
const SegmentRevisionHistory = _.compose(
Segments.load({ id: (_state, { id }) => id }),
Tables.load({
id: (_state, { segment }) => segment?.table_id,
fetchType: "fetchMetadataAndForeignTables",
requestType: "fetchMetadataDeprecated",
}),
)(SegmentRevisionHistoryInner);

View File

@@ -0,0 +1,124 @@
/* eslint-disable react/prop-types */
import { useCallback, useState } from "react";
import { push } from "react-router-redux";
import _ from "underscore";
import { LeaveRouteConfirmModal } from "metabase/common/components/LeaveConfirmModal";
import { useCallbackEffect } from "metabase/common/hooks/use-callback-effect";
import Segments from "metabase/entities/segments";
import Tables from "metabase/entities/tables";
import { connect } from "metabase/lib/redux";
import SegmentForm from "../components/SegmentForm";
import { updatePreviewSummary } from "../datamodel";
import { getPreviewSummary } from "../selectors";
const mapDispatchToProps = {
updatePreviewSummary,
createSegment: Segments.actions.create,
updateSegment: Segments.actions.update,
onChangeLocation: push,
};
const mapStateToProps = (state, props) => ({
previewSummary: getPreviewSummary(state),
});
const UpdateSegmentFormInner = ({
route,
segment,
updateSegment,
onChangeLocation,
...props
}) => {
const [isDirty, setIsDirty] = useState(false);
const handleSubmit = useCallback(
async (segment) => {
setIsDirty(false);
try {
await updateSegment(segment);
onChangeLocation("/admin/datamodel/segments");
} catch (error) {
setIsDirty(isDirty);
}
},
[updateSegment, isDirty, onChangeLocation],
);
return (
<>
<SegmentForm
{...props}
segment={segment.getPlainObject()}
onIsDirtyChange={setIsDirty}
onSubmit={handleSubmit}
/>
<LeaveRouteConfirmModal isEnabled={isDirty} route={route} />
</>
);
};
const UpdateSegmentForm = _.compose(
Segments.load({
id: (_state, { params }) => parseInt(params.id),
}),
Tables.load({
id: (_state, { segment }) => segment?.table_id,
fetchType: "fetchMetadataAndForeignTables",
requestType: "fetchMetadataDeprecated",
}),
)(UpdateSegmentFormInner);
const CreateSegmentForm = ({
route,
createSegment,
onChangeLocation,
...props
}) => {
const [isDirty, setIsDirty] = useState(false);
/**
* Navigation is scheduled so that LeaveConfirmationModal's isEnabled
* prop has a chance to re-compute on re-render
*/
const [, scheduleCallback] = useCallbackEffect();
const handleSubmit = useCallback(
(segment) => {
setIsDirty(false);
scheduleCallback(async () => {
try {
await createSegment(segment);
onChangeLocation("/admin/datamodel/segments");
} catch (error) {
setIsDirty(isDirty);
}
});
},
[scheduleCallback, createSegment, isDirty, onChangeLocation],
);
return (
<>
<SegmentForm
{...props}
onIsDirtyChange={setIsDirty}
onSubmit={handleSubmit}
/>
<LeaveRouteConfirmModal isEnabled={isDirty} route={route} />
</>
);
};
const SegmentApp = (props) => {
if (props.params.id) {
return <UpdateSegmentForm {...props} />;
}
return <CreateSegmentForm {...props} />;
};
export default connect(mapStateToProps, mapDispatchToProps)(SegmentApp);

View File

@@ -0,0 +1,158 @@
import userEvent from "@testing-library/user-event";
import { callMockEvent } from "__support__/events";
import {
setupCardDataset,
setupDatabasesEndpoints,
setupRecentViewsAndSelectionsEndpoints,
setupSearchEndpoints,
setupSegmentsEndpoints,
} from "__support__/server-mocks";
import {
act,
mockGetBoundingClientRect,
renderWithProviders,
screen,
waitFor,
waitForLoaderToBeRemoved,
} from "__support__/ui";
import { BEFORE_UNLOAD_UNSAVED_MESSAGE } from "metabase/common/hooks/use-before-unload";
import { Route } from "metabase/hoc/Title";
import { checkNotNull } from "metabase/lib/types";
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
import SegmentApp from "./SegmentApp";
const TestHome = () => <div />;
const SEGMENTS_URL = "/admin/datamodel/segments";
const FORM_URL = "/admin/datamodel/segment/create";
interface SetupOpts {
initialRoute?: string;
}
const setup = ({ initialRoute = FORM_URL }: SetupOpts = {}) => {
mockGetBoundingClientRect();
setupDatabasesEndpoints([createSampleDatabase()]);
setupSearchEndpoints([]);
setupCardDataset({
data: {
rows: [[null]],
},
});
setupRecentViewsAndSelectionsEndpoints([], ["selections"]);
setupSegmentsEndpoints([]);
const { history } = renderWithProviders(
<>
<Route path="/" component={TestHome} />
<Route path={SEGMENTS_URL} component={TestHome} />
<Route path={FORM_URL} component={SegmentApp} />
</>,
{
initialRoute,
withRouter: true,
},
);
const mockEventListener = jest.spyOn(window, "addEventListener");
return {
history: checkNotNull(history),
mockEventListener,
};
};
describe("SegmentApp", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("should have beforeunload event when user makes edits to a segment", async () => {
const { mockEventListener } = setup();
await userEvent.type(screen.getByLabelText("Name Your Segment"), "Name");
const mockEvent = await waitFor(() => {
return callMockEvent(mockEventListener, "beforeunload");
});
expect(mockEvent.preventDefault).toHaveBeenCalled();
expect(mockEvent.returnValue).toBe(BEFORE_UNLOAD_UNSAVED_MESSAGE);
});
it("should not have an beforeunload event when segment is unedited", () => {
const { mockEventListener } = setup();
const mockEvent = callMockEvent(mockEventListener, "beforeunload");
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
expect(mockEvent.returnValue).toBe(undefined);
});
it("does not show custom warning modal when leaving with no changes via SPA navigation", () => {
const { history } = setup({ initialRoute: "/" });
act(() => {
history.push(FORM_URL);
history.goBack();
});
expect(screen.queryByTestId("leave-confirmation")).not.toBeInTheDocument();
});
it("shows custom warning modal when leaving with unsaved changes via SPA navigation", async () => {
const { history } = setup({ initialRoute: "/" });
act(() => {
history.push(FORM_URL);
});
await userEvent.type(
await screen.findByLabelText("Name Your Segment"),
"Name",
);
act(() => {
history.goBack();
});
expect(await screen.findByTestId("leave-confirmation")).toBeInTheDocument();
});
it("does not show custom warning modal when saving changes", async () => {
const { history } = setup();
await userEvent.click(screen.getByText("Select a table"));
await waitForLoaderToBeRemoved();
await userEvent.click(await screen.findByText("Orders"));
await waitForLoaderToBeRemoved();
await userEvent.click(
screen.getByText("Add filters to narrow your answer"),
);
await userEvent.click(screen.getByText("ID"));
await userEvent.type(screen.getByPlaceholderText("Enter an ID"), "1");
await userEvent.click(screen.getByText("Add filter"));
await userEvent.type(screen.getByLabelText("Name Your Segment"), "Name");
await userEvent.type(
screen.getByLabelText("Describe Your Segment"),
"Description",
);
await waitFor(() => {
expect(screen.getByText("Save changes")).toBeEnabled();
});
await userEvent.click(screen.getByText("Save changes"));
await waitFor(() => {
expect(history.getCurrentLocation().pathname).toBe(SEGMENTS_URL);
});
expect(screen.queryByTestId("leave-confirmation")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,66 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { Component } from "react";
import { t } from "ttag";
import _ from "underscore";
import { SegmentItem } from "metabase/admin/datamodel/components/SegmentItem";
import FilteredToUrlTable from "metabase/admin/datamodel/hoc/FilteredToUrlTable";
import Button from "metabase/common/components/Button";
import Link from "metabase/common/components/Link";
import AdminS from "metabase/css/admin.module.css";
import CS from "metabase/css/core/index.css";
import Segments from "metabase/entities/segments";
import { connect } from "metabase/lib/redux";
class SegmentListAppInner extends Component {
render() {
const { segments, tableSelector, setArchived } = this.props;
return (
<div
className={cx(CS.px3, CS.pb2, CS.wrapper, CS.scrollY, CS.bgWhite)}
data-testid="segment-list-app"
>
<div className={cx(CS.flex, CS.py2)}>
{tableSelector}
<Link to="/admin/datamodel/segment/create" className={CS.mlAuto}>
<Button primary>{t`New segment`}</Button>
</Link>
</div>
<table className={AdminS.AdminTable}>
<thead className={CS.textBold}>
<tr>
<th style={{ minWidth: "320px" }}>{t`Name`}</th>
<th>{t`Table`}</th>
<th className={CS.full}>{t`Definition`}</th>
<th>{t`Actions`}</th>
</tr>
</thead>
<tbody>
{segments.map((segment) => (
<SegmentItem
key={segment.id}
onRetire={() => setArchived(segment, true)}
segment={segment}
/>
))}
</tbody>
</table>
{segments.length === 0 && (
<div className={cx(CS.flex, CS.layoutCentered, CS.m4, CS.textMedium)}>
{t`Create segments to add them to the Filter dropdown in the query builder`}
</div>
)}
</div>
);
}
}
const SegmentListApp = _.compose(
Segments.loadList(),
FilteredToUrlTable("segments"),
connect(null, { setArchived: Segments.actions.setArchived }),
)(SegmentListAppInner);
export default SegmentListApp;

View File

@@ -0,0 +1,41 @@
import {
combineReducers,
createAction,
createThunkAction,
handleActions,
} from "metabase/lib/redux";
import { MetabaseApi, RevisionsApi } from "metabase/services";
export const UPDATE_PREVIEW_SUMMARY =
"metabase/admin/datamodel/UPDATE_PREVIEW_SUMMARY";
export const updatePreviewSummary = createAction(
UPDATE_PREVIEW_SUMMARY,
async (query) => {
const result = await MetabaseApi.dataset(query);
return result.data.rows[0][0];
},
);
// REVISION HISTORY
export const FETCH_REVISIONS = "metabase/admin/datamodel/FETCH_REVISIONS";
export const fetchSegmentRevisions = createThunkAction(
FETCH_REVISIONS,
(id) => async () => RevisionsApi.get({ entity: "segment", id }),
);
// reducers
const previewSummary = handleActions(
{ [UPDATE_PREVIEW_SUMMARY]: { next: (state, { payload }) => payload } },
null,
);
const revisions = handleActions(
{ [FETCH_REVISIONS]: { next: (state, { payload }) => payload } },
null,
);
export default combineReducers({ previewSummary, revisions });

View File

@@ -0,0 +1,101 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { Component } from "react";
import { push } from "react-router-redux";
import { t } from "ttag";
import { FieldSet } from "metabase/common/components/FieldSet";
import CS from "metabase/css/core/index.css";
import Tables from "metabase/entities/tables";
import { connect } from "metabase/lib/redux";
import { DatabaseSchemaAndTableDataSelector } from "metabase/query_builder/components/DataSelector";
import { Icon } from "metabase/ui";
/**
* @deprecated HOCs are deprecated
*/
const FilteredToUrlTable = (propName) => (ComposedComponent) =>
connect(null, { push })(
class FilteredToUrlTable extends Component {
constructor(props) {
super(props);
const { table } = props.location.query || {};
this.state = { tableId: table != null ? parseInt(table) : null };
}
setTableId = (tableId) => {
this.setState({ tableId });
this.props.push({
...this.props.location,
query: tableId == null ? {} : { table: tableId },
});
};
render() {
const { [propName]: items, otherProps } = this.props;
const { tableId } = this.state;
const props = {
[propName]:
tableId == null
? items
: items.filter((item) => item.table_id === tableId),
tableSelector: (
<TableSelector tableId={tableId} setTableId={this.setTableId} />
),
...otherProps,
};
return <ComposedComponent {...props} />;
}
},
);
export default FilteredToUrlTable;
class TableSelectorInner extends Component {
render() {
const { table, tableId, setTableId } = this.props;
return (
<FieldSet
noPadding
className={cx(CS.p0, { [CS.borderBrand]: tableId != null })}
>
<div className={CS.p2} style={{ width: 200 }}>
<DatabaseSchemaAndTableDataSelector
selectedTableId={tableId}
setSourceTableFn={setTableId}
triggerElement={
<span
className={cx(
CS.flex,
CS.alignCenter,
CS.justifyBetween,
CS.flexFull,
CS.textMedium,
CS.textBold,
)}
data-testid="segment-list-table"
>
{table ? table.displayName() : t`Filter by table`}
<Icon
name={table ? "close" : "chevrondown"}
size={12}
onClick={(e) => {
if (table) {
e.stopPropagation();
setTableId(null);
}
}}
/>
</span>
}
/>
</div>
</FieldSet>
);
}
}
const TableSelector = Tables.load({
id: (state, props) => props.tableId,
loadingAndErrorWrapper: false,
})(TableSelectorInner);

View File

@@ -0,0 +1,4 @@
export const getPreviewSummary = (state) =>
state.admin.datamodel.previewSummary;
export const getRevisions = (state) => state.admin.datamodel.revisions;
export const getCurrentUser = (state) => state.currentUser;

View File

@@ -0,0 +1,29 @@
import * as Lib from "metabase-lib";
import type Metadata from "metabase-lib/v1/metadata/Metadata";
import type { StructuredQuery, TableId } from "metabase-types/api";
export function getSegmentQuery(
query: StructuredQuery | undefined,
tableId: TableId | undefined,
metadata: Metadata,
) {
const table = metadata.table(tableId);
const metadataProvider = table
? Lib.metadataProvider(table.db_id, metadata)
: undefined;
return table && query && metadataProvider
? Lib.fromLegacyQuery(table.db_id, metadataProvider, {
type: "query",
database: table.db_id,
query: query,
})
: undefined;
}
export function getSegmentQueryDefinition(query: Lib.Query) {
const datasetQuery = Lib.toLegacyQuery(query);
if (datasetQuery.type === "query") {
return datasetQuery.query;
}
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
export function useDarkMode(): boolean {
const [isDarkMode, setIsDarkMode] = useState(() => {
// Check if we're in a browser environment
if (typeof window === "undefined") {
return false;
}
// Check user's system preference
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
useEffect(() => {
// Check if we're in a browser environment
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (e: MediaQueryListEvent) => {
setIsDarkMode(e.matches);
};
// Listen for changes in the user's color scheme preference
mediaQuery.addEventListener("change", handleChange);
// Cleanup function
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, []);
return isDarkMode;
}

View File

@@ -0,0 +1,55 @@
{
"rules": {
// Note: adding this rule to a eslint config file in a subfolder will remove
// *not* carry over the restricted imports from parent folders, you will
// need to copy them over
"no-restricted-imports": [
"error",
{
"patterns": [
"metabase-enterprise",
"metabase-enterprise/*",
"cljs/metabase.lib*",
"/embedding-sdk-package",
"/embedding-sdk-bundle",
"/embedding-sdk-shared",
"metabase-lib/v1/metadata",
"metabase-lib/v1/metadata/*",
"metabase/entities",
"metabase/entities/*",
],
"paths": [
{
"name": "react-redux",
"importNames": ["useSelector", "useDispatch", "connect"],
"message": "Please import from `metabase/lib/redux` instead.",
},
{
"name": "@mantine/core",
"message": "Please import from `metabase/ui` instead.",
},
{
"name": "moment",
"message": "Moment is deprecated, please use dayjs",
},
{
"name": "moment-timezone",
"message": "Moment is deprecated, please use dayjs",
},
{
"name": "@emotion/styled",
"message": "Please style components using css modules.",
},
{
"name": "@emotion/react",
"message": "Please use components from `metabase/ui` instead and style them using css modules.",
},
{
"name": "@storybook/test",
"message": "Please use `testing-library/react` or `@testing-library/user-event`",
},
],
},
],
},
}

View File

@@ -0,0 +1,5 @@
# Metadata
This directory is primarily maintained by the Semantic Layer team.
As we revamp various metadata-related UIs, most of the content from `admin/datamodel` will eventually be moved here.

View File

@@ -0,0 +1,3 @@
.root {
overflow: visible; /* prevent scrollbar when error is displayed */
}

View File

@@ -0,0 +1,63 @@
import { useMemo, useState } from "react";
import { t } from "ttag";
import { coercions_for_type } from "cljs/metabase.types.core";
import { useDebouncedValue } from "metabase/common/hooks/use-debounced-value";
import { Select, type SelectProps } from "metabase/ui";
import type { Field } from "metabase-types/api";
import S from "./CoercionStrategyPicker.module.css";
import { humanizeCoercionStrategy } from "./utils";
interface Props extends Omit<SelectProps, "data" | "value" | "onChange"> {
baseType: Field["base_type"];
value: string | undefined;
onChange: (value: string) => void;
}
export const CoercionStrategyPicker = ({
baseType,
comboboxProps,
value,
onChange,
...props
}: Props) => {
const [isTouched, setIsTouched] = useState(false);
// debounce to prevent error briefly shown when disabling casting
const isTouchedDebounced = useDebouncedValue(isTouched, 100);
const data = useMemo(() => {
return coercions_for_type(baseType).map((coercion: string) => ({
label: humanizeCoercionStrategy(coercion),
value: coercion,
}));
}, [baseType]);
return (
<Select
classNames={{
root: S.root,
}}
comboboxProps={{
middlewares: {
flip: true,
size: {
padding: 6,
},
},
position: "bottom-start",
...comboboxProps,
}}
data={data}
error={
isTouchedDebounced && value == null
? t`To enable casting, please select a data type`
: undefined
}
placeholder={t`Select data type`}
value={value}
onBlur={() => setIsTouched(true)}
onChange={(value) => onChange(value)}
{...props}
/>
);
};

View File

@@ -0,0 +1,129 @@
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { renderWithProviders, screen, waitFor, within } from "__support__/ui";
import type { Field } from "metabase-types/api";
import {
createOrdersQuantityField,
createProductsCreatedAtField,
createProductsVendorField,
} from "metabase-types/api/mocks/presets";
import { CoercionStrategyPicker } from "./CoercionStrategyPicker";
interface TestComponentProps {
initialValue: string | undefined;
baseType: string;
}
function TestComponent({ baseType, initialValue }: TestComponentProps) {
const [value, setValue] = useState(initialValue);
return (
<CoercionStrategyPicker
baseType={baseType}
value={value}
onChange={setValue}
/>
);
}
interface SetupOpts {
field: Field;
initialValue?: string;
}
const setup = ({ field, initialValue }: SetupOpts) => {
renderWithProviders(
<TestComponent baseType={field.base_type} initialValue={initialValue} />,
);
};
describe("CoercionStrategyPicker", () => {
it("shows error on blur when no type is selected", async () => {
setup({ field: createProductsVendorField() });
await userEvent.click(screen.getByPlaceholderText("Select data type"));
await userEvent.tab();
await waitFor(() => {
expect(
screen.getByText("To enable casting, please select a data type"),
).toBeInTheDocument();
});
});
it("does not show error when type is selected", async () => {
setup({
field: createProductsVendorField(),
initialValue: "String → Float",
});
await userEvent.click(screen.getByPlaceholderText("Select data type"));
await userEvent.tab();
expect(
screen.queryByText("To enable casting, please select a data type"),
).not.toBeInTheDocument();
});
it("updates value when a new type is selected", async () => {
setup({ field: createProductsVendorField() });
await userEvent.click(screen.getByPlaceholderText("Select data type"));
const dropdown = within(screen.getByRole("listbox"));
await userEvent.click(dropdown.getByText("String → Float"));
expect(screen.getByPlaceholderText("Select data type")).toHaveValue(
"String → Float",
);
});
it("shows appropriate coercion types for text fields", async () => {
setup({ field: createProductsVendorField() });
await assertCoercionTypesVisibility([
"ISO 8601 → Time",
"ISO 8601 → Date",
"YYYYMMDDHHMMSS string → Temporal",
"ISO 8601 → Datetime",
"String → Integer",
"String → Float",
]);
});
it("shows appropriate coercion types for number fields", async () => {
setup({ field: createOrdersQuantityField() });
await assertCoercionTypesVisibility([
"UNIX microseconds → Datetime",
"UNIX milliseconds → Datetime",
"UNIX nanoseconds → Datetime",
"UNIX seconds → Datetime",
]);
});
it("shows appropriate coercion types for temporal fields", async () => {
setup({ field: createProductsCreatedAtField() });
await assertCoercionTypesVisibility(["DateTime → Date"]);
});
});
async function assertCoercionTypesVisibility(visibleTypes: string[]) {
await userEvent.click(screen.getByPlaceholderText("Select data type"));
const dropdown = within(screen.getByRole("listbox"));
for (const type of visibleTypes) {
expect(dropdown.getByRole("option", { name: type })).toBeInTheDocument();
}
if (visibleTypes.length === 0) {
expect(dropdown.queryAllByRole("option")).toHaveLength(visibleTypes.length);
} else {
expect(dropdown.getAllByRole("option")).toHaveLength(visibleTypes.length);
}
}

View File

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

View File

@@ -0,0 +1,47 @@
import { c, t } from "ttag";
const GET_LEFT_TERM_CONVERSIONS = (): Record<string, string> => ({
ISO8601: t`ISO 8601`,
UNIXSeconds: t`UNIX seconds`,
UNIXMilliSeconds: t`UNIX milliseconds`,
UNIXMicroSeconds: t`UNIX microseconds`,
UNIXNanoSeconds: t`UNIX nanoseconds`,
YYYYMMDDHHMMSSString: t`YYYYMMDDHHMMSS string`,
YYYYMMDDHHMMSSBytes: t`YYYYMMDDHHMMSS bytes`,
});
const GET_RIGHT_TERM_CONVERSIONS = (): Record<string, string> => ({
DateTime: t`Datetime`,
});
/**
* Converts -> to → and humanizes strings
* @param {string} fullString - The coercion strategy as it comes from the back-end
* @returns {string}
*/
export function humanizeCoercionStrategy(fullString: string) {
const shortString = fullString.replace("Coercion/", "");
const [leftTerm, rightTerm] = shortString.split("->");
return rightTerm === undefined
? shortString
: treatTermsAndJoin(leftTerm, rightTerm);
}
function treatTermsAndJoin(left: string, right: string) {
const treatedLeftTerm = treatLeftTerm(left);
const treatedRightTerm = treatRightTerm(right);
return [treatedLeftTerm, treatedRightTerm].join(
c("arrow denoting a conversion. eg: string → date").t` → `,
);
}
function treatLeftTerm(term: string) {
return GET_LEFT_TERM_CONVERSIONS()[term] || term;
}
function treatRightTerm(term: string) {
return GET_RIGHT_TERM_CONVERSIONS()[term] || term;
}

View File

@@ -0,0 +1,175 @@
import { humanizeCoercionStrategy } from "./utils";
describe("humanizeCoercionStrategy", () => {
it("does not convert `Don't cast`", () => {
const original = "Don't cast";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(original);
});
describe("ISO 8601", () => {
it("converts to ISO 8601 → Time", () => {
const original = "ISO8601->Time";
const expected = "ISO 8601 → Time";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to ISO 8601 → Date", () => {
const original = "ISO8601->Date";
const expected = "ISO 8601 → Date";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to ISO 8601 → Datetime", () => {
const original = "ISO8601->DateTime";
const expected = "ISO 8601 → Datetime";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
});
describe("UNIX seconds", () => {
it("converts to UNIX seconds → Time", () => {
const original = "UNIXSeconds->Time";
const expected = "UNIX seconds → Time";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX seconds → Date", () => {
const original = "UNIXSeconds->Date";
const expected = "UNIX seconds → Date";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX seconds → Datetime", () => {
const original = "UNIXSeconds->DateTime";
const expected = "UNIX seconds → Datetime";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
});
describe("UNIX milliseconds", () => {
it("converts to UNIX milliseconds → Time", () => {
const original = "UNIXMilliSeconds->Time";
const expected = "UNIX milliseconds → Time";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX milliseconds → Date", () => {
const original = "UNIXMilliSeconds->Date";
const expected = "UNIX milliseconds → Date";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX milliseconds → Datetime", () => {
const original = "UNIXMilliSeconds->DateTime";
const expected = "UNIX milliseconds → Datetime";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
});
describe("UNIX microseconds", () => {
it("converts to UNIX microseconds → Time", () => {
const original = "UNIXMicroSeconds->Time";
const expected = "UNIX microseconds → Time";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX microseconds → Date", () => {
const original = "UNIXMicroSeconds->Date";
const expected = "UNIX microseconds → Date";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX microseconds → Datetime", () => {
const original = "UNIXMicroSeconds->DateTime";
const expected = "UNIX microseconds → Datetime";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
});
describe("UNIX nanoseconds", () => {
it("converts to UNIX nanoseconds → Time", () => {
const original = "UNIXNanoSeconds->Time";
const expected = "UNIX nanoseconds → Time";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX nanoseconds → Date", () => {
const original = "UNIXNanoSeconds->Date";
const expected = "UNIX nanoseconds → Date";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts to UNIX nanoseconds → Datetime", () => {
const original = "UNIXNanoSeconds->DateTime";
const expected = "UNIX nanoseconds → Datetime";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
});
describe("YYYYMMDDHHMMSS", () => {
it("converts YYYYMMDDHHMMSSString->Temporal to YYYYMMDDHHMMSS string → Temporal", () => {
const original = "YYYYMMDDHHMMSSString->Temporal";
const expected = "YYYYMMDDHHMMSS string → Temporal";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
it("converts YYYYMMDDHHMMSSBytes->Temporal to YYYYMMDDHHMMSS bytes → Temporal", () => {
const original = "YYYYMMDDHHMMSSBytes->Temporal";
const expected = "YYYYMMDDHHMMSS bytes → Temporal";
const humanized = humanizeCoercionStrategy(original);
expect(humanized).toBe(expected);
});
});
});

View File

@@ -0,0 +1,6 @@
.symbol {
[aria-selected="true"] & {
/* override styling from SelectItem.module.css */
color: var(--mb-color-text-selected) !important;
}
}

View File

@@ -0,0 +1,116 @@
import type { FocusEvent } from "react";
import { t } from "ttag";
import { currency } from "cljs/metabase.util.currency";
import {
Combobox,
Flex,
Icon,
Select,
SelectItem,
type SelectProps,
Text,
} from "metabase/ui";
import S from "./CurrencyPicker.module.css";
const DATA = getData();
const SYMBOLS = getSymbols();
interface Props extends Omit<SelectProps, "data" | "value" | "onChange"> {
value: string;
onChange: (value: string) => void;
}
export const CurrencyPicker = ({
comboboxProps,
value,
onChange,
onFocus,
...props
}: Props) => {
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
event.target.select();
onFocus?.(event);
};
return (
<Select
comboboxProps={{
middlewares: {
flip: true,
size: {
padding: 6,
},
},
position: "bottom-start",
...comboboxProps,
}}
data={DATA}
nothingFoundMessage={t`Didn't find any results`}
placeholder={t`Select a currency type`}
renderOption={(item) => {
const selected = item.option.value === value;
return (
<SelectItem selected={selected}>
<Icon name={selected ? "check" : "empty"} />
<Flex align="center" flex="1" gap="xs" justify="space-between">
<span>{item.option.label}</span>
<Text
c="text-light"
className={S.symbol}
flex="0 0 auto"
lh="1rem"
>
{SYMBOLS[item.option.value]}
</Text>
</Flex>
</SelectItem>
);
}}
rightSection={
<Flex align="center" gap="xs" pos="relative">
<Text
bg="var(--input-bg)"
c="text-light"
pos="absolute"
px="sm"
right="100%"
>
{SYMBOLS[value]}
</Text>
<Combobox.Chevron />
</Flex>
}
searchable
value={value}
onChange={(value) => onChange(value)}
onFocus={handleFocus}
{...props}
/>
);
};
type Currency = {
name: string;
code: string;
symbol: string;
};
function getData() {
const currencyData = currency as [Currency["symbol"], Currency][];
return currencyData.map(([, currency]) => ({
label: currency.name,
value: currency.code,
symbol: currency.symbol,
}));
}
function getSymbols() {
return Object.fromEntries(getData().map((item) => [item.value, item.symbol]));
}

View File

@@ -0,0 +1,67 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "__support__/ui";
import { CurrencyPicker } from "./CurrencyPicker";
interface SetupOpts {
value?: string;
}
function setup({ value = "USD" }: SetupOpts = {}) {
const onChange = jest.fn();
const { rerender } = renderWithProviders(
<CurrencyPicker value={value} onChange={onChange} />,
);
return { rerender, onChange };
}
describe("CurrencyPicker", () => {
it("renders currency options in the dropdown", async () => {
setup();
await userEvent.click(
screen.getByPlaceholderText("Select a currency type"),
);
expect(screen.getByText("US Dollar")).toBeInTheDocument();
expect(screen.getByText("Canadian Dollar")).toBeInTheDocument();
expect(screen.getByText("CA$")).toBeInTheDocument();
});
it("calls onChange when a different currency is selected", async () => {
const { onChange } = setup({ value: "USD" });
await userEvent.click(
screen.getByPlaceholderText("Select a currency type"),
);
await userEvent.click(screen.getByText("Euro"));
expect(onChange).toHaveBeenCalledWith("EUR");
});
it("does not call onChange when the same currency is selected", async () => {
const { onChange } = setup({ value: "USD" });
await userEvent.click(
screen.getByPlaceholderText("Select a currency type"),
);
await userEvent.click(screen.getByText("US Dollar"));
expect(onChange).not.toHaveBeenCalled();
});
it("updates selected value when value prop changes", async () => {
const { rerender, onChange } = setup();
expect(screen.getByDisplayValue("US Dollar")).toBeInTheDocument();
expect(
screen.queryByDisplayValue("British Pound Sterling"),
).not.toBeInTheDocument();
rerender(<CurrencyPicker value="GBP" onChange={onChange} />);
expect(
screen.getByDisplayValue("British Pound Sterling"),
).toBeInTheDocument();
expect(screen.queryByDisplayValue("US Dollar")).not.toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,33 @@
import { t } from "ttag";
import { useDiscardFieldValuesMutation } from "metabase/api";
import { useTemporaryState } from "metabase/common/hooks";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Button } from "metabase/ui";
import type { FieldId } from "metabase-types/api";
interface Props {
fieldId: FieldId;
}
export const DiscardFieldValuesButton = ({ fieldId }: Props) => {
const [discardFieldValues] = useDiscardFieldValuesMutation();
const { sendErrorToast } = useMetadataToasts();
const [started, setStarted] = useTemporaryState(false, 2000);
const handleClick = async () => {
const { error } = await discardFieldValues(fieldId);
if (error) {
sendErrorToast(t`Failed to discard values`);
} else {
setStarted(true);
}
};
return (
<Button c="error" variant="subtle" onClick={handleClick}>
{started ? t`Discard triggered!` : t`Discard cached field values`}
</Button>
);
};

View File

@@ -0,0 +1,140 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupFieldEndpoints } from "__support__/server-mocks";
import {
act,
renderWithProviders,
screen,
waitFor,
within,
} from "__support__/ui";
import { UndoListing } from "metabase/common/components/UndoListing";
import { getRawTableFieldId } from "metabase/metadata/utils/field";
import { createMockField } from "metabase-types/api/mocks";
import { DiscardFieldValuesButton } from "./DiscardFieldValuesButton";
function setup() {
const field = createMockField();
setupFieldEndpoints(field);
renderWithProviders(
<>
<DiscardFieldValuesButton fieldId={getRawTableFieldId(field)} />
<UndoListing />
</>,
);
return { field };
}
describe("DiscardFieldValuesButton", () => {
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
});
it("should show success message for 2 seconds", async () => {
const { field } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Discard cached field values");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/field/${field.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Discard triggered!");
});
await act(() => {
jest.advanceTimersByTime(2000);
});
expect(button).toHaveTextContent("Discard cached field values");
});
it("resets success message timer after next click", async () => {
const { field } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Discard cached field values");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/field/${field.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Discard triggered!");
});
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Discard triggered!");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/field/${field.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(2);
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Discard triggered!");
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Discard cached field values");
});
it("should show error message toast", async () => {
const { field } = setup();
fetchMock.modifyRoute(`field-${field.id}-discard-values`, {
response: { status: 500 },
});
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Discard cached field values");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/field/${field.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Discard cached field values");
});
await waitFor(() => {
const undo = screen.getByTestId("undo-list");
expect(
within(undo).getByText("Failed to discard values"),
).toBeInTheDocument();
});
});
});

View File

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

View File

@@ -0,0 +1,33 @@
import { t } from "ttag";
import { useDiscardTableFieldValuesMutation } from "metabase/api";
import { useTemporaryState } from "metabase/common/hooks";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Button } from "metabase/ui";
import type { TableId } from "metabase-types/api";
interface Props {
tableId: TableId;
}
export const DiscardTableFieldValuesButton = ({ tableId }: Props) => {
const [discardTableFieldValues] = useDiscardTableFieldValuesMutation();
const { sendErrorToast } = useMetadataToasts();
const [started, setStarted] = useTemporaryState(false, 2000);
const handleClick = async () => {
const { error } = await discardTableFieldValues(tableId);
if (error) {
sendErrorToast(t`Failed to discard values`);
} else {
setStarted(true);
}
};
return (
<Button c="error" variant="subtle" onClick={handleClick}>
{started ? t`Discard triggered!` : t`Discard cached field values`}
</Button>
);
};

View File

@@ -0,0 +1,139 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupTableEndpoints } from "__support__/server-mocks";
import {
act,
renderWithProviders,
screen,
waitFor,
within,
} from "__support__/ui";
import { UndoListing } from "metabase/common/components/UndoListing";
import { createMockTable } from "metabase-types/api/mocks";
import { DiscardTableFieldValuesButton } from "./DiscardTableFieldValuesButton";
function setup() {
const table = createMockTable();
setupTableEndpoints(table);
renderWithProviders(
<>
<DiscardTableFieldValuesButton tableId={table.id} />
<UndoListing />
</>,
);
return { table };
}
describe("DiscardTableFieldValuesButton", () => {
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
});
it("should show success message for 2 seconds", async () => {
const { table } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Discard cached field values");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/table/${table.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Discard triggered!");
});
await act(() => {
jest.advanceTimersByTime(2000);
});
expect(button).toHaveTextContent("Discard cached field values");
});
it("resets success message timer after next click", async () => {
const { table } = setup();
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Discard cached field values");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/table/${table.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Discard triggered!");
});
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Discard triggered!");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/table/${table.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(2);
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Discard triggered!");
await act(() => {
jest.advanceTimersByTime(1000);
});
expect(button).toHaveTextContent("Discard cached field values");
});
it("should show error message toast", async () => {
const { table } = setup();
fetchMock.modifyRoute(`table-${table.id}-discard-values`, {
response: { status: 500 },
});
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Discard cached field values");
await userEvent.click(button);
expect(
fetchMock.callHistory.calls(
`path:/api/table/${table.id}/discard_values`,
{
method: "POST",
},
),
).toHaveLength(1);
await waitFor(() => {
expect(button).toHaveTextContent("Discard cached field values");
});
await waitFor(() => {
const undo = screen.getByTestId("undo-list");
expect(
within(undo).getByText("Failed to discard values"),
).toBeInTheDocument();
});
});
});

View File

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

View File

@@ -0,0 +1,19 @@
.root {
--sc-background-color: var(
--mantine-color-accent-gray-light-filled
) !important;
/**
* We're using :after to apply a border here, to prevent increasing element's height.
*/
&:after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
border: 1px solid var(--mb-color-border);
border-radius: inherit;
}
}

View File

@@ -0,0 +1,66 @@
import { useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import { SegmentedControl, type SegmentedControlProps } from "metabase/ui";
import type { TableFieldOrder } from "metabase-types/api";
import S from "./FieldOrderPicker.module.css";
import { Label } from "./Label";
interface Props
extends Omit<
SegmentedControlProps<TableFieldOrder>,
"data" | "value" | "onChange"
> {
value: TableFieldOrder;
onChange: (value: TableFieldOrder) => void;
}
export const FieldOrderPicker = ({ value, onChange, ...props }: Props) => {
const data = useMemo(() => getData(), []);
// State is managed internally for instant visual feedback
// in case onChange handler is asynchronous.
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (newValue: TableFieldOrder) => {
setLocalValue(newValue);
onChange(newValue);
};
return (
<SegmentedControl
aria-label={t`Column order`}
className={S.root}
data={data}
size="sm"
value={localValue}
onChange={handleChange}
{...props}
/>
);
};
function getData() {
return [
{
value: "smart" as const,
label: <Label icon="sparkles" tooltip={t`Auto order`} />,
},
{
value: "database" as const,
label: <Label icon="database" tooltip={t`Database order`} />,
},
{
value: "alphabetical" as const,
label: <Label icon="string" tooltip={t`Alphabetical order`} />,
},
{
value: "custom" as const,
label: <Label icon="palette" tooltip={t`Custom order`} />,
},
];
}

View File

@@ -0,0 +1,87 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import type { TableFieldOrder } from "metabase-types/api";
import { FieldOrderPicker } from "./FieldOrderPicker";
const OPTIONS: TableFieldOrder[] = [
"smart",
"database",
"alphabetical",
"custom",
];
const TOOLTIP_LABELS = [
"Auto order",
"Database order",
"Alphabetical order",
"Custom order",
];
interface SetupOpts {
value?: TableFieldOrder;
}
function setup({ value = "smart" }: SetupOpts = {}) {
const onChange = jest.fn();
const { rerender } = renderWithProviders(
<FieldOrderPicker value={value} onChange={onChange} />,
);
return { rerender, onChange };
}
describe("FieldOrderPicker", () => {
it("renders all options with correct tooltips", async () => {
setup();
for (const label of TOOLTIP_LABELS) {
expect(screen.getByLabelText(label)).toBeInTheDocument();
userEvent.hover(screen.getByLabelText(label));
await waitFor(() => {
expect(
screen.getByRole("tooltip", { name: label }),
).toBeInTheDocument();
});
}
});
it.each(OPTIONS)("sets the correct option as selected - %s", (value) => {
setup({ value });
const index = OPTIONS.indexOf(value);
const radio = screen.getByRole("radio", { name: TOOLTIP_LABELS[index] });
expect(radio).toBeChecked();
});
it("calls onChange when a different option is selected", async () => {
const { onChange } = setup({ value: "smart" });
const radio = screen.getByRole("radio", { name: "Database order" });
await userEvent.click(radio);
expect(onChange).toHaveBeenCalledWith("database");
});
it("does not call onChange when the same option is selected", async () => {
const { onChange } = setup({ value: "smart" });
const radio = screen.getByRole("radio", { name: "Auto order" });
await userEvent.click(radio);
expect(onChange).not.toHaveBeenCalled();
});
it("updates selected value when value prop changes", () => {
const { rerender } = renderWithProviders(
<FieldOrderPicker value="smart" onChange={jest.fn()} />,
);
expect(screen.getByRole("radio", { name: "Auto order" })).toBeChecked();
rerender(<FieldOrderPicker value="custom" onChange={jest.fn()} />);
expect(screen.getByRole("radio", { name: "Custom order" })).toBeChecked();
});
});

View File

@@ -0,0 +1,14 @@
import { Flex, Icon, type IconName, Tooltip } from "metabase/ui";
interface Props {
icon: IconName;
tooltip: string;
}
export const Label = ({ icon, tooltip }: Props) => (
<Tooltip label={tooltip}>
<Flex aria-label={tooltip} align="center" justify="center" w={24}>
<Icon name={icon} />
</Flex>
</Tooltip>
);

View File

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

View File

@@ -0,0 +1,69 @@
import { useMemo } from "react";
import { t } from "ttag";
import { HAS_FIELD_VALUES_OPTIONS } from "metabase/lib/core";
import { Select, type SelectProps } from "metabase/ui";
import type { FieldValuesType } from "metabase-types/api";
interface Props extends Omit<SelectProps, "data" | "value" | "onChange"> {
value: FieldValuesType;
onChange: (value: FieldValuesType) => void;
}
export const FieldValuesTypePicker = ({
comboboxProps,
value,
onChange,
...props
}: Props) => {
const data = useMemo(() => getData(), []);
const handleChange = (value: string) => {
if (!isFieldValuesType(value)) {
throw new Error("Unknown FieldValuesType. This should never happen");
}
onChange(value);
};
return (
<Select
comboboxProps={{
middlewares: {
flip: true,
size: {
padding: 6,
},
},
position: "bottom-start",
...comboboxProps,
}}
data={data}
placeholder={t`Select field filtering`}
value={value}
onChange={handleChange}
{...props}
/>
);
};
function getData() {
return HAS_FIELD_VALUES_OPTIONS.map((type) => ({
label: type.name,
value: type.value,
}));
}
function isFieldValuesType(value: string): value is FieldValuesType {
/**
* Using a Record, so that this gives compilation error when FieldValuesType is extended,
* so that whoever changes that type does not forget to update this type guard.
*/
const fieldValuesTypesMap: Record<FieldValuesType, boolean> = {
list: true,
search: true,
none: true,
};
return Object.keys(fieldValuesTypesMap).includes(value);
}

View File

@@ -0,0 +1,57 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "__support__/ui";
import type { FieldValuesType } from "metabase-types/api";
import { FieldValuesTypePicker } from "./FieldValuesTypePicker";
interface SetupOpts {
value?: FieldValuesType;
onChange?: (value: FieldValuesType) => void;
}
const setup = ({ value = "list", onChange = jest.fn() }: SetupOpts = {}) => {
renderWithProviders(
<FieldValuesTypePicker
placeholder="Select..."
value={value}
onChange={onChange}
/>,
);
return { onChange };
};
describe("FieldValuesTypePicker", () => {
it("renders with the correct initial value", () => {
setup({ value: "search" });
const select = screen.getByPlaceholderText("Select...");
expect(select).toHaveValue("Search box");
});
it("displays all available options when clicked", async () => {
setup();
const select = screen.getByPlaceholderText("Select...");
await userEvent.click(select);
expect(screen.getByText("Search box")).toBeInTheDocument();
expect(screen.getByText("A list of all values")).toBeInTheDocument();
expect(screen.getByText("Plain input box")).toBeInTheDocument();
});
it("calls onChange with the selected value when an option is chosen", async () => {
const { onChange } = setup();
const select = screen.getByPlaceholderText("Select...");
await userEvent.click(select);
await userEvent.click(screen.getByText("Search box"));
expect(onChange).toHaveBeenCalledWith("search");
});
});

View File

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

View File

@@ -0,0 +1,6 @@
.description {
[aria-selected="true"] & {
/* override styling from SelectItem.module.css */
color: var(--mb-color-text-selected) !important;
}
}

View File

@@ -0,0 +1,104 @@
import { t } from "ttag";
import { FIELD_VISIBILITY_TYPES } from "metabase/lib/core";
import {
Flex,
Icon,
Select,
SelectItem,
type SelectProps,
Text,
} from "metabase/ui";
import type { FieldVisibilityType } from "metabase-types/api";
import S from "./FieldVisibilityPicker.module.css";
const DATA = getData();
interface Props extends Omit<SelectProps, "data" | "value" | "onChange"> {
value: FieldVisibilityType;
onChange: (value: FieldVisibilityType) => void;
}
export const FieldVisibilityPicker = ({
comboboxProps,
value,
onChange,
...props
}: Props) => {
const handleChange = (value: string) => {
if (!isFieldVisibilityType(value)) {
throw new Error("Unknown FieldVisibilityType. This should never happen");
}
onChange(value);
};
return (
<Select
comboboxProps={{
middlewares: {
flip: true,
size: {
padding: 6,
},
},
position: "bottom-start",
...comboboxProps,
}}
data={DATA}
placeholder={t`Select a field visibility`}
renderOption={(item) => {
const selected = item.option.value === value;
const option = DATA.find(
(option) => option.value === item.option.value,
);
return (
<SelectItem selected={selected}>
<Icon name={selected ? "check" : "empty"} />
<Flex direction="column" flex="1" gap="xs">
<Text c="inherit" fw="bold" lh="1rem">
{item.option.label}
</Text>
{option?.description && (
<Text c="text-tertiary" className={S.description} lh="1rem">
{option.description}
</Text>
)}
</Flex>
</SelectItem>
);
}}
value={value}
onChange={handleChange}
{...props}
/>
);
};
function getData() {
return FIELD_VISIBILITY_TYPES.map((type) => ({
description: type.description,
label: type.name,
value: type.id,
}));
}
function isFieldVisibilityType(value: string): value is FieldVisibilityType {
/**
* Using a Record, so that this gives compilation error when FieldVisibilityType is extended,
* so that whoever changes that type does not forget to update this type guard.
*/
const visibilityTypesMap: Record<FieldVisibilityType, boolean> = {
"details-only": true,
hidden: true,
normal: true,
retired: true,
sensitive: true,
};
return Object.keys(visibilityTypesMap).includes(value);
}

View File

@@ -0,0 +1,77 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, within } from "__support__/ui";
import type { FieldVisibilityType } from "metabase-types/api";
import { FieldVisibilityPicker } from "./FieldVisibilityPicker";
interface SetupOpts {
value?: FieldVisibilityType;
onChange?: (value: FieldVisibilityType) => void;
}
const setup = ({ value = "normal", onChange = jest.fn() }: SetupOpts = {}) => {
renderWithProviders(
<FieldVisibilityPicker value={value} onChange={onChange} />,
);
return { onChange };
};
describe("FieldVisibilityPicker", () => {
it("renders with the correct initial value", () => {
setup({ value: "sensitive" });
const select = screen.getByPlaceholderText("Select a field visibility");
expect(select).toHaveValue("Do not include");
});
it("displays all available options when clicked", async () => {
setup();
const select = screen.getByPlaceholderText("Select a field visibility");
await userEvent.click(select);
expect(screen.getByText("Everywhere")).toBeInTheDocument();
expect(screen.getByText("Only in detail views")).toBeInTheDocument();
expect(screen.getByText("Do not include")).toBeInTheDocument();
});
it("calls onChange with the selected value when an option is chosen", async () => {
const { onChange } = setup();
const select = screen.getByPlaceholderText("Select a field visibility");
await userEvent.click(select);
await userEvent.click(screen.getByText("Do not include"));
expect(onChange).toHaveBeenCalledWith("sensitive");
});
it("shows correct visibility descriptions (metabase#56077)", async () => {
setup();
const picker = screen.getByPlaceholderText("Select a field visibility");
await userEvent.click(picker);
const dropdown = within(screen.getByRole("listbox"));
expect(
dropdown.getByText(
"The default setting. This field will be displayed normally in tables and charts.",
),
).toBeInTheDocument();
expect(
dropdown.getByText(
"This field will only be displayed when viewing the details of a single record. Use this for information that's lengthy or that isn't useful in a table or chart.",
),
).toBeInTheDocument();
expect(
dropdown.getByText(
"This field won't be visible or selectable in questions created with the GUI interfaces. It will still be accessible in SQL/native queries.",
),
).toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,6 @@
.description {
[aria-selected="true"] & {
/* override styling from SelectItem.module.css */
color: var(--mb-color-text-selected) !important;
}
}

View File

@@ -0,0 +1,194 @@
import { type FocusEvent, useMemo } from "react";
import { t } from "ttag";
import {
areFieldsComparable,
getFieldDisplayName,
getRawTableFieldId,
} from "metabase/metadata/utils/field";
import {
Flex,
Icon,
Select,
SelectItem,
type SelectProps,
Text,
} from "metabase/ui";
import { isFK } from "metabase-lib/v1/types/utils/isa";
import type { Field as ApiField, Field, FieldId } from "metabase-types/api";
import S from "./FkTargetPicker.module.css";
interface Props extends Omit<SelectProps, "data" | "value" | "onChange"> {
field: ApiField;
idFields: Field[];
value: FieldId | null;
onChange: (value: FieldId | null) => void;
}
export const FkTargetPicker = ({
comboboxProps,
field,
idFields,
value,
onChange,
onFocus,
...props
}: Props) => {
const { comparableIdFields, hasIdFields, data, optionsByFieldId } =
useMemo(() => {
const comparableIdFields = idFields.filter((idField) => {
return areFieldsComparable(idField, field);
});
const hasIdFields = comparableIdFields.length > 0;
const includeSchema = hasMultipleSchemas(comparableIdFields);
const data = getData(comparableIdFields, includeSchema);
const optionsByFieldId = Object.fromEntries(
data.map((option) => [option.value, option]),
);
return { comparableIdFields, data, hasIdFields, optionsByFieldId };
}, [field, idFields]);
const getFieldFromValue = (fieldId: string | null) => {
if (fieldId == null) {
return null;
}
const option = optionsByFieldId[fieldId];
return option?.field;
};
const getFieldIdFromValue = (fieldId: string | null): FieldId => {
const field = getFieldFromValue(fieldId);
if (!field) {
throw new Error("unreachable");
}
return getRawTableFieldId(field);
};
const handleChange = (value: string | null) => {
if (value != null) {
const fieldId = getFieldIdFromValue(value);
onChange(fieldId);
}
};
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
event.target.select();
onFocus?.(event);
};
return (
<Select
aria-label={t`Foreign key target`}
comboboxProps={{
middlewares: {
flip: true,
size: {
padding: 6,
},
},
position: "bottom-start",
...comboboxProps,
}}
data={data}
disabled={!hasIdFields}
filter={({ options, search }) => {
const query = search.toLowerCase().trim();
return options.filter((option) => {
if ("group" in option) {
return false;
}
const field = getFieldFromValue(option.value);
if (!field) {
return false;
}
return (
option.label.toLowerCase().includes(query) ||
field.description?.toLowerCase().includes(query)
);
});
}}
nothingFoundMessage={t`Didn't find any results`}
placeholder={getFkFieldPlaceholder(field, comparableIdFields)}
renderOption={(item) => {
const field = getFieldFromValue(item.option.value);
const selected = getFieldIdFromValue(item.option.value) === value;
return (
<SelectItem selected={selected}>
<Icon name={selected ? "check" : "empty"} />
<Flex direction="column" flex="1" gap="xs">
<Text c="inherit" component="span" lh="1rem">
{item.option.label}
</Text>
{field?.description && (
<Text
c="text-tertiary"
className={S.description}
component="span"
lh="1rem"
>
{field.description}
</Text>
)}
</Flex>
</SelectItem>
);
}}
searchable
value={stringifyValue(value)}
onChange={handleChange}
onFocus={handleFocus}
{...props}
/>
);
};
function getData(comparableIdFields: Field[], includeSchema: boolean) {
return comparableIdFields
.map((field) => {
return {
field,
label: getFieldDisplayName(
field,
field.table,
includeSchema && field.table ? field.table.schema : undefined,
),
value: stringifyValue(getRawTableFieldId(field)) ?? "",
};
})
.sort((a, b) => a.label.localeCompare(b.label));
}
function stringifyValue(value: FieldId | null): string | null {
return value === null ? null : JSON.stringify(value);
}
function getFkFieldPlaceholder(field: ApiField, idFields: Field[]) {
const hasIdFields = idFields?.length > 0;
const isRestrictedFKTargetSelected =
isFK(field) &&
field.fk_target_field_id != null &&
!idFields?.some((idField) => idField.id === field.fk_target_field_id);
if (isRestrictedFKTargetSelected) {
return t`Field access denied`;
}
return hasIdFields ? t`Select a target` : t`No key available`;
}
function hasMultipleSchemas(fields: Field[]) {
const schemas = new Set(fields.map((field) => field.table?.schema));
return schemas.size > 1;
}

View File

@@ -0,0 +1,167 @@
import userEvent from "@testing-library/user-event";
import { render, screen } from "__support__/ui";
import { getNextId } from "__support__/utils";
import { getRawTableFieldId } from "metabase/metadata/utils/field";
import type { Field, FieldId } from "metabase-types/api";
import { createMockField, createMockTable } from "metabase-types/api/mocks";
import { FkTargetPicker } from "./FkTargetPicker";
const FIELD = createMockField({
id: getNextId(),
display_name: "Field 1",
});
const TABLE_SCHEMA_PUBLIC = createMockTable({
id: getNextId(),
schema: "public",
display_name: "Table 1",
});
const TABLE_SCHEMA_OTHER = createMockTable({
id: getNextId(),
schema: "other",
display_name: "Table 2",
});
const ID_FIELD_1 = createMockField({
id: getNextId(),
display_name: "Field A",
description: "Description A",
table: TABLE_SCHEMA_PUBLIC,
});
const ID_FIELD_2 = createMockField({
id: getNextId(),
display_name: "Field B",
description: "Description B",
table: TABLE_SCHEMA_OTHER,
});
const ID_FIELDS = [ID_FIELD_1, ID_FIELD_2];
interface SetupOpts {
field?: Field;
idFields?: Field[];
value?: FieldId | null;
}
function setup({
field = FIELD,
idFields = ID_FIELDS,
value = null,
}: SetupOpts = {}) {
const onChange = jest.fn();
const { rerender } = render(
<FkTargetPicker
field={field}
idFields={idFields}
value={value}
onChange={onChange}
/>,
);
return { rerender, onChange };
}
describe("FkTargetPicker", () => {
it("should show empty state when there are no idFields", async () => {
setup({ idFields: [] });
const select = screen.getByPlaceholderText("No key available");
await userEvent.click(select);
expect(screen.getByText("Didn't find any results")).toBeInTheDocument();
});
it("should render options with label, description, and icon", async () => {
setup();
const select = screen.getByPlaceholderText("Select a target");
await userEvent.click(select);
expect(screen.getByText("Public.Table 1 → Field A")).toBeInTheDocument();
expect(screen.getByText("Description A")).toBeInTheDocument();
expect(screen.getByText("Other.Table 2 → Field B")).toBeInTheDocument();
expect(screen.getByText("Description B")).toBeInTheDocument();
expect(screen.getAllByRole("img").length).toBeGreaterThan(0);
});
it("calls onChange when a different field is selected", async () => {
const { onChange } = setup({ value: getRawTableFieldId(ID_FIELD_1) });
await userEvent.click(screen.getByPlaceholderText("Select a target"));
await userEvent.click(screen.getByText("Other.Table 2 → Field B"));
expect(onChange).toHaveBeenCalledWith(getRawTableFieldId(ID_FIELD_2));
});
it("does not call onChange when the same field is selected", async () => {
const { onChange } = setup({ value: getRawTableFieldId(ID_FIELD_1) });
await userEvent.click(screen.getByPlaceholderText("Select a target"));
await userEvent.click(screen.getByText("Public.Table 1 → Field A"));
expect(onChange).not.toHaveBeenCalled();
});
it("should filter options using based on label and description", async () => {
setup();
const select = screen.getByPlaceholderText("Select a target");
await userEvent.click(select);
// Should show both options initially
expect(screen.getByText("Public.Table 1 → Field A")).toBeInTheDocument();
expect(screen.getByText("Other.Table 2 → Field B")).toBeInTheDocument();
// Filter by label case-insensitive
await userEvent.type(select, "tAbLe 1");
expect(screen.getByText("Public.Table 1 → Field A")).toBeInTheDocument();
expect(
screen.queryByText("Other.Table 2 → Field B"),
).not.toBeInTheDocument();
// Filter by description case-insensitive
await userEvent.clear(select);
await userEvent.type(select, "DeScRiPtIoN B");
expect(screen.getByText("Other.Table 2 → Field B")).toBeInTheDocument();
expect(
screen.queryByText("Public.Table 1 → Field A"),
).not.toBeInTheDocument();
});
it("should include schema in the label if there are multiple schemas", async () => {
setup();
const select = screen.getByPlaceholderText("Select a target");
await userEvent.click(select);
expect(screen.getByText("Public.Table 1 → Field A")).toBeInTheDocument();
expect(screen.getByText("Other.Table 2 → Field B")).toBeInTheDocument();
});
it("should not include schema in the label if all fields have the same schema", async () => {
setup({
idFields: [ID_FIELD_1],
});
const select = screen.getByPlaceholderText("Select a target");
await userEvent.click(select);
expect(screen.getByText("Table 1 → Field A")).toBeInTheDocument();
});
it("should show special placeholder when no access", () => {
setup({
field: createMockField({
...FIELD,
semantic_type: "type/FK",
fk_target_field_id: getNextId(),
}),
});
expect(
screen.getByPlaceholderText("Field access denied"),
).toBeInTheDocument();
});
});

View File

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

View File

@@ -0,0 +1,42 @@
import {
TextInputBlurChange,
type TextInputBlurChangeProps,
} from "metabase/ui";
interface Props
extends Omit<
TextInputBlurChangeProps,
"normalize" | "value" | "onBlurChange" | "onChange"
> {
normalize?: (
value: string | number | readonly string[] | undefined,
) => string;
value: string;
onChange: (value: string) => void;
}
export const Input = ({
normalize,
required,
value,
onChange,
...props
}: Props) => {
return (
<TextInputBlurChange
normalize={normalize}
required={required}
resetOnEsc
value={value}
onBlurChange={(event) => {
const newValue = event.target.value;
const newNormalizedValue = normalize ? normalize(newValue) : newValue;
if (newNormalizedValue !== value) {
onChange(event.target.value);
}
}}
{...props}
/>
);
};

View File

@@ -0,0 +1,30 @@
.name {
&:focus-within {
position: relative;
z-index: 1; /* prevent focus state from being cut off after imitating collapsed borders */
}
}
.section {
width: auto;
}
.description {
position: relative;
top: -2px; /* Imitate collapsed borders. -1px does not seem to do the trick on Mac. */
}
.nameInput {
border-start-start-radius: var(--mantine-radius-md);
border-start-end-radius: var(--mantine-radius-md);
border-end-start-radius: 0;
border-end-end-radius: 0;
}
.descriptionInput {
line-height: var(--mantine-line-height-lg);
border-start-start-radius: 0;
border-start-end-radius: 0;
border-end-start-radius: var(--mantine-radius-md);
border-end-end-radius: var(--mantine-radius-md);
}

Some files were not shown because too many files have changed in this diff Show More