Compare commits
50 Commits
transforms
...
easy-data-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfca8bacc9 | ||
|
|
0ce67c84ba | ||
|
|
f0873a8ed5 | ||
|
|
9419600308 | ||
|
|
ea0bcc1648 | ||
|
|
9d5cf66bc4 | ||
|
|
25c217346b | ||
|
|
b69d600c4b | ||
|
|
86d571fb4b | ||
|
|
6e1d196f24 | ||
|
|
b2da483689 | ||
|
|
ae294bfdea | ||
|
|
24d33159ba | ||
|
|
ff11a7981d | ||
|
|
9eb6888b24 | ||
|
|
b3ded09ade | ||
|
|
267a049bf7 | ||
|
|
aa194d23d7 | ||
|
|
97937432f8 | ||
|
|
0fce1ac6f0 | ||
|
|
9b37c7cd65 | ||
|
|
c6b86ce8f2 | ||
|
|
382ba6cd95 | ||
|
|
4530844329 | ||
|
|
34b5f79c2e | ||
|
|
bf6761c5bb | ||
|
|
0c83c166ca | ||
|
|
315c91903f | ||
|
|
a15e0fea05 | ||
|
|
e2be93a3ff | ||
|
|
eb4d848d6a | ||
|
|
ff3e32c6d0 | ||
|
|
522ec39542 | ||
|
|
537ccb3e1e | ||
|
|
d39ba9a140 | ||
|
|
d0db9a9564 | ||
|
|
385362f276 | ||
|
|
b37818fbb1 | ||
|
|
d166a3de9c | ||
|
|
8db485228f | ||
|
|
e66a9be848 | ||
|
|
a06af99414 | ||
|
|
dbb56a4d2d | ||
|
|
0074ffdb9c | ||
|
|
7c84626e83 | ||
|
|
df3ffbfaf8 | ||
|
|
697f4c5b79 | ||
|
|
e07ea0463d | ||
|
|
7e622d9c93 | ||
|
|
9fc6a6f23a |
132
frontend/src/metabase/bench/BenchApp.module.css
Normal file
132
frontend/src/metabase/bench/BenchApp.module.css
Normal 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;
|
||||
}
|
||||
256
frontend/src/metabase/bench/BenchApp.tsx
Normal file
256
frontend/src/metabase/bench/BenchApp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/metabase/bench/BenchLayout.tsx
Normal file
98
frontend/src/metabase/bench/BenchLayout.tsx
Normal 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);
|
||||
108
frontend/src/metabase/bench/BenchOverview.tsx
Normal file
108
frontend/src/metabase/bench/BenchOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
frontend/src/metabase/bench/MetricsApp.tsx
Normal file
66
frontend/src/metabase/bench/MetricsApp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
frontend/src/metabase/bench/ModelsApp.tsx
Normal file
67
frontend/src/metabase/bench/ModelsApp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
frontend/src/metabase/bench/SegmentsApp.tsx
Normal file
53
frontend/src/metabase/bench/SegmentsApp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { BenchMetabot } from "./BenchMetabot";
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { BenchSidebar } from "./BenchSidebar";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { BenchToolbar } from "./BenchToolbar";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CollectionsCard } from "./CollectionsCard";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
161
frontend/src/metabase/bench/components/Panel/Panel.tsx
Normal file
161
frontend/src/metabase/bench/components/Panel/Panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { TransformExecutionCard } from "./TransformExecutionCard";
|
||||
11
frontend/src/metabase/bench/components/index.ts
Normal file
11
frontend/src/metabase/bench/components/index.ts
Normal 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";
|
||||
@@ -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")};
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export { default } from "./FormInput";
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export { default } from "./FormLabel";
|
||||
@@ -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")};
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export { default } from "./FormTextArea";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./QueryDefinition";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
`;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/no-default-export -- deprecated usage
|
||||
export { default } from "./SegmentForm";
|
||||
@@ -0,0 +1,3 @@
|
||||
.cell {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>"{message}"</p>}
|
||||
{diffKeys.map((key) => (
|
||||
<RevisionDiff
|
||||
key={key}
|
||||
property={key}
|
||||
diff={revision.diff[key]}
|
||||
tableId={tableId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
`;
|
||||
@@ -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`} "{segment.name}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
"
|
||||
{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>
|
||||
)}
|
||||
"
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
124
frontend/src/metabase/bench/datamodel/containers/SegmentApp.jsx
Normal file
124
frontend/src/metabase/bench/datamodel/containers/SegmentApp.jsx
Normal 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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
41
frontend/src/metabase/bench/datamodel/datamodel.js
Normal file
41
frontend/src/metabase/bench/datamodel/datamodel.js
Normal 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 });
|
||||
101
frontend/src/metabase/bench/datamodel/hoc/FilteredToUrlTable.jsx
Normal file
101
frontend/src/metabase/bench/datamodel/hoc/FilteredToUrlTable.jsx
Normal 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);
|
||||
4
frontend/src/metabase/bench/datamodel/selectors.js
Normal file
4
frontend/src/metabase/bench/datamodel/selectors.js
Normal 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;
|
||||
29
frontend/src/metabase/bench/datamodel/utils/segments.ts
Normal file
29
frontend/src/metabase/bench/datamodel/utils/segments.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
frontend/src/metabase/bench/hooks/useDarkMode.ts
Normal file
36
frontend/src/metabase/bench/hooks/useDarkMode.ts
Normal 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;
|
||||
}
|
||||
55
frontend/src/metabase/bench/metadata/.eslintrc
Normal file
55
frontend/src/metabase/bench/metadata/.eslintrc
Normal 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`",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
5
frontend/src/metabase/bench/metadata/README.md
Normal file
5
frontend/src/metabase/bench/metadata/README.md
Normal 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.
|
||||
@@ -0,0 +1,3 @@
|
||||
.root {
|
||||
overflow: visible; /* prevent scrollbar when error is displayed */
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CoercionStrategyPicker";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
.symbol {
|
||||
[aria-selected="true"] & {
|
||||
/* override styling from SelectItem.module.css */
|
||||
color: var(--mb-color-text-selected) !important;
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./CurrencyPicker";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiscardFieldValuesButton";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./DiscardTableFieldValuesButton";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FieldOrderPicker";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FieldValuesTypePicker";
|
||||
@@ -0,0 +1,6 @@
|
||||
.description {
|
||||
[aria-selected="true"] & {
|
||||
/* override styling from SelectItem.module.css */
|
||||
color: var(--mb-color-text-selected) !important;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FieldVisibilityPicker";
|
||||
@@ -0,0 +1,6 @@
|
||||
.description {
|
||||
[aria-selected="true"] & {
|
||||
/* override styling from SelectItem.module.css */
|
||||
color: var(--mb-color-text-selected) !important;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FkTargetPicker";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user