From cb7978315861bd5e4cd21d2c8c914f386c9c9b82 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Mon, 12 Aug 2024 16:54:37 -0400 Subject: [PATCH] FE - Support multiple breakout positions (#46555) --- .../src/metabase-lib/breakout.unit.spec.ts | 10 +- frontend/src/metabase-lib/types.ts | 2 +- .../QueryColumnPicker.unit.spec.tsx | 4 +- .../components/notebook/NotebookStep/steps.ts | 2 +- .../steps/BreakoutStep/BreakoutStep.tsx | 32 +-- .../BreakoutStep/BreakoutStep.unit.spec.tsx | 2 +- .../notebook/steps/BreakoutStep/index.ts | 3 +- .../notebook/steps/SummarizeStep.tsx | 2 +- .../BreakoutColumnList/BreakoutColumnList.tsx | 155 +++++++------- .../BreakoutColumnListItem.tsx | 39 ++-- .../SummarizeBreakoutColumnList.tsx | 4 +- .../SummarizeContent/use-summarize-query.ts | 11 +- .../SummarizeSidebar.unit.spec.tsx | 198 ++++++++++++------ .../TimeseriesBucketPicker.tsx | 18 +- .../TimeseriesBucketPicker.unit.spec.tsx | 57 +++-- .../TimeseriesChrome/TimeseriesChrome.tsx | 15 +- .../TimeseriesChrome.unit.spec.tsx | 100 +++++++++ .../components/TimeseriesChrome/utils.ts | 26 +-- src/metabase/lib/breakout.cljc | 76 ++++--- src/metabase/lib/metadata/calculation.cljc | 2 +- src/metabase/lib/remove_replace.cljc | 39 ++-- test/metabase/lib/binning_test.cljc | 12 +- test/metabase/lib/breakout_test.cljc | 128 +++++++++-- test/metabase/lib/equality_test.cljc | 2 +- test/metabase/lib/remove_replace_test.cljc | 22 +- 25 files changed, 628 insertions(+), 333 deletions(-) create mode 100644 frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.unit.spec.tsx diff --git a/frontend/src/metabase-lib/breakout.unit.spec.ts b/frontend/src/metabase-lib/breakout.unit.spec.ts index 25d10b0297f..96bd13f1382 100644 --- a/frontend/src/metabase-lib/breakout.unit.spec.ts +++ b/frontend/src/metabase-lib/breakout.unit.spec.ts @@ -34,9 +34,9 @@ describe("breakout", () => { "TAX", ); - expect( - Lib.displayInfo(nextQuery, 0, nextTaxColumn).breakoutPosition, - ).toBe(0); + expect(Lib.displayInfo(nextQuery, 0, nextTaxColumn)).toMatchObject({ + breakoutPositions: [0], + }); const roundtripQuery = createQuery({ query: Lib.toLegacyQuery(nextQuery), @@ -48,8 +48,8 @@ describe("breakout", () => { )("ORDERS", "TAX"); expect( - Lib.displayInfo(roundtripQuery, 0, roundtripTaxColumn).breakoutPosition, - ).toBe(0); + Lib.displayInfo(roundtripQuery, 0, roundtripTaxColumn), + ).toMatchObject({ breakoutPositions: [0] }); }); it("should note whether the temporal unit is for extraction in the displayInfo", () => { diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts index 1186395a319..9637cd3149c 100644 --- a/frontend/src/metabase-lib/types.ts +++ b/frontend/src/metabase-lib/types.ts @@ -160,7 +160,7 @@ export type ColumnDisplayInfo = { table?: TableInlineDisplayInfo; fingerprint?: FingerprintDisplayInfo; - breakoutPosition?: number; + breakoutPositions?: number[]; filterPositions?: number[]; orderByPosition?: number; selected?: boolean; // used in aggregation and field clauses diff --git a/frontend/src/metabase/common/components/QueryColumnPicker/QueryColumnPicker.unit.spec.tsx b/frontend/src/metabase/common/components/QueryColumnPicker/QueryColumnPicker.unit.spec.tsx index 67d6ae067a3..75a03cee24e 100644 --- a/frontend/src/metabase/common/components/QueryColumnPicker/QueryColumnPicker.unit.spec.tsx +++ b/frontend/src/metabase/common/components/QueryColumnPicker/QueryColumnPicker.unit.spec.tsx @@ -49,7 +49,9 @@ function setup({ columnGroups={Lib.groupColumns(columns)} hasBinning={hasBinning} hasTemporalBucketing={hasTemporalBucketing} - checkIsColumnSelected={item => item.breakoutPosition === 0} + checkIsColumnSelected={({ breakoutPositions = [] }) => + breakoutPositions.length > 0 + } onSelect={onSelect} onClose={onClose} />, diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookStep/steps.ts b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/steps.ts index e5135c01c06..2806a94db1d 100644 --- a/frontend/src/metabase/query_builder/components/notebook/NotebookStep/steps.ts +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookStep/steps.ts @@ -5,7 +5,7 @@ import { color } from "metabase/lib/colors"; import type { IconName } from "metabase/ui"; import { AggregateStep } from "../steps/AggregateStep"; -import BreakoutStep from "../steps/BreakoutStep"; +import { BreakoutStep } from "../steps/BreakoutStep"; import { DataStep } from "../steps/DataStep"; import { ExpressionStep } from "../steps/ExpressionStep"; import { FilterStep } from "../steps/FilterStep"; diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.tsx b/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.tsx index d4fd33c4da9..3f671b1d11a 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.tsx @@ -7,7 +7,7 @@ import * as Lib from "metabase-lib"; import type { NotebookStepUiComponentProps } from "../../types"; import { ClauseStep } from "../ClauseStep"; -function BreakoutStep({ +export function BreakoutStep({ query, step, color, @@ -105,16 +105,21 @@ const BreakoutPopover = ({ }: BreakoutPopoverProps) => { const columnGroups = useMemo(() => { const columns = Lib.breakoutableColumns(query, stageIndex); - - const filteredColumns = columns.filter(column => { - const columnInfo = Lib.displayInfo(query, stageIndex, column); - const isAlreadyUsed = columnInfo.breakoutPosition != null; - const isSelected = checkColumnSelected(columnInfo, breakoutIndex); - return isSelected || !isAlreadyUsed; - }); - + const filteredColumns = columns.reduce( + (columns: Lib.ColumnMetadata[], column) => { + const columnInfo = Lib.displayInfo(query, stageIndex, column); + const { breakoutPositions = [] } = columnInfo; + if (breakout && checkColumnSelected(columnInfo, breakoutIndex)) { + columns.push(Lib.breakoutColumn(query, stageIndex, breakout)); + } else if (breakoutPositions.length === 0) { + columns.push(column); + } + return columns; + }, + [], + ); return Lib.groupColumns(filteredColumns); - }, [query, stageIndex, breakoutIndex]); + }, [query, stageIndex, breakout, breakoutIndex]); return ( <QueryColumnPicker @@ -140,11 +145,8 @@ const BreakoutPopover = ({ }; const checkColumnSelected = ( - columnInfo: Lib.ColumnDisplayInfo, + { breakoutPositions = [] }: Lib.ColumnDisplayInfo, breakoutIndex?: number, ) => { - return breakoutIndex != null && columnInfo.breakoutPosition === breakoutIndex; + return breakoutIndex != null && breakoutPositions.includes(breakoutIndex); }; - -// eslint-disable-next-line import/no-default-export -- deprecated usage -export default BreakoutStep; diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.unit.spec.tsx b/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.unit.spec.tsx index 60a3db89655..ebf29b553ba 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/BreakoutStep.unit.spec.tsx @@ -11,7 +11,7 @@ import { import { createMockNotebookStep } from "../../test-utils"; -import BreakoutStep from "./BreakoutStep"; +import { BreakoutStep } from "./BreakoutStep"; function createQueryWithBreakout() { const initialQuery = createQuery(); diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/index.ts b/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/index.ts index 0f673eb8bc2..5f8dd8f4afb 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/index.ts +++ b/frontend/src/metabase/query_builder/components/notebook/steps/BreakoutStep/index.ts @@ -1,2 +1 @@ -// eslint-disable-next-line import/no-default-export -- deprecated usage -export { default } from "./BreakoutStep"; +export * from "./BreakoutStep"; diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/SummarizeStep.tsx b/frontend/src/metabase/query_builder/components/notebook/steps/SummarizeStep.tsx index b9e2a7a0bc3..b1a4214c4bd 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/SummarizeStep.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/steps/SummarizeStep.tsx @@ -5,7 +5,7 @@ import { Box, Flex } from "metabase/ui"; import type { NotebookStepUiComponentProps } from "../types"; import { AggregateStep } from "./AggregateStep"; -import BreakoutStep from "./BreakoutStep"; +import { BreakoutStep } from "./BreakoutStep"; function SummarizeStep({ color, diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnList.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnList.tsx index 5f893f8b66d..707e5849733 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnList.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnList.tsx @@ -1,6 +1,5 @@ -import { useCallback, useMemo, useState } from "react"; +import { type ChangeEvent, useCallback, useMemo, useState } from "react"; import { t } from "ttag"; -import _ from "underscore"; import { getColumnGroupName } from "metabase/common/utils/column-groups"; import Input from "metabase/core/components/Input"; @@ -18,10 +17,10 @@ export interface BreakoutColumnListProps { onAddBreakout: (column: Lib.ColumnMetadata) => void; onUpdateBreakout: ( breakout: Lib.BreakoutClause, - nextColumn: Lib.ColumnMetadata, + column: Lib.ColumnMetadata, ) => void; - onRemoveBreakout: (column: Lib.ColumnMetadata) => void; - onReplaceBreakout: (column: Lib.ColumnMetadata) => void; + onRemoveBreakout: (breakout: Lib.BreakoutClause) => void; + onReplaceBreakouts: (column: Lib.ColumnMetadata) => void; } export function BreakoutColumnList({ @@ -30,7 +29,7 @@ export function BreakoutColumnList({ onAddBreakout, onUpdateBreakout, onRemoveBreakout, - onReplaceBreakout, + onReplaceBreakouts, }: BreakoutColumnListProps) { const [searchQuery, setSearchQuery] = useState(""); const debouncedSearchQuery = useDebouncedValue( @@ -40,27 +39,27 @@ export function BreakoutColumnList({ const isSearching = searchQuery.trim().length > 0; const breakouts = Lib.breakouts(query, stageIndex); - const [pinnedBreakouts, setPinnedBreakouts] = useState(breakouts); + const [pinnedItemCount, setPinnedItemCount] = useState(breakouts.length); + + const pinnedItems = useMemo( + () => + breakouts + .slice(0, pinnedItemCount) + .map(breakout => getBreakoutListItem(query, stageIndex, breakout)), + [query, stageIndex, breakouts, pinnedItemCount], + ); const allColumns = useMemo( () => Lib.breakoutableColumns(query, stageIndex), [query, stageIndex], ); - const [pinnedColumns, unpinnedColumns] = useMemo( - () => - _.partition(allColumns, column => - isPinnedColumn(query, stageIndex, pinnedBreakouts, column), - ), - [query, stageIndex, pinnedBreakouts, allColumns], - ); - - const pinnedItems = useMemo( + const unpinnedColumns = useMemo( () => - pinnedColumns.map(column => - getColumnListItem(query, stageIndex, breakouts, column), + allColumns.filter( + column => !isPinnedColumn(query, stageIndex, column, pinnedItemCount), ), - [query, stageIndex, breakouts, pinnedColumns], + [query, stageIndex, allColumns, pinnedItemCount], ); const sections = useMemo( @@ -82,31 +81,23 @@ export function BreakoutColumnList({ ); const handleRemovePinnedBreakout = useCallback( - (column: Lib.ColumnMetadata) => { - const { breakoutPosition } = Lib.displayInfo(query, stageIndex, column); - const isPinned = - breakoutPosition != null && breakoutPosition < pinnedBreakouts.length; - - if (isPinned) { - const breakout = pinnedBreakouts[breakoutPosition]; - setPinnedBreakouts(breakouts => breakouts.filter(b => b !== breakout)); - } - - onRemoveBreakout(column); + (breakout: Lib.BreakoutClause) => { + setPinnedItemCount(pinnedItemCount - 1); + onRemoveBreakout(breakout); }, - [query, stageIndex, pinnedBreakouts, onRemoveBreakout], + [pinnedItemCount, onRemoveBreakout], ); - const handleReplaceBreakout = useCallback( + const handleReplaceBreakouts = useCallback( (column: Lib.ColumnMetadata) => { - onReplaceBreakout(column); - setPinnedBreakouts([]); + onReplaceBreakouts(column); + setPinnedItemCount(0); }, - [onReplaceBreakout], + [onReplaceBreakouts], ); const handleChangeSearchQuery = useCallback( - (event: React.ChangeEvent<HTMLInputElement>) => { + (event: ChangeEvent<HTMLInputElement>) => { setSearchQuery(event.target.value); }, [], @@ -137,15 +128,9 @@ export function BreakoutColumnList({ item={item} breakout={item.breakout} isPinned - onAddColumn={onAddBreakout} - onUpdateColumn={column => { - if (item.breakout) { - onUpdateBreakout(item.breakout, column); - } else { - onAddBreakout(column); - } - }} - onRemoveColumn={handleRemovePinnedBreakout} + onAddBreakout={onAddBreakout} + onUpdateBreakout={onUpdateBreakout} + onRemoveBreakout={handleRemovePinnedBreakout} /> ))} </ul> @@ -164,16 +149,10 @@ export function BreakoutColumnList({ stageIndex={stageIndex} item={item} breakout={item.breakout} - onAddColumn={onAddBreakout} - onUpdateColumn={column => { - if (item.breakout) { - onUpdateBreakout(item.breakout, column); - } else { - onAddBreakout(column); - } - }} - onRemoveColumn={onRemoveBreakout} - onReplaceColumns={handleReplaceBreakout} + onAddBreakout={onAddBreakout} + onUpdateBreakout={onUpdateBreakout} + onRemoveBreakout={onRemoveBreakout} + onReplaceBreakouts={handleReplaceBreakouts} /> ))} </ul> @@ -185,22 +164,46 @@ export function BreakoutColumnList({ ); } -function getColumnListItem( +type ListItem = Lib.ColumnDisplayInfo & { + column: Lib.ColumnMetadata; + breakout?: Lib.BreakoutClause; +}; + +type ListSection = { + name: string; + items: ListItem[]; +}; + +function getBreakoutListItem( + query: Lib.Query, + stageIndex: number, + breakout: Lib.BreakoutClause, +): ListItem { + const column = Lib.breakoutColumn(query, stageIndex, breakout); + const columnInfo = Lib.displayInfo(query, stageIndex, column); + return { ...columnInfo, column, breakout }; +} + +function getColumnListItems( query: Lib.Query, stageIndex: number, breakouts: Lib.BreakoutClause[], column: Lib.ColumnMetadata, -) { - const displayInfo = Lib.displayInfo(query, stageIndex, column); - const breakout = - displayInfo.breakoutPosition != null - ? breakouts[displayInfo.breakoutPosition] - : undefined; - return { - ...displayInfo, - column, - breakout, - }; +): ListItem[] { + const columnInfo = Lib.displayInfo(query, stageIndex, column); + const { breakoutPositions = [] } = columnInfo; + if (breakoutPositions.length === 0) { + return [{ ...columnInfo, column }]; + } + + return breakoutPositions.map(index => { + const breakout = breakouts[index]; + return { + ...columnInfo, + column: Lib.breakoutColumn(query, stageIndex, breakout), + breakout, + }; + }); } function getColumnSections( @@ -208,7 +211,7 @@ function getColumnSections( stageIndex: number, columns: Lib.ColumnMetadata[], searchQuery: string, -) { +): ListSection[] { const breakouts = Lib.breakouts(query, stageIndex); const formattedSearchQuery = searchQuery.trim().toLowerCase(); @@ -223,8 +226,8 @@ function getColumnSections( return Lib.groupColumns(filteredColumns).map(group => { const groupInfo = Lib.displayInfo(query, stageIndex, group); - const items = Lib.getColumnsFromColumnGroup(group).map(column => - getColumnListItem(query, stageIndex, breakouts, column), + const items = Lib.getColumnsFromColumnGroup(group).flatMap(column => + getColumnListItems(query, stageIndex, breakouts, column), ); return { @@ -237,10 +240,12 @@ function getColumnSections( function isPinnedColumn( query: Lib.Query, stageIndex: number, - pinnedBreakouts: Lib.BreakoutClause[], column: Lib.ColumnMetadata, -) { - const { breakoutPosition } = Lib.displayInfo(query, stageIndex, column); - const maxPinnedBreakoutIndex = pinnedBreakouts.length - 1; - return breakoutPosition != null && breakoutPosition <= maxPinnedBreakoutIndex; + pinnedItemCount: number, +): boolean { + const { breakoutPositions = [] } = Lib.displayInfo(query, stageIndex, column); + return ( + breakoutPositions.length > 0 && + breakoutPositions.every(breakoutIndex => breakoutIndex < pinnedItemCount) + ); } diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnListItem/BreakoutColumnListItem.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnListItem/BreakoutColumnListItem.tsx index 54fc0128846..ac89c74e2c3 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnListItem/BreakoutColumnListItem.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/BreakoutColumnList/BreakoutColumnListItem/BreakoutColumnListItem.tsx @@ -23,10 +23,13 @@ interface BreakoutColumnListItemProps { item: Lib.ColumnDisplayInfo & { column: Lib.ColumnMetadata }; breakout?: Lib.BreakoutClause; isPinned?: boolean; - onAddColumn: (column: Lib.ColumnMetadata) => void; - onUpdateColumn: (column: Lib.ColumnMetadata) => void; - onRemoveColumn: (column: Lib.ColumnMetadata) => void; - onReplaceColumns?: (column: Lib.ColumnMetadata) => void; + onAddBreakout: (column: Lib.ColumnMetadata) => void; + onUpdateBreakout: ( + breakout: Lib.BreakoutClause, + column: Lib.ColumnMetadata, + ) => void; + onRemoveBreakout: (breakout: Lib.BreakoutClause) => void; + onReplaceBreakouts?: (column: Lib.ColumnMetadata) => void; } export function BreakoutColumnListItem({ @@ -35,27 +38,29 @@ export function BreakoutColumnListItem({ item, breakout, isPinned = false, - onAddColumn, - onUpdateColumn, - onRemoveColumn, - onReplaceColumns, + onAddBreakout, + onUpdateBreakout, + onRemoveBreakout, + onReplaceBreakouts, }: BreakoutColumnListItemProps) { - const isSelected = typeof item.breakoutPosition === "number"; + const isSelected = breakout != null; const handleAddClick = useCallback(() => { - onAddColumn(Lib.withDefaultBucket(query, stageIndex, item.column)); - }, [query, stageIndex, item.column, onAddColumn]); + onAddBreakout(Lib.withDefaultBucket(query, stageIndex, item.column)); + }, [query, stageIndex, item.column, onAddBreakout]); const handleListItemClick = useCallback(() => { - onReplaceColumns?.(Lib.withDefaultBucket(query, stageIndex, item.column)); - }, [query, stageIndex, item.column, onReplaceColumns]); + onReplaceBreakouts?.(Lib.withDefaultBucket(query, stageIndex, item.column)); + }, [query, stageIndex, item.column, onReplaceBreakouts]); const handleRemoveColumn = useCallback( (event: MouseEvent) => { event.stopPropagation(); - onRemoveColumn(item.column); + if (breakout) { + onRemoveBreakout(breakout); + } }, - [item.column, onRemoveColumn], + [breakout, onRemoveBreakout], ); const displayName = isPinned ? item.longDisplayName : item.displayName; @@ -89,7 +94,9 @@ export function BreakoutColumnListItem({ hasBinning hasTemporalBucketing onSelect={column => - breakout ? onUpdateColumn(column) : onAddColumn(column) + breakout + ? onUpdateBreakout(breakout, column) + : onAddBreakout(column) } /> {isSelected && ( diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/SummarizeBreakoutColumnList.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/SummarizeBreakoutColumnList.tsx index e34a62777e9..e909a59f0c1 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/SummarizeBreakoutColumnList.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/SummarizeBreakoutColumnList.tsx @@ -14,7 +14,7 @@ type SummarizeBreakoutColumnListProps = { clause: Lib.BreakoutClause, column: Lib.ColumnMetadata, ) => void; - onRemoveBreakout: (column: Lib.ColumnMetadata) => void; + onRemoveBreakout: (clause: Lib.BreakoutClause) => void; onReplaceBreakouts: (column: Lib.ColumnMetadata) => void; } & StackProps; @@ -40,7 +40,7 @@ export const SummarizeBreakoutColumnList = ({ onAddBreakout={onAddBreakout} onUpdateBreakout={onUpdateBreakout} onRemoveBreakout={onRemoveBreakout} - onReplaceBreakout={onReplaceBreakouts} + onReplaceBreakouts={onReplaceBreakouts} /> </Stack> ); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/use-summarize-query.ts b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/use-summarize-query.ts index be17671f5bf..b68c5d75ca7 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/use-summarize-query.ts +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeContent/use-summarize-query.ts @@ -88,14 +88,9 @@ export const useSummarizeQuery = ({ ); const handleRemoveBreakout = useCallback( - (column: Lib.ColumnMetadata) => { - const { breakoutPosition } = Lib.displayInfo(query, STAGE_INDEX, column); - if (breakoutPosition != null) { - const breakouts = Lib.breakouts(query, STAGE_INDEX); - const clause = breakouts[breakoutPosition]; - const nextQuery = Lib.removeClause(query, STAGE_INDEX, clause); - handleChange(nextQuery); - } + (clause: Lib.BreakoutClause) => { + const nextQuery = Lib.removeClause(query, STAGE_INDEX, clause); + handleChange(nextQuery); }, [query, handleChange], ); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.unit.spec.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.unit.spec.tsx index 2e4e301ec2d..d05adb145b1 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.unit.spec.tsx @@ -1,23 +1,10 @@ import userEvent from "@testing-library/user-event"; import { useState } from "react"; -import { createMockMetadata } from "__support__/metadata"; import { renderWithProviders, screen, waitFor, within } from "__support__/ui"; -import { checkNotNull } from "metabase/lib/types"; import * as Lib from "metabase-lib"; -import Question from "metabase-lib/v1/Question"; -import type { Card, UnsavedCard } from "metabase-types/api"; +import { createQuery, createQueryWithClauses } from "metabase-lib/test-helpers"; import { createMockCard } from "metabase-types/api/mocks"; -import { - ORDERS, - ORDERS_ID, - PEOPLE_ID, - PRODUCTS, - PRODUCTS_ID, - SAMPLE_DB_ID, - createAdHocCard, - createSampleDatabase, -} from "metabase-types/api/mocks/presets"; import { createMockQueryBuilderState, createMockState, @@ -26,46 +13,56 @@ import { import { SummarizeSidebar } from "./SummarizeSidebar"; type SetupOpts = { - card?: Card | UnsavedCard; + query?: Lib.Query; withDefaultAggregation?: boolean; }; -function createSummarizedCard() { - return createAdHocCard({ - dataset_query: { - type: "query", - database: SAMPLE_DB_ID, - query: { - "source-table": ORDERS_ID, - aggregation: [["max", ["field", ORDERS.QUANTITY, null]]], - breakout: [ - ["field", ORDERS.CREATED_AT, { "temporal-unit": "month" }], - ["field", PRODUCTS.CATEGORY, { "source-field": ORDERS.PRODUCT_ID }], - ], +function createSummarizedQuery() { + return createQueryWithClauses({ + aggregations: [ + { operatorName: "max", tableName: "ORDERS", columnName: "QUANTITY" }, + ], + breakouts: [ + { + tableName: "ORDERS", + columnName: "CREATED_AT", + temporalBucketName: "Month", + }, + { + tableName: "PRODUCTS", + columnName: "CATEGORY", }, - }, + ], + }); +} + +function createQueryWithBreakoutsForSameColumn() { + return createQueryWithClauses({ + aggregations: [{ operatorName: "count" }], + breakouts: [ + { + tableName: "ORDERS", + columnName: "CREATED_AT", + temporalBucketName: "Year", + }, + { + tableName: "ORDERS", + columnName: "CREATED_AT", + temporalBucketName: "Month of year", + }, + ], }); } async function setup({ - card = createAdHocCard(), + query: initialQuery = createQuery(), withDefaultAggregation = true, }: SetupOpts = {}) { const onQueryChange = jest.fn(); const onClose = jest.fn(); - const metadata = createMockMetadata({ - databases: [createSampleDatabase()], - questions: "id" in card ? [card] : [], - }); - - const question = - "id" in card - ? checkNotNull(metadata.question(card.id)) - : new Question(card, metadata); - function Wrapper() { - const [query, setQuery] = useState(question.query()); + const [query, setQuery] = useState(initialQuery); return ( <SummarizeSidebar @@ -112,7 +109,6 @@ async function setup({ } return { - metadata, getNextAggregations, getNextBreakouts, onQueryChange, @@ -161,28 +157,22 @@ describe("SummarizeSidebar", () => { }); it("shouldn't apply default aggregation if a query is already aggregated", async () => { - await setup({ card: createSummarizedCard() }); + await setup({ query: createSummarizedQuery() }); expect(screen.queryByLabelText("Count")).not.toBeInTheDocument(); }); }); it("should list breakoutable columns", async () => { - const { metadata } = await setup(); - const ordersTable = checkNotNull(metadata.table(ORDERS_ID)); - const productsTable = checkNotNull(metadata.table(PRODUCTS_ID)); - const peopleTable = checkNotNull(metadata.table(PEOPLE_ID)); - const expectedColumnCount = [ - ordersTable.fields, - productsTable.fields, - peopleTable.fields, - ].flat().length; + const query = createQuery(); + const columns = Lib.breakoutableColumns(query, -1); + await setup({ query }); expect(screen.getByText("Group by")).toBeInTheDocument(); expect(screen.getByText("Discount")).toBeInTheDocument(); expect(screen.getByText("Category")).toBeInTheDocument(); expect(screen.getByText("Email")).toBeInTheDocument(); expect(screen.getAllByTestId("dimension-list-item")).toHaveLength( - expectedColumnCount, + columns.length, ); }); @@ -213,7 +203,7 @@ describe("SummarizeSidebar", () => { }); it("should highlight selected breakout columns", async () => { - await setup({ card: createSummarizedCard() }); + await setup({ query: createSummarizedQuery() }); const [ordersCreatedAt, peopleCreatedAt] = screen.getAllByLabelText("Created At"); @@ -225,26 +215,88 @@ describe("SummarizeSidebar", () => { }); it("should list breakouts added before opening the sidebar in a separate section", async () => { - await setup({ card: createSummarizedCard() }); - - const pinnedColumnList = screen.getByTestId("pinned-dimensions"); - const unpinnedColumnList = screen.getByTestId("unpinned-dimensions"); + await setup({ query: createSummarizedQuery() }); expect( - within(pinnedColumnList).getByText("Product → Category"), + within(getPinnedColumnList()).getByText("Product → Category"), ).toBeInTheDocument(); expect( - within(pinnedColumnList).getByText("Created At"), + within(getPinnedColumnList()).getByText("Created At"), ).toBeInTheDocument(); expect( - within(unpinnedColumnList).queryByText("Category"), + within(getUnpinnedColumnList()).queryByText("Category"), ).not.toBeInTheDocument(); // "Product → Created At" and "User → Created At" should still be there - expect(within(unpinnedColumnList).getAllByText("Created At")).toHaveLength( - 2, + expect( + within(getUnpinnedColumnList()).getAllByText("Created At"), + ).toHaveLength(2); + }); + + it("should show multiple breakouts of the same column", async () => { + await setup({ + query: createQueryWithBreakoutsForSameColumn(), + }); + expect( + within(getPinnedColumnList()).getAllByText("Created At"), + ).toHaveLength(2); + }); + + it("should allow to modify temporal units for breakouts of the same column", async () => { + const { getNextBreakouts } = await setup({ + query: createQueryWithBreakoutsForSameColumn(), + }); + + await userEvent.click(await screen.findByText("by year")); + await userEvent.click(await screen.findByText("Quarter")); + expect(getNextBreakouts()).toMatchObject([ + { displayName: "Created At: Quarter" }, + { displayName: "Created At: Month of year" }, + ]); + + await userEvent.click(await screen.findByText("by month of year")); + await userEvent.click(await screen.findByText("Quarter of year")); + expect(getNextBreakouts()).toMatchObject([ + { displayName: "Created At: Quarter" }, + { displayName: "Created At: Quarter of year" }, + ]); + }); + + it("should allow to remove breakouts of the same column", async () => { + const { getNextBreakouts } = await setup({ + query: createQueryWithBreakoutsForSameColumn(), + }); + + const [firstBreakout] = within(getPinnedColumnList()).getAllByLabelText( + "Created At", + ); + await userEvent.click( + within(firstBreakout).getByLabelText("Remove dimension"), + ); + expect( + within(getPinnedColumnList()).getByText("Created At"), + ).toBeInTheDocument(); + expect( + within(getUnpinnedColumnList()).getAllByText("Created At"), + ).toHaveLength(2); + expect(getNextBreakouts()).toMatchObject([ + { displayName: "Created At: Month of year" }, + ]); + + const [secondBreakout] = within(getPinnedColumnList()).getAllByLabelText( + "Created At", + ); + await userEvent.click( + within(secondBreakout).getByLabelText("Remove dimension"), ); + expect( + within(getPinnedColumnList()).queryByText("Created At"), + ).not.toBeInTheDocument(); + expect( + within(getUnpinnedColumnList()).getAllByText("Created At"), + ).toHaveLength(3); + expect(getNextBreakouts()).toEqual([]); }); it("should add an aggregation", async () => { @@ -288,7 +340,7 @@ describe("SummarizeSidebar", () => { }); it("shouldn't allow changing an aggregation to an expression", async () => { - await setup({ card: createSummarizedCard() }); + await setup({ query: createSummarizedQuery() }); await userEvent.click(screen.getByText("Max of Quantity")); await userEvent.click(await screen.findByLabelText("Back")); @@ -362,7 +414,9 @@ describe("SummarizeSidebar", () => { }); it("should add a new column instead of replacing when adding a bucketed column", async () => { - const { getNextBreakouts } = await setup({ card: createSummarizedCard() }); + const { getNextBreakouts } = await setup({ + query: createSummarizedQuery(), + }); const [total] = screen.getAllByLabelText("Total"); await userEvent.hover(total); @@ -374,7 +428,9 @@ describe("SummarizeSidebar", () => { }); it("should remove breakout", async () => { - const { getNextBreakouts } = await setup({ card: createSummarizedCard() }); + const { getNextBreakouts } = await setup({ + query: createSummarizedQuery(), + }); const [breakout] = screen.getAllByLabelText("Created At"); await userEvent.click(within(breakout).getByLabelText("Remove dimension")); @@ -384,7 +440,9 @@ describe("SummarizeSidebar", () => { }); it("should replace breakouts by clicking on a column", async () => { - const { getNextBreakouts } = await setup({ card: createSummarizedCard() }); + const { getNextBreakouts } = await setup({ + query: createSummarizedQuery(), + }); await userEvent.click(screen.getByText("Quantity")); @@ -401,3 +459,11 @@ describe("SummarizeSidebar", () => { expect(onClose).toHaveBeenCalled(); }); }); + +function getPinnedColumnList() { + return screen.getByTestId("pinned-dimensions"); +} + +function getUnpinnedColumnList() { + return screen.getByTestId("unpinned-dimensions"); +} diff --git a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.tsx b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.tsx index 938b68a68d2..45ab52e7a86 100644 --- a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.tsx +++ b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.tsx @@ -11,6 +11,7 @@ interface TimeseriesBucketPickerProps { query: Lib.Query; stageIndex: number; column: Lib.ColumnMetadata; + breakout: Lib.BreakoutClause; onChange: (newColumn: Lib.ColumnMetadata) => void; } @@ -18,14 +19,15 @@ export function TimeseriesBucketPicker({ query, stageIndex, column, + breakout, onChange, }: TimeseriesBucketPickerProps) { const [isOpened, setIsOpened] = useState(false); - const columnBucketInfo = useMemo(() => { - const bucket = Lib.temporalBucket(column); + const bucketInfo = useMemo(() => { + const bucket = Lib.temporalBucket(breakout); return bucket ? Lib.displayInfo(query, stageIndex, bucket) : undefined; - }, [query, stageIndex, column]); + }, [query, stageIndex, breakout]); const handleChange = (newColumn: Lib.ColumnMetadata) => { onChange(newColumn); @@ -40,7 +42,7 @@ export function TimeseriesBucketPicker({ data-testid="timeseries-bucket-button" onClick={() => setIsOpened(!isOpened)} > - {columnBucketInfo ? columnBucketInfo.displayName : t`Unbinned`} + {bucketInfo ? bucketInfo.displayName : t`Unbinned`} </Button> </Popover.Target> <Popover.Dropdown> @@ -48,7 +50,7 @@ export function TimeseriesBucketPicker({ query={query} stageIndex={stageIndex} column={column} - columnBucketInfo={columnBucketInfo} + bucketInfo={bucketInfo} onChange={handleChange} /> </Popover.Dropdown> @@ -60,7 +62,7 @@ interface TimeseriesBucketDropdownProps { query: Lib.Query; stageIndex: number; column: Lib.ColumnMetadata; - columnBucketInfo: Lib.BucketDisplayInfo | undefined; + bucketInfo: Lib.BucketDisplayInfo | undefined; onChange: (newColumn: Lib.ColumnMetadata) => void; } @@ -68,7 +70,7 @@ function TimeseriesBucketDropdown({ query, stageIndex, column, - columnBucketInfo, + bucketInfo, onChange, }: TimeseriesBucketDropdownProps) { const availableBuckets = Lib.availableTemporalBuckets( @@ -100,7 +102,7 @@ function TimeseriesBucketDropdown({ return ( <TemporalUnitPicker - value={columnBucketInfo?.shortName} + value={bucketInfo?.shortName} availableItems={availableItems} canRemove onChange={handleChange} diff --git a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.unit.spec.tsx b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.unit.spec.tsx index 5197d3f0bba..f7ea81f58d3 100644 --- a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.unit.spec.tsx +++ b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesBucketPicker/TimeseriesBucketPicker.unit.spec.tsx @@ -22,6 +22,7 @@ function findMonthBucket(query: Lib.Query, column: Lib.ColumnMetadata) { interface QueryWithBreakoutOpts { query?: Lib.Query; + stageIndex?: number; column?: Lib.ColumnMetadata; bucket?: Lib.Bucket | null; } @@ -30,28 +31,36 @@ function createQueryWithBreakout({ query: initialQuery = createQuery(), column = findBreakoutColumn(initialQuery), bucket = findMonthBucket(initialQuery, column), + stageIndex = -1, }: QueryWithBreakoutOpts = {}) { const query = Lib.breakout( initialQuery, - 0, + stageIndex, Lib.withTemporalBucket(column, bucket), ); - - return { query, column: findBreakoutColumn(query) }; + const [breakout] = Lib.breakouts(query, stageIndex); + return { + query, + breakout, + column: Lib.breakoutColumn(query, stageIndex, breakout), + }; } interface SetupOpts { query: Lib.Query; + stageIndex?: number; + breakout: Lib.BreakoutClause; column: Lib.ColumnMetadata; } -function setup({ query, column }: SetupOpts) { +function setup({ query, breakout, column, stageIndex = -1 }: SetupOpts) { const onChange = jest.fn(); renderWithProviders( <TimeseriesBucketPicker query={query} - stageIndex={0} + stageIndex={stageIndex} + breakout={breakout} column={column} onChange={onChange} />, @@ -68,8 +77,10 @@ function setup({ query, column }: SetupOpts) { describe("TimeseriesBucketPicker", () => { it("should allow to add a temporal bucket", async () => { - const { query, column } = createQueryWithBreakout({ bucket: null }); - const { getNextBucketName } = setup({ query, column }); + const { query, breakout, column } = createQueryWithBreakout({ + bucket: null, + }); + const { getNextBucketName } = setup({ query, breakout, column }); await userEvent.click(screen.getByText("Unbinned")); await userEvent.click(await screen.findByText("Month")); @@ -78,8 +89,8 @@ describe("TimeseriesBucketPicker", () => { }); it("should allow to update a temporal bucket", async () => { - const { query, column } = createQueryWithBreakout(); - const { getNextBucketName } = setup({ query, column }); + const { query, breakout, column } = createQueryWithBreakout(); + const { getNextBucketName } = setup({ query, breakout, column }); await userEvent.click(screen.getByText("Month")); await userEvent.click(await screen.findByText("Year")); @@ -88,8 +99,8 @@ describe("TimeseriesBucketPicker", () => { }); it("should allow to show more binning options", async () => { - const { query, column } = createQueryWithBreakout(); - const { getNextBucketName } = setup({ query, column }); + const { query, breakout, column } = createQueryWithBreakout(); + const { getNextBucketName } = setup({ query, breakout, column }); await userEvent.click(screen.getByText("Month")); await userEvent.click(screen.getByText("More…")); @@ -99,8 +110,8 @@ describe("TimeseriesBucketPicker", () => { }); it("should allow to remove a temporal bucket", async () => { - const { query, column } = createQueryWithBreakout(); - const { getNextBucketName } = setup({ query, column }); + const { query, breakout, column } = createQueryWithBreakout(); + const { getNextBucketName } = setup({ query, breakout, column }); await userEvent.click(screen.getByText("Month")); await userEvent.click(screen.getByText("More…")); @@ -110,17 +121,21 @@ describe("TimeseriesBucketPicker", () => { }); it("should show all options when the current bucket is below the More button", async () => { - const _query = createQuery(); - const _column = findBreakoutColumn(_query); - const bucket = findTemporalBucket(_query, _column, "Quarter of year"); - - const { query, column } = createQueryWithBreakout({ - query: _query, - column: _column, + const initialQuery = createQuery(); + const initialColumn = findBreakoutColumn(initialQuery); + const bucket = findTemporalBucket( + initialQuery, + initialColumn, + "Quarter of year", + ); + + const { query, breakout, column } = createQueryWithBreakout({ + query: initialQuery, + column: initialColumn, bucket, }); - setup({ query, column }); + setup({ query, breakout, column }); await userEvent.click(screen.getByText("Quarter of year")); expect(await screen.findByText("Month of year")).toBeInTheDocument(); diff --git a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.tsx b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.tsx index 7b17d3d2d1f..f18b787e139 100644 --- a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.tsx +++ b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.tsx @@ -9,7 +9,6 @@ import { TimeseriesBucketPicker } from "./TimeseriesBucketPicker"; import { TimeseriesFilterPicker } from "./TimeseriesFilterPicker"; import { findBreakoutClause, - findBreakoutColumn, findFilterClause, findFilterColumn, } from "./utils"; @@ -55,15 +54,14 @@ function TimeseriesControls({ stageIndex, onChange, }: TimeseriesControlsProps) { - const breakoutColumn = useMemo( - () => findBreakoutColumn(query, stageIndex), + const breakout = useMemo( + () => findBreakoutClause(query, stageIndex), [query, stageIndex], ); - const breakout = useMemo( - () => - breakoutColumn && findBreakoutClause(query, stageIndex, breakoutColumn), - [query, stageIndex, breakoutColumn], + const breakoutColumn = useMemo( + () => breakout && Lib.breakoutColumn(query, stageIndex, breakout), + [query, stageIndex, breakout], ); const isTemporalBucketable = useMemo( @@ -99,7 +97,7 @@ function TimeseriesControls({ } }; - if (!breakoutColumn || !filterColumn) { + if (!breakout || !breakoutColumn || !filterColumn) { return null; } @@ -125,6 +123,7 @@ function TimeseriesControls({ query={query} stageIndex={stageIndex} column={breakoutColumn} + breakout={breakout} onChange={handleBreakoutChange} /> </> diff --git a/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.unit.spec.tsx b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.unit.spec.tsx new file mode 100644 index 00000000000..a0f8fcc8fe1 --- /dev/null +++ b/frontend/src/metabase/querying/components/TimeseriesChrome/TimeseriesChrome.unit.spec.tsx @@ -0,0 +1,100 @@ +import { userEvent } from "@storybook/testing-library"; + +import { renderWithProviders, screen } from "__support__/ui"; +import * as Lib from "metabase-lib"; +import { + createQuery, + createQueryWithClauses, + SAMPLE_METADATA, +} from "metabase-lib/test-helpers"; +import Question from "metabase-lib/v1/Question"; +import { createMockCard } from "metabase-types/api/mocks"; + +import { TimeseriesChrome } from "./TimeseriesChrome"; + +interface SetupOpts { + query?: Lib.Query; +} + +function setup({ query = createQuery() }: SetupOpts = {}) { + const question = new Question(createMockCard(), SAMPLE_METADATA).setQuery( + query, + ); + const stageIndex = -1; + const updateQuestion = jest.fn(); + + const getNextBreakouts = () => { + const nextQuestion = updateQuestion.mock.calls[0][0]; + const nextQuery = nextQuestion.query(); + return Lib.breakouts(nextQuery, stageIndex).map(breakout => + Lib.displayInfo(nextQuery, stageIndex, breakout), + ); + }; + + renderWithProviders( + <TimeseriesChrome question={question} updateQuestion={updateQuestion} />, + ); + + return { getNextBreakouts }; +} + +describe("TimeseriesChrome", () => { + it("should render nothing if there are no breakouts", () => { + setup(); + expect(screen.queryByText("View")).not.toBeInTheDocument(); + }); + + it("should render nothing if there are no breakouts on a temporal column", () => { + const query = createQueryWithClauses({ + breakouts: [{ tableName: "PRODUCTS", columnName: "CATEGORY" }], + }); + setup({ query }); + expect(screen.queryByText("View")).not.toBeInTheDocument(); + }); + + it("should allow to change the temporal unit for a breakout", async () => { + const query = createQueryWithClauses({ + breakouts: [ + { + tableName: "PRODUCTS", + columnName: "CREATED_AT", + temporalBucketName: "Month", + }, + ], + }); + const { getNextBreakouts } = setup({ query }); + + await userEvent.click(screen.getByText("Month")); + await userEvent.click(await screen.findByText("Year")); + + expect(getNextBreakouts()).toMatchObject([ + { displayName: "Created At: Year" }, + ]); + }); + + it("should allow to change the temporal unit for a breakout when there are multiple breakouts of the same column", async () => { + const query = createQueryWithClauses({ + breakouts: [ + { + tableName: "PRODUCTS", + columnName: "CREATED_AT", + temporalBucketName: "Month", + }, + { + tableName: "PRODUCTS", + columnName: "CREATED_AT", + temporalBucketName: "Year", + }, + ], + }); + const { getNextBreakouts } = setup({ query }); + + await userEvent.click(screen.getByText("Month")); + await userEvent.click(await screen.findByText("Quarter")); + + expect(getNextBreakouts()).toMatchObject([ + { displayName: "Created At: Quarter" }, + { displayName: "Created At: Year" }, + ]); + }); +}); diff --git a/frontend/src/metabase/querying/components/TimeseriesChrome/utils.ts b/frontend/src/metabase/querying/components/TimeseriesChrome/utils.ts index 0804e48a85e..1547bffad69 100644 --- a/frontend/src/metabase/querying/components/TimeseriesChrome/utils.ts +++ b/frontend/src/metabase/querying/components/TimeseriesChrome/utils.ts @@ -1,20 +1,5 @@ import * as Lib from "metabase-lib"; -export function findBreakoutColumn( - query: Lib.Query, - stageIndex: number, -): Lib.ColumnMetadata | undefined { - const columns = Lib.breakoutableColumns(query, stageIndex); - return columns.find(column => { - if (!Lib.isTemporal(column)) { - return false; - } - - const { breakoutPosition } = Lib.displayInfo(query, stageIndex, column); - return breakoutPosition != null; - }); -} - export function findFilterColumn( query: Lib.Query, stageIndex: number, @@ -46,13 +31,10 @@ export function findFilterClause( export function findBreakoutClause( query: Lib.Query, stageIndex: number, - breakoutColumn: Lib.ColumnMetadata, ): Lib.BreakoutClause | undefined { const breakouts = Lib.breakouts(query, stageIndex); - const { breakoutPosition } = Lib.displayInfo( - query, - stageIndex, - breakoutColumn, - ); - return breakoutPosition != null ? breakouts[breakoutPosition] : undefined; + return breakouts.find(breakout => { + const column = Lib.breakoutColumn(query, stageIndex, breakout); + return Lib.isTemporal(column); + }); } diff --git a/src/metabase/lib/breakout.cljc b/src/metabase/lib/breakout.cljc index 539cf243918..13201c0426f 100644 --- a/src/metabase/lib/breakout.cljc +++ b/src/metabase/lib/breakout.cljc @@ -10,6 +10,7 @@ [metabase.lib.schema.expression :as lib.schema.expression] [metabase.lib.schema.metadata :as lib.schema.metadata] [metabase.lib.schema.ref :as lib.schema.ref] + [metabase.lib.schema.util :as lib.schema.util] [metabase.lib.temporal-bucket :as lib.temporal-bucket] [metabase.lib.util :as lib.util] [metabase.shared.util.i18n :as i18n] @@ -23,19 +24,6 @@ (for [breakout breakouts] (lib.metadata.calculation/display-name query stage-number breakout :long)))))) -(mu/defn breakout :- ::lib.schema/query - "Add a new breakout on an expression, presumably a Field reference." - ([query expr] - (breakout query -1 expr)) - - ([query :- ::lib.schema/query - stage-number :- :int - expr :- some?] - (let [expr (lib.ref/ref (if (fn? expr) - (expr query stage-number) - expr))] - (lib.util/add-summary-clause query stage-number :breakout expr)))) - (mu/defn breakouts :- [:maybe [:sequential ::lib.schema.expression/expression]] "Return the current breakouts" ([query] @@ -55,6 +43,18 @@ (-> (lib.metadata.calculation/metadata query stage-number field-ref) (assoc :lib/source :source/breakouts))))))) +(mu/defn breakout :- ::lib.schema/query + "Add a new breakout on an expression, presumably a Field reference. Ignores attempts to add a duplicate breakout." + ([query expr] + (breakout query -1 expr)) + ([query :- ::lib.schema/query + stage-number :- :int + expr :- some?] + (let [expr (if (fn? expr) (expr query stage-number) expr)] + (if (lib.schema.util/distinct-refs? (map lib.ref/ref (cons expr (breakouts query stage-number)))) + (lib.util/add-summary-clause query stage-number :breakout expr) + query)))) + (mu/defn breakoutable-columns :- [:sequential ::lib.schema.metadata/column] "Get column metadata for all the columns that can be broken out by in the stage number `stage-number` of the query `query` @@ -75,24 +75,23 @@ ([query :- ::lib.schema/query stage-number :- :int] - (let [cols (let [stage (lib.util/query-stage query stage-number) - options {:include-implicitly-joinable-for-source-card? false}] - (lib.metadata.calculation/visible-columns query stage-number stage options))] - (when (seq cols) - (let [matching (into {} (keep-indexed (fn [index a-breakout] - (when-let [col (lib.equality/find-matching-column - query stage-number a-breakout cols - {:generous? true})] - [col [index a-breakout]])) - (or (breakouts query stage-number) [])))] - (mapv #(let [[pos a-breakout] (matching %) - binning (lib.binning/binning a-breakout) - {:keys [unit]} (lib.temporal-bucket/temporal-bucket a-breakout)] - (cond-> (assoc % :lib/hide-bin-bucket? true) - binning (lib.binning/with-binning binning) - unit (lib.temporal-bucket/with-temporal-bucket unit) - pos (assoc :breakout-position pos))) - cols)))))) + (let [columns (let [stage (lib.util/query-stage query stage-number) + options {:include-implicitly-joinable-for-source-card? false}] + (lib.metadata.calculation/visible-columns query stage-number stage options))] + (when (seq columns) + (let [existing-breakouts (breakouts query stage-number) + column->breakout-positions (group-by + (fn [position] + (lib.equality/find-matching-column query + stage-number + (get existing-breakouts position) + columns + {:generous? true})) + (range (count existing-breakouts)))] + (mapv #(let [positions (column->breakout-positions %)] + (cond-> (assoc % :lib/hide-bin-bucket? true) + positions (assoc :breakout-positions positions))) + columns)))))) (mu/defn existing-breakouts :- [:maybe [:sequential {:min 1} ::lib.schema.ref/ref]] "Returns existing breakouts (as MBQL expressions) for `column` in a stage if there are any. Returns `nil` if there @@ -138,11 +137,20 @@ (mu/defn breakout-column :- ::lib.schema.metadata/column "Returns the input column used for this breakout." - [query :- ::lib.schema/query + ([query :- ::lib.schema/query + breakout-ref :- ::lib.schema.ref/ref] + (breakout-column query -1 breakout-ref)) + ([query :- ::lib.schema/query stage-number :- :int breakout-ref :- ::lib.schema.ref/ref] - (->> (breakoutable-columns query stage-number) - (lib.equality/find-matching-column breakout-ref))) + (when-let [column (lib.equality/find-matching-column breakout-ref + (breakoutable-columns query stage-number) + {:generous? true})] + (let [binning (lib.binning/binning breakout-ref) + bucket (lib.temporal-bucket/temporal-bucket breakout-ref)] + (cond-> column + binning (lib.binning/with-binning binning) + bucket (lib.temporal-bucket/with-temporal-bucket bucket)))))) (mu/defn remove-all-breakouts :- ::lib.schema/query "Remove all breakouts from a query stage." diff --git a/src/metabase/lib/metadata/calculation.cljc b/src/metabase/lib/metadata/calculation.cljc index be72f4942c4..523e2315667 100644 --- a/src/metabase/lib/metadata/calculation.cljc +++ b/src/metabase/lib/metadata/calculation.cljc @@ -387,7 +387,7 @@ {:is-temporal-extraction (and (contains? lib.schema.temporal-bucketing/datetime-extraction-units temporal-unit) (not (contains? lib.schema.temporal-bucketing/datetime-truncation-units temporal-unit)))}) - (select-keys x-metadata [:breakout-position :order-by-position :filter-positions])))) + (select-keys x-metadata [:breakout-positions :order-by-position :filter-positions])))) (defmethod display-info-method :default [query stage-number x] diff --git a/src/metabase/lib/remove_replace.cljc b/src/metabase/lib/remove_replace.cljc index f4085a3732a..4517f632fb9 100644 --- a/src/metabase/lib/remove_replace.cljc +++ b/src/metabase/lib/remove_replace.cljc @@ -14,6 +14,7 @@ [metabase.lib.options :as lib.options] [metabase.lib.ref :as lib.ref] [metabase.lib.schema :as lib.schema] + [metabase.lib.schema.util :as lib.schema.util] [metabase.lib.util :as lib.util] [metabase.lib.util.match :as lib.util.match] [metabase.util :as u] @@ -186,23 +187,27 @@ (first replacement-clause)) (= (last target-clause) (last replacement-clause)))) - query (cond - sync-breakout-ordering? - (sync-order-by-options-with-breakout - query - stage-number - target-clause - (select-keys (second replacement-clause) [:binning :temporal-unit])) - - changing-breakout? - (remove-breakout-order-by query stage-number target-clause) - - :else - query)] - (if location - (-> query - (remove-replace-location stage-number query location target-clause remove-replace-fn) - (normalize-fields-clauses location)) + new-query (cond + sync-breakout-ordering? + (sync-order-by-options-with-breakout + query + stage-number + target-clause + (select-keys (second replacement-clause) [:binning :temporal-unit])) + + changing-breakout? + (remove-breakout-order-by query stage-number target-clause) + + :else + query) + new-query (if location + (-> new-query + (remove-replace-location stage-number new-query location target-clause remove-replace-fn) + (normalize-fields-clauses location)) + new-query) + new-stage (lib.util/query-stage new-query stage-number)] + (if (or (not changing-breakout?) (lib.schema.util/distinct-refs? (:breakout new-stage))) + new-query query)))) (mu/defn remove-clause :- :metabase.lib.schema/query diff --git a/test/metabase/lib/binning_test.cljc b/test/metabase/lib/binning_test.cljc index a9fabca5794..5c9da6b0874 100644 --- a/test/metabase/lib/binning_test.cljc +++ b/test/metabase/lib/binning_test.cljc @@ -31,7 +31,7 @@ (is (= {:bin-width 12.5, :min-value 15, :max-value 27.5} (lib.binning/resolve-bin-width query col-quantity 15))))) -(deftest ^:parallel binning-and-bucketing-only-show-up-for-returned-and-breakoutable-columns +(deftest ^:parallel binning-and-bucketing-only-show-up-for-returned-and-breakout-columns (testing "Within the stage, binning and bucketing at breakout should be invisible, outside the stage it should be visible" (let [query (-> (lib/query meta/metadata-provider (meta/table-metadata :orders)) (lib/aggregate (lib/count))) @@ -45,8 +45,11 @@ (complement :metabase.lib.field/binning) (m/find-first (comp #{"TOTAL"} :name) (lib/visible-columns query)))) (is (=? - {:metabase.lib.field/binning {:strategy :default}} + (complement :metabase.lib.field/binning) (m/find-first (comp #{"TOTAL"} :name) (lib/breakoutable-columns query)))) + (is (=? + {:metabase.lib.field/binning {:strategy :default}} + (lib/breakout-column query -1 (first (lib/breakouts query))))) (is (=? {:metabase.lib.field/binning {:strategy :default}} (m/find-first (comp #{"TOTAL"} :name) (lib/returned-columns query)))) @@ -54,8 +57,11 @@ (complement :metabase.lib.field/temporal-unit) (m/find-first (comp #{"CREATED_AT"} :name) (lib/visible-columns query)))) (is (=? - {:metabase.lib.field/temporal-unit :minute} + (complement :metabase.lib.field/temporal-unit) (m/find-first (comp #{"CREATED_AT"} :name) (lib/breakoutable-columns query)))) + (is (=? + {:metabase.lib.field/temporal-unit :minute} + (lib/breakout-column query -1 (second (lib/breakouts query))))) (is (=? {:metabase.lib.field/temporal-unit :minute} (m/find-first (comp #{"CREATED_AT"} :name) (lib/returned-columns query))))))) diff --git a/test/metabase/lib/breakout_test.cljc b/test/metabase/lib/breakout_test.cljc index 2b0fbc9d305..52138838844 100644 --- a/test/metabase/lib/breakout_test.cljc +++ b/test/metabase/lib/breakout_test.cljc @@ -6,6 +6,7 @@ [metabase.lib.breakout :as lib.breakout] [metabase.lib.card :as lib.card] [metabase.lib.core :as lib] + [metabase.lib.query :as lib.query] [metabase.lib.test-metadata :as meta] [metabase.lib.test-util :as lib.tu] [metabase.lib.test-util.mocks-31368 :as lib.tu.mocks-31368] @@ -169,13 +170,74 @@ (is (=? [{:display-name "ID"} {:display-name "Name"} {:display-name "Category ID"} - {:display-name "Latitude", :breakout-position 1} + {:display-name "Latitude", :breakout-positions [1]} {:display-name "Longitude"} - {:display-name "Price", :breakout-position 0} + {:display-name "Price", :breakout-positions [0]} {:display-name "ID"} {:display-name "Name"}] (lib/breakoutable-columns query))))))) +(deftest ^:parallel multiple-breakouts-for-the-same-column-test + (testing "multiple breakout positions for the same column" + (let [base-query lib.tu/venues-query + column (meta/field-metadata :venues :price) + binning-strategies (lib/available-binning-strategies base-query column) + query (-> base-query + (lib/breakout (lib/with-binning column (first binning-strategies))) + (lib/breakout (lib/with-binning column (second binning-strategies))))] + (is (=? [{:display-name "ID"} + {:display-name "Name"} + {:display-name "Category ID"} + {:display-name "Latitude"} + {:display-name "Longitude"} + {:display-name "Price", :breakout-positions [0 1]} + {:display-name "ID"} + {:display-name "Name"}] + (lib/breakoutable-columns query))))) + (testing "should ignore duplicate breakouts without binning strategy or temporal bucket" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (first (lib/breakoutable-columns base-query)) + query (-> base-query + (lib/breakout column) + (lib/breakout column)) + breakouts (lib/breakouts query)] + (is (= 1 (count breakouts))))) + (testing "should ignore duplicate breakouts with the same binning strategy" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (meta/field-metadata :people :latitude) + binning-strategy (first (lib/available-binning-strategies base-query column)) + query (-> base-query + (lib/breakout (lib/with-binning column binning-strategy)) + (lib/breakout (lib/with-binning column binning-strategy))) + breakouts (lib/breakouts query)] + (is (= 1 (count breakouts))))) + (testing "should ignore duplicate breakouts with the same temporal bucket" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (meta/field-metadata :people :birth-date) + query (-> base-query + (lib/breakout (lib/with-temporal-bucket column :month)) + (lib/breakout (lib/with-temporal-bucket column :month))) + breakouts (lib/breakouts query)] + (is (= 1 (count breakouts))))) + (testing "should ignore duplicate breakouts with the same temporal bucket when converting from legacy MBQL" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (meta/field-metadata :people :birth-date) + query (lib/breakout (->> (lib/breakout base-query (lib/with-temporal-bucket column :year)) + (lib.query/->legacy-MBQL) + (lib/query meta/metadata-provider)) + (lib/with-temporal-bucket column :year)) + breakouts (lib/breakouts query)] + (is (= 1 (count breakouts))))) + (testing "should allow multiple breakouts with different temporal buckets when converting from legacy MBQL" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (meta/field-metadata :people :birth-date) + query (lib/breakout (->> (lib/breakout base-query (lib/with-temporal-bucket column :year)) + (lib.query/->legacy-MBQL) + (lib/query meta/metadata-provider)) + (lib/with-temporal-bucket column :month)) + breakouts (lib/breakouts query)] + (is (= 2 (count breakouts)))))) + (deftest ^:parallel breakoutable-explicit-joins-test (testing "breakoutable-columns should include columns from explicit joins" (let [query (-> lib.tu/venues-query @@ -335,11 +397,11 @@ {:display-name "Category ID", :lib/source :source/table-defaults} {:display-name "Latitude", :lib/source :source/table-defaults} {:display-name "Longitude", :lib/source :source/table-defaults} - {:display-name "Price" :lib/source :source/table-defaults, :breakout-position 1} + {:display-name "Price" :lib/source :source/table-defaults, :breakout-positions [1]} {:display-name "ID", :lib/source :source/implicitly-joinable} - {:display-name "Name", :lib/source :source/implicitly-joinable, :breakout-position 0}] + {:display-name "Name", :lib/source :source/implicitly-joinable, :breakout-positions [0]}] breakoutables')) - (is (= 2 (count (filter :breakout-position breakoutables')))) + (is (= 2 (count (filter :breakout-positions breakoutables')))) (is (=? [{:table {:name "VENUES", :display-name "Venues", :is-source-table true} :semantic-type :type/PK :name "ID" @@ -394,7 +456,7 @@ :is-from-previous-stage false :is-calculated false :is-implicitly-joinable false - :breakout-position 1} + :breakout-positions [1]} {:table {:name "CATEGORIES", :display-name "Categories", :is-source-table false} :semantic-type :type/PK :name "ID" @@ -413,7 +475,7 @@ :is-from-previous-stage false :is-calculated false :is-implicitly-joinable true - :breakout-position 0}] + :breakout-positions [0]}] (map #(lib/display-info query' %) breakoutables')))))) (deftest ^:parallel breakoutable-columns-with-source-card-e2e-test @@ -530,8 +592,8 @@ (lib/breakout field-ref))] (is (= [field-ref] (lib/breakouts query))) - (is (=? {:name "NAME" - :breakout-position 0} + (is (=? {:name "NAME" + :breakout-positions [0]} (m/find-first #(= (:id %) (meta/id :categories :name)) (lib/breakoutable-columns query))))))))) @@ -558,8 +620,8 @@ (deftest ^:parallel legacy-query-with-broken-breakout-breakoutable-columns-test (testing "Handle busted references to joined Fields in broken breakouts from broken drill-thrus (#31482)" - (is (=? {:display-name "Products → Category" - :breakout-position 0} + (is (=? {:display-name "Products → Category" + :breakout-positions [0]} (m/find-first #(= (:id %) (meta/id :products :category)) (lib/breakoutable-columns (legacy-query-with-broken-breakout))))))) @@ -605,15 +667,35 @@ (lib.breakout/remove-existing-breakouts-for-column query' (meta/field-metadata :people :latitude))))))) (deftest ^:parallel breakout-column-test - (let [query (-> lib.tu/venues-query - (lib/breakout (meta/field-metadata :venues :category-id)) - (lib/breakout (meta/field-metadata :venues :price)) - (lib/aggregate (lib/count))) - category (m/find-first #(= (:name %) "CATEGORY_ID") (lib/visible-columns query)) - price (m/find-first #(= (:name %) "PRICE") (lib/visible-columns query)) - breakouts (lib/breakouts query)] - (is (= (count breakouts) 2)) - (is (=? category - (lib.breakout/breakout-column query -1 (first breakouts)))) - (is (=? price - (lib.breakout/breakout-column query -1 (second breakouts)))))) + (testing "should find the correct column" + (let [query (-> lib.tu/venues-query + (lib/breakout (meta/field-metadata :venues :category-id)) + (lib/breakout (meta/field-metadata :venues :price)) + (lib/aggregate (lib/count))) + category (meta/field-metadata :venues :category-id) + price (meta/field-metadata :venues :price) + breakouts (lib/breakouts query)] + (is (= (count breakouts) 2)) + (is (=? category + (lib.breakout/breakout-column query (first breakouts)))) + (is (=? price + (lib.breakout/breakout-column query (second breakouts)))))) + (testing "should set the binning strategy from the breakout clause" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (meta/field-metadata :people :latitude) + binning-strategy (first (lib/available-binning-strategies base-query column)) + query (->> (lib/with-binning column binning-strategy) + (lib/breakout base-query)) + breakout (first (lib/breakouts query))] + (is (=? {:strategy :default} + (->> (lib/breakout-column query breakout) + (lib/binning)))))) + (testing "should set the temporal unit from the breakout clause" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (meta/field-metadata :people :birth-date) + query (->> (lib/with-temporal-bucket column :month) + (lib/breakout base-query)) + breakout (first (lib/breakouts query))] + (is (=? {:unit :month} + (->> (lib/breakout-column query breakout) + (lib/temporal-bucket))))))) diff --git a/test/metabase/lib/equality_test.cljc b/test/metabase/lib/equality_test.cljc index c5c94d1af53..4bc051bf47f 100644 --- a/test/metabase/lib/equality_test.cljc +++ b/test/metabase/lib/equality_test.cljc @@ -291,7 +291,7 @@ (let [query (-> lib.tu/venues-query (lib/breakout (meta/field-metadata :venues :id))) filterable-cols (lib/filterable-columns query) - matched-from-col (lib.equality/find-matching-column query -1 (m/find-first :breakout-position (lib/breakoutable-columns query)) filterable-cols) + matched-from-col (lib.equality/find-matching-column query -1 (m/find-first :breakout-positions (lib/breakoutable-columns query)) filterable-cols) matched-from-ref (lib.equality/find-matching-column query -1 (first (lib/breakouts query)) filterable-cols)] (is (=? {:id (meta/id :venues :id)} diff --git a/test/metabase/lib/remove_replace_test.cljc b/test/metabase/lib/remove_replace_test.cljc index 496244637bc..9dd7ea2668a 100644 --- a/test/metabase/lib/remove_replace_test.cljc +++ b/test/metabase/lib/remove_replace_test.cljc @@ -7,6 +7,7 @@ [metabase.lib.core :as lib] [metabase.lib.metadata :as lib.metadata] [metabase.lib.options :as lib.options] + [metabase.lib.query :as lib.query] [metabase.lib.remove-replace :as lib.remove-replace] [metabase.lib.test-metadata :as meta] [metabase.lib.test-util :as lib.tu] @@ -386,7 +387,26 @@ (lib/append-stage) (lib/filter (lib/= [:field {:lib/uuid (str (random-uuid)) :base-type :type/Integer} "ID"] 1)) (lib/replace-clause 0 (second breakouts) (meta/field-metadata :venues :price)) - (lib/breakouts 0))))))) + (lib/breakouts 0))))) + (testing "should ignore duplicate breakouts" + (let [id-column (meta/field-metadata :venues :id) + price-column (meta/field-metadata :venues :price) + query (-> lib.tu/venues-query + (lib/breakout id-column) + (lib/breakout price-column)) + breakouts (lib/breakouts query)] + (is (= query (lib/replace-clause query (first breakouts) price-column))))) + (testing "should ignore duplicate breakouts with the same temporal bucket when converting from legacy MBQL" + (let [base-query (lib/query meta/metadata-provider (meta/table-metadata :people)) + column (meta/field-metadata :people :birth-date) + query (-> base-query + (lib/breakout (lib/with-temporal-bucket column :year)) + (lib/breakout (lib/with-temporal-bucket column :month))) + query (->> query + (lib.query/->legacy-MBQL) + (lib/query meta/metadata-provider))] + (is (= query (lib/replace-clause query (first breakouts) + (lib/with-temporal-bucket column :month)))))))) (deftest ^:parallel replace-clause-fields-test (let [query (-> lib.tu/venues-query -- GitLab