diff --git a/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js b/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js index 10da5566eb0730e78a0ace20ef85e8afc5f5e9e5..448def481d6d61071a8625f795e622b69830792b 100644 --- a/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js +++ b/e2e/test/scenarios/visualizations-tabular/drillthroughs/column_extract_drill.cy.spec.js @@ -1,13 +1,18 @@ import _ from "underscore"; +import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data"; import { + describeWithSnowplow, enterCustomColumnDetails, + expectGoodSnowplowEvent, + expectNoBadSnowplowEvents, getNotebookStep, openNotebook, openOrdersTable, popover, + resetSnowplow, restore, visitQuestion, visualize, @@ -65,7 +70,7 @@ const DATE_QUESTION = { }, }; -describe("extract action", () => { +describeWithSnowplow("extract action", () => { beforeEach(() => { restore(); cy.signInAsAdmin(); @@ -266,3 +271,33 @@ function extractColumnAndCheck({ function checkColumnIndex({ column, columnIndex }) { cy.findAllByRole("columnheader").eq(columnIndex).should("have.text", column); } + +describeWithSnowplow("extract action", () => { + beforeEach(() => { + restore(); + resetSnowplow(); + cy.signInAsAdmin(); + }); + + afterEach(() => { + expectNoBadSnowplowEvents(); + }); + + it("should create a snowplow event for the column extraction action", () => { + openOrdersTable({ limit: 1 }); + + cy.wait(1); + + extractColumnAndCheck({ + column: "Created At", + option: "Year", + value: "2,025", + }); + + expectGoodSnowplowEvent({ + event: "column_extract_via_column_header", + custom_expressions_used: ["get-year"], + database_id: SAMPLE_DB_ID, + }); + }); +}); diff --git a/frontend/src/metabase-lib/extractions.ts b/frontend/src/metabase-lib/extractions.ts index 5c1500b9126baef3ee641c0d30fc7508a384929e..493cd518dfd8ca3f39b15a1a8d96158b749ae9f3 100644 --- a/frontend/src/metabase-lib/extractions.ts +++ b/frontend/src/metabase-lib/extractions.ts @@ -1,6 +1,34 @@ import * as ML from "cljs/metabase.lib.js"; -import type { ColumnExtraction, ColumnMetadata, Query } from "./types"; +import { expressionParts } from "./expression"; +import type { + ColumnExtraction, + ColumnMetadata, + Query, + DrillThru, + ExpressionParts, + ExpressionArg, +} from "./types"; + +export function extract( + query: Query, + stageIndex: number, + extraction: ColumnExtraction, +): Query { + return ML.extract(query, stageIndex, extraction); +} + +export function extractionExpression( + query: Query, + stageIndex: number, + extraction: ColumnExtraction, +) { + return ML.extraction_expression(query, stageIndex, extraction); +} + +export function extractionsForDrill(drill: DrillThru): ColumnExtraction[] { + return ML.column_extract_drill_extractions(drill); +} export function columnExtractions( query: Query, @@ -9,10 +37,42 @@ export function columnExtractions( return ML.column_extractions(query, column); } -export function extract( +export type ColumnExtractionTag = + | "hour-of-day" + | "day-of-month" + | "day-of-week" + | "month-of-year" + | "quarter-of-year" + | "year" + | "domain" + | "host" + | "subdomain"; + +/** + * Return the functions used by a specific column extraction. + */ +export function functionsUsedByExtraction( query: Query, stageIndex: number, extraction: ColumnExtraction, -): Query { - return ML.extract(query, stageIndex, extraction); +): string[] { + const expression = extractionExpression(query, stageIndex, extraction); + const parts = expressionParts(query, stageIndex, expression); + return walk(parts); +} + +function walk(parts: ExpressionParts): string[] { + const res: string[] = [parts.operator]; + parts.args.forEach(arg => { + if (isExpressionParts(arg)) { + res.push(...walk(arg)); + } + }); + return res; +} + +export function isExpressionParts( + arg: ExpressionParts | ExpressionArg, +): arg is ExpressionParts { + return arg != null && typeof arg === "object" && "operator" in arg; } diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts index a6e0335353277e93700ec22c81cd27797104378d..c9fceec95d2564e99ef5395708212ffaedead7e4 100644 --- a/frontend/src/metabase-lib/types.ts +++ b/frontend/src/metabase-lib/types.ts @@ -19,6 +19,7 @@ import type { RELATIVE_DATE_BUCKETS, TIME_FILTER_OPERATORS, } from "./constants"; +import type { ColumnExtractionTag } from "./extractions"; /** * An "opaque type": this technique gives us a way to pass around opaque CLJS values that TS will track for us, @@ -454,7 +455,7 @@ export type ColumnExtraction = unknown & { }; export type ColumnExtractionInfo = { - tag: string; + tag: ColumnExtractionTag; displayName: string; }; diff --git a/frontend/src/metabase/querying/analytics.ts b/frontend/src/metabase/querying/analytics.ts new file mode 100644 index 0000000000000000000000000000000000000000..3acde7209f4b4151d74f375adb6f2550a71a972b --- /dev/null +++ b/frontend/src/metabase/querying/analytics.ts @@ -0,0 +1,21 @@ +import { trackSchemaEvent } from "metabase/lib/analytics"; +import * as Lib from "metabase-lib"; +import type Question from "metabase-lib/v1/Question"; + +export const trackColumnExtractViaHeader = ( + query: Lib.Query, + stageIndex: number, + extraction: Lib.ColumnExtraction, + question?: Question, +) => { + trackSchemaEvent("question", "1-0-4", { + event: "column_extract_via_column_header", + custom_expressions_used: Lib.functionsUsedByExtraction( + query, + stageIndex, + extraction, + ), + database_id: Lib.databaseID(query), + question_id: question?.id() ?? 0, + }); +}; diff --git a/frontend/src/metabase/querying/utils/drills/column-extract-drill/column-extract-drill.tsx b/frontend/src/metabase/querying/utils/drills/column-extract-drill/column-extract-drill.tsx index a73649077c5ed65aa627987f3a4cc25511242748..3bbb19477a439456d959e929dda9c0d5e2be8271 100644 --- a/frontend/src/metabase/querying/utils/drills/column-extract-drill/column-extract-drill.tsx +++ b/frontend/src/metabase/querying/utils/drills/column-extract-drill/column-extract-drill.tsx @@ -1,35 +1,53 @@ +import { trackColumnExtractViaHeader } from "metabase/querying/analytics"; import { ClickActionsView } from "metabase/visualizations/components/ClickActions"; import type { ClickActionPopoverProps, Drill, RegularClickAction, } from "metabase/visualizations/types/click-actions"; -import type * as Lib from "metabase-lib"; +import * as Lib from "metabase-lib"; export const columnExtractDrill: Drill<Lib.ColumnExtractDrillThruInfo> = ({ + query, + stageIndex, + question, drill, drillInfo, clicked, applyDrill, }) => { const DrillPopover = ({ onClose, onClick }: ClickActionPopoverProps) => { + const extractions = Lib.extractionsForDrill(drill); + const actions: RegularClickAction[] = drillInfo.extractions.map( - extraction => ({ + (extraction, index) => ({ name: `extract.${extraction.displayName}`, title: extraction.displayName, subTitle: getExample(extraction), section: "extract-popover", buttonType: "horizontal", question: () => applyDrill(drill, extraction.tag), - extra: () => ({ settingsSyncOptions: { column: clicked.column } }), + extra: () => ({ + extraction: extractions[index], + settingsSyncOptions: { column: clicked.column }, + }), }), ); + function handleClick(action: RegularClickAction) { + const { extraction } = action.extra?.() as { + extraction: Lib.ColumnExtraction; + }; + + trackColumnExtractViaHeader(query, stageIndex, extraction, question); + onClick(action); + } + return ( <ClickActionsView clickActions={actions} close={onClose} - onClick={onClick} + onClick={handleClick} /> ); }; diff --git a/src/metabase/lib/core.cljc b/src/metabase/lib/core.cljc index 8d134a34e9e863e93bde1e56c1dfe8811d065a69..d0c8566802bc8d0c99688ccfdea16886d7957fe5 100644 --- a/src/metabase/lib/core.cljc +++ b/src/metabase/lib/core.cljc @@ -13,6 +13,7 @@ [metabase.lib.convert :as lib.convert] [metabase.lib.database :as lib.database] [metabase.lib.drill-thru :as lib.drill-thru] + [metabase.lib.drill-thru.column-extract :as lib.drill-thru.column-extract] [metabase.lib.drill-thru.pivot :as lib.drill-thru.pivot] [metabase.lib.equality :as lib.equality] [metabase.lib.expression :as lib.expression] @@ -120,6 +121,8 @@ [lib.drill-thru available-drill-thrus drill-thru] + [lib.drill-thru.column-extract + extractions-for-drill] [lib.drill-thru.pivot pivot-columns-for-type pivot-types] diff --git a/src/metabase/lib/drill_thru/column_extract.cljc b/src/metabase/lib/drill_thru/column_extract.cljc index ad073ee1d00b1f68d7cbf24dbaabad9f7f4014a2..f39918360f262ed5bbf792c5b1b1be858216c3bd 100644 --- a/src/metabase/lib/drill_thru/column_extract.cljc +++ b/src/metabase/lib/drill_thru/column_extract.cljc @@ -20,6 +20,7 @@ [metabase.lib.metadata.calculation :as lib.metadata.calculation] [metabase.lib.schema :as lib.schema] [metabase.lib.schema.drill-thru :as lib.schema.drill-thru] + [metabase.lib.schema.extraction :as lib.schema.extraction] [metabase.lib.types.isa :as lib.types.isa] [metabase.shared.util.i18n :as i18n] [metabase.util.malli :as mu])) @@ -47,6 +48,11 @@ (lib.drill-thru.column-filter/prepare-query-for-drill-addition query stage-number column column-ref :expression))))) +(mu/defn extractions-for-drill :- [:sequential ::lib.schema.extraction/extraction] + "Returns the extractions from a given drill." + [drill :- ::lib.schema.drill-thru/drill-thru.column-extract] + (:extractions drill)) + (defmethod lib.drill-thru.common/drill-thru-info-method :drill-thru/column-extract [query stage-number drill] (-> drill diff --git a/src/metabase/lib/js.cljs b/src/metabase/lib/js.cljs index f186eb9fb70867bee647e50048feb4dbbfe4445c..0487dd5591b0209d9a152bfdc4e9d713bed391a3 100644 --- a/src/metabase/lib/js.cljs +++ b/src/metabase/lib/js.cljs @@ -1918,6 +1918,15 @@ "stageIndex" stage-number "value" (if (= value :null) nil value)}) +(defn ^:export column-extract-drill-extractions + "Returns a JS array of the possible column *extractions* offered by `column-extract-drill`. + + The extractions are opaque values of the same type as are returned by [[column-extractions]]. + + > **Code health:** Single use. This is only here to support UI for column extract drills, and should not be reused." + [column-extract-drill] + (to-array (lib.core/extractions-for-drill column-extract-drill))) + (defn ^:export pivot-types "Returns a JS array of pivot types that are available in `a-drill-thru`, which must be a `pivot` drill-thru. diff --git a/test/metabase/lib/drill_thru/column_extract_test.cljc b/test/metabase/lib/drill_thru/column_extract_test.cljc index c8b32f13822f486cebea58b191d8d828e1df1900..9db7f9c8dc0a06fd2c4ec791ad82e942b29de904 100644 --- a/test/metabase/lib/drill_thru/column_extract_test.cljc +++ b/test/metabase/lib/drill_thru/column_extract_test.cljc @@ -1,8 +1,9 @@ (ns metabase.lib.drill-thru.column-extract-test "See also [[metabase.query-processor-test.drill-thru-e2e-test/quick-filter-on-bucketed-date-test]]" (:require - [clojure.test :refer [deftest testing]] + [clojure.test :refer [deftest is testing]] [metabase.lib.core :as lib] + [metabase.lib.drill-thru.column-extract :as lib.drill-thru.column-extract] [metabase.lib.drill-thru.test-util :as lib.drill-thru.tu] [metabase.lib.drill-thru.test-util.canned :as canned] [metabase.lib.metadata :as lib.metadata] @@ -338,3 +339,14 @@ :query-type :unaggregated :column-name "EMAIL" :custom-query query-no-regex})))) + +(deftest ^:parallel extractions-for-drill-test + (let [drill (lib.drill-thru.tu/test-returns-drill + {:click-type :header + :query-type :unaggregated + :column-name "CREATED_AT" + :drill-type :drill-thru/column-extract + :expected {:type :drill-thru/column-extract + :extractions datetime-extraction-units}})] + (is (=? datetime-extraction-units + (lib.drill-thru.column-extract/extractions-for-drill drill)))))