From 8f87659cb04aa3e1921218b017e9e7cd6b4a2d45 Mon Sep 17 00:00:00 2001 From: Denis Berezin <denis.berezin@metabase.com> Date: Fri, 6 Oct 2023 21:14:18 +0300 Subject: [PATCH] [33482] Add more unit tests for all implemented drills (#33974) * Add more drills tests, unify tests structure, add issues refs * Add tests for fk-details drill apply * Tests fixes after merge with master * Code review fixes * Comment failing tests after merging with master * Restore sort drill tests --- frontend/src/metabase-lib/drills.unit.spec.ts | 2543 +++++++++++++---- frontend/src/metabase-lib/test-helpers.ts | 6 + frontend/src/metabase-lib/types.ts | 21 +- .../api/mocks/presets/sample_database.ts | 100 + frontend/src/metabase-types/api/query.ts | 2 +- .../click-actions/Mode/constants.ts | 1 + .../drills/mlv2/QuickFilterDrill.tsx | 146 + .../drills/mlv2/SummarizeColumnDrill.ts | 49 + .../ClickActionsPopover.unit.spec.tsx | 117 +- 9 files changed, 2401 insertions(+), 584 deletions(-) create mode 100644 frontend/src/metabase/visualizations/click-actions/drills/mlv2/QuickFilterDrill.tsx create mode 100644 frontend/src/metabase/visualizations/click-actions/drills/mlv2/SummarizeColumnDrill.ts diff --git a/frontend/src/metabase-lib/drills.unit.spec.ts b/frontend/src/metabase-lib/drills.unit.spec.ts index d39f65bd684..f0c90511654 100644 --- a/frontend/src/metabase-lib/drills.unit.spec.ts +++ b/frontend/src/metabase-lib/drills.unit.spec.ts @@ -9,8 +9,18 @@ import { createOrdersTaxDatasetColumn, createOrdersTotalDatasetColumn, createOrdersUserIdDatasetColumn, + createProductsCategoryDatasetColumn, + createProductsCreatedAtDatasetColumn, + createProductsEanDatasetColumn, + createProductsIdDatasetColumn, + createProductsPriceDatasetColumn, + createProductsRatingDatasetColumn, + createProductsTitleDatasetColumn, + createProductsVendorDatasetColumn, ORDERS, ORDERS_ID, + PRODUCTS, + PRODUCTS_ID, SAMPLE_DB_ID, } from "metabase-types/api/mocks/presets"; import { createMockColumn } from "metabase-types/api/mocks"; @@ -20,35 +30,65 @@ import type { StructuredDatasetQuery, } from "metabase-types/api"; import type { StructuredQuery as StructuredQueryApi } from "metabase-types/api/query"; -import type { - ColumnFilterDrillThruInfo, - DistributionDrillThruInfo, - DrillThru, - DrillThruDisplayInfo, - DrillThruType, - FKDetailsDrillThruInfo, - FKFilterDrillThruInfo, - Query, - QuickFilterDrillThruInfo, - SortDrillThruInfo, - SummarizeColumnByTimeDrillThruInfo, - SummarizeColumnDrillThruInfo, - UnderlyingRecordsDrillThruInfo, - ZoomDrillThruInfo, -} from "metabase-lib/types"; import type StructuredQuery from "metabase-lib/queries/StructuredQuery"; import Question from "metabase-lib/Question"; -import { SAMPLE_METADATA } from "./test-helpers"; +import { columnFinder, SAMPLE_METADATA } from "./test-helpers"; import { availableDrillThrus, drillThru } from "./drills"; +type TestCaseQueryType = "unaggregated" | "aggregated"; + +type BaseTestCase = { + clickType: "cell" | "header"; + customQuestion?: Question; +} & ( + | { + queryTable?: "ORDERS"; + queryType: "unaggregated"; + columnName: keyof typeof ORDERS_COLUMNS; + } + | { + queryTable?: "ORDERS"; + queryType: "aggregated"; + columnName: keyof typeof AGGREGATED_ORDERS_COLUMNS; + } + | { + queryTable: "PRODUCTS"; + queryType: "unaggregated"; + columnName: keyof typeof PRODUCTS_COLUMNS; + } + | { + queryTable: "PRODUCTS"; + queryType: "aggregated"; + columnName: keyof typeof AGGREGATED_PRODUCTS_COLUMNS; + } +); + +type AvailableDrillsTestCase = BaseTestCase & { + expectedDrills: Lib.DrillThruDisplayInfo[]; +}; + +type DrillDisplayInfoTestCase = BaseTestCase & { + drillType: Lib.DrillThruType; + expectedParameters: Lib.DrillThruDisplayInfo; +}; + +type ApplyDrillTestCase = BaseTestCase & { + drillType: Lib.DrillThruType; + drillArgs?: any[]; + expectedQuery: StructuredQueryApi; +}; + +const ORDERS_DATASET_QUERY: StructuredDatasetQuery = { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": ORDERS_ID, + }, +}; const ORDERS_QUESTION = Question.create({ - databaseId: SAMPLE_DB_ID, - tableId: ORDERS_ID, metadata: SAMPLE_METADATA, + dataset_query: ORDERS_DATASET_QUERY, }); - -type TestCaseConfig<T extends string> = [T, DrillThruDisplayInfo[]]; - const ORDERS_COLUMNS = { ID: createOrdersIdDatasetColumn(), USER_ID: createOrdersUserIdDatasetColumn(), @@ -60,539 +100,1546 @@ const ORDERS_COLUMNS = { CREATED_AT: createOrdersCreatedAtDatasetColumn(), QUANTITY: createOrdersQuantityDatasetColumn(), }; -const ORDERS_ROW_VALUES: Record<keyof typeof ORDERS_COLUMNS, string | number> = - { - ID: "3", - USER_ID: "1", - PRODUCT_ID: "105", - SUBTOTAL: 52.723521442619514, - TAX: 2.9, - TOTAL: 49.206842233769756, - DISCOUNT: 6.416679208849759, - CREATED_AT: "2025-12-06T22:22:48.544+02:00", - QUANTITY: 2, - }; -describe("availableDrillThrus", () => { - describe("should return list of available drills", () => { - describe("unaggregated query", () => { - it.each<TestCaseConfig<keyof typeof ORDERS_COLUMNS>>([ - [ - "ID", - [ - { - type: "drill-thru/zoom", - objectId: ORDERS_ROW_VALUES.ID, - "manyPks?": false, - } as ZoomDrillThruInfo, - ], - ], +const ORDERS_ROW_VALUES: Record<keyof typeof ORDERS_COLUMNS, RowValue> = { + ID: "3", + USER_ID: "1", + PRODUCT_ID: "105", + SUBTOTAL: 52.723521442619514, + TAX: 2.9, + TOTAL: 49.206842233769756, + DISCOUNT: null, + CREATED_AT: "2025-12-06T22:22:48.544+02:00", + QUANTITY: 2, +}; +const AGGREGATED_ORDERS_DATASET_QUERY: StructuredDatasetQuery = { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + aggregation: [ + ["count"], + [ + "sum", [ - "USER_ID", - [ - { - type: "drill-thru/fk-filter", - } as FKFilterDrillThruInfo, - { - type: "drill-thru/fk-details", - objectId: ORDERS_ROW_VALUES.USER_ID, - "manyPks?": false, - } as FKDetailsDrillThruInfo, - ], + "field", + ORDERS.TAX, + { + "base-type": "type/Float", + }, ], - + ], + [ + "max", [ - "SUBTOTAL", - [ - { - type: "drill-thru/zoom", - objectId: ORDERS_ROW_VALUES.ID, - "manyPks?": false, - } as ZoomDrillThruInfo, - { - type: "drill-thru/quick-filter", - operators: ["<", ">", "=", "≠"], - } as QuickFilterDrillThruInfo, - ], + "field", + ORDERS.DISCOUNT, + { + "base-type": "type/Float", + }, ], + ], + ], + breakout: [ + [ + "field", + ORDERS.PRODUCT_ID, + { + "base-type": "type/Integer", + }, + ], + [ + "field", + ORDERS.CREATED_AT, + { + "base-type": "type/DateTime", + "temporal-unit": "month", + }, + ], + ], + }, +}; +const AGGREGATED_ORDERS_QUESTION = Question.create({ + metadata: SAMPLE_METADATA, + dataset_query: AGGREGATED_ORDERS_DATASET_QUERY, +}); +const AGGREGATED_ORDERS_COLUMNS = { + PRODUCT_ID: createOrdersProductIdDatasetColumn({ + source: "breakout", + field_ref: [ + "field", + ORDERS.PRODUCT_ID, + { + "base-type": "type/Integer", + }, + ], + }), + CREATED_AT: createOrdersCreatedAtDatasetColumn({ + source: "breakout", + field_ref: [ + "field", + ORDERS.CREATED_AT, + { + "base-type": "type/DateTime", + "temporal-unit": "month", + }, + ], + }), - [ - "CREATED_AT", - [ - { - type: "drill-thru/zoom", - objectId: ORDERS_ROW_VALUES.ID, - "manyPks?": false, - } as ZoomDrillThruInfo, - { - type: "drill-thru/quick-filter", - operators: ["<", ">", "=", "≠"], - } as QuickFilterDrillThruInfo, - ], - ], - ])("ORDERS -> %s cell click", (clickedColumnName, expectedDrills) => { - const { query, stageIndex, column, cellValue, row } = setup({ - clickedColumnName, - columns: ORDERS_COLUMNS, - rowValues: ORDERS_ROW_VALUES, - }); + count: createMockColumn({ + base_type: "type/BigInteger", + name: "count", + display_name: "Count", + semantic_type: "type/Quantity", + source: "aggregation", + field_ref: ["aggregation", 0], + effective_type: "type/BigInteger", + }), - const drills = availableDrillThrus( - query, - stageIndex, - column, - cellValue, - row, - undefined, - ); + sum: createMockColumn({ + base_type: "type/Float", + name: "sum", + display_name: "Sum of Tax", + source: "aggregation", + field_ref: ["aggregation", 1], + effective_type: "type/Float", + }), - expect( - drills.map(drill => Lib.displayInfo(query, stageIndex, drill)), - ).toEqual(expectedDrills); - }); + max: createMockColumn({ + base_type: "type/Float", + name: "max", + display_name: "Max of Discount", + source: "aggregation", + field_ref: ["aggregation", 2], + effective_type: "type/Float", + }), +}; +const AGGREGATED_ORDERS_ROW_VALUES: Record< + keyof typeof AGGREGATED_ORDERS_COLUMNS, + RowValue +> = { + PRODUCT_ID: 3, + CREATED_AT: "2022-12-01T00:00:00+02:00", + count: 77, + sum: 1, + max: null, +}; - it.each<TestCaseConfig<keyof typeof ORDERS_COLUMNS>>([ - [ - "ID", - [ - { - initialOp: expect.objectContaining({ short: "=" }), - type: "drill-thru/column-filter", - } as ColumnFilterDrillThruInfo, - { - directions: ["asc", "desc"], - type: "drill-thru/sort", - } as SortDrillThruInfo, - { - aggregations: ["distinct"], - type: "drill-thru/summarize-column", - } as SummarizeColumnDrillThruInfo, - ], - ], - [ - "PRODUCT_ID", - [ - { - type: "drill-thru/distribution", - } as DistributionDrillThruInfo, - { - initialOp: expect.objectContaining({ short: "=" }), - type: "drill-thru/column-filter", - } as ColumnFilterDrillThruInfo, - { - directions: ["asc", "desc"], - type: "drill-thru/sort", - } as SortDrillThruInfo, - { - aggregations: ["distinct"], - type: "drill-thru/summarize-column", - } as SummarizeColumnDrillThruInfo, - ], - ], - [ - "SUBTOTAL", - [ - { type: "drill-thru/distribution" } as DistributionDrillThruInfo, - { - type: "drill-thru/column-filter", - initialOp: expect.objectContaining({ short: "=" }), - } as ColumnFilterDrillThruInfo, - { - type: "drill-thru/sort", - directions: ["asc", "desc"], - } as SortDrillThruInfo, - { - type: "drill-thru/summarize-column", - aggregations: ["distinct", "sum", "avg"], - } as SummarizeColumnDrillThruInfo, - { - type: "drill-thru/summarize-column-by-time", - } as SummarizeColumnByTimeDrillThruInfo, - ], - ], - [ - "CREATED_AT", - [ - { type: "drill-thru/distribution" } as DistributionDrillThruInfo, - { - type: "drill-thru/column-filter", - initialOp: null, - } as ColumnFilterDrillThruInfo, - { - type: "drill-thru/sort", - directions: ["asc", "desc"], - } as SortDrillThruInfo, - { - type: "drill-thru/summarize-column", - aggregations: ["distinct"], - } as SummarizeColumnDrillThruInfo, - ], - ], - ])("ORDERS -> %s header click", (clickedColumnName, expectedDrills) => { - const { query, stageIndex, column } = setup({ - clickedColumnName, - columns: ORDERS_COLUMNS, - rowValues: ORDERS_ROW_VALUES, - }); +const PRODUCTS_DATASET_QUERY: StructuredDatasetQuery = { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": PRODUCTS_ID, + }, +}; +const PRODUCTS_QUESTION = Question.create({ + metadata: SAMPLE_METADATA, + dataset_query: PRODUCTS_DATASET_QUERY, +}); +const PRODUCTS_COLUMNS = { + ID: createProductsIdDatasetColumn(), + EAN: createProductsEanDatasetColumn(), + TITLE: createProductsTitleDatasetColumn(), + CATEGORY: createProductsCategoryDatasetColumn(), + VENDOR: createProductsVendorDatasetColumn(), + PRICE: createProductsPriceDatasetColumn(), + RATING: createProductsRatingDatasetColumn(), + CREATED_AT: createProductsCreatedAtDatasetColumn(), +}; +const PRODUCTS_ROW_VALUES: Record<keyof typeof PRODUCTS_COLUMNS, RowValue> = { + ID: "3", + EAN: "4966277046676", + TITLE: "Synergistic Granite Chair", + CATEGORY: "Doohickey", + VENDOR: "Murray, Watsica and Wunsch", + PRICE: 35.38, + RATING: 4, + CREATED_AT: "2024-09-08T22:03:20.239+03:00", +}; - const drills = availableDrillThrus( - query, - stageIndex, - column, - undefined, - undefined, - undefined, - ); +const AGGREGATED_PRODUCTS_DATASET_QUERY: StructuredDatasetQuery = { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": PRODUCTS_ID, + aggregation: [["count"]], + breakout: [ + [ + "field", + PRODUCTS.CATEGORY, + { + "base-type": "type/Text", + }, + ], + ], + }, +}; +const AGGREGATED_PRODUCTS_QUESTION = Question.create({ + metadata: SAMPLE_METADATA, + dataset_query: AGGREGATED_PRODUCTS_DATASET_QUERY, +}); +const AGGREGATED_PRODUCTS_COLUMNS = { + CATEGORY: createProductsCategoryDatasetColumn({ + source: "breakout", + field_ref: [ + "field", + PRODUCTS.CATEGORY, + { + "base-type": "type/Text", + }, + ], + }), - expect( - drills.map(drill => Lib.displayInfo(query, stageIndex, drill)), - ).toEqual(expectedDrills); - }); - }); + count: createMockColumn({ + base_type: "type/BigInteger", + name: "count", + display_name: "Count", + semantic_type: "type/Quantity", + source: "aggregation", + field_ref: ["aggregation", 0], + effective_type: "type/BigInteger", + }), +}; +const AGGREGATED_PRODUCTS_ROW_VALUES: Record< + keyof typeof AGGREGATED_PRODUCTS_COLUMNS, + RowValue +> = { + CATEGORY: "Doohickey", + count: 42, +}; - // FIXME MLv2 returns distribution drill for aggregated query, which is does not match current behavior on stats - // eslint-disable-next-line jest/no-disabled-tests - describe.skip("aggregated query", () => { - const COLUMNS = { - PRODUCT_ID: createOrdersProductIdDatasetColumn({ - source: "breakout", - field_ref: [ - "field", - ORDERS.PRODUCT_ID, - { - "base-type": "type/Integer", - }, - ], - }), - - count: createMockColumn({ - base_type: "type/BigInteger", - name: "count", - display_name: "Count", - semantic_type: "type/Quantity", - source: "aggregation", - field_ref: ["aggregation", 0], - effective_type: "type/BigInteger", - }), - }; - const ROW_VALUES = { - PRODUCT_ID: 3, - count: 77, - }; - const QUESTION = Question.create({ - databaseId: SAMPLE_DB_ID, - tableId: ORDERS_ID, +const STAGE_INDEX = -1; + +describe("availableDrillThrus", () => { + it.each<AvailableDrillsTestCase>([ + { + clickType: "cell", + queryType: "unaggregated", + columnName: "ID", + expectedDrills: [ + { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + ], + }, + { + clickType: "cell", + queryType: "unaggregated", + columnName: "USER_ID", + expectedDrills: [ + { + type: "drill-thru/fk-filter", + }, + { + type: "drill-thru/fk-details", + objectId: ORDERS_ROW_VALUES.USER_ID as string, + "manyPks?": false, + }, + ], + }, + { + clickType: "cell", + queryType: "unaggregated", + columnName: "SUBTOTAL", + expectedDrills: [ + { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + { + type: "drill-thru/quick-filter", + operators: ["<", ">", "=", "≠"], + }, + ], + }, + { + clickType: "cell", + queryType: "unaggregated", + columnName: "CREATED_AT", + expectedDrills: [ + { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + { + type: "drill-thru/quick-filter", + operators: ["<", ">", "=", "≠"], + }, + ], + }, + { + clickType: "header", + queryType: "unaggregated", + columnName: "ID", + expectedDrills: [ + { + initialOp: expect.objectContaining({ short: "=" }), + type: "drill-thru/column-filter", + }, + { + directions: ["asc", "desc"], + type: "drill-thru/sort", + }, + { + aggregations: ["distinct"], + type: "drill-thru/summarize-column", + }, + ], + }, + { + clickType: "header", + queryType: "unaggregated", + columnName: "PRODUCT_ID", + expectedDrills: [ + { + type: "drill-thru/distribution", + }, + { + initialOp: expect.objectContaining({ short: "=" }), + type: "drill-thru/column-filter", + }, + { + directions: ["asc", "desc"], + type: "drill-thru/sort", + }, + { + aggregations: ["distinct"], + type: "drill-thru/summarize-column", + }, + ], + }, + { + clickType: "header", + queryType: "unaggregated", + columnName: "SUBTOTAL", + expectedDrills: [ + { type: "drill-thru/distribution" }, + { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + { + type: "drill-thru/summarize-column", + aggregations: ["distinct", "sum", "avg"], + }, + { + type: "drill-thru/summarize-column-by-time", + }, + ], + }, + { + clickType: "header", + queryType: "unaggregated", + columnName: "CREATED_AT", + expectedDrills: [ + { type: "drill-thru/distribution" }, + { + type: "drill-thru/column-filter", + initialOp: null, + }, + { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + { + type: "drill-thru/summarize-column", + aggregations: ["distinct"], + }, + ], + }, + // FIXME: fk-filter gets returned for non-fk column (metabase#34440), fk-details gets returned for non-fk colum (metabase#34441), underlying-records drill gets shown two times for aggregated query (metabase#34439) + // { + // clickType: "cell", + // queryType: "aggregated", + // columnName: "count", + // expectedDrills: [ + // { + // type: "drill-thru/quick-filter", + // operators: ["<", ">", "=", "≠"], + // }, + // { + // type: "drill-thru/underlying-records", + // rowCount: 2, // FIXME: (metabase#32108) this should return real count of rows + // tableName: "Orders", + // }, + // { + // displayName: "See this month by week", + // type: "drill-thru/zoom-in.timeseries", + // }, + // ], + // }, + // FIXME: fk-filter gets returned for non-fk column (metabase#34440), fk-details gets returned for non-fk colum (metabase#34441), underlying-records drill gets shown two times for aggregated query (metabase#34439) + // { + // clickType: "cell", + // queryType: "aggregated", + // columnName: "max", + // expectedDrills: [ + // { + // type: "drill-thru/quick-filter", + // operators: ["=", "≠"], + // }, + // { + // type: "drill-thru/underlying-records", + // rowCount: 2, // FIXME: (metabase#32108) this should return real count of rows + // tableName: "Orders", + // }, + // + // { + // type: "drill-thru/zoom-in.timeseries", + // displayName: "See this month by week", + // }, + // ], + // }, + // FIXME: quick-filter gets returned for non-metric column (metabase#34443) + // { + // clickType: "cell", + // queryType: "aggregated", + // columnName: "PRODUCT_ID", + // expectedDrills: [ + // { + // type: "drill-thru/fk-filter", + // }, + // { + // type: "drill-thru/fk-details", + // objectId: AGGREGATED_ORDERS_ROW_VALUES.PRODUCT_ID as number, + // "manyPks?": false, + // }, + // { + // rowCount: 2, // FIXME: (metabase#32108) this should return real count of rows + // tableName: "Orders", + // type: "drill-thru/underlying-records", + // }, + // ], + // }, + // FIXME: quick-filter gets returned for non-metric column (metabase#34443) + // { + // clickType: "cell", + // queryType: "aggregated", + // columnName: "CREATED_AT", + // expectedDrills: [ + // { + // type: "drill-thru/quick-filter", + // operators: ["<", ">", "=", "≠"], + // }, + // { + // rowCount: 3, // FIXME: (metabase#32108) this should return real count of rows + // tableName: "Orders", + // type: "drill-thru/underlying-records", + // }, + // ], + // }, + + // FIXME for some reason the results for aggregated query are not correct (metabase#34223, metabase#34341) + // We expect column-filter and sort drills, but get distribution and summarize-column + // { + // clickType: "header", + // queryType: "aggregated", + // columnName: "count", + // expectedDrills: [ + // { + // initialOp: expect.objectContaining({ short: "=" }), + // type: "drill-thru/column-filter", + // }, + // { + // directions: ["asc", "desc"], + // type: "drill-thru/sort", + // }, + // ], + // }, + // FIXME for some reason the results for aggregated query are not correct (metabase#34223, metabase#34341) + // We expect column-filter and sort drills, but get distribution and summarize-column + // { + // clickType: "header", + // queryType: "aggregated", + // columnName: "PRODUCT_ID", + // expectedDrills: [ + // { + // initialOp: expect.objectContaining({ short: "=" }), + // type: "drill-thru/column-filter", + // }, + // { + // directions: ["asc", "desc"], + // type: "drill-thru/sort", + // }, + // ], + // }, + // FIXME for some reason the results for aggregated query are not correct (metabase#34223, metabase#34341) + // We expect column-filter and sort drills, but get distribution and summarize-column + // { + // clickType: "header", + // queryType: "aggregated", + // columnName: "CREATED_AT", + // expectedDrills: [ + // { + // initialOp: expect.objectContaining({ short: "=" }), + // type: "drill-thru/column-filter", + // }, + // { + // directions: ["asc", "desc"], + // type: "drill-thru/sort", + // }, + // ], + // }, + ])( + "should return correct drills for $columnName $clickType in $queryType query", + ({ + columnName, + clickType, + queryType, + expectedDrills, + queryTable = "ORDERS", + }) => { + const { drillsDisplayInfo } = + queryTable === "PRODUCTS" + ? setupAvailableDrillsWithProductsQuery({ + clickType, + queryType, + columnName, + }) + : setupAvailableDrillsWithOrdersQuery({ + clickType, + queryType, + columnName, + }); + + expect(drillsDisplayInfo).toEqual(expectedDrills); + }, + ); + + it.each<DrillDisplayInfoTestCase>([ + // region --- drill-thru/sort + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "unaggregated", + columnName: "ID", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "unaggregated", + columnName: "USER_ID", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "unaggregated", + columnName: "TOTAL", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "unaggregated", + columnName: "TOTAL", + customQuestion: Question.create({ metadata: SAMPLE_METADATA, dataset_query: { - type: "query", database: SAMPLE_DB_ID, + type: "query", query: { + "order-by": [["desc", ["field", ORDERS.TOTAL, null]]], "source-table": ORDERS_ID, - aggregation: [["count"]], - breakout: [ - ["field", ORDERS.PRODUCT_ID, { "base-type": "type/Integer" }], - ], }, }, - }); - - it.each<TestCaseConfig<keyof typeof COLUMNS>>([ - [ - "count", - [ - { - type: "drill-thru/quick-filter", - operators: ["<", ">", "=", "≠"], - } as QuickFilterDrillThruInfo, - { - type: "drill-thru/underlying-records", - rowCount: 77, - tableName: "Orders", - } as UnderlyingRecordsDrillThruInfo, - ], - ], - [ - "PRODUCT_ID", - [ - { - type: "drill-thru/fk-filter", - } as FKFilterDrillThruInfo, - { - type: "drill-thru/fk-details", - objectId: ROW_VALUES.PRODUCT_ID, - "manyPks?": false, - } as FKDetailsDrillThruInfo, - ], - ], - ])("ORDERS -> %s cell click", (clickedColumnName, expectedDrills) => { - const { query, stageIndex, column, cellValue, row } = setup({ - question: QUESTION, - clickedColumnName, - columns: COLUMNS, - rowValues: ROW_VALUES, - }); - - const dimensions = row - .filter( - ({ col }) => - col.source === "breakout" && col.name !== clickedColumnName, - ) - .map(({ value, col }) => ({ value, column: col })); - - const drills = availableDrillThrus( - query, - stageIndex, - column, - cellValue, - row, - dimensions.length ? dimensions : undefined, - ); - - expect( - drills.map(drill => Lib.displayInfo(query, stageIndex, drill)), - ).toEqual(expectedDrills); - }); + }), + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "unaggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "aggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "aggregated", + columnName: "PRODUCT_ID", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "aggregated", + columnName: "count", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + { + drillType: "drill-thru/sort", + clickType: "header", + queryType: "aggregated", + columnName: "max", + expectedParameters: { + type: "drill-thru/sort", + directions: ["asc", "desc"], + }, + }, + // endregion - it.each<TestCaseConfig<keyof typeof COLUMNS>>([ - [ - "count", + // region --- drill-thru/column-filter + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "unaggregated", + columnName: "ID", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "unaggregated", + columnName: "USER_ID", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "unaggregated", + columnName: "TAX", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "unaggregated", + columnName: "DISCOUNT", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "unaggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: null, + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "unaggregated", + columnName: "QUANTITY", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "aggregated", + columnName: "PRODUCT_ID", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "aggregated", + columnName: "PRODUCT_ID", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: expect.objectContaining({ short: "=" }), + }, + }, + { + drillType: "drill-thru/column-filter", + clickType: "header", + queryType: "aggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/column-filter", + initialOp: null, + }, + }, + // FIXME "column-filter" should be available for aggregated query metric column (metabase#34223) + // { + // drillType: "drill-thru/column-filter", + // clickType: "header", + // queryType: "aggregated", + // columnName: "count", + // expectedParameters: { + // type: "drill-thru/column-filter", + // initialOp: expect.objectContaining({ short: "=" }), + // }, + // }, + // FIXME "column-filter" should be available for aggregated query metric column (metabase#34223) + // { + // drillType: "drill-thru/column-filter", + // clickType: "header", + // queryType: "aggregated", + // columnName: "max", + // expectedParameters: { + // type: "drill-thru/column-filter", + // initialOp: expect.objectContaining({ short: "=" }), + // }, + // }, + // endregion + + // region --- drill-thru/summarize-column + { + drillType: "drill-thru/summarize-column", + clickType: "header", + queryType: "unaggregated", + columnName: "ID", + expectedParameters: { + type: "drill-thru/summarize-column", + aggregations: ["distinct"], + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + queryType: "unaggregated", + columnName: "USER_ID", + expectedParameters: { + type: "drill-thru/summarize-column", + aggregations: ["distinct"], + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + queryType: "unaggregated", + columnName: "SUBTOTAL", + expectedParameters: { + type: "drill-thru/summarize-column", + aggregations: ["distinct", "sum", "avg"], + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + queryType: "unaggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/summarize-column", + aggregations: ["distinct"], + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + queryType: "unaggregated", + columnName: "QUANTITY", + expectedParameters: { + type: "drill-thru/summarize-column", + aggregations: ["distinct", "sum", "avg"], + }, + }, + // endregion + + // region --- drill-thru/distribution + { + drillType: "drill-thru/distribution", + clickType: "header", + queryType: "unaggregated", + columnName: "USER_ID", + expectedParameters: { + type: "drill-thru/distribution", + }, + }, + { + drillType: "drill-thru/distribution", + clickType: "header", + queryType: "unaggregated", + columnName: "TAX", + expectedParameters: { + type: "drill-thru/distribution", + }, + }, + { + drillType: "drill-thru/distribution", + clickType: "header", + queryType: "unaggregated", + columnName: "QUANTITY", + expectedParameters: { + type: "drill-thru/distribution", + }, + }, + { + drillType: "drill-thru/distribution", + clickType: "header", + queryType: "aggregated", + columnName: "PRODUCT_ID", + expectedParameters: { + type: "drill-thru/distribution", + }, + }, + { + drillType: "drill-thru/distribution", + clickType: "header", + queryType: "aggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/distribution", + }, + }, + // endregion + + // region --- drill-thru/fk-filter + { + drillType: "drill-thru/fk-filter", + clickType: "cell", + queryType: "unaggregated", + columnName: "USER_ID", + expectedParameters: { + type: "drill-thru/fk-filter", + }, + }, + { + drillType: "drill-thru/fk-filter", + clickType: "cell", + queryType: "unaggregated", + columnName: "PRODUCT_ID", + expectedParameters: { + type: "drill-thru/fk-filter", + }, + }, + // FIXME: `fk-filter` doesn't get returned for fk column that was used as breakout (metabase#34440) + // { + // drillType: "drill-thru/fk-filter", + // clickType: "cell", + // queryType: "aggregated", + // columnName: "PRODUCT_ID", + // expectedParameters: { + // type: "drill-thru/fk-filter", + // }, + // }, + // endregion + + // region --- drill-thru/quick-filter + { + drillType: "drill-thru/quick-filter", + clickType: "cell", + queryType: "unaggregated", + columnName: "SUBTOTAL", + expectedParameters: { + type: "drill-thru/quick-filter", + operators: ["<", ">", "=", "≠"], + }, + }, + { + drillType: "drill-thru/quick-filter", + clickType: "cell", + queryType: "unaggregated", + columnName: "DISCOUNT", + expectedParameters: { + type: "drill-thru/quick-filter", + operators: ["=", "≠"], + }, + }, + { + drillType: "drill-thru/quick-filter", + clickType: "cell", + queryType: "unaggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/quick-filter", + operators: ["<", ">", "=", "≠"], + }, + }, + { + drillType: "drill-thru/quick-filter", + clickType: "cell", + queryType: "unaggregated", + columnName: "QUANTITY", + expectedParameters: { + type: "drill-thru/quick-filter", + operators: ["<", ">", "=", "≠"], + }, + }, + // FIXME: quick-filter doesn't get returned for CREATED_AT column in aggregated query (metabase#34443) + // { + // drillType: "drill-thru/quick-filter", + // clickType: "cell", + // queryType: "aggregated", + // columnName: "CREATED_AT", + // expectedParameters: { + // type: "drill-thru/quick-filter", + // operators: ["<", ">", "=", "≠"], + // }, + // }, + { + drillType: "drill-thru/quick-filter", + clickType: "cell", + queryType: "aggregated", + columnName: "count", + expectedParameters: { + type: "drill-thru/quick-filter", + operators: ["<", ">", "=", "≠"], + }, + }, + { + drillType: "drill-thru/quick-filter", + clickType: "cell", + queryType: "aggregated", + columnName: "sum", + expectedParameters: { + type: "drill-thru/quick-filter", + operators: ["<", ">", "=", "≠"], + }, + }, + // FIXME: quick-filter returns extra "<", ">" operators for cell with no value (metabase#34445) + // { + // drillType: "drill-thru/quick-filter", + // clickType: "cell", + // queryType: "aggregated", + // columnName: "max", + // expectedParameters: { + // type: "drill-thru/quick-filter", + // operators: ["=", "≠"], + // }, + // }, + // endregion + + // region --- drill-thru/underlying-records + { + drillType: "drill-thru/underlying-records", + clickType: "cell", + queryType: "aggregated", + columnName: "count", + expectedParameters: { + type: "drill-thru/underlying-records", + rowCount: 3, // FIXME: (metabase#32108) this should return real count of rows + tableName: "Orders", + }, + }, + { + drillType: "drill-thru/underlying-records", + clickType: "cell", + queryType: "aggregated", + columnName: "sum", + expectedParameters: { + type: "drill-thru/underlying-records", + rowCount: 3, // FIXME: (metabase#32108) this should return real count of rows + tableName: "Orders", + }, + }, + { + drillType: "drill-thru/underlying-records", + clickType: "cell", + queryType: "aggregated", + columnName: "max", + expectedParameters: { + type: "drill-thru/underlying-records", + rowCount: 3, // FIXME: (metabase#32108) this should return real count of rows + tableName: "Orders", + }, + }, + // endregion + + // region --- drill-thru/summarize-column-by-time + { + drillType: "drill-thru/summarize-column-by-time", + clickType: "header", + queryType: "unaggregated", + columnName: "SUBTOTAL", + expectedParameters: { + type: "drill-thru/summarize-column-by-time", + }, + }, + { + drillType: "drill-thru/summarize-column-by-time", + clickType: "header", + queryType: "unaggregated", + columnName: "DISCOUNT", + expectedParameters: { + type: "drill-thru/summarize-column-by-time", + }, + }, + { + drillType: "drill-thru/summarize-column-by-time", + clickType: "header", + queryType: "unaggregated", + columnName: "QUANTITY", + expectedParameters: { + type: "drill-thru/summarize-column-by-time", + }, + }, + // endregion + + // region --- drill-thru/zoom-in.timeseries + // FIXME: "zoom-in.timeseries" should be returned for aggregated query metric click (metabase#33811) + // { + // drillType: "drill-thru/zoom-in.timeseries", + // clickType: "header", + // queryType: "aggregated", + // columnName: "count", + // expectedParameters: { + // type: "drill-thru/zoom-in.timeseries", + // }, + // }, + // { + // drillType: "drill-thru/zoom-in.timeseries", + // clickType: "header", + // queryType: "aggregated", + // columnName: "max", + // expectedParameters: { + // type: "drill-thru/zoom-in.timeseries", + // }, + // }, + // { + // drillType: "drill-thru/zoom-in.timeseries", + // clickType: "header", + // queryType: "aggregated", + // columnName: "sum", + // expectedParameters: { + // type: "drill-thru/zoom-in.timeseries", + // }, + // }, + // endregion + + // region --- drill-thru/zoom + { + drillType: "drill-thru/zoom", + clickType: "cell", + queryType: "unaggregated", + columnName: "ID", + expectedParameters: { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + }, + { + drillType: "drill-thru/zoom", + clickType: "cell", + queryType: "unaggregated", + columnName: "TAX", + expectedParameters: { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + }, + { + drillType: "drill-thru/zoom", + clickType: "cell", + queryType: "unaggregated", + columnName: "DISCOUNT", + expectedParameters: { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + }, + { + drillType: "drill-thru/zoom", + clickType: "cell", + queryType: "unaggregated", + columnName: "CREATED_AT", + expectedParameters: { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + }, + { + drillType: "drill-thru/zoom", + clickType: "cell", + queryType: "unaggregated", + columnName: "QUANTITY", + expectedParameters: { + type: "drill-thru/zoom", + objectId: ORDERS_ROW_VALUES.ID as string, + "manyPks?": false, + }, + }, + // endregion + + // region --- drill-thru/pk + // FIXME: how to trigger this ??? + // endregion + + // region --- drill-thru/fk-details + { + drillType: "drill-thru/fk-details", + clickType: "cell", + queryType: "unaggregated", + columnName: "PRODUCT_ID", + expectedParameters: { + type: "drill-thru/fk-details", + objectId: ORDERS_ROW_VALUES.PRODUCT_ID as string, + "manyPks?": false, + }, + }, + { + drillType: "drill-thru/fk-details", + clickType: "cell", + queryType: "unaggregated", + columnName: "USER_ID", + expectedParameters: { + type: "drill-thru/fk-details", + objectId: ORDERS_ROW_VALUES.USER_ID as string, + "manyPks?": false, + }, + }, + // endregion + + // region --- drill-thru/pivot + // FIXME: pivot is not implemented yet (metabase#33559) + // { + // drillType: "drill-thru/pivot", + // clickType: "cell", + // queryType: "aggregated", + // queryTable: "PRODUCTS", + // columnName: "count", + // expectedParameters: { + // type: "drill-thru/pivot", + // }, + // }, + // endregion + ])( + 'should return "$drillType" drill config for $columnName $clickType in $queryType query', + ({ + drillType, + columnName, + clickType, + queryType, + queryTable = "ORDERS", + customQuestion, + expectedParameters, + }) => { + const { drillDisplayInfo } = + queryTable === "PRODUCTS" + ? setupDrillDisplayInfoWithProductsQuery({ + drillType, + clickType, + queryType, + columnName, + customQuestion, + }) + : setupDrillDisplayInfoWithOrdersQuery({ + drillType, + clickType, + queryType, + columnName, + customQuestion, + }); + + expect(drillDisplayInfo).toEqual(expectedParameters); + }, + ); + + it("should return list of available drills for aggregated query with custom column", () => { + const question = Question.create({ + metadata: SAMPLE_METADATA, + dataset_query: { + database: SAMPLE_DB_ID, + type: "query", + query: { + "source-table": ORDERS_ID, + expressions: { CustomColumn: ["+", 1, 1] }, + aggregation: [["count"]], + breakout: [ + ["expression", "CustomColumn"], + [ + "field", + ORDERS.CREATED_AT, + { "base-type": "type/DateTime", "temporal-unit": "month" }, + ], + ], + }, + } as StructuredDatasetQuery, + }); + + const columns = { + CustomColumn: createMockColumn({ + base_type: "type/Integer", + name: "Custom", + display_name: "Custom", + expression_name: "Custom", + field_ref: ["expression", "Custom"], + source: "breakout", + effective_type: "type/Integer", + }), + CREATED_AT: createOrdersCreatedAtDatasetColumn({ + source: "breakout", + field_ref: [ + "field", + ORDERS.CREATED_AT, + { + "base-type": "type/DateTime", + "temporal-unit": "month", + }, + ], + }), + count: createMockColumn({ + base_type: "type/BigInteger", + name: "count", + display_name: "Count", + semantic_type: "type/Quantity", + source: "aggregation", + field_ref: ["aggregation", 0], + effective_type: "type/BigInteger", + }), + }; + const rowValues = { + Math: 2, + CREATED_AT: "2022-06-01T00:00:00+03:00", + count: 37, + }; + const clickedColumnName = "count"; + + const { query, stageIndex, column, cellValue, row } = setup({ + question, + clickedColumnName, + columns, + rowValues, + tableName: "ORDERS", + }); + + const dimensions = row + .filter(({ col }) => col?.name !== clickedColumnName) + .map(({ value, col }) => ({ value, column: col })); + + const drills = availableDrillThrus( + query, + stageIndex, + column, + cellValue, + row, + dimensions, + ); + + expect(drills).toBeInstanceOf(Array); + }); +}); + +describe("drillThru", () => { + it.each<ApplyDrillTestCase>([ + // FIXME: sort drill returns wrong result query (metabase#34342) + // { + // drillType: "drill-thru/sort", + // clickType: "header", + // columnName: "ID", + // queryType: "unaggregated", + // drillArgs: ["asc"], + // expectedQuery: { + // "order-by": [ + // [ + // "asc", + // [ + // "field", + // ORDERS.ID, + // { + // "base-type": "type/BigInteger", + // }, + // ], + // ], + // ], + // "source-table": ORDERS_ID, + // }, + // }, + // { + // drillType: "drill-thru/sort", + // clickType: "header", + // columnName: "PRODUCT_ID", + // queryType: "unaggregated", + // drillArgs: ["desc"], + // expectedQuery: { + // "order-by": [ + // [ + // "desc", + // [ + // "field", + // ORDERS.PRODUCT_ID, + // { + // "base-type": "type/Integer", + // }, + // ], + // ], + // ], + // "source-table": ORDERS_ID, + // }, + // }, + // { + // drillType: "drill-thru/sort", + // clickType: "header", + // columnName: "SUBTOTAL", + // queryType: "unaggregated", + // drillArgs: ["asc"], + // expectedQuery: { + // "order-by": [ + // [ + // "asc", + // [ + // "field", + // ORDERS.SUBTOTAL, + // { + // "base-type": "type/Float", + // }, + // ], + // ], + // ], + // "source-table": ORDERS_ID, + // }, + // }, + // { + // drillType: "drill-thru/sort", + // clickType: "header", + // columnName: "DISCOUNT", + // queryType: "unaggregated", + // drillArgs: ["desc"], + // expectedQuery: { + // "order-by": [ + // [ + // "desc", + // [ + // "field", + // ORDERS.DISCOUNT, + // { + // "base-type": "type/Float", + // }, + // ], + // ], + // ], + // "source-table": ORDERS_ID, + // }, + // }, + // { + // drillType: "drill-thru/sort", + // clickType: "header", + // columnName: "CREATED_AT", + // queryType: "unaggregated", + // drillArgs: ["asc"], + // expectedQuery: { + // "order-by": [ + // [ + // "asc", + // [ + // "field", + // ORDERS.CREATED_AT, + // { + // "base-type": "type/DateTime", + // }, + // ], + // ], + // ], + // "source-table": ORDERS_ID, + // }, + // }, + + { + drillType: "drill-thru/summarize-column", + clickType: "header", + queryType: "unaggregated", + columnName: "ID", + drillArgs: ["distinct"], + expectedQuery: { + aggregation: [ + [ + "distinct", + [ + "field", + ORDERS.ID, + { + "base-type": "type/BigInteger", + }, + ], + ], + ], + "source-table": ORDERS_ID, + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + columnName: "PRODUCT_ID", + queryType: "unaggregated", + drillArgs: ["distinct"], + expectedQuery: { + aggregation: [ + [ + "distinct", + [ + "field", + ORDERS.PRODUCT_ID, + { + "base-type": "type/Integer", + }, + ], + ], + ], + "source-table": ORDERS_ID, + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + columnName: "SUBTOTAL", + queryType: "unaggregated", + drillArgs: ["distinct"], + expectedQuery: { + aggregation: [ + [ + "distinct", + [ + "field", + ORDERS.SUBTOTAL, + { + "base-type": "type/Float", + }, + ], + ], + ], + "source-table": ORDERS_ID, + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + columnName: "TAX", + queryType: "unaggregated", + drillArgs: ["sum"], + expectedQuery: { + aggregation: [ + [ + "sum", + [ + "field", + ORDERS.TAX, + { + "base-type": "type/Float", + }, + ], + ], + ], + "source-table": ORDERS_ID, + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + columnName: "DISCOUNT", + queryType: "unaggregated", + drillArgs: ["avg"], + expectedQuery: { + aggregation: [ [ - { - initialOp: expect.objectContaining({ short: "=" }), - type: "drill-thru/column-filter", - } as ColumnFilterDrillThruInfo, - { - directions: ["asc", "desc"], - type: "drill-thru/sort", - } as SortDrillThruInfo, + "avg", + [ + "field", + ORDERS.DISCOUNT, + { + "base-type": "type/Float", + }, + ], ], ], - [ - "PRODUCT_ID", + "source-table": ORDERS_ID, + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + columnName: "CREATED_AT", + queryType: "unaggregated", + drillArgs: ["distinct"], + expectedQuery: { + aggregation: [ [ - { - initialOp: expect.objectContaining({ short: "=" }), - type: "drill-thru/column-filter", - } as ColumnFilterDrillThruInfo, - { - directions: ["asc", "desc"], - type: "drill-thru/sort", - } as SortDrillThruInfo, + "distinct", + [ + "field", + ORDERS.CREATED_AT, + { + "base-type": "type/DateTime", + }, + ], ], ], - ])("ORDERS -> %s header click", (clickedColumnName, expectedDrills) => { - const { query, stageIndex, column } = setup({ - question: QUESTION, - clickedColumnName, - columns: COLUMNS, - rowValues: ROW_VALUES, - }); - - const drills = availableDrillThrus( - query, - stageIndex, - column, - undefined, - undefined, - undefined, - ); - - expect( - drills.map(drill => Lib.displayInfo(query, stageIndex, drill)), - ).toEqual(expectedDrills); - }); - }); - - // FIXME MLv2 throws runtime error when we have a custom expression column - // eslint-disable-next-line jest/no-disabled-tests - it.skip("should return list of available drills for aggregated query with custom column", () => { - const question = Question.create({ - databaseId: SAMPLE_DB_ID, - tableId: ORDERS_ID, - metadata: SAMPLE_METADATA, - dataset_query: { - database: SAMPLE_DB_ID, - type: "query", - query: { - "source-table": ORDERS_ID, - expressions: { CustomColumn: ["+", 1, 1] }, - aggregation: [["count"]], - breakout: [ - ["expression", "CustomColumn"], - [ - "field", - ORDERS.CREATED_AT, - { "base-type": "type/DateTime", "temporal-unit": "month" }, - ], + "source-table": ORDERS_ID, + }, + }, + { + drillType: "drill-thru/summarize-column", + clickType: "header", + columnName: "QUANTITY", + queryType: "unaggregated", + drillArgs: ["avg"], + expectedQuery: { + aggregation: [ + [ + "avg", + [ + "field", + ORDERS.QUANTITY, + { + "base-type": "type/Integer", + }, ], - }, - parameters: [], - } as StructuredDatasetQuery, - }); - const columns = { - Math: createMockColumn({ - base_type: "type/Integer", - name: "Math", - display_name: "Math", - expression_name: "Math", - field_ref: ["expression", "Math"], - source: "breakout", - effective_type: "type/Integer", - }), - CREATED_AT: createOrdersCreatedAtDatasetColumn({ - source: "breakout", - field_ref: [ - "field", - ORDERS.CREATED_AT, - { - "base-type": "type/DateTime", - "temporal-unit": "month", - }, ], - }), - count: createMockColumn({ - base_type: "type/BigInteger", - name: "count", - display_name: "Count", - semantic_type: "type/Quantity", - source: "aggregation", - field_ref: ["aggregation", 0], - effective_type: "type/BigInteger", - }), - }; - const rowValues = { - Math: 2, - CREATED_AT: "2022-06-01T00:00:00+03:00", - count: 37, - }; - const clickedColumnName = "count"; - - const { query, stageIndex, column, cellValue, row } = setup({ - question, - clickedColumnName, - columns, - rowValues, - }); - - const dimensions = row - .filter(({ col }) => col?.name !== clickedColumnName) - .map(({ value, col }) => ({ value, column: col })); - - const drills = availableDrillThrus( - query, - stageIndex, - column, - cellValue, - row, - dimensions, - ); - - expect(drills).toBeInstanceOf(Array); - }); - }); -}); - -describe("drillThru", () => { - it.each<{ - drillType: DrillThruType; - clickType: "cell" | "header"; - columnName: string; - drillArgs?: any[]; - expectedQuery: StructuredQueryApi; - }>([ - // FIXME: sort drill is not yet implemented on the BE side - // { - // drillType: "drill-thru/sort", - // clickType: "header", - // columnName: ORDERS_COLUMNS.ID.name, - // drillArgs: ["asc"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/sort", - // clickType: "header", - // columnName: ORDERS_COLUMNS.PRODUCT_ID.name, - // drillArgs: ["desc"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/sort", - // clickType: "header", - // columnName: ORDERS_COLUMNS.SUBTOTAL.name, - // drillArgs: ["asc"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/sort", - // clickType: "header", - // columnName: ORDERS_COLUMNS.DISCOUNT.name, - // drillArgs: ["desc"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/sort", - // clickType: "header", - // columnName: ORDERS_COLUMNS.CREATED_AT.name, - // drillArgs: ["asc"], - // expectedQuery: {}, - // }, - - // FIXME: summarize-column drill does not work due to metabase#33480 - // { - // drillType: "drill-thru/summarize-column", - // clickType: "header", - // columnName: ORDERS_COLUMNS.ID.name, - // drillArgs: ["distinct"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/summarize-column", - // clickType: "header", - // columnName: ORDERS_COLUMNS.PRODUCT_ID.name, - // drillArgs: ["distinct"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/summarize-column", - // clickType: "header", - // columnName: ORDERS_COLUMNS.SUBTOTAL.name, - // drillArgs: ["distinct"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/summarize-column", - // clickType: "header", - // columnName: ORDERS_COLUMNS.TAX.name, - // drillArgs: ["sum"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/summarize-column", - // clickType: "header", - // columnName: ORDERS_COLUMNS.DISCOUNT.name, - // drillArgs: ["avg"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/summarize-column", - // clickType: "header", - // columnName: ORDERS_COLUMNS.CREATED_AT.name, - // drillArgs: ["distinct"], - // expectedQuery: {}, - // }, - // { - // drillType: "drill-thru/summarize-column", - // clickType: "header", - // columnName: ORDERS_COLUMNS.QUANTITY.name, - // drillArgs: ["avg"], - // expectedQuery: {}, - // }, + ], + "source-table": ORDERS_ID, + }, + }, - // FIXME: distribution drill does not work on FK columns + // FIXME: distribution drill result for FK columns creates extra binning, which is wrong (metabase#34343) // { // drillType: "drill-thru/distribution", // clickType: "header", - // columnName: ORDERS_COLUMNS.USER_ID.name, + // columnName: "USER_ID", + // queryType: "unaggregated", // expectedQuery: { // aggregation: [["count"]], // breakout: [["field", ORDERS.USER_ID, null]], @@ -602,7 +1649,8 @@ describe("drillThru", () => { { drillType: "drill-thru/distribution", clickType: "header", - columnName: ORDERS_COLUMNS.SUBTOTAL.name, + columnName: "SUBTOTAL", + queryType: "unaggregated", expectedQuery: { aggregation: [["count"]], breakout: [ @@ -623,7 +1671,8 @@ describe("drillThru", () => { { drillType: "drill-thru/distribution", clickType: "header", - columnName: ORDERS_COLUMNS.CREATED_AT.name, + columnName: "CREATED_AT", + queryType: "unaggregated", expectedQuery: { aggregation: [["count"]], breakout: [ @@ -643,7 +1692,8 @@ describe("drillThru", () => { { drillType: "drill-thru/summarize-column-by-time", clickType: "header", - columnName: ORDERS_COLUMNS.TAX.name, + columnName: "TAX", + queryType: "unaggregated", expectedQuery: { aggregation: [ [ @@ -673,7 +1723,8 @@ describe("drillThru", () => { { drillType: "drill-thru/summarize-column-by-time", clickType: "header", - columnName: ORDERS_COLUMNS.QUANTITY.name, + columnName: "QUANTITY", + queryType: "unaggregated", expectedQuery: { aggregation: [ [ @@ -701,19 +1752,33 @@ describe("drillThru", () => { }, }, - // FIXME: cell quick-filter drill doesn't work yet with a simple operator like "=", seems we need to implement MLv2 Filters first - // { - // drillType: "drill-thru/quick-filter", - // clickType: "cell", - // columnName: ORDERS_COLUMNS.SUBTOTAL.name, - // drillArgs: ["="], - // expectedQuery: {}, - // }, + { + drillType: "drill-thru/quick-filter", + clickType: "cell", + columnName: "SUBTOTAL", + queryType: "unaggregated", + drillArgs: ["="], + expectedQuery: { + filter: [ + "=", + [ + "field", + ORDERS.SUBTOTAL, + { + "base-type": "type/Float", + }, + ], + ORDERS_ROW_VALUES.SUBTOTAL, + ], + "source-table": ORDERS_ID, + }, + }, { drillType: "drill-thru/fk-filter", clickType: "cell", - columnName: ORDERS_COLUMNS.USER_ID.name, + columnName: "USER_ID", + queryType: "unaggregated", expectedQuery: { filter: [ "=", @@ -732,7 +1797,8 @@ describe("drillThru", () => { { drillType: "drill-thru/fk-filter", clickType: "cell", - columnName: ORDERS_COLUMNS.PRODUCT_ID.name, + columnName: "PRODUCT_ID", + queryType: "unaggregated", expectedQuery: { filter: [ "=", @@ -748,36 +1814,125 @@ describe("drillThru", () => { "source-table": ORDERS_ID, }, }, - ])( - 'should apply "$drillType" drill to $columnName on $clickType click', - ({ drillType, columnName, clickType, expectedQuery, drillArgs = [] }) => { - const { query, stageIndex, column, cellValue, row } = setup({ - question: ORDERS_QUESTION, - clickedColumnName: columnName, - columns: ORDERS_COLUMNS, - rowValues: ORDERS_ROW_VALUES, - }); - const drills = - clickType === "cell" - ? availableDrillThrus( - query, - stageIndex, - column, - cellValue, - row, - undefined, - ) - : availableDrillThrus( - query, - stageIndex, - column, - undefined, - undefined, - undefined, - ); - - const drill = findDrillByType(drills, drillType, query, stageIndex); + // FIXME: filter gets applied on the the same query stage as aggregations, but it should wrap the query (metabase#34346) + // { + // drillType: "drill-thru/quick-filter", + // clickType: "cell", + // columnName: "sum", + // queryType: "aggregated", + // drillArgs: ["="], + // expectedQuery: { + // "source-query": AGGREGATED_ORDERS_DATASET_QUERY.query, + // filter: [ + // "=", + // [ + // "field", + // "sum", + // { + // "base-type": "type/Float", + // }, + // ], + // AGGREGATED_ORDERS_ROW_VALUES.sum, + // ], + // }, + // }, + // { + // drillType: "drill-thru/quick-filter", + // clickType: "cell", + // columnName: "CREATED_AT", + // queryType: "aggregated", + // drillArgs: ["<"], + // expectedQuery: { + // ...AGGREGATED_ORDERS_DATASET_QUERY.query, + // filter: [ + // "<", + // [ + // "field", + // ORDERS.CREATED_AT, + // { + // "base-type": "type/DateTime", + // "temporal-unit": "month", + // }, + // ], + // AGGREGATED_ORDERS_ROW_VALUES.CREATED_AT, + // ] as ComparisonFilter, + // }, + // }, + // { + // drillType: "drill-thru/quick-filter", + // clickType: "cell", + // columnName: "max", + // queryType: "aggregated", + // drillArgs: ["≠"], + // expectedQuery: { + // "source-query": AGGREGATED_ORDERS_DATASET_QUERY.query, + // filter: [ + // "not-null", + // [ + // "field", + // "max", + // { + // "base-type": "type/Float", + // }, + // ], + // ], + // }, + // }, + + // FIXME: fk-details doesn't create a query for fk target table (metabase#34383) + // { + // drillType: "drill-thru/fk-details", + // clickType: "cell", + // columnName: "PRODUCT_ID", + // queryType: "unaggregated", + // expectedQuery: { + // filter: [ + // "=", + // ["field", PRODUCTS.ID, null], + // ORDERS_ROW_VALUES.PRODUCT_ID, + // ], + // "source-table": PRODUCTS_ID, + // }, + // }, + // { + // drillType: "drill-thru/fk-details", + // clickType: "cell", + // columnName: "USER_ID", + // queryType: "unaggregated", + // expectedQuery: { + // filter: ["=", ["field", PEOPLE.ID, null], ORDERS_ROW_VALUES.USER_ID], + // "source-table": PEOPLE_ID, + // }, + // }, + ])( + 'should return correct result on "$drillType" drill apply to $columnName on $clickType in $queryType query', + ({ + drillType, + columnName, + clickType, + queryType, + queryTable, + customQuestion, + drillArgs = [], + expectedQuery, + }) => { + const { drill, stageIndex, query } = + queryTable === "PRODUCTS" + ? setupDrillDisplayInfoWithProductsQuery({ + drillType, + clickType, + queryType, + columnName, + customQuestion, + }) + : setupDrillDisplayInfoWithOrdersQuery({ + drillType, + clickType, + queryType, + columnName, + customQuestion, + }); const updatedQuery = drillThru(query, stageIndex, drill, ...drillArgs); @@ -790,16 +1945,30 @@ describe("drillThru", () => { ); }); +const getMetadataColumns = (query: Lib.Query): Lib.ColumnMetadata[] => { + const aggregations = Lib.aggregations(query, STAGE_INDEX); + const breakouts = Lib.breakouts(query, STAGE_INDEX); + + return aggregations.length === 0 && breakouts.length === 0 + ? Lib.visibleColumns(query, STAGE_INDEX) + : [ + ...Lib.breakoutableColumns(query, STAGE_INDEX), + ...Lib.orderableColumns(query, STAGE_INDEX), + ]; +}; + function setup({ question = ORDERS_QUESTION, clickedColumnName, columns, rowValues, + tableName, }: { question?: Question; clickedColumnName: string; columns: Record<string, DatasetColumn>; rowValues: Record<string, RowValue>; + tableName: string; }) { const query = question._getMLv2Query(); const legacyQuery = question.query() as StructuredQuery; @@ -808,10 +1977,16 @@ function setup({ const legacyColumns = legacyQuery.columns(); + const metadataColumns = getMetadataColumns(query); + const column = columnFinder(query, metadataColumns)( + tableName, + clickedColumnName, + ); + return { query, stageIndex, - column: columns[clickedColumnName], + column, cellValue: rowValues[clickedColumnName], row: legacyColumns.map(({ name }) => ({ col: columns[name], @@ -820,19 +1995,233 @@ function setup({ }; } -function findDrillByType( - drills: DrillThru[], - drillType: DrillThruType, - query: Query, - stageIndex: number, -): DrillThru { - const drill = drills.find( - drill => Lib.displayInfo(query, stageIndex, drill)?.type === drillType, +function setupAvailableDrillsWithOrdersQuery({ + clickType, + queryType, + columnName, + customQuestion, +}: { + clickType: "cell" | "header"; + queryType: TestCaseQueryType; + columnName: string; + customQuestion?: Question; + debug?: boolean; +}) { + const { query, stageIndex, column, cellValue, row } = setup( + queryType === "unaggregated" + ? { + question: customQuestion || ORDERS_QUESTION, + clickedColumnName: columnName, + columns: ORDERS_COLUMNS, + rowValues: ORDERS_ROW_VALUES, + tableName: "ORDERS", + } + : { + question: customQuestion || AGGREGATED_ORDERS_QUESTION, + clickedColumnName: columnName, + columns: AGGREGATED_ORDERS_COLUMNS, + rowValues: AGGREGATED_ORDERS_ROW_VALUES, + tableName: "ORDERS", + }, + ); + + return { + ...setupDrillDisplayInfo({ + clickType, + queryType, + columnName, + query, + stageIndex, + column, + cellValue, + row, + }), + query, + stageIndex, + }; +} + +function setupDrillDisplayInfoWithOrdersQuery({ + drillType, + clickType, + queryType, + columnName, + customQuestion, +}: { + drillType: Lib.DrillThruType; + clickType: "cell" | "header"; + queryType: TestCaseQueryType; + columnName: string; + customQuestion?: Question; +}) { + const { drills, drillsDisplayInfo, query, stageIndex } = + setupAvailableDrillsWithOrdersQuery({ + clickType, + queryType, + columnName, + customQuestion, + }); + + const drillIndex = drillsDisplayInfo.findIndex( + ({ type }) => type === drillType, + ); + const drill = drills[drillIndex]; + const drillDisplayInfo = drillsDisplayInfo[drillIndex]; + + if (!drill) { + throw new TypeError(`Failed to find ${drillType} drill`); + } + + return { + drill, + drillDisplayInfo, + query, + stageIndex, + }; +} + +function setupAvailableDrillsWithProductsQuery({ + clickType, + queryType, + columnName, + customQuestion, +}: { + clickType: "cell" | "header"; + queryType: TestCaseQueryType; + columnName: string; + customQuestion?: Question; + debug?: boolean; +}) { + const { query, stageIndex, column, cellValue, row } = setup( + queryType === "unaggregated" + ? { + question: customQuestion || PRODUCTS_QUESTION, + clickedColumnName: columnName, + columns: PRODUCTS_COLUMNS, + rowValues: PRODUCTS_ROW_VALUES, + tableName: "PRODUCTS", + } + : { + question: customQuestion || AGGREGATED_PRODUCTS_QUESTION, + clickedColumnName: columnName, + columns: AGGREGATED_PRODUCTS_COLUMNS, + rowValues: AGGREGATED_PRODUCTS_ROW_VALUES, + tableName: "PRODUCTS", + }, + ); + + return { + ...setupDrillDisplayInfo({ + clickType, + queryType, + columnName, + query, + stageIndex, + column, + cellValue, + row, + }), + query, + stageIndex, + }; +} + +function setupDrillDisplayInfoWithProductsQuery({ + drillType, + clickType, + queryType, + columnName, + customQuestion, +}: { + drillType: Lib.DrillThruType; + clickType: "cell" | "header"; + queryType: TestCaseQueryType; + columnName: string; + customQuestion?: Question; + debug?: boolean; +}) { + const { drills, drillsDisplayInfo, query, stageIndex } = + setupAvailableDrillsWithProductsQuery({ + clickType, + queryType, + columnName, + customQuestion, + }); + + const drillIndex = drillsDisplayInfo.findIndex( + ({ type }) => type === drillType, ); + const drill = drills[drillIndex]; + const drillDisplayInfo = drillsDisplayInfo[drillIndex]; if (!drill) { - throw new TypeError(); + throw new TypeError(`Failed to find ${drillType} drill`); } - return drill; + return { + drill, + drillDisplayInfo, + query, + stageIndex, + }; +} + +function setupDrillDisplayInfo({ + clickType, + queryType, + columnName, + query, + stageIndex, + column, + cellValue, + row, +}: { + clickType: "cell" | "header"; + queryType: TestCaseQueryType; + columnName: string; + query: Lib.Query; + stageIndex: number; + column: Lib.ColumnMetadata; + cellValue: RowValue; + row: { + col: DatasetColumn; + value: RowValue; + }[]; +}) { + const dimensions = + queryType === "aggregated" + ? row + .filter( + ({ col }) => col?.source === "breakout" && col?.name !== columnName, + ) + .map(({ value, col }) => ({ value, column: col })) + : undefined; + + const drills = + clickType === "cell" + ? availableDrillThrus( + query, + stageIndex, + column, + cellValue, + row, + dimensions, + ) + : availableDrillThrus( + query, + stageIndex, + column, + undefined, + undefined, + undefined, + ); + + const drillsDisplayInfo = drills.map(drill => + Lib.displayInfo(query, stageIndex, drill), + ); + + return { + drills, + drillsDisplayInfo, + }; } diff --git a/frontend/src/metabase-lib/test-helpers.ts b/frontend/src/metabase-lib/test-helpers.ts index bc632328858..e20b99af50e 100644 --- a/frontend/src/metabase-lib/test-helpers.ts +++ b/frontend/src/metabase-lib/test-helpers.ts @@ -53,6 +53,12 @@ export const columnFinder = (tableName: string, columnName: string): ML.ColumnMetadata => { const column = columns.find(column => { const displayInfo = ML.displayInfo(query, 0, column); + + // for non-table columns - aggregations, custom columns + if (!displayInfo.table) { + return displayInfo?.name === columnName; + } + return ( displayInfo?.table?.name === tableName && displayInfo?.name === columnName diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts index 4d716f80b78..9586a923d3f 100644 --- a/frontend/src/metabase-lib/types.ts +++ b/frontend/src/metabase-lib/types.ts @@ -255,7 +255,8 @@ export type DrillThruType = | "drill-thru/summarize-column" | "drill-thru/summarize-column-by-time" | "drill-thru/column-filter" - | "drill-thru/underlying-records"; + | "drill-thru/underlying-records" + | "drill-thru/zoom-in.timeseries"; export type BaseDrillThruInfo<Type extends DrillThruType> = { type: Type }; @@ -273,7 +274,8 @@ export type PKDrillThruInfo = ObjectDetailsDrillThruInfo<"drill-thru/pk">; export type ZoomDrillThruInfo = ObjectDetailsDrillThruInfo<"drill-thru/zoom">; export type FKDetailsDrillThruInfo = ObjectDetailsDrillThruInfo<"drill-thru/fk-details">; -export type PivotDrillThruInfo = ObjectDetailsDrillThruInfo<"drill-thru/pivot">; + +export type PivotDrillThruInfo = BaseDrillThruInfo<"drill-thru/pivot">; export type FKFilterDrillThruInfo = BaseDrillThruInfo<"drill-thru/fk-filter">; export type DistributionDrillThruInfo = @@ -283,9 +285,14 @@ export type SortDrillThruInfo = BaseDrillThruInfo<"drill-thru/sort"> & { directions: Array<"asc" | "desc">; }; +export type SummarizeColumnDrillAggregationOperator = + | "sum" + | "avg" + | "distinct"; + export type SummarizeColumnDrillThruInfo = BaseDrillThruInfo<"drill-thru/summarize-column"> & { - aggregations: Array<"sum" | "avg" | "distinct">; + aggregations: Array<SummarizeColumnDrillAggregationOperator>; }; export type SummarizeColumnByTimeDrillThruInfo = BaseDrillThruInfo<"drill-thru/summarize-column-by-time">; @@ -301,6 +308,11 @@ export type UnderlyingRecordsDrillThruInfo = tableName: string; }; +export type ZoomTimeseriesDrillThruInfo = + BaseDrillThruInfo<"drill-thru/zoom-in.timeseries"> & { + displayName?: string; + }; + export type DrillThruDisplayInfo = | QuickFilterDrillThruInfo | PKDrillThruInfo @@ -313,7 +325,8 @@ export type DrillThruDisplayInfo = | SummarizeColumnDrillThruInfo | SummarizeColumnByTimeDrillThruInfo | ColumnFilterDrillThruInfo - | UnderlyingRecordsDrillThruInfo; + | UnderlyingRecordsDrillThruInfo + | ZoomTimeseriesDrillThruInfo; export interface Dimension { column: DatasetColumn; diff --git a/frontend/src/metabase-types/api/mocks/presets/sample_database.ts b/frontend/src/metabase-types/api/mocks/presets/sample_database.ts index e3bd614acb7..31014e8e375 100644 --- a/frontend/src/metabase-types/api/mocks/presets/sample_database.ts +++ b/frontend/src/metabase-types/api/mocks/presets/sample_database.ts @@ -1459,3 +1459,103 @@ export const createOrdersTableDatasetColumns = () => [ createOrdersCreatedAtDatasetColumn(), createOrdersQuantityDatasetColumn(), ]; + +export const createProductsIdDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsIdField(), + id: PRODUCTS.ID, + source: "fields", + field_ref: ["field", PRODUCTS.ID, null], + semantic_type: "type/PK", + ...opts, + }); + +export const createProductsEanDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsEanField(), + id: PRODUCTS.EAN, + source: "fields", + field_ref: ["field", PRODUCTS.EAN, null], + ...opts, + }); + +export const createProductsTitleDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsTitleField(), + id: PRODUCTS.TITLE, + source: "fields", + field_ref: ["field", PRODUCTS.TITLE, null], + ...opts, + }); + +export const createProductsCategoryDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsCategoryField(), + id: PRODUCTS.CATEGORY, + source: "fields", + field_ref: ["field", PRODUCTS.CATEGORY, null], + ...opts, + }); + +export const createProductsVendorDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsVendorField(), + id: PRODUCTS.VENDOR, + source: "fields", + field_ref: ["field", PRODUCTS.VENDOR, null], + ...opts, + }); + +export const createProductsPriceDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsPriceField(), + id: PRODUCTS.PRICE, + source: "fields", + field_ref: ["field", PRODUCTS.PRICE, null], + ...opts, + }); + +export const createProductsRatingDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsRatingField(), + id: PRODUCTS.RATING, + source: "fields", + field_ref: ["field", PRODUCTS.RATING, null], + ...opts, + }); + +export const createProductsCreatedAtDatasetColumn = ( + opts?: Partial<DatasetColumn>, +): DatasetColumn => + createMockColumn({ + ...createProductsCreatedAtField(), + id: PRODUCTS.CREATED_AT, + source: "fields", + field_ref: ["field", PRODUCTS.CREATED_AT, null], + ...opts, + }); + +export const createProductsTableDatasetColumns = () => [ + createProductsIdDatasetColumn(), + createProductsEanDatasetColumn(), + createProductsTitleDatasetColumn(), + createProductsCategoryDatasetColumn(), + createProductsVendorDatasetColumn(), + createProductsPriceDatasetColumn(), + createProductsRatingDatasetColumn(), + createProductsCreatedAtDatasetColumn(), +]; diff --git a/frontend/src/metabase-types/api/query.ts b/frontend/src/metabase-types/api/query.ts index 3f661d02205..fdec97bb791 100644 --- a/frontend/src/metabase-types/api/query.ts +++ b/frontend/src/metabase-types/api/query.ts @@ -199,7 +199,7 @@ export type FieldFilter = type NotFilter = ["not", Filter]; type EqualityFilter = ["=" | "!=", ConcreteFieldReference, Value]; -type ComparisonFilter = [ +export type ComparisonFilter = [ "<" | "<=" | ">=" | ">", ConcreteFieldReference, OrderableValue, diff --git a/frontend/src/metabase/visualizations/click-actions/Mode/constants.ts b/frontend/src/metabase/visualizations/click-actions/Mode/constants.ts index ce523363e5f..3e3e54f8375 100644 --- a/frontend/src/metabase/visualizations/click-actions/Mode/constants.ts +++ b/frontend/src/metabase/visualizations/click-actions/Mode/constants.ts @@ -37,4 +37,5 @@ export const DRILL_TYPE_TO_HANDLER_MAP: Record< "drill-thru/summarize-column": null, // SummarizeColumnDrill, "drill-thru/summarize-column-by-time": SummarizeColumnByTimeDrill, "drill-thru/underlying-records": null, // UnderlyingRecordsDrill, + "drill-thru/zoom-in.timeseries": null, }; diff --git a/frontend/src/metabase/visualizations/click-actions/drills/mlv2/QuickFilterDrill.tsx b/frontend/src/metabase/visualizations/click-actions/drills/mlv2/QuickFilterDrill.tsx new file mode 100644 index 00000000000..cbcbdf93289 --- /dev/null +++ b/frontend/src/metabase/visualizations/click-actions/drills/mlv2/QuickFilterDrill.tsx @@ -0,0 +1,146 @@ +import type * as React from "react"; +import { t } from "ttag"; +import type { DatasetColumn, RowValue } from "metabase-types/api"; +import type { + ClickActionButtonType, + Drill, +} from "metabase/visualizations/types/click-actions"; +import type * as Lib from "metabase-lib"; +import { isBoolean, isDate, isNumeric } from "metabase-lib/types/utils/isa"; +import { TextIcon } from "../QuickFilterDrill/QuickFilterDrill.styled"; + +type FilterOperator = "=" | "≠" | "<" | ">"; +type DateQuickFilterOperatorType = "<" | ">" | "=" | "≠"; +type FilterValueType = "null" | "numeric" | "date" | "boolean" | "text"; + +const DateButtonTitleMap: Record<DateQuickFilterOperatorType, string> = { + ["<"]: t`Before`, + [">"]: t`After`, + ["="]: t`On`, + ["≠"]: t`Not on`, +}; + +const SPECIFIC_VALUE_TITLE_MAX_LENGTH = 20; + +const getTextValueTitle = (value: string): string => { + if (value.length === 0) { + return t`empty`; + } + + if (value.length > SPECIFIC_VALUE_TITLE_MAX_LENGTH) { + return t`this`; + } + + return value; +}; + +const getOperatorOverrides = ( + operator: FilterOperator, + valueType: FilterValueType, + value: RowValue | undefined, +): { + title?: string; + icon?: React.ReactNode; + buttonType?: ClickActionButtonType; +} | null => { + if (valueType === "text" && typeof value === "string") { + const textValue = getTextValueTitle(value); + + if (operator === "=") { + return { + title: t`Is ${textValue}`, + icon: <TextIcon>=</TextIcon>, + buttonType: "horizontal", + }; + } + if (operator === "≠") { + return { + title: t`Is not ${textValue}`, + icon: <TextIcon>≠</TextIcon>, + buttonType: "horizontal", + }; + } + // if (operator === "contains") { + // return { + // title: t`Contains…`, + // icon: "filter", + // buttonType: "horizontal", + // }; + // } + // if (operator === "does-not-contain") { + // return { + // title: t`Does not contain…`, + // icon: <TextIcon>≠</TextIcon>, + // buttonType: "horizontal", + // }; + // } + } + + if (valueType === "date") { + return { + title: DateButtonTitleMap[operator], + buttonType: "horizontal", + }; + } + + return null; +}; + +export const QuickFilterDrill: Drill<Lib.QuickFilterDrillThruInfo> = ({ + drill, + drillDisplayInfo, + applyDrill, + clicked, +}) => { + if (!drill || !clicked || !clicked.column) { + return []; + } + + const { operators } = drillDisplayInfo; + const { value, column } = clicked; + + const columnValueType = getValueType(value, column); + + return operators.map(operator => { + const overrides = getOperatorOverrides(operator, columnValueType, value); + + return { + name: operator, + title: operator, + section: "filter", + buttonType: "token-filter", + + question: () => applyDrill(drill, operator), + + extra: () => ({ + valueType: columnValueType, + columnName: clicked.column?.display_name, + }), + + ...overrides, + }; + }); +}; + +const getValueType = ( + value: unknown, + column: DatasetColumn, +): FilterValueType => { + if (value == null) { + return "null"; + } + + if (isNumeric(column)) { + return "numeric"; + } + + if (isDate(column)) { + return "date"; + } + + if (isBoolean(column)) { + return "boolean"; + } + + return "text"; +}; diff --git a/frontend/src/metabase/visualizations/click-actions/drills/mlv2/SummarizeColumnDrill.ts b/frontend/src/metabase/visualizations/click-actions/drills/mlv2/SummarizeColumnDrill.ts new file mode 100644 index 00000000000..d88a46a83ab --- /dev/null +++ b/frontend/src/metabase/visualizations/click-actions/drills/mlv2/SummarizeColumnDrill.ts @@ -0,0 +1,49 @@ +import { t } from "ttag"; +import type { + ClickActionBase, + Drill, +} from "metabase/visualizations/types/click-actions"; +import type { Dispatch } from "metabase-types/store"; +import type * as Lib from "metabase-lib"; + +const ACTIONS: Record< + Lib.SummarizeColumnDrillAggregationOperator, + Omit<ClickActionBase, "name"> +> = { + sum: { + title: t`Sum`, + section: "sum", + buttonType: "token", + }, + avg: { + title: t`Avg`, + section: "sum", + buttonType: "token", + }, + distinct: { + title: t`Distinct values`, + section: "sum", + buttonType: "token", + }, +}; + +export const SummarizeColumnDrill: Drill<Lib.SummarizeColumnDrillThruInfo> = ({ + drill, + drillDisplayInfo, + applyDrill, +}) => { + if (!drill) { + return []; + } + + const { aggregations } = drillDisplayInfo; + + return aggregations.map(operator => ({ + name: operator, + ...ACTIONS[operator], + question: () => applyDrill(drill, operator), + action: () => (dispatch: Dispatch) => + // HACK: drill through closes sidebars, so open sidebar asynchronously + setTimeout(() => dispatch({ type: "metabase/qb/EDIT_SUMMARY" })), + })); +}; diff --git a/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsPopover.unit.spec.tsx b/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsPopover.unit.spec.tsx index 371ee56af25..187b41f2310 100644 --- a/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsPopover.unit.spec.tsx +++ b/frontend/src/metabase/visualizations/components/ClickActions/ClickActionsPopover.unit.spec.tsx @@ -2,6 +2,8 @@ import userEvent from "@testing-library/user-event"; import { waitFor } from "@testing-library/react"; import { getIcon, renderWithProviders, screen } from "__support__/ui"; import { + createOrdersCreatedAtDatasetColumn, + createOrdersDiscountDatasetColumn, createOrdersIdDatasetColumn, createOrdersProductIdDatasetColumn, createOrdersQuantityDatasetColumn, @@ -16,8 +18,8 @@ import { ClickActionsPopover } from "metabase/visualizations/components/ClickAct import type { RegularClickAction } from "metabase/visualizations/types"; import { getMode } from "metabase/visualizations/click-actions/lib/modes"; import { checkNotNull } from "metabase/core/utils/types"; +import type { DatasetQuery, Filter, Series } from "metabase-types/api"; import registerVisualizations from "metabase/visualizations/register"; -import type { DatasetQuery, Series } from "metabase-types/api"; import { POPOVER_TEST_ID } from "metabase/visualizations/click-actions/actions/ColumnFormattingAction/ColumnFormattingAction"; import { createMockSingleSeries } from "metabase-types/api/mocks"; import type { ClickObject } from "metabase-lib/queries/drills/types"; @@ -29,6 +31,17 @@ import type Dimension from "metabase-lib/Dimension"; registerVisualizations(); const ORDERS_COLUMNS = createOrdersTableDatasetColumns(); +const ORDERS_ROW_VALUES = { + ID: "3", + USER_ID: "1", + PRODUCT_ID: "105", + SUBTOTAL: 52.723521442619514, + TAX: 2.9, + TOTAL: 49.206842233769756, + DISCOUNT: 6.416679208849759, + CREATED_AT: "2025-12-06T22:22:48.544+02:00", + QUANTITY: 2, +}; describe("ClickActionsPopover", function () { describe("apply click actions", () => { @@ -120,6 +133,7 @@ describe("ClickActionsPopover", function () { it.each([ { column: createOrdersTotalDatasetColumn(), + columnName: createOrdersTotalDatasetColumn().name, expectedCard: { dataset_query: getSummarizedOverTimeResultDatasetQuery( ORDERS.TOTAL, @@ -130,6 +144,7 @@ describe("ClickActionsPopover", function () { }, { column: createOrdersQuantityDatasetColumn(), + columnName: createOrdersQuantityDatasetColumn().name, expectedCard: { dataset_query: getSummarizedOverTimeResultDatasetQuery( ORDERS.QUANTITY, @@ -139,7 +154,7 @@ describe("ClickActionsPopover", function () { }, }, ])( - "should apply drill to default ORDERS question on header click", + "should apply drill to default ORDERS question on $columnName header click", async ({ column, expectedCard }) => { const { props } = await setup({ clicked: { @@ -213,6 +228,77 @@ describe("ClickActionsPopover", function () { }, ); }); + + describe("QuickFilterDrill", () => { + it.each([ + { + column: createOrdersTotalDatasetColumn(), + columnName: createOrdersTotalDatasetColumn().name, + cellValue: ORDERS_ROW_VALUES.TOTAL, + drillTitle: ">", + expectedCard: { + dataset_query: getQuickFilterResultDatasetQuery({ + filteredColumnId: ORDERS.TOTAL, + filterOperator: ">", + cellValue: ORDERS_ROW_VALUES.TOTAL, + }), + display: "table", + }, + }, + + { + column: createOrdersCreatedAtDatasetColumn(), + columnName: createOrdersCreatedAtDatasetColumn().name, + cellValue: ORDERS_ROW_VALUES.CREATED_AT, + drillTitle: "Before", + expectedCard: { + dataset_query: getQuickFilterResultDatasetQuery({ + filteredColumnId: ORDERS.CREATED_AT, + filterOperator: "<", + cellValue: ORDERS_ROW_VALUES.CREATED_AT, + }), + display: "table", + }, + }, + + { + column: createOrdersDiscountDatasetColumn(), + columnName: createOrdersDiscountDatasetColumn().name, + cellValue: null, + drillTitle: "=", + expectedCard: { + dataset_query: getQuickFilterResultDatasetQuery({ + filteredColumnId: ORDERS.DISCOUNT, + filterOperator: "is-null", + cellValue: null, + }), + display: "table", + }, + }, + ])( + "should apply drill on $columnName cell click", + async ({ column, cellValue, drillTitle, expectedCard }) => { + const { props } = await setup({ + clicked: { + column, + value: cellValue, + }, + }); + + const drill = screen.getByText(drillTitle); + expect(drill).toBeInTheDocument(); + + userEvent.click(drill); + + expect(props.onChangeCardAndRun).toHaveBeenCalledTimes(1); + expect(props.onChangeCardAndRun).toHaveBeenLastCalledWith( + expect.objectContaining({ + nextCard: expect.objectContaining(expectedCard), + }), + ); + }, + ); + }); }); }); @@ -387,3 +473,30 @@ function getFKFilteredResultDatasetQuery( type: "query", }; } + +function getQuickFilterResultDatasetQuery({ + filteredColumnId, + filterOperator, + cellValue, +}: { + filteredColumnId: number; + filterOperator: "=" | "!=" | ">" | "<" | "is-null" | "not-null"; + cellValue: string | number | null | undefined; +}): DatasetQuery { + const filterClause = ["is-null", "not-null"].includes(filterOperator) + ? ([filterOperator, ["field", filteredColumnId, null]] as Filter) + : ([ + filterOperator, + ["field", filteredColumnId, null], + cellValue, + ] as Filter); + + return { + database: SAMPLE_DB_ID, + query: { + filter: filterClause, + "source-table": ORDERS_ID, + }, + type: "query", + }; +} -- GitLab