diff --git a/e2e/test/scenarios/metrics/metrics-editing.cy.spec.js b/e2e/test/scenarios/metrics/metrics-editing.cy.spec.js index 3da11423ad649ea147e7b3b16d31b8ef59b6c5d1..e040f7e4a84ef26e97835f9645e57eca1efc8935 100644 --- a/e2e/test/scenarios/metrics/metrics-editing.cy.spec.js +++ b/e2e/test/scenarios/metrics/metrics-editing.cy.spec.js @@ -130,9 +130,9 @@ describe("scenarios > metrics > editing", () => { ); openQuestionActions(); popover().findByText("Edit metric definition").click(); - addBreakout({ tableName: "Product", columnName: "Category" }); + addBreakout({ tableName: "Product", columnName: "Created At" }); updateMetric(); - verifyLineAreaBarChart({ xAxis: "Product → Category", yAxis: "Count" }); + verifyLineAreaBarChart({ xAxis: "Product → Created At", yAxis: "Count" }); }); it("should be able to change the query definition of a metric based on a model", () => { @@ -141,9 +141,9 @@ describe("scenarios > metrics > editing", () => { ); openQuestionActions(); popover().findByText("Edit metric definition").click(); - addBreakout({ tableName: "Product", columnName: "Category" }); + addBreakout({ tableName: "Product", columnName: "Created At" }); updateMetric(); - verifyLineAreaBarChart({ xAxis: "Product → Category", yAxis: "Count" }); + verifyLineAreaBarChart({ xAxis: "Created At", yAxis: "Count" }); }); it("should pin new metrics automatically", () => { @@ -154,7 +154,6 @@ describe("scenarios > metrics > editing", () => { entityPickerModalTab("Tables").click(); cy.findByText("Orders").click(); }); - addAggregation({ operatorName: "Count of rows" }); saveMetric({ name: "New metric" }); cy.findByTestId("head-crumbs-container") @@ -180,7 +179,6 @@ describe("scenarios > metrics > editing", () => { columnName: "Category", values: ["Gadget"], }); - addAggregation({ operatorName: "Count of rows" }); saveMetric(); verifyScalarValue("4,939"); }); @@ -196,7 +194,6 @@ describe("scenarios > metrics > editing", () => { columnName: "Category", values: ["Gadget"], }); - addAggregation({ operatorName: "Count of rows" }); saveMetric(); verifyScalarValue("4,939"); }); @@ -213,7 +210,6 @@ describe("scenarios > metrics > editing", () => { minValue: 5, maxValue: 100, }); - addAggregation({ operatorName: "Count of rows" }); saveMetric(); verifyScalarValue("5"); }); @@ -229,7 +225,6 @@ describe("scenarios > metrics > editing", () => { columnName: "Category", values: ["Gadget"], }); - addAggregation({ operatorName: "Count of rows" }); saveMetric(); verifyScalarValue("4,939"); }); @@ -246,7 +241,6 @@ describe("scenarios > metrics > editing", () => { minValue: 5, maxValue: 100, }); - addAggregation({ operatorName: "Count of rows" }); saveMetric(); verifyScalarValue("5"); }); @@ -257,9 +251,20 @@ describe("scenarios > metrics > editing", () => { entityPickerModalTab("Models").click(); cy.findByText("Orders Model").click(); }); - addAggregation({ operatorName: "Count of rows" }); getActionButton("Summarize").should("not.exist"); }); + + it("should allow to run the query from the metric empty state", () => { + startNewMetric(); + entityPickerModal().within(() => { + entityPickerModalTab("Tables").click(); + cy.findByText("Orders").click(); + }); + cy.intercept("POST", "/api/dataset").as("dataset"); + cy.findByTestId("metric-empty-state").button("Visualize").click(); + cy.wait("@dataset"); + verifyScalarValue("18,760"); + }); }); describe("joins", () => { @@ -281,7 +286,6 @@ describe("scenarios > metrics > editing", () => { cy.findByText("CA").click(); cy.button("Add filter").click(); }); - addAggregation({ operatorName: "Count of rows" }); saveMetric(); verifyScalarValue("613"); }); @@ -327,7 +331,11 @@ describe("scenarios > metrics > editing", () => { name: "Total2", }); popover().button("Done").click(); - addAggregation({ operatorName: "Sum of ...", columnName: "Total2" }); + getNotebookStep("summarize").findByText("Count").click(); + popover().within(() => { + cy.findByText("Sum of ...").click(); + cy.findByText("Total2").click(); + }); saveMetric(); verifyScalarValue("755,310.84"); }); @@ -344,7 +352,11 @@ describe("scenarios > metrics > editing", () => { name: "Price2", }); popover().button("Done").click(); - addAggregation({ operatorName: "Average of ...", columnName: "Price2" }); + getNotebookStep("summarize").findByText("Count").click(); + popover().within(() => { + cy.findByText("Average of ...").click(); + cy.findByText("Price2").click(); + }); saveMetric(); verifyScalarValue("111.38"); }); @@ -357,37 +369,18 @@ describe("scenarios > metrics > editing", () => { entityPickerModalTab("Tables").click(); cy.findByText("Orders").click(); }); - addAggregation({ operatorName: "Sum of ...", columnName: "Total" }); + getNotebookStep("summarize").findByText("Count").click(); + popover().within(() => { + cy.findByText("Sum of ...").click(); + cy.findByText("Total").click(); + }); addBreakout({ columnName: "Created At" }); saveMetric(); verifyLineAreaBarChart({ xAxis: "Created At", yAxis: "Sum of Total" }); }); - - it("should create a geo metric with multiple breakouts", () => { - startNewMetric(); - entityPickerModal().within(() => { - entityPickerModalTab("Tables").click(); - cy.findByText("People").click(); - }); - addAggregation({ operatorName: "Count of rows" }); - addBreakout({ columnName: "Latitude" }); - addBreakout({ columnName: "Longitude" }); - saveMetric(); - verifyPinMap(); - }); }); describe("aggregations", () => { - it("should not be possible to save a metric without an aggregation clause", () => { - startNewMetric(); - entityPickerModal().within(() => { - entityPickerModalTab("Tables").click(); - cy.findByText("Orders").click(); - }); - cy.button("Save").should("be.disabled"); - cy.findByTestId("run-button").should("not.be.visible"); - }); - it("should create a metric with a custom aggregation expression based on 1 metric", () => { createQuestion(ORDERS_SCALAR_METRIC); startNewMetric(); @@ -623,7 +616,3 @@ function verifyLineAreaBarChart({ xAxis, yAxis }) { cy.findByText(xAxis).should("be.visible"); }); } - -function verifyPinMap() { - cy.get("[data-element-id=pin-map]").should("exist"); -} diff --git a/e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts b/e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts index 09750e8bdad927e4d8e0639ca10784d9882fc729..eb29e375887064033fc955880dbc9a1d71439bb4 100644 --- a/e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts +++ b/e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts @@ -635,9 +635,6 @@ describe("scenarios > notebook > link to data source", () => { cy.log("Model should open in a new tab"); getNotebookStep("join", { stage: 0, index: 0 }).within(() => { - // Clicking on a left join cell does not have any effect - cy.findByLabelText("Left table").click(clickConfig); - cy.findByLabelText("Right table") .should("have.text", "Orders Model") .click(clickConfig); @@ -663,9 +660,6 @@ describe("scenarios > notebook > link to data source", () => { cy.log("Saved question should open in a new tab"); getNotebookStep("join", { stage: 0, index: 1 }).within(() => { - // Clicking on a left join cell does not have any effect - cy.findByLabelText("Left table").click(clickConfig); - cy.findByLabelText("Right table") .should("have.text", savedQuestion.name) .click(clickConfig); @@ -690,9 +684,6 @@ describe("scenarios > notebook > link to data source", () => { (function testRawTable() { cy.log("Raw table should open in a new tab"); getNotebookStep("join", { stage: 0, index: 2 }).within(() => { - // Clicking on a left join cell does not have any effect - cy.findByLabelText("Left table").click(clickConfig); - cy.findByLabelText("Right table") .should("have.text", "Reviews") .click(clickConfig); diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx index a712e5393032e6d91e3e83dc4e01909917bc7c7d..a1afccd513b098f22e7b04202939a44498d1c974 100644 --- a/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/DatasetEditor.jsx @@ -54,6 +54,7 @@ import { import DatasetFieldMetadataSidebar from "./DatasetFieldMetadataSidebar"; import DatasetQueryEditor from "./DatasetQueryEditor"; import { EditorTabs } from "./EditorTabs"; +import { MetricEmptyState } from "./MetricEmptyState"; import { TabHintToast } from "./TabHintToast"; import { EDITOR_TAB_INDEXES } from "./constants"; @@ -200,6 +201,7 @@ function DatasetEditor(props) { height, isDirty: isModelQueryDirty, isResultDirty, + isRunning, setQueryBuilderMode, runQuestionQuery, setDatasetEditorTab, @@ -213,6 +215,7 @@ function DatasetEditor(props) { } = props; const isMetric = question.type() === "metric"; + const isMetricEmptyState = isMetric && !result && !isRunning; const { isNative } = Lib.queryDisplayInfo(question.query()); const isDirty = isModelQueryDirty || isMetadataDirty; const [showCancelEditWarning, setShowCancelEditWarning] = useState(false); @@ -456,11 +459,7 @@ function DatasetEditor(props) { ) { return t`You must run the query before you can save this model`; } - - if (isMetric && Lib.aggregations(question.query(), -1).length === 0) { - return t`You must define how the measure is calculated to save this metric`; - } - }, [isNative, isMetric, isDirty, isResultDirty, question]); + }, [isNative, isDirty, isResultDirty, question]); const sidebar = getSidebar( { ...props, modelIndexes }, @@ -542,18 +541,25 @@ function DatasetEditor(props) { </QueryEditorContainer> <TableContainer isSidebarOpen={!!sidebar}> <DebouncedFrame className={cx(CS.flexFull)} enabled> - <QueryVisualization - {...props} - className={CS.spread} - noHeader - queryBuilderMode="dataset" - isShowingDetailsOnlyColumns={datasetEditorTab === "metadata"} - hasMetadataPopovers={false} - handleVisualizationClick={handleTableElementClick} - tableHeaderHeight={isEditingMetadata && TABLE_HEADER_HEIGHT} - renderTableHeaderWrapper={renderTableHeaderWrapper} - scrollToColumn={focusedFieldIndex + scrollToColumnModifier} - /> + {isMetricEmptyState ? ( + <MetricEmptyState + query={question.query()} + runQuestionQuery={runQuestionQuery} + /> + ) : ( + <QueryVisualization + {...props} + className={CS.spread} + noHeader + queryBuilderMode="dataset" + isShowingDetailsOnlyColumns={datasetEditorTab === "metadata"} + hasMetadataPopovers={false} + handleVisualizationClick={handleTableElementClick} + tableHeaderHeight={isEditingMetadata && TABLE_HEADER_HEIGHT} + renderTableHeaderWrapper={renderTableHeaderWrapper} + scrollToColumn={focusedFieldIndex + scrollToColumnModifier} + /> + )} </DebouncedFrame> <TabHintToastContainer isVisible={isEditingMetadata && isTabHintVisible && !result.error} diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/MetricEmptyState.tsx b/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/MetricEmptyState.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20552f92b26473e1f875edcc8134f0d8c769d9e7 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/MetricEmptyState.tsx @@ -0,0 +1,38 @@ +import { t } from "ttag"; + +import { Button, Flex, Text } from "metabase/ui"; +import * as Lib from "metabase-lib"; + +type MetricEmptyStateProps = { + query: Lib.Query; + runQuestionQuery: () => void; +}; + +export function MetricEmptyState({ + query, + runQuestionQuery, +}: MetricEmptyStateProps) { + const canRun = Lib.canRun(query, "metric"); + + return ( + <Flex + direction="column" + align="center" + justify="center" + mih="100%" + data-testid="metric-empty-state" + > + <Text mb="sm" fw="bold" fz="lg"> + {t`A metric is one of the key numbers you want to keep track of`} + </Text> + <Text mb="lg" c="text-medium" maw="28.75rem" align="center"> + {t`To create one, you’ll need to define how its calculated, add any required filters, and optionally pick the main dimension for your metric.`} + </Text> + {canRun && ( + <Button variant="filled" onClick={() => runQuestionQuery()}> + {t`Visualize`} + </Button> + )} + </Flex> + ); +} diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/MetricEmptyState.unit.spec.tsx b/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/MetricEmptyState.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc61904904feb1e96d1f5d379175f40d7b393e5b --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/MetricEmptyState.unit.spec.tsx @@ -0,0 +1,40 @@ +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders, screen } from "__support__/ui"; +import type * as Lib from "metabase-lib"; +import { createQuery, createQueryWithClauses } from "metabase-lib/test-helpers"; + +import { MetricEmptyState } from "./MetricEmptyState"; + +type SetupOpts = { + query: Lib.Query; +}; + +function setup({ query }: SetupOpts) { + const runQuestionQuery = jest.fn(); + + renderWithProviders( + <MetricEmptyState query={query} runQuestionQuery={runQuestionQuery} />, + ); + + return { runQuestionQuery }; +} + +describe("MetricEmptyState", () => { + it("should allow to run a valid metric query", async () => { + const query = createQueryWithClauses({ + aggregations: [{ operatorName: "count" }], + }); + const { runQuestionQuery } = setup({ query }); + await userEvent.click(screen.getByRole("button", { name: "Visualize" })); + expect(runQuestionQuery).toHaveBeenCalled(); + }); + + it("should not allow to run an invalid metric query", () => { + const query = createQuery(); + setup({ query }); + expect( + screen.queryByRole("button", { name: "Visualize" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/index.ts b/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d57e00af17026429105bf72f5de7a0810be57b63 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/DatasetEditor/MetricEmptyState/index.ts @@ -0,0 +1 @@ +export * from "./MetricEmptyState"; diff --git a/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.tsx b/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.tsx index 826c6f1f1204d2b7e830d0aaee6821cafff59924..6522b8374268d87b7e0907885c44d8e5d3daa5c9 100644 --- a/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.tsx +++ b/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.tsx @@ -15,12 +15,16 @@ export function AggregateStep({ readOnly, updateQuery, }: NotebookStepProps) { - const { stageIndex } = step; + const { question, stageIndex } = step; + const isMetric = question.type() === "metric"; - const clauses = useMemo(() => { + const aggregations = useMemo(() => { return Lib.aggregations(query, stageIndex); }, [query, stageIndex]); + const hasAddButton = !readOnly && (!isMetric || aggregations.length === 0); + const hasRemoveButton = !readOnly && !isMetric; + const handleReorderAggregation = ( sourceClause: Lib.AggregationClause, targetClause: Lib.AggregationClause, @@ -44,11 +48,13 @@ export function AggregateStep({ return ( <ClauseStep - items={clauses} + items={aggregations} initialAddText={t`Pick the metric you want to see`} readOnly={readOnly} color={color} isLastOpened={isLastOpened} + hasAddButton={hasAddButton} + hasRemoveButton={hasRemoveButton} renderName={renderAggregationName} renderPopover={({ item: aggregation, index, onClose }) => ( <AggregationPopover diff --git a/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx index fe50b7f3e1654da1e7b2ba923a4e0e6e174bed05..37afa296b08c4caa9ad656df6329b99a774940f9 100644 --- a/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/AggregateStep/AggregateStep.unit.spec.tsx @@ -1,38 +1,37 @@ import userEvent from "@testing-library/user-event"; -import { getIcon, renderWithProviders, screen } from "__support__/ui"; -import * as Lib from "metabase-lib"; import { - columnFinder, - createQuery, - findAggregationOperator, -} from "metabase-lib/test-helpers"; + getIcon, + queryIcon, + renderWithProviders, + screen, +} from "__support__/ui"; +import * as Lib from "metabase-lib"; +import { createQueryWithClauses } from "metabase-lib/test-helpers"; import { createMockCard } from "metabase-types/api/mocks"; import { createMockQueryBuilderState, createMockState, } from "metabase-types/store/mocks"; -import { createMockNotebookStep } from "../../test-utils"; +import { DEFAULT_QUESTION, createMockNotebookStep } from "../../test-utils"; +import type { NotebookStep } from "../../types"; import { AggregateStep } from "./AggregateStep"; -function createAggregatedQuery({ - table = "ORDERS", - column = "QUANTITY", -}: { table?: string; column?: string } = {}) { - const initialQuery = createQuery(); - const average = findAggregationOperator(initialQuery, "avg"); - const findColumn = columnFinder( - initialQuery, - Lib.aggregationOperatorColumns(average), - ); - const quantity = findColumn(table, column); - const clause = Lib.aggregationClause(average, quantity); - return Lib.aggregate(initialQuery, 0, clause); +function createAggregatedQuery() { + return createQueryWithClauses({ + aggregations: [ + { operatorName: "avg", tableName: "ORDERS", columnName: "QUANTITY" }, + ], + }); +} + +interface SetupOpts { + step?: NotebookStep; } -function setup(step = createMockNotebookStep()) { +function setup({ step = createMockNotebookStep() }: SetupOpts = {}) { const updateQuery = jest.fn(); renderWithProviders( @@ -81,19 +80,24 @@ describe("AggregateStep", () => { }); it("should render correctly with an aggregation", () => { - setup(createMockNotebookStep({ query: createAggregatedQuery() })); + setup({ step: createMockNotebookStep({ query: createAggregatedQuery() }) }); expect(screen.getByText("Average of Quantity")).toBeInTheDocument(); }); it("should use foreign key name for foreign table columns", () => { - setup( - createMockNotebookStep({ - query: createAggregatedQuery({ - table: "PRODUCTS", - column: "RATING", + setup({ + step: createMockNotebookStep({ + query: createQueryWithClauses({ + aggregations: [ + { + operatorName: "avg", + tableName: "PRODUCTS", + columnName: "RATING", + }, + ], }), }), - ); + }); expect(screen.getByText("Average of Product → Rating")).toBeInTheDocument(); }); @@ -114,9 +118,9 @@ describe("AggregateStep", () => { }); it("should change an aggregation operator", async () => { - const { getNextQuery, getRecentAggregationClause } = setup( - createMockNotebookStep({ query: createAggregatedQuery() }), - ); + const { getNextQuery, getRecentAggregationClause } = setup({ + step: createMockNotebookStep({ query: createAggregatedQuery() }), + }); await userEvent.click(screen.getByText("Average of Quantity")); await userEvent.click(screen.getByText("Average of ...")); // go back to operator selection @@ -134,9 +138,9 @@ describe("AggregateStep", () => { }); it("should change an aggregation column", async () => { - const { getNextQuery, getRecentAggregationClause } = setup( - createMockNotebookStep({ query: createAggregatedQuery() }), - ); + const { getNextQuery, getRecentAggregationClause } = setup({ + step: createMockNotebookStep({ query: createAggregatedQuery() }), + }); await userEvent.click(screen.getByText("Average of Quantity")); await userEvent.click(screen.getByText("Total")); @@ -153,13 +157,26 @@ describe("AggregateStep", () => { }); it("should remove an aggregation", async () => { - const { getNextQuery } = setup( - createMockNotebookStep({ query: createAggregatedQuery() }), - ); + const { getNextQuery } = setup({ + step: createMockNotebookStep({ query: createAggregatedQuery() }), + }); await userEvent.click(getIcon("close")); const nextQuery = getNextQuery(); expect(Lib.aggregations(nextQuery, 0)).toHaveLength(0); }); + + describe("metrics", () => { + it("should not allow to remove an existing aggregation or add another one", () => { + const query = createAggregatedQuery(); + const question = DEFAULT_QUESTION.setType("metric").setQuery(query); + const step = createMockNotebookStep({ question, query }); + setup({ step }); + + expect(screen.getByText("Average of Quantity")).toBeInTheDocument(); + expect(queryIcon("close")).not.toBeInTheDocument(); + expect(queryIcon("add")).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.tsx b/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.tsx index 06e368838e6f82cb26ff141573da5bf28847c1c5..3ca32da3cbe86369ba64860d0c34892fcd2e3469 100644 --- a/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.tsx +++ b/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.tsx @@ -15,11 +15,22 @@ export function BreakoutStep({ readOnly, updateQuery, }: NotebookStepProps) { - const { stageIndex } = step; + const { question, stageIndex } = step; + const isMetric = question.type() === "metric"; - const breakouts = useMemo(() => { - return Lib.breakouts(query, stageIndex); - }, [query, stageIndex]); + const breakouts = useMemo( + () => Lib.breakouts(query, stageIndex), + [query, stageIndex], + ); + + const metricColumns = useMemo(() => { + return isMetric + ? Lib.breakoutableColumns(query, stageIndex).filter(Lib.isDateOrDateTime) + : []; + }, [query, stageIndex, isMetric]); + + const hasAddButton = !readOnly && (!isMetric || breakouts.length === 0); + const isAddButtonDisabled = isMetric && metricColumns.length === 0; const renderBreakoutName = (clause: Lib.BreakoutClause) => Lib.displayInfo(query, stageIndex, clause).longDisplayName; @@ -58,10 +69,16 @@ export function BreakoutStep({ return ( <ClauseStep items={breakouts} - initialAddText={t`Pick a column to group by`} + initialAddText={ + isAddButtonDisabled + ? t`No datetime columns available` + : t`Pick a column to group by` + } readOnly={readOnly} color={color} isLastOpened={isLastOpened} + hasAddButton={hasAddButton} + isAddButtonDisabled={isAddButtonDisabled} renderName={renderBreakoutName} renderPopover={({ item: breakout, index, onClose }) => ( <BreakoutPopover @@ -69,6 +86,7 @@ export function BreakoutStep({ stageIndex={stageIndex} breakout={breakout} breakoutIndex={index} + isMetric={isMetric} onAddBreakout={handleAddBreakout} onUpdateBreakoutColumn={handleUpdateBreakoutColumn} onClose={onClose} @@ -86,6 +104,7 @@ interface BreakoutPopoverProps { stageIndex: number; breakout: Lib.BreakoutClause | undefined; breakoutIndex: number | undefined; + isMetric: boolean; onAddBreakout: (column: Lib.ColumnMetadata) => void; onUpdateBreakoutColumn: ( breakout: Lib.BreakoutClause, @@ -99,6 +118,7 @@ const BreakoutPopover = ({ stageIndex, breakout, breakoutIndex, + isMetric, onAddBreakout, onUpdateBreakoutColumn, onClose, @@ -108,7 +128,9 @@ const BreakoutPopover = ({ const filteredColumns = columns.reduce( (columns: Lib.ColumnMetadata[], column) => { const columnInfo = Lib.displayInfo(query, stageIndex, column); - if (breakout && checkColumnSelected(columnInfo, breakoutIndex)) { + if (isMetric && !Lib.isDateOrDateTime(column)) { + return columns; + } else if (breakout && checkColumnSelected(columnInfo, breakoutIndex)) { columns.push(Lib.breakoutColumn(query, stageIndex, breakout)); } else { columns.push(column); @@ -118,7 +140,7 @@ const BreakoutPopover = ({ [], ); return Lib.groupColumns(filteredColumns); - }, [query, stageIndex, breakout, breakoutIndex]); + }, [query, stageIndex, breakout, breakoutIndex, isMetric]); return ( <QueryColumnPicker diff --git a/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.unit.spec.tsx index e465b09602aff0ca7ec75ecd3a0c964ea91124db..251e7cb4b56cee0e417e2e0ac031c7db5bf5519a 100644 --- a/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/BreakoutStep/BreakoutStep.unit.spec.tsx @@ -1,10 +1,26 @@ import userEvent from "@testing-library/user-event"; -import { fireEvent, getIcon, render, screen, within } from "__support__/ui"; +import { createMockMetadata } from "__support__/metadata"; +import { + fireEvent, + getIcon, + queryIcon, + render, + screen, + within, +} from "__support__/ui"; import * as Lib from "metabase-lib"; import { createQueryWithClauses } from "metabase-lib/test-helpers"; +import Question from "metabase-lib/v1/Question"; +import { createMockCard } from "metabase-types/api/mocks"; +import { + createOrdersIdField, + createOrdersTable, + createSampleDatabase, +} from "metabase-types/api/mocks/presets"; -import { createMockNotebookStep } from "../../test-utils"; +import { DEFAULT_QUESTION, createMockNotebookStep } from "../../test-utils"; +import type { NotebookStep } from "../../types"; import { BreakoutStep } from "./BreakoutStep"; @@ -74,7 +90,12 @@ function createQueryWithMultipleBreakoutsAndTemporalBucket() { }); } -function setup(step = createMockNotebookStep()) { +interface SetupOpts { + step?: NotebookStep; + readOnly?: boolean; +} + +function setup({ step = createMockNotebookStep(), readOnly }: SetupOpts = {}) { const updateQuery = jest.fn(); render( @@ -83,6 +104,7 @@ function setup(step = createMockNotebookStep()) { stageIndex={step.stageIndex} query={step.query} color="summarize" + readOnly={readOnly} isLastOpened={false} reportTimezone="UTC" updateQuery={updateQuery} @@ -115,7 +137,7 @@ describe("BreakoutStep", () => { it("should render a breakout correctly", async () => { const query = createQueryWithBreakout(); - setup(createMockNotebookStep({ query })); + setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(screen.getByText("Tax")); @@ -136,7 +158,9 @@ describe("BreakoutStep", () => { it("should change a breakout column", async () => { const query = createQueryWithBreakout(); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(screen.getByText("Tax")); await userEvent.click(await screen.findByText("Discount")); @@ -147,7 +171,7 @@ describe("BreakoutStep", () => { it("should remove a breakout", async () => { const query = createQueryWithBreakout(); - const { getNextQuery } = setup(createMockNotebookStep({ query })); + const { getNextQuery } = setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(getIcon("close")); @@ -186,7 +210,7 @@ describe("BreakoutStep", () => { it("should highlight selected binning strategy", async () => { const query = createQueryWithBreakoutAndBinningStrategy(); - setup(createMockNotebookStep({ query })); + setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(screen.getByText("Tax: 10 bins")); const option = await screen.findByRole("option", { name: "Tax" }); @@ -199,7 +223,9 @@ describe("BreakoutStep", () => { it("shouldn't update a query when clicking a selected binned column", async () => { const query = createQueryWithBreakoutAndBinningStrategy(); - const { updateQuery } = setup(createMockNotebookStep({ query })); + const { updateQuery } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(screen.getByText("Tax: 10 bins")); await userEvent.click(await screen.findByText("Tax")); @@ -209,7 +235,7 @@ describe("BreakoutStep", () => { it("should highlight the `Don't bin` option when a column is not binned", async () => { const query = createQueryWithBreakoutAndBinningStrategy("Don't bin"); - setup(createMockNotebookStep({ query })); + setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(screen.getByText("Tax")); const option = await screen.findByRole("option", { @@ -251,7 +277,7 @@ describe("BreakoutStep", () => { it("should highlight selected temporal bucket", async () => { const query = createQueryWithBreakoutAndTemporalBucket("Quarter"); - setup(createMockNotebookStep({ query })); + setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(screen.getByText("Created At: Quarter")); const option = await screen.findByRole("option", { name: "Created At" }); @@ -264,7 +290,7 @@ describe("BreakoutStep", () => { it("should handle `Don't bin` option for temporal bucket (metabase#19684)", async () => { const query = createQueryWithBreakoutAndTemporalBucket("Don't bin"); - setup(createMockNotebookStep({ query })); + setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(screen.getByText("Created At")); const option = await screen.findByRole("option", { @@ -286,7 +312,9 @@ describe("BreakoutStep", () => { it("shouldn't update a query when clicking a selected column with temporal bucketing", async () => { const query = createQueryWithBreakoutAndTemporalBucket("Quarter"); - const { updateQuery } = setup(createMockNotebookStep({ query })); + const { updateQuery } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(screen.getByText("Created At: Quarter")); await userEvent.click(await screen.findByText("Created At")); @@ -296,7 +324,9 @@ describe("BreakoutStep", () => { it("should allow to add a breakout for a column with an existing breakout but with a different binning strategy", async () => { const query = createQueryWithBreakoutAndBinningStrategy("10 bins"); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(getIcon("add")); const option = await screen.findByRole("option", { name: "Tax" }); @@ -311,7 +341,9 @@ describe("BreakoutStep", () => { it("should ignore attempts to add a breakout for a column with the same binning strategy", async () => { const query = createQueryWithBreakoutAndBinningStrategy("10 bins"); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(getIcon("add")); const option = await screen.findByRole("option", { name: "Tax" }); @@ -325,7 +357,9 @@ describe("BreakoutStep", () => { it("should allow to change a breakout for a column with an existing breakout but with a different binning strategy", async () => { const query = createQueryWithMultipleBreakoutsAndBinningStrategy(); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(await screen.findByText("Tax: 10 bins")); const option = await screen.findByRole("option", { name: "Tax" }); @@ -340,7 +374,9 @@ describe("BreakoutStep", () => { it("should ignore attempts to create duplicate breakouts by changing the binning strategy for an existing breakout", async () => { const query = createQueryWithMultipleBreakoutsAndBinningStrategy(); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(await screen.findByText("Tax: 10 bins")); const option = await screen.findByRole("option", { name: "Tax" }); @@ -355,7 +391,9 @@ describe("BreakoutStep", () => { it("should allow to remove a breakout for a column with an existing breakout but with a different binning strategy", async () => { const query = createQueryWithMultipleBreakoutsAndBinningStrategy(); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); const clause = await screen.findByText("Tax: 50 bins"); await userEvent.click(within(clause).getByLabelText("close icon")); @@ -367,7 +405,9 @@ describe("BreakoutStep", () => { it("should allow to add a breakout for a column with an existing breakout but with a different temporal bucket", async () => { const query = createQueryWithBreakoutAndTemporalBucket("Year"); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(getIcon("add")); const option = await screen.findByRole("option", { name: "Created At" }); @@ -382,7 +422,9 @@ describe("BreakoutStep", () => { it("should ignore attempts to add a breakout for a column with the same temporal bucket", async () => { const query = createQueryWithBreakoutAndTemporalBucket("Year"); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(getIcon("add")); const option = await screen.findByRole("option", { name: "Created At" }); @@ -396,7 +438,9 @@ describe("BreakoutStep", () => { it("should allow to change a breakout for a column with an existing breakout but with a different temporal bucket", async () => { const query = createQueryWithMultipleBreakoutsAndTemporalBucket(); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(await screen.findByText("Created At: Month")); const option = await screen.findByRole("option", { name: "Created At" }); @@ -411,7 +455,9 @@ describe("BreakoutStep", () => { it("should ignore attempts to create duplicate breakouts by changing the temporal bucket for an existing breakout", async () => { const query = createQueryWithMultipleBreakoutsAndTemporalBucket(); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); await userEvent.click(await screen.findByText("Created At: Month")); const option = await screen.findByRole("option", { name: "Created At" }); @@ -426,7 +472,9 @@ describe("BreakoutStep", () => { it("should allow to remove a breakout for a column with an existing breakout but with a different temporal bucket", async () => { const query = createQueryWithMultipleBreakoutsAndTemporalBucket(); - const { getNextBreakouts } = setup(createMockNotebookStep({ query })); + const { getNextBreakouts } = setup({ + step: createMockNotebookStep({ query }), + }); const clause = await screen.findByText("Created At: Month"); await userEvent.click(within(clause).getByLabelText("close icon")); @@ -436,4 +484,77 @@ describe("BreakoutStep", () => { expect(breakouts[0].displayName).toBe("Created At: Year"); }); }); + + describe("metrics", () => { + it("should allow to select date and datetime columns only", async () => { + const question = DEFAULT_QUESTION.setType("metric"); + const step = createMockNotebookStep({ question }); + const { getNextBreakouts } = setup({ step }); + + await userEvent.click(screen.getByText("Pick a column to group by")); + expect(await screen.findByText("Order")).toBeInTheDocument(); + expect(await screen.findByText("Created At")).toBeInTheDocument(); + expect(screen.queryByText("Tax")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText("User")); + expect(await screen.findByText("Created At")).toBeInTheDocument(); + expect(await screen.findByText("Birth Date")).toBeInTheDocument(); + expect(screen.queryByText("Email")).not.toBeInTheDocument(); + + await userEvent.click(await screen.findByText("Created At")); + expect(getNextBreakouts()).toMatchObject([ + { displayName: "Created At: Month" }, + ]); + }); + + it("should not allow to select columns in readonly mode", () => { + const question = DEFAULT_QUESTION.setType("metric"); + const step = createMockNotebookStep({ question }); + setup({ step, readOnly: true }); + + expect( + screen.queryByText("Pick a column to group by"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("No datetime columns available"), + ).not.toBeInTheDocument(); + }); + + it("should not allow to select when there are no date or datetime columns", () => { + const metadata = createMockMetadata({ + databases: [ + createSampleDatabase({ + tables: [createOrdersTable({ fields: [createOrdersIdField()] })], + }), + ], + }); + const question = new Question( + createMockCard({ type: "metric" }), + metadata, + ); + const step = createMockNotebookStep({ + question, + query: question.query(), + }); + setup({ step }); + + expect( + screen.getByText("No datetime columns available"), + ).toBeInTheDocument(); + expect( + screen.queryByText("Pick a column to group by"), + ).not.toBeInTheDocument(); + }); + + it("should not allow to add more than 1 breakout", async () => { + const query = createQueryWithClauses({ + breakouts: [{ tableName: "ORDERS", columnName: "CREATED_AT" }], + }); + const question = DEFAULT_QUESTION.setType("metric").setQuery(query); + const step = createMockNotebookStep({ question }); + setup({ step }); + + expect(queryIcon("add")).not.toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/metabase/querying/notebook/components/ClauseStep/ClauseStep.tsx b/frontend/src/metabase/querying/notebook/components/ClauseStep/ClauseStep.tsx index c8b8c64b46f1f5b3ee0e5a6b50cd4b685e0fdee6..9cc0ee3840bafd36b5270a296f99c71c5ed28126 100644 --- a/frontend/src/metabase/querying/notebook/components/ClauseStep/ClauseStep.tsx +++ b/frontend/src/metabase/querying/notebook/components/ClauseStep/ClauseStep.tsx @@ -36,6 +36,9 @@ export type ClauseStepProps<T> = { initialAddText?: string; readOnly?: boolean; isLastOpened?: boolean; + hasAddButton?: boolean; + isAddButtonDisabled?: boolean; + hasRemoveButton?: boolean; renderName: (item: T, index: number) => JSX.Element | string; renderPopover: (opts: RenderPopoverOpts<T>) => JSX.Element | null; onRemove: (item: T, index: number) => void; @@ -49,6 +52,9 @@ export const ClauseStep = <T,>({ initialAddText, readOnly = false, isLastOpened = false, + hasAddButton = !readOnly, + isAddButtonDisabled = false, + hasRemoveButton = !readOnly, renderName, renderPopover, onRemove, @@ -59,7 +65,7 @@ export const ClauseStep = <T,>({ <ClauseStepDndItem index={index} readOnly={readOnly}> <NotebookCellItem color={color} readOnly={readOnly} onClick={onOpen}> {renderName(item, index)} - {!readOnly && ( + {hasRemoveButton && ( <Icon className={CS.ml1} name="close" @@ -77,6 +83,7 @@ export const ClauseStep = <T,>({ <NotebookCellAdd initialAddText={items.length === 0 && initialAddText} color={color} + disabled={isAddButtonDisabled} onClick={onOpen} /> ); @@ -92,7 +99,7 @@ export const ClauseStep = <T,>({ /> ))} </ClauseStepDndContext> - {!readOnly && ( + {hasAddButton && ( <ClausePopover isInitiallyOpen={isLastOpened} renderItem={onOpen => renderNewItem({ onOpen })} diff --git a/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.tsx b/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.tsx index 80f4c1e72a99a333e15cbbb90662435ff3f7cba7..fb0bd60ec9106fe9b92fd000aac504e1e8546a47 100644 --- a/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.tsx +++ b/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.tsx @@ -18,9 +18,10 @@ export const DataStep = ({ color, updateQuery, }: NotebookStepProps) => { - const { stageIndex } = step; + const { question, stageIndex } = step; const tableId = Lib.sourceTableOrCardId(query); const table = tableId ? Lib.tableOrCardMetadata(query, tableId) : undefined; + const isMetric = question.type() === "metric"; const isRaw = useMemo(() => { return ( @@ -36,7 +37,12 @@ export const DataStep = ({ metadataProvider: Lib.MetadataProvider, ) => { const newQuery = Lib.queryFromTableOrCardMetadata(metadataProvider, table); - await updateQuery(newQuery); + const newAggregations = Lib.aggregations(newQuery, stageIndex); + if (isMetric && newAggregations.length === 0) { + await updateQuery(Lib.aggregateByCount(newQuery, stageIndex)); + } else { + await updateQuery(newQuery); + } }; return ( diff --git a/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx b/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx index ccafa71fa79ad9a64cbcf6750eb9e7bf56e412ba..1558bbc630654b06b64f3b444431a81bbd010342 100644 --- a/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx +++ b/frontend/src/metabase/querying/notebook/components/DataStep/DataStep.unit.spec.tsx @@ -28,7 +28,8 @@ import { createMockState, } from "metabase-types/store/mocks"; -import { createMockNotebookStep } from "../../test-utils"; +import { DEFAULT_QUESTION, createMockNotebookStep } from "../../test-utils"; +import type { NotebookStep } from "../../types"; import { DataStep } from "./DataStep"; @@ -54,13 +55,17 @@ const createQueryWithBreakout = () => { return Lib.breakout(query, 0, column); }; -const setup = ( +interface SetupOpts { + step?: NotebookStep; + readOnly?: boolean; + isEmbeddingSdk?: boolean; +} + +const setup = ({ step = createMockNotebookStep(), - { - readOnly = false, - isEmbeddingSdk = false, - }: { readOnly?: boolean; isEmbeddingSdk?: boolean } = {}, -) => { + readOnly = false, + isEmbeddingSdk = false, +}: SetupOpts = {}) => { const mockWindowOpen = jest.spyOn(window, "open").mockImplementation(); const updateQuery = jest.fn(); @@ -111,7 +116,7 @@ const setup = ( const setupEmptyQuery = () => { const question = Question.create({ databaseId: SAMPLE_DB_ID }); const query = question.query(); - return setup(createMockNotebookStep({ query })); + return setup({ step: createMockNotebookStep({ query }) }); }; describe("DataStep", () => { @@ -179,7 +184,7 @@ describe("DataStep", () => { Lib.tableOrCardMetadata(metadataProvider, `card__${card.id}`), ); const step = createMockNotebookStep({ query }); - setup(step); + setup({ step }); expect(screen.getByText(card.name)).toBeInTheDocument(); expect(getIcon(icon)).toBeInTheDocument(); @@ -208,7 +213,7 @@ describe("DataStep", () => { it("should render with a single column selected", async () => { const query = createQueryWithFields(["ID"]); - setup(createMockNotebookStep({ query })); + setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(screen.getByLabelText("Pick columns")); expect(screen.getByLabelText("Select all")).not.toBeChecked(); @@ -220,7 +225,7 @@ describe("DataStep", () => { it("should render with multiple columns selected", async () => { const query = createQueryWithFields(["ID", "TOTAL"]); - setup(createMockNotebookStep({ query })); + setup({ step: createMockNotebookStep({ query }) }); await userEvent.click(screen.getByLabelText("Pick columns")); expect(screen.getByLabelText("Select all")).not.toBeChecked(); @@ -235,7 +240,7 @@ describe("DataStep", () => { it("should allow selecting a column", async () => { const query = createQueryWithFields(["ID"]); const step = createMockNotebookStep({ query }); - const { getNextColumn } = setup(step); + const { getNextColumn } = setup({ step }); await userEvent.click(screen.getByLabelText("Pick columns")); await userEvent.click(screen.getByLabelText("Tax")); @@ -259,7 +264,7 @@ describe("DataStep", () => { it("should allow selecting all columns", async () => { const query = createQueryWithFields(["ID"]); const step = createMockNotebookStep({ query }); - const { getNextColumn } = setup(step); + const { getNextColumn } = setup({ step }); await userEvent.click(screen.getByLabelText("Pick columns")); await userEvent.click(screen.getByLabelText("Select all")); @@ -280,7 +285,7 @@ describe("DataStep", () => { }); it("should not display fields picker in read-only mode", () => { - setup(createMockNotebookStep(), { readOnly: true }); + setup({ readOnly: true }); expect(screen.queryByLabelText("Pick columns")).not.toBeInTheDocument(); }); @@ -292,7 +297,7 @@ describe("DataStep", () => { it("should not display fields picker if a query has aggregations", () => { const query = createQueryWithAggregation(); const step = createMockNotebookStep({ query }); - setup(step); + setup({ step }); expect(screen.queryByLabelText("Pick columns")).not.toBeInTheDocument(); }); @@ -300,7 +305,7 @@ describe("DataStep", () => { it("should not display fields picker if a query has breakouts", () => { const query = createQueryWithBreakout(); const step = createMockNotebookStep({ query }); - setup(step); + setup({ step }); expect(screen.queryByLabelText("Pick columns")).not.toBeInTheDocument(); }); @@ -379,7 +384,7 @@ describe("DataStep", () => { describe("embedding SDK context", () => { it("should not show the tooltip", async () => { - setup(createMockNotebookStep(), { isEmbeddingSdk: true }); + setup({ isEmbeddingSdk: true }); await userEvent.hover(screen.getByText("Orders")); expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); @@ -388,7 +393,7 @@ describe("DataStep", () => { it.each([{ metaKey: true }, { ctrlKey: true }])( "meta/ctrl click should not open the data source", async clickConfig => { - const { mockWindowOpen } = setup(createMockNotebookStep(), { + const { mockWindowOpen } = setup({ isEmbeddingSdk: true, }); @@ -404,7 +409,7 @@ describe("DataStep", () => { ); it("middle click should not open the data source", async () => { - const { mockWindowOpen } = setup(createMockNotebookStep(), { + const { mockWindowOpen } = setup({ isEmbeddingSdk: true, }); @@ -424,4 +429,39 @@ describe("DataStep", () => { }); }); }); + + describe("metrics", () => { + it("should automatically aggregate by count for metrics", async () => { + const step = createMockNotebookStep({ + question: DEFAULT_QUESTION.setType("metric"), + }); + const { getNextQuery } = setup({ step }); + + await userEvent.click(screen.getByText("Orders")); + await userEvent.click(await screen.findByText("Products")); + + const { stageIndex } = step; + const nextQuery = getNextQuery(); + const nextAggregations = Lib.aggregations(nextQuery, stageIndex); + expect(nextAggregations).toHaveLength(1); + expect( + Lib.displayInfo(nextQuery, stageIndex, nextAggregations[0]), + ).toMatchObject({ + displayName: "Count", + }); + }); + + it("should not automatically aggregate by count for non-metrics", async () => { + const step = createMockNotebookStep(); + const { getNextQuery } = setup({ step }); + + await userEvent.click(screen.getByText("Orders")); + await userEvent.click(await screen.findByText("Products")); + + const { stageIndex } = step; + const nextQuery = getNextQuery(); + const nextAggregations = Lib.aggregations(nextQuery, stageIndex); + expect(nextAggregations).toHaveLength(0); + }); + }); }); diff --git a/frontend/src/metabase/querying/notebook/components/NotebookCell/NotebookCell.styled.tsx b/frontend/src/metabase/querying/notebook/components/NotebookCell/NotebookCell.styled.tsx index 2f8ed4fdd78f0dec7bc92dd4fce69ad93354b08e..11ff972bcba836ce331d16f488b8a5f78a32e903 100644 --- a/frontend/src/metabase/querying/notebook/components/NotebookCell/NotebookCell.styled.tsx +++ b/frontend/src/metabase/querying/notebook/components/NotebookCell/NotebookCell.styled.tsx @@ -35,6 +35,7 @@ export const NotebookCellItemContainer = styled.div<{ (!props.inactive || props.onClick) && !props.readOnly && !props.disabled ? "pointer" : "default"}; + pointer-events: ${props => (props.disabled ? "none" : "auto")}; &:hover { border-color: ${props => props.inactive && alpha(props.color, 0.8)}; @@ -59,7 +60,6 @@ export const NotebookCellItemContentContainer = styled.div<{ align-items: center; padding: ${CONTAINER_PADDING}; background-color: ${props => (props.inactive ? "transparent" : props.color)}; - pointer-events: ${props => (props.disabled ? "none" : "auto")}; &:hover { background-color: ${props => diff --git a/frontend/test/__support__/server-mocks/database.ts b/frontend/test/__support__/server-mocks/database.ts index c80606f7b9306e75a9dd130a71886efff878da63..0425e4c4bbfe1c9a0f6a184e16f8e11c6dcd3ecc 100644 --- a/frontend/test/__support__/server-mocks/database.ts +++ b/frontend/test/__support__/server-mocks/database.ts @@ -15,7 +15,7 @@ export function setupDatabaseEndpoints(db: Database) { fetchMock.post(`path:/api/database/${db.id}/discard_values`, {}); setupSchemaEndpoints(db); setupDatabaseIdFieldsEndpoints(db); - db.tables?.forEach(table => setupTableEndpoints(table)); + db.tables?.forEach(table => setupTableEndpoints({ ...table, db })); fetchMock.put(`path:/api/database/${db.id}`, async url => { const call = fetchMock.lastCall(url); diff --git a/frontend/test/__support__/server-mocks/table.ts b/frontend/test/__support__/server-mocks/table.ts index a76953814fa61b735abd0826faca6d21f212d707..f02eb59a2e418bc8a28b3ba12234cf9803ee19b1 100644 --- a/frontend/test/__support__/server-mocks/table.ts +++ b/frontend/test/__support__/server-mocks/table.ts @@ -13,7 +13,7 @@ export function setupTableEndpoints( fetchMock.post(`path:/api/table/${table.id}/rescan_values`, {}); fetchMock.post(`path:/api/table/${table.id}/discard_values`, {}); setupTableQueryMetadataEndpoint(table); - table.fields?.forEach(field => setupFieldEndpoints(field)); + table.fields?.forEach(field => setupFieldEndpoints({ ...field, table })); } export function setupTableQueryMetadataEndpoint(table: Table) {