diff --git a/e2e/test/scenarios/question/offset.cy.spec.ts b/e2e/test/scenarios/question/offset.cy.spec.ts index f20370dc1e370c280047e0368f123558bb4c1cbb..76ac383fc08a7e4e1283136e528e3ae91dcb7fb4 100644 --- a/e2e/test/scenarios/question/offset.cy.spec.ts +++ b/e2e/test/scenarios/question/offset.cy.spec.ts @@ -1,43 +1,395 @@ import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; -import type { StructuredQuestionDetails } from "e2e/support/helpers"; import { createQuestion, + echartsContainer, enterCustomColumnDetails, getNotebookStep, + modal, openNotebook, popover, restore, + startNewQuestion, + visitQuestion, + visualize, } from "e2e/support/helpers"; +import { uuid } from "metabase/lib/uuid"; +import type { + Aggregation, + Breakout, + FieldReference, + StructuredQuery, +} from "metabase-types/api"; -const { ORDERS_ID } = SAMPLE_DATABASE; +const { ORDERS, ORDERS_ID, PRODUCTS } = SAMPLE_DATABASE; + +const ORDERS_TOTAL_FIELD_REF: FieldReference = [ + "field", + ORDERS.TOTAL, + { "base-type": "type/Float" }, +]; + +const ORDERS_CREATED_AT_BREAKOUT: Breakout = [ + "field", + ORDERS.CREATED_AT, + { "base-type": "type/DateTime", "temporal-unit": "month" }, +]; + +const PRODUCTS_CATEGORY_BREAKOUT: Breakout = [ + "field", + PRODUCTS.CATEGORY, + { "base-type": "type/text", "source-field": ORDERS.PRODUCT_ID }, +]; + +const SUM_TOTAL_AGGREGATION: Aggregation = ["sum", ORDERS_TOTAL_FIELD_REF]; + +const OFFSET_SUM_TOTAL_AGGREGATION_NAME = "Offsetted sum of total"; + +const OFFSET_SUM_TOTAL_AGGREGATION: Aggregation = [ + "offset", + createOffsetOptions(OFFSET_SUM_TOTAL_AGGREGATION_NAME), + SUM_TOTAL_AGGREGATION, + -1, +]; describe("scenarios > question > offset", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); + cy.intercept("POST", "/api/card").as("saveQuestion"); + }); + + it("does not work without a breakout", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyQuestionError( + "Window function requires either breakouts or order by in the query", + ); + }); + + it("works with a single breakout", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT], + limit: 5, + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", ""], + ["May 2022", "52.76"], + ]); + }); + + it("works with a single breakout and sorting by breakout", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT], + limit: 5, + "order-by": [["desc", ORDERS_CREATED_AT_BREAKOUT]], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2026", "45,683.68"], + ["March 2026", "47,403.97"], + ]); + }); + + it.skip("works with a single breakout and sorting by aggregation (metabase#42554)", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT], + limit: 5, + "order-by": [["desc", ["aggregation", 0]]], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + /* TODO: assert actual values */ + // verifyTableContent([[]]); + }); + + it("works with multiple breakouts", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT, PRODUCTS_CATEGORY_BREAKOUT], + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", "", "", "", ""], + ["May 2022", "", "52.76", "", ""], + ["June 2022", "339.14", "203.57", "493.51", "229.5"], + ]); + }); + + it.skip("works with multiple breakouts and a limit (metabase#42509)", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT, PRODUCTS_CATEGORY_BREAKOUT], + limit: 5, + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", "", "", "", ""], + ["May 2022", "", "52.76", "", ""], + ["June 2022", "339.14", "203.57", "493.51", "229.5"], + ]); + }); + + it("works with multiple aggregations and breakouts", () => { + const query: StructuredQuery = { + "source-table": ORDERS_ID, + aggregation: [SUM_TOTAL_AGGREGATION, OFFSET_SUM_TOTAL_AGGREGATION], + breakout: [ORDERS_CREATED_AT_BREAKOUT, PRODUCTS_CATEGORY_BREAKOUT], + limit: 9, + }; + + createQuestion({ query }, { visitQuestion: true }); + + verifyNoQuestionError(); + verifyTableContent([ + ["April 2022", "Gadget", "52.76", ""], + ["May 2022", "Doohickey", "339.14", ""], + ["May 2022", "Gadget", "203.57", "52.76"], + ["May 2022", "Gizmo", "493.51", ""], + ["May 2022", "Widget", "229.5", ""], + ["June 2022", "Doohickey", "482.56", "339.14"], + ["June 2022", "Gadget", "515.53", "203.57"], + ["June 2022", "Gizmo", "387.79", "493.51"], + ["June 2022", "Widget", "687.06", "229.5"], + ]); + }); + + it("works after saving a question (metabase#42323)", () => { + const breakoutName = "Created At"; + + startNewQuestion(); + popover().within(() => { + cy.findByText("Raw Data").click(); + cy.findByText("Orders").click(); + }); + addCustomAggregation({ + formula: "Offset(Sum([Total]), -1)", + name: OFFSET_SUM_TOTAL_AGGREGATION_NAME, + isFirst: true, + }); + addBreakout(breakoutName); + + visualize(); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + yAxis: OFFSET_SUM_TOTAL_AGGREGATION_NAME, + }); + + saveQuestion().then(({ response }) => { + visitQuestion(response?.body.id); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + yAxis: OFFSET_SUM_TOTAL_AGGREGATION_NAME, + }); + }); + }); + + it("works in a complex real-life scenario", () => { + const breakoutName = "Created At"; + const totalSalesName = "Total sales (this month)"; + const totalSalesLastMonthName = "Total sales (last month)"; + const percentSalesGrowthName = "Percent sales growth over last month"; + const rollingTotalName = "Rolling total of sales last 3 months"; + const rollingAverageName = "Rolling average of sales last 3 months"; + const legendItems = [ + totalSalesName, + totalSalesLastMonthName, + percentSalesGrowthName, + rollingTotalName, + rollingAverageName, + ]; + + startNewQuestion(); + popover().within(() => { + cy.findByText("Raw Data").click(); + cy.findByText("Orders").click(); + }); + addCustomAggregation({ + formula: "Sum([Total])", + name: totalSalesName, + isFirst: true, + }); + addCustomAggregation({ + formula: "Offset(Sum([Total]), -1)", + name: totalSalesLastMonthName, + }); + addCustomAggregation({ + formula: "Sum([Total]) / Offset(Sum([Total]), -1) - 1", + name: percentSalesGrowthName, + }); + addCustomAggregation({ + formula: + "Sum([Total]) + Offset(Sum([Total]), -1) + Offset(Sum([Total]), -2)", + name: rollingTotalName, + }); + addCustomAggregation({ + formula: + "(Sum([Total]) + Offset(Sum([Total]), -1) + Offset(Sum([Total]), -2)) / 3", + name: rollingAverageName, + }); + addBreakout(breakoutName); + + visualize(); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + legendItems, + }); + + saveQuestion().then(({ response }) => { + visitQuestion(response?.body.id); + verifyNoQuestionError(); + verifyLineChart({ + xAxis: breakoutName, + legendItems, + }); + }); }); it("should allow using OFFSET as a CASE argument (metabase#42377)", () => { const formula = "Sum(case([Total] > 0, Offset([Total], -1)))"; const name = "Aggregation"; - const questionDetails: StructuredQuestionDetails = { - query: { - "source-table": ORDERS_ID, - limit: 5, - }, + const query: StructuredQuery = { + "source-table": ORDERS_ID, }; - createQuestion(questionDetails, { visitQuestion: true }); - openNotebook(); + createQuestion({ query }, { visitQuestion: true }); + openNotebook(); cy.icon("sum").click(); - getNotebookStep("summarize") - .findByText("Pick the metric you want to see") - .click(); - popover().contains("Custom Expression").click(); - enterCustomColumnDetails({ formula, name }); + addCustomAggregation({ formula, name, isFirst: true }); + + cy.findAllByTestId("notebook-cell-item").contains(name).click(); + cy.findByTestId("expression-editor-textfield").should("contain", formula); cy.on("uncaught:exception", error => { expect(error.message.includes("Error normalizing")).not.to.be.true; }); }); }); + +function addCustomAggregation({ + formula, + name, + isFirst, +}: { + formula: string; + name: string; + isFirst?: boolean; +}) { + if (isFirst) { + getNotebookStep("summarize") + .findByText("Pick the metric you want to see") + .click(); + } else { + getNotebookStep("summarize").icon("add").click(); + } + + popover().contains("Custom Expression").click(); + enterCustomColumnDetails({ formula, name }); + popover().button("Done").click(); +} + +function addBreakout(name: string) { + getNotebookStep("summarize").findByText("Pick a column to group by").click(); + popover().findByText(name).click(); +} + +function saveQuestion() { + cy.button("Save").click(); + modal().button("Save").click(); + return cy.wait("@saveQuestion"); +} + +function verifyLineChart({ + xAxis, + yAxis, + legendItems, +}: { + xAxis: string; + yAxis?: string; + legendItems?: string[]; +}) { + echartsContainer().within(() => { + cy.findByText(xAxis).should("be.visible"); + + if (yAxis) { + cy.findByText(yAxis).should("be.visible"); + } + }); + + if (legendItems) { + for (const legendItem of legendItems) { + cy.findAllByTestId("legend-item").contains(legendItem).should("exist"); + } + } +} + +function verifyTableContent(rows: string[][]) { + const columnsCount = rows[0].length; + const pairs = rows.flatMap((row, rowIndex) => { + return row.map((text, cellIndex) => { + const index = rowIndex * columnsCount + cellIndex; + return { index, text }; + }); + }); + + for (const { index, text } of pairs) { + verifyTableCellContent(index, text); + } +} + +function verifyTableCellContent(index: number, text: string) { + cy.findAllByRole("gridcell").eq(index).should("have.text", text); +} + +function verifyQuestionError(error: string) { + cy.findByTestId("query-builder-main").within(() => { + cy.findByText("There was a problem with your question").should("exist"); + cy.findByText("Show error details").click(); + cy.findByText(error).should("exist"); + }); +} + +function verifyNoQuestionError() { + cy.findByTestId("query-builder-main").within(() => { + cy.findByText("There was a problem with your question").should("not.exist"); + cy.findByText("Show error details").should("not.exist"); + }); +} + +function createOffsetOptions(name = "offset") { + return { + "lib/uuid": uuid(), + name, + "display-name": name, + }; +} diff --git a/frontend/src/metabase-types/api/query.ts b/frontend/src/metabase-types/api/query.ts index d009da26dce59fa18ca6aa65ccb730eb5c996a52..15ad7636331508f050f96afdf7106420672187e9 100644 --- a/frontend/src/metabase-types/api/query.ts +++ b/frontend/src/metabase-types/api/query.ts @@ -155,6 +155,12 @@ type StdDevAgg = ["stddev", ConcreteFieldReference]; type SumAgg = ["sum", ConcreteFieldReference]; type MinAgg = ["min", ConcreteFieldReference]; type MaxAgg = ["max", ConcreteFieldReference]; +type OffsetAgg = [ + "offset", + { "lib/uuid": string; name: string; "display-name": string }, + Aggregation, + number, +]; type CommonAggregation = | CountAgg @@ -166,7 +172,8 @@ type CommonAggregation = | StdDevAgg | SumAgg | MinAgg - | MaxAgg; + | MaxAgg + | OffsetAgg; type MetricAgg = ["metric", MetricId]; diff --git a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx index 3b0f107c12571b58759a3be5abe6db4e2f0605fa..926c295dc7d3d41510b5a677e739ac045c537945 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx +++ b/frontend/src/metabase/admin/settings/components/widgets/CustomGeoJSONWidget.jsx @@ -12,7 +12,7 @@ import Select, { Option } from "metabase/core/components/Select"; import AdminS from "metabase/css/admin.module.css"; import ButtonsS from "metabase/css/components/buttons.module.css"; import CS from "metabase/css/core/index.css"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { SettingsApi, GeoJSONApi } from "metabase/services"; import LeafletChoropleth from "metabase/visualizations/components/LeafletChoropleth"; diff --git a/frontend/src/metabase/dashboard/utils.ts b/frontend/src/metabase/dashboard/utils.ts index 5d8b70c0c490581e1c2897261bc83956fc476a9f..14c6856be4ad5e682e18311bcfe5923c7fdfa42a 100644 --- a/frontend/src/metabase/dashboard/utils.ts +++ b/frontend/src/metabase/dashboard/utils.ts @@ -3,7 +3,8 @@ import _ from "underscore"; import { IS_EMBED_PREVIEW } from "metabase/lib/embed"; import { SERVER_ERROR_TYPES } from "metabase/lib/errors"; -import { isUUID, isJWT } from "metabase/lib/utils"; +import { isJWT } from "metabase/lib/utils"; +import { isUuid } from "metabase/lib/uuid"; import { getGenericErrorMessage, getPermissionErrorMessage, @@ -182,7 +183,7 @@ export function getDashboardType(id: unknown) { if (id == null || typeof id === "object") { // HACK: support inline dashboards return "inline"; - } else if (isUUID(id)) { + } else if (isUuid(id)) { return "public"; } else if (isJWT(id)) { return "embed"; diff --git a/frontend/src/metabase/lib/utils.ts b/frontend/src/metabase/lib/utils.ts index a7dc02eb659a1a62eca1088aae569f30aca08ad3..830fafaa9b393fd8fc8e7986e408e772654b718f 100644 --- a/frontend/src/metabase/lib/utils.ts +++ b/frontend/src/metabase/lib/utils.ts @@ -6,12 +6,6 @@ import { PLUGIN_IS_EE_BUILD } from "metabase/plugins"; const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -function s4() { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); -} - export function isEmpty(str: string | null) { if (str != null) { str = String(str); @@ -41,30 +35,6 @@ export function numberToWord(num: number) { } } -export function uuid() { - return ( - s4() + - s4() + - "-" + - s4() + - "-" + - s4() + - "-" + - s4() + - "-" + - s4() + - s4() + - s4() - ); -} - -export function isUUID(uuid: unknown) { - return ( - typeof uuid === "string" && - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid) - ); -} - export function isJWT(string: unknown) { return ( typeof string === "string" && diff --git a/frontend/src/metabase/lib/uuid.ts b/frontend/src/metabase/lib/uuid.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9c1e40418dc0eaed889cbf4347fd94c47c469f5 --- /dev/null +++ b/frontend/src/metabase/lib/uuid.ts @@ -0,0 +1,29 @@ +export function uuid() { + return ( + s4() + + s4() + + "-" + + s4() + + "-" + + s4() + + "-" + + s4() + + "-" + + s4() + + s4() + + s4() + ); +} + +function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); +} + +export function isUuid(uuid: unknown): uuid is string { + return ( + typeof uuid === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid) + ); +} diff --git a/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx b/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx index 6e86de5fc0f377ada7a98c73fb96a4d9a7e3f53a..7ed4865bc821a5bd2bc4ee9e6e49c67106ba5b0b 100644 --- a/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx +++ b/frontend/src/metabase/query_builder/components/template_tags/TagEditorHelp/TagEditorHelp.tsx @@ -7,7 +7,7 @@ import ExternalLink from "metabase/core/components/ExternalLink"; import CS from "metabase/css/core/index.css"; import { useSelector } from "metabase/lib/redux"; import MetabaseSettings from "metabase/lib/settings"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { getShowMetabaseLinks } from "metabase/selectors/whitelabel"; import type Database from "metabase-lib/v1/metadata/Database"; import type { DatabaseId, NativeDatasetQuery } from "metabase-types/api"; diff --git a/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx b/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx index f60ce9680d84f74c0188f99f00424f4beb745ec0..eb774a45a9428f8db77ef6501fdf18f66dfffd0e 100644 --- a/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx +++ b/frontend/src/metabase/visualizations/visualizations/SmartScalar/SettingsComponents/SmartScalarSettingsWidgets.tsx @@ -1,21 +1,20 @@ import type { DragEndEvent } from "@dnd-kit/core"; -import { DndContext, useSensor, PointerSensor } from "@dnd-kit/core"; +import { DndContext, PointerSensor, useSensor } from "@dnd-kit/core"; import { - restrictToVerticalAxis, restrictToParentElement, + restrictToVerticalAxis, } from "@dnd-kit/modifiers"; import { + SortableContext, arrayMove, verticalListSortingStrategy, - SortableContext, } from "@dnd-kit/sortable"; import { useCallback } from "react"; import { usePreviousDistinct } from "react-use"; import { t } from "ttag"; -import _ from "underscore"; import { Sortable } from "metabase/core/components/Sortable"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { Stack } from "metabase/ui"; import type { DatasetColumn, SmartScalarComparison } from "metabase-types/api"; diff --git a/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts b/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts index fa00f21a63a0211241098d7b77a4efa687493562..28ff1ab4469e1d144f436d93786f23ff18982090 100644 --- a/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts +++ b/frontend/src/metabase/visualizations/visualizations/SmartScalar/utils.ts @@ -4,7 +4,7 @@ import _ from "underscore"; import { formatNumber } from "metabase/lib/formatting/numbers"; import { measureText } from "metabase/lib/measure-text"; -import { uuid } from "metabase/lib/utils"; +import { uuid } from "metabase/lib/uuid"; import { isEmpty } from "metabase/lib/validate"; import { isDate, isNumeric } from "metabase-lib/v1/types/utils/isa"; import type {