From 3305c815b0bbb04706b53a135e299826f010a348 Mon Sep 17 00:00:00 2001 From: Kamil Mielnik <kamil@kamilmielnik.com> Date: Tue, 4 Jun 2024 15:15:47 +0700 Subject: [PATCH] Previous period comparison shortcut in notebook mode (#43220) * Sort functions * Add boilerplate for aggregateOffset and tests * Simplify assertion * Make basic case work * Handle name generation * Do not require "lib/uuid" attribute to be present since MLv2 will normalize it under the hood anyway * Fix typing * Group tests * Refactor offsetClause to return a new clause instead of a new query - Move offsetClause to expressions.ts - Add assertion * Revert "Sort functions" This reverts commit ab9ce2b24ea6bdad5ff7e9bed8ef38d4b5923f00. * Move tests * Handle names dynamically * Shorten a test * Update comment * Add TODO * Update expression types * Add diffOffsetClause & percentDiffOffsetClause * Add a test for diffOffsetClause * Add TODOs * Add tests for diffOffsetClause and percentDiffOffsetClause * Unwrap tests * Add skeleton for new tests * Refactor first offsetClause tests * Add tests for names * Fix case of non-datetime columns * Handle a case with offset < 1 and no breakouts * Handle a case with offset < 1 and breakouts on non-datetime column * Handle a case with offset < 1 and breakouts binned datetime column * Handle a case with offset < 1 and breakout on non-binned datetime column * Refactor * Refactor * Remove TODO * Add tests for diffOffsetClause * Add tests for percentDiffOffsetClause * Move offset stuff to offset.ts and offset.unit.spec.ts * Use template string for prefixes * Use breakoutColumn + isDate to check column type * Refactor * Fix error message * Add boilerplate for CompareAggregations * Fix title * Render aggregations list * Style AccordionList * Sort props * Fix bucket name * Use displayName instead shortName * Support parseValue prop in NumberInput * Add period input accepting integers only * Accept non-negative values only * Do not accept zeros * Add state * Add submit button * Export offset functions via Lib * Make it possible to skip rightSection * Add column picker * Map offset user input to api input (negative value) * Add label * Fix crash * Extract shouldCreate * Make onSelect in AggregationPicker support multiple values * Extract ReferenceAggregationPicker * Extract ColumnPicker * Extract getAggregations * Rename * Add custom items * Refactor item component * Extract OffsetInput * Remove unused data-testid * Style OffsetInput * Generate titles according to specs * Generate label * Generate help * Extract utils * Use different width for the 1st step * Format code * Use MultiSelect directly * Avoid custom parseValue * Revert MultiaAutocomplete changes * Improve typing in describeTemporalInterval and describeRelativeDatetime * Use describeTemporalUnit to pluralize * Use interface * Avoid setting value as DOM attribute * Fix test * Add onAdd prop to AggregationPicker and revert the change to have onSelect pass multiple aggregations * Reduce number of props * Render checkboxes in custom items * Introduce and use --mb-color-brand-lighter * Avoid !important * Remove redundant prop * Fix warning about isSelected being used as DOM attribute * Fix positioning in case dir attribute is not present in any parent component * Add type attribute to all ListItems --- .../components/private/SdkContentWrapper.tsx | 4 +- frontend/src/metabase-lib/index.ts | 1 + frontend/src/metabase-lib/offset.ts | 10 +- frontend/src/metabase-lib/temporal_bucket.ts | 4 +- .../AggregationPicker/AggregationPicker.tsx | 99 +++++++++++++-- .../AggregationPicker.unit.spec.tsx | 2 + .../CompareAggregations.tsx | 114 ++++++++++++++++++ .../ColumnPicker/ColumnPicker.module.css | 3 + .../components/ColumnPicker/ColumnPicker.tsx | 95 +++++++++++++++ .../components/ColumnPicker/index.ts | 1 + .../OffsetInput/OffsetInput.module.css | 12 ++ .../components/OffsetInput/OffsetInput.tsx | 52 ++++++++ .../components/OffsetInput/index.ts | 1 + .../components/OffsetInput/utils.ts | 58 +++++++++ .../ReferenceAggregationPicker.module.css | 3 + .../ReferenceAggregationPicker.tsx | 61 ++++++++++ .../ReferenceAggregationPicker/index.ts | 1 + .../CompareAggregations/components/index.ts | 3 + .../components/CompareAggregations/index.ts | 2 + .../components/CompareAggregations/types.ts | 1 + .../components/CompareAggregations/utils.ts | 95 +++++++++++++++ .../steps/AggregateStep/AggregateStep.tsx | 16 ++- .../AddAggregationButton.tsx | 10 +- .../AggregationItem/AggregationItem.tsx | 3 + .../SummarizeSidebar/SummarizeSidebar.tsx | 12 +- .../containers/GlobalStyles/GlobalStyles.tsx | 3 +- 26 files changed, 631 insertions(+), 35 deletions(-) create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/index.ts create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/types.ts create mode 100644 frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts diff --git a/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx b/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx index 9e1c9b7e206..809c574a869 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/SdkContentWrapper.tsx @@ -4,7 +4,7 @@ import type { HTMLAttributes } from "react"; import { getRootStyle } from "metabase/css/core/base.styled"; import { defaultFontFiles } from "metabase/css/core/fonts.styled"; -import { alpha } from "metabase/lib/colors"; +import { alpha, lighten } from "metabase/lib/colors"; import { useSelector } from "metabase/lib/redux"; import { aceEditorStyles } from "metabase/query_builder/components/NativeQueryEditor/NativeQueryEditor.styled"; import { getFontFiles } from "metabase/styled-components/selectors"; @@ -37,6 +37,8 @@ const SdkContentWrapperInner = styled.div< --mb-color-bg-light: ${({ theme }) => theme.fn.themeColor("bg-light")}; --mb-color-bg-dark: ${({ theme }) => theme.fn.themeColor("bg-dark")}; --mb-color-brand: ${({ theme }) => theme.fn.themeColor("brand")}; + --mb-color-brand-lighter: ${({ theme }) => + lighten(theme.fn.themeColor("brand"), 0.598)}; --mb-color-brand-alpha-04: ${({ theme }) => alpha(theme.fn.themeColor("brand"), 0.04)}; --mb-color-brand-alpha-88: ${({ theme }) => diff --git a/frontend/src/metabase-lib/index.ts b/frontend/src/metabase-lib/index.ts index 1cc3d436783..49abee69e7a 100644 --- a/frontend/src/metabase-lib/index.ts +++ b/frontend/src/metabase-lib/index.ts @@ -17,6 +17,7 @@ export * from "./limit"; export * from "./metadata"; export * from "./metrics"; export * from "./native"; +export * from "./offset"; export * from "./order_by"; export * from "./query"; export * from "./segments"; diff --git a/frontend/src/metabase-lib/offset.ts b/frontend/src/metabase-lib/offset.ts index cfceaa288b2..860905ff9cf 100644 --- a/frontend/src/metabase-lib/offset.ts +++ b/frontend/src/metabase-lib/offset.ts @@ -1,11 +1,9 @@ import { t } from "ttag"; -import { inflect } from "metabase/lib/formatting"; - import { breakoutColumn, breakouts } from "./breakout"; import { isDate } from "./column_types"; import { expressionClause, withExpressionName } from "./expression"; -import { displayInfo } from "./metadata"; +import { describeTemporalUnit, displayInfo } from "./metadata"; import { temporalBucket } from "./temporal_bucket"; import type { AggregationClause, ExpressionClause, Query } from "./types"; @@ -103,8 +101,10 @@ function getOffsetClauseName( } const bucketInfo = displayInfo(query, stageIndex, bucket); - const bucketName = bucketInfo.displayName.toLowerCase(); - const period = inflect(bucketName, absoluteOffset); + const period = describeTemporalUnit( + bucketInfo.shortName, + absoluteOffset, + ).toLowerCase(); return absoluteOffset === 1 ? t`${displayName} (${prefix}previous ${period})` diff --git a/frontend/src/metabase-lib/temporal_bucket.ts b/frontend/src/metabase-lib/temporal_bucket.ts index 2728758ccd9..8c54a0bb3e1 100644 --- a/frontend/src/metabase-lib/temporal_bucket.ts +++ b/frontend/src/metabase-lib/temporal_bucket.ts @@ -52,14 +52,14 @@ type IntervalAmount = number | "current" | "next" | "last"; export function describeTemporalInterval( n: IntervalAmount, - unit?: string, + unit?: BucketName, ): string { return ML.describe_temporal_interval(n, unit); } export function describeRelativeDatetime( n: IntervalAmount, - unit?: string, + unit?: BucketName, ): string { return ML.describe_relative_datetime(n, unit); } diff --git a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx index 57658185c44..3181318228a 100644 --- a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx +++ b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.tsx @@ -4,6 +4,10 @@ import { t } from "ttag"; import AccordionList from "metabase/core/components/AccordionList"; import { useToggle } from "metabase/hooks/use-toggle"; import { useSelector } from "metabase/lib/redux"; +import { + CompareAggregations, + getOffsetPeriod, +} from "metabase/query_builder/components/CompareAggregations"; import { ExpressionWidget } from "metabase/query_builder/components/expressions/ExpressionWidget"; import { ExpressionWidgetHeader } from "metabase/query_builder/components/expressions/ExpressionWidgetHeader"; import { getMetadata } from "metabase/selectors/metadata"; @@ -27,20 +31,29 @@ interface AggregationPickerProps { clauseIndex?: number; operators: Lib.AggregationOperator[]; hasExpressionInput?: boolean; - onSelect: (operator: Lib.Aggregable) => void; + onAdd: (aggregations: Lib.Aggregable[]) => void; + onSelect: (aggregation: Lib.Aggregable) => void; onClose?: () => void; } type OperatorListItem = Lib.AggregationOperatorDisplayInfo & { + type: "operator"; operator: Lib.AggregationOperator; }; type MetricListItem = Lib.MetricDisplayInfo & { + type: "metric"; metric: Lib.MetricMetadata; selected: boolean; }; -type ListItem = OperatorListItem | MetricListItem; +type CompareListItem = { + type: "compare"; + displayName: string; + selected?: boolean; +}; + +type ListItem = OperatorListItem | MetricListItem | CompareListItem; type Section = { name?: string; @@ -50,10 +63,6 @@ type Section = { type?: string; }; -function isOperatorListItem(item: ListItem): item is OperatorListItem { - return "operator" in item; -} - export function AggregationPicker({ className, query, @@ -62,6 +71,7 @@ export function AggregationPicker({ clauseIndex, operators, hasExpressionInput = true, + onAdd, onSelect, onClose, }: AggregationPickerProps) { @@ -76,6 +86,7 @@ export function AggregationPicker({ ] = useToggle( isExpressionEditorInitiallyOpen(query, stageIndex, clause, operators), ); + const [isComparing, setIsComparing] = useState(false); // For really simple inline expressions like Average([Price]), // MLv2 can figure out that "Average" operator is used. @@ -97,14 +108,17 @@ export function AggregationPicker({ const database = metadata.database(databaseId); const canUseExpressions = database?.hasFeature("expression-aggregations"); const isMetricBased = Lib.isMetricBased(query, stageIndex); + const compareItem = getCompareListItem(query, stageIndex); + + if ((compareItem || operators.length > 0) && !isMetricBased) { + const operatorItems = operators.map(operator => + getOperatorListItem(query, stageIndex, operator), + ); - if (operators.length > 0 && !isMetricBased) { sections.push({ key: "operators", name: t`Basic Metrics`, - items: operators.map(operator => - getOperatorListItem(query, stageIndex, operator), - ), + items: compareItem ? [compareItem, ...operatorItems] : operatorItems, icon: "table2", }); } @@ -175,15 +189,25 @@ export function AggregationPicker({ [onSelect, onClose], ); + const handleCompareSelect = useCallback(() => { + setIsComparing(true); + }, []); + + const handleCompareClose = useCallback(() => { + setIsComparing(false); + }, []); + const handleChange = useCallback( (item: ListItem) => { - if (isOperatorListItem(item)) { + if (item.type === "operator") { handleOperatorSelect(item); - } else { + } else if (item.type === "metric") { handleMetricSelect(item); + } else if (item.type === "compare") { + handleCompareSelect(); } }, - [handleOperatorSelect, handleMetricSelect], + [handleOperatorSelect, handleMetricSelect, handleCompareSelect], ); const handleSectionChange = useCallback( @@ -204,6 +228,25 @@ export function AggregationPicker({ [onSelect, onClose], ); + const handleCompareSubmit = useCallback( + (aggregations: Lib.Aggregable[]) => { + onAdd(aggregations); + onClose?.(); + }, + [onAdd, onClose], + ); + + if (isComparing) { + return ( + <CompareAggregations + query={query} + stageIndex={stageIndex} + onSubmit={handleCompareSubmit} + onClose={handleCompareClose} + /> + ); + } + if (isEditingExpression) { return ( <ExpressionWidget @@ -330,6 +373,7 @@ function getOperatorListItem( const operatorInfo = Lib.displayInfo(query, stageIndex, operator); return { ...operatorInfo, + type: "operator", operator, }; } @@ -343,12 +387,41 @@ function getMetricListItem( const metricInfo = Lib.displayInfo(query, stageIndex, metric); return { ...metricInfo, + type: "metric", metric, selected: clauseIndex != null && metricInfo.aggregationPosition === clauseIndex, }; } +function getCompareListItem( + query: Lib.Query, + stageIndex: number, +): CompareListItem | undefined { + const aggregations = Lib.aggregations(query, stageIndex); + + if (aggregations.length === 0) { + return undefined; + } + + const period = getOffsetPeriod(query, stageIndex); + + if (aggregations.length > 1) { + return { + type: "compare", + displayName: t`Compare to previous ${period} ...`, + }; + } + + const [aggregation] = aggregations; + const info = Lib.displayInfo(query, stageIndex, aggregation); + + return { + type: "compare", + displayName: t`Compare “${info.displayName}†to previous ${period} ...`, + }; +} + function checkIsColumnSelected(columnInfo: Lib.ColumnDisplayInfo) { return !!columnInfo.selected; } diff --git a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx index a132ccbed71..8c8398fbdd5 100644 --- a/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx +++ b/frontend/src/metabase/common/components/AggregationPicker/AggregationPicker.unit.spec.tsx @@ -131,6 +131,7 @@ function setup({ : baseOperators; const onSelect = jest.fn(); + const onAdd = jest.fn(); renderWithProviders( <AggregationPicker @@ -139,6 +140,7 @@ function setup({ stageIndex={stageIndex} operators={operators} hasExpressionInput={hasExpressionInput} + onAdd={onAdd} onSelect={onSelect} />, { storeInitialState: state }, diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx new file mode 100644 index 00000000000..bf5fcb099d2 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/CompareAggregations.tsx @@ -0,0 +1,114 @@ +import type { FormEvent } from "react"; +import { useMemo, useState } from "react"; +import { t } from "ttag"; + +import { Box, Button, Flex, Stack } from "metabase/ui"; +import * as Lib from "metabase-lib"; + +import { ExpressionWidgetHeader } from "../expressions/ExpressionWidgetHeader"; + +import { + ColumnPicker, + OffsetInput, + ReferenceAggregationPicker, +} from "./components"; +import type { ColumnType } from "./types"; +import { canSubmit, getAggregations, getTitle } from "./utils"; + +interface Props { + query: Lib.Query; + stageIndex: number; + onClose: () => void; + onSubmit: (aggregations: Lib.ExpressionClause[]) => void; +} + +const DEFAULT_OFFSET = 1; +const DEFAULT_COLUMNS: ColumnType[] = ["offset", "percent-diff-offset"]; +const STEP_1_WIDTH = 378; +const STEP_2_WIDTH = 472; + +export const CompareAggregations = ({ + query, + stageIndex, + onClose, + onSubmit, +}: Props) => { + const aggregations = useMemo(() => { + return Lib.aggregations(query, stageIndex); + }, [query, stageIndex]); + const hasManyAggregations = aggregations.length > 1; + const [aggregation, setAggregation] = useState< + Lib.AggregationClause | Lib.ExpressionClause | undefined + >(hasManyAggregations ? undefined : aggregations[0]); + const [offset, setOffset] = useState<number | "">(DEFAULT_OFFSET); + const [columns, setColumns] = useState<ColumnType[]>(DEFAULT_COLUMNS); + const width = aggregation ? STEP_2_WIDTH : STEP_1_WIDTH; + + const title = useMemo( + () => getTitle(query, stageIndex, aggregation), + [query, stageIndex, aggregation], + ); + + const handleBack = () => { + if (hasManyAggregations && aggregation) { + setAggregation(undefined); + } else { + onClose(); + } + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (aggregation && offset !== "") { + const aggregations = getAggregations( + query, + stageIndex, + aggregation, + columns, + offset, + ); + onSubmit(aggregations); + onClose(); + } + }; + + return ( + <Box miw={width} maw={width}> + <ExpressionWidgetHeader title={title} onBack={handleBack} /> + + {!aggregation && ( + <ReferenceAggregationPicker + query={query} + stageIndex={stageIndex} + onChange={setAggregation} + /> + )} + + {aggregation && ( + <form onSubmit={handleSubmit}> + <Stack p="lg" spacing="xl"> + <Stack spacing="md"> + <OffsetInput + query={query} + stageIndex={stageIndex} + value={offset} + onChange={setOffset} + /> + + <ColumnPicker value={columns} onChange={setColumns} /> + </Stack> + + <Flex justify="flex-end"> + <Button + disabled={!canSubmit(offset, columns)} + type="submit" + variant="filled" + >{t`Done`}</Button> + </Flex> + </Stack> + </form> + )} + </Box> + ); +}; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css new file mode 100644 index 00000000000..70e0cab0b40 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.module.css @@ -0,0 +1,3 @@ +.itemContent { + flex: 1; +} diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx new file mode 100644 index 00000000000..0c680c7fc0d --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/ColumnPicker.tsx @@ -0,0 +1,95 @@ +import type { ComponentPropsWithoutRef } from "react"; +import { forwardRef, useCallback } from "react"; +import { t } from "ttag"; + +import { Checkbox, Flex, MultiSelect, Text } from "metabase/ui"; + +import type { ColumnType } from "../../types"; + +import S from "./ColumnPicker.module.css"; + +interface ItemType { + example: string; + label: string; + value: ColumnType; +} + +interface Props { + value: ColumnType[]; + onChange: (value: ColumnType[]) => void; +} + +const COLUMN_OPTIONS: ItemType[] = [ + { + example: "1826, 3004", + label: t`Previous value`, + value: "offset", + }, + { + example: "+2.3%, -0.1%", + label: t`Percentage difference`, + value: "percent-diff-offset", + }, + { + example: "+42, -3", + label: t`Value difference`, + value: "diff-offset", + }, +]; + +export const ColumnPicker = ({ value, onChange }: Props) => { + const handleChange = useCallback( + (values: string[]) => { + onChange(values as ColumnType[]); + }, + [onChange], + ); + + return ( + <MultiSelect + data={COLUMN_OPTIONS} + disableSelectedItemFiltering + itemComponent={Item} + label={t`Columns to create`} + placeholder={t`Columns to create`} + styles={{ + item: { + "&[data-selected]": { + backgroundColor: "transparent", + }, + "&[data-selected]:hover": { + backgroundColor: "var(--mb-color-brand-lighter)", + }, + "&[data-selected][data-hovered]": { + backgroundColor: "var(--mb-color-brand-lighter)", + }, + "&[data-hovered]": { + backgroundColor: "var(--mb-color-brand-lighter)", + }, + }, + }} + value={value} + onChange={handleChange} + /> + ); +}; + +const Item = forwardRef< + HTMLDivElement, + ItemType & ComponentPropsWithoutRef<"div"> & { selected: boolean } +>(function Item({ example, label, selected, value, ...props }, ref) { + return ( + <div ref={ref} {...props}> + <Flex align="center" gap="sm"> + <Checkbox checked={selected} readOnly /> + + <Flex align="center" className={S.itemContent} justify="space-between"> + <Text>{label}</Text> + <Text c="text-light" size="sm"> + {example} + </Text> + </Flex> + </Flex> + </div> + ); +}); diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts new file mode 100644 index 00000000000..9e32cfe25f8 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ColumnPicker/index.ts @@ -0,0 +1 @@ +export * from "./ColumnPicker"; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css new file mode 100644 index 00000000000..f3dbdd8e908 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.module.css @@ -0,0 +1,12 @@ +.input { + max-width: 50px; +} + +.wrapper { + margin-top: 0.25rem; +} + +.help { + position: absolute; + left: 50px; +} diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx new file mode 100644 index 00000000000..24b90fec8d2 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/OffsetInput.tsx @@ -0,0 +1,52 @@ +import { useCallback, useMemo } from "react"; + +import { Flex, NumberInput, Text } from "metabase/ui"; +import type * as Lib from "metabase-lib"; + +import S from "./OffsetInput.module.css"; +import { getHelp, getLabel } from "./utils"; + +interface Props { + query: Lib.Query; + stageIndex: number; + value: number | ""; + onChange: (value: number | "") => void; +} + +export const OffsetInput = ({ query, stageIndex, value, onChange }: Props) => { + const label = useMemo(() => getLabel(query, stageIndex), [query, stageIndex]); + const help = useMemo(() => getHelp(query, stageIndex), [query, stageIndex]); + + const handleChange = useCallback( + (value: number | "") => { + if (typeof value === "number") { + onChange(Math.floor(Math.max(Math.abs(value), 1))); + } else { + onChange(value); + } + }, + [onChange], + ); + + return ( + <Flex align="flex-end" pos="relative"> + <NumberInput + classNames={{ + input: S.input, + wrapper: S.wrapper, + }} + label={label} + min={1} + precision={0} + size="md" + step={1} + type="number" + value={value} + onChange={handleChange} + /> + <Text className={S.help} c="text-light" p="sm"> + {help} + </Text> + </Flex> + ); +}; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts new file mode 100644 index 00000000000..336cbf1a199 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/index.ts @@ -0,0 +1 @@ +export * from "./OffsetInput"; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts new file mode 100644 index 00000000000..7e8e7fbba3c --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/OffsetInput/utils.ts @@ -0,0 +1,58 @@ +import { t } from "ttag"; + +import * as Lib from "metabase-lib"; + +export const getLabel = (query: Lib.Query, stageIndex: number): string => { + const firstBreakout = Lib.breakouts(query, stageIndex)[0]; + + if (firstBreakout) { + const firstBreakoutColumn = Lib.breakoutColumn( + query, + stageIndex, + firstBreakout, + ); + + if (!Lib.isDate(firstBreakoutColumn)) { + return t`Row for comparison`; + } + } + + return t`Previous period`; +}; + +export const getHelp = (query: Lib.Query, stageIndex: number): string => { + const firstBreakout = Lib.breakouts(query, stageIndex)[0]; + + if (!firstBreakout) { + return t`periods ago based on grouping`; + } + + const firstBreakoutColumn = Lib.breakoutColumn( + query, + stageIndex, + firstBreakout, + ); + const firstBreakoutColumnInfo = Lib.displayInfo( + query, + stageIndex, + firstBreakoutColumn, + ); + + if (!Lib.isDate(firstBreakoutColumn)) { + return t`rows above based on “${firstBreakoutColumnInfo.displayName}â€`; + } + + const bucket = Lib.temporalBucket(firstBreakout); + + if (!bucket) { + return t`periods ago based on “${firstBreakoutColumnInfo.displayName}â€`; + } + + const bucketInfo = Lib.displayInfo(query, stageIndex, bucket); + const periodPlural = Lib.describeTemporalUnit( + bucketInfo.shortName, + 2, + ).toLowerCase(); + + return t`${periodPlural} ago based on “${firstBreakoutColumnInfo.displayName}â€`; +}; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css new file mode 100644 index 00000000000..e30c401a0ea --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.module.css @@ -0,0 +1,3 @@ +.accordionList { + color: var(--mb-color-summarize); +} diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx new file mode 100644 index 00000000000..1a27cb4d0a5 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/ReferenceAggregationPicker.tsx @@ -0,0 +1,61 @@ +import { useCallback, useMemo } from "react"; + +import AccordionList from "metabase/core/components/AccordionList"; +import * as Lib from "metabase-lib"; + +import S from "./ReferenceAggregationPicker.module.css"; + +type AggregationItem = Lib.AggregationClauseDisplayInfo & { + aggregation: Lib.AggregationClause; +}; + +interface Props { + query: Lib.Query; + stageIndex: number; + onChange: (aggregation: Lib.AggregationClause | Lib.ExpressionClause) => void; +} + +export const ReferenceAggregationPicker = ({ + query, + stageIndex, + onChange, +}: Props) => { + const sections = useMemo( + () => getSections(query, stageIndex), + [query, stageIndex], + ); + + const handleChange = useCallback( + (item: AggregationItem) => { + onChange(item.aggregation); + }, + [onChange], + ); + + return ( + <AccordionList + alwaysExpanded + className={S.accordionList} + maxHeight={Infinity} + renderItemDescription={renderItemDescription} + renderItemName={renderItemName} + sections={sections} + width="100%" + onChange={handleChange} + /> + ); +}; + +const renderItemName = (item: AggregationItem) => item.displayName; + +const renderItemDescription = () => null; + +const getSections = (query: Lib.Query, stageIndex: number) => { + const aggregations = Lib.aggregations(query, stageIndex); + const items = aggregations.map<AggregationItem>(aggregation => { + const info = Lib.displayInfo(query, stageIndex, aggregation); + return { ...info, aggregation }; + }); + const sections = [{ items }]; + return sections; +}; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts new file mode 100644 index 00000000000..01641f63a32 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/ReferenceAggregationPicker/index.ts @@ -0,0 +1 @@ +export * from "./ReferenceAggregationPicker"; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts new file mode 100644 index 00000000000..f9448e4a379 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/components/index.ts @@ -0,0 +1,3 @@ +export * from "./ColumnPicker"; +export * from "./OffsetInput"; +export * from "./ReferenceAggregationPicker"; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/index.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/index.ts new file mode 100644 index 00000000000..618449483b1 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/index.ts @@ -0,0 +1,2 @@ +export * from "./CompareAggregations"; +export * from "./utils"; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/types.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/types.ts new file mode 100644 index 00000000000..2577e73cd5e --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/types.ts @@ -0,0 +1 @@ +export type ColumnType = "offset" | "diff-offset" | "percent-diff-offset"; diff --git a/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts b/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts new file mode 100644 index 00000000000..d9221516265 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/CompareAggregations/utils.ts @@ -0,0 +1,95 @@ +import { t } from "ttag"; + +import * as Lib from "metabase-lib"; + +import type { ColumnType } from "./types"; + +export const getOffsetPeriod = ( + query: Lib.Query, + stageIndex: number, +): string => { + const firstBreakout = Lib.breakouts(query, stageIndex)[0]; + + if (!firstBreakout) { + return t`period`; + } + + const firstBreakoutColumn = Lib.breakoutColumn( + query, + stageIndex, + firstBreakout, + ); + + if (!Lib.isDate(firstBreakoutColumn)) { + return t`rows`; + } + + const bucket = Lib.temporalBucket(firstBreakout); + + if (!bucket) { + return t`period`; + } + + const bucketInfo = Lib.displayInfo(query, stageIndex, bucket); + const periodPlural = Lib.describeTemporalUnit( + bucketInfo.shortName, + 2, + ).toLowerCase(); + + return periodPlural; +}; + +export const getTitle = ( + query: Lib.Query, + stageIndex: number, + aggregation: Lib.AggregationClause | Lib.ExpressionClause | undefined, +): string => { + const period = getOffsetPeriod(query, stageIndex); + + if (!aggregation) { + return t`Compare one of these to the previous ${period}`; + } + + const info = Lib.displayInfo(query, stageIndex, aggregation); + + return t`Compare “${info.displayName}†to previous ${period}`; +}; + +export const getAggregations = ( + query: Lib.Query, + stageIndex: number, + aggregation: Lib.AggregationClause | Lib.ExpressionClause, + columns: ColumnType[], + offset: number, +): Lib.ExpressionClause[] => { + const aggregations: Lib.ExpressionClause[] = []; + + if (columns.includes("offset")) { + aggregations.push( + Lib.offsetClause(query, stageIndex, aggregation, -offset), + ); + } + + if (columns.includes("diff-offset")) { + aggregations.push( + Lib.diffOffsetClause(query, stageIndex, aggregation, -offset), + ); + } + + if (columns.includes("percent-diff-offset")) { + aggregations.push( + Lib.percentDiffOffsetClause(query, stageIndex, aggregation, -offset), + ); + } + + return aggregations; +}; + +export const canSubmit = ( + period: number | "", + columns: ColumnType[], +): boolean => { + const isPeriodValid = typeof period === "number" && period > 0; + const areColumnsValid = columns.length > 0; + return isPeriodValid && areColumnsValid; +}; diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx b/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx index b248cbbfacc..d7267211f7a 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/steps/AggregateStep/AggregateStep.tsx @@ -21,8 +21,11 @@ export function AggregateStep({ return Lib.aggregations(query, stageIndex); }, [query, stageIndex]); - const handleAddAggregation = (aggregation: Lib.Aggregable) => { - const nextQuery = Lib.aggregate(query, stageIndex, aggregation); + const handleAddAggregations = (aggregations: Lib.Aggregable[]) => { + const nextQuery = aggregations.reduce( + (query, aggregation) => Lib.aggregate(query, stageIndex, aggregation), + query, + ); updateQuery(nextQuery); }; @@ -74,7 +77,7 @@ export function AggregateStep({ stageIndex={stageIndex} clause={aggregation} clauseIndex={index} - onAddAggregation={handleAddAggregation} + onAddAggregations={handleAddAggregations} onUpdateAggregation={handleUpdateAggregation} onClose={onClose} /> @@ -94,7 +97,7 @@ interface AggregationPopoverProps { currentClause: Lib.AggregationClause, nextClause: Lib.Aggregable, ) => void; - onAddAggregation: (aggregation: Lib.Aggregable) => void; + onAddAggregations: (aggregations: Lib.Aggregable[]) => void; clauseIndex?: number; @@ -106,7 +109,7 @@ function AggregationPopover({ stageIndex, clause, clauseIndex, - onAddAggregation, + onAddAggregations, onUpdateAggregation, onClose, }: AggregationPopoverProps) { @@ -126,11 +129,12 @@ function AggregationPopover({ clause={clause} clauseIndex={clauseIndex} operators={operators} + onAdd={onAddAggregations} onSelect={aggregation => { if (isUpdate) { onUpdateAggregation(clause, aggregation); } else { - onAddAggregation(aggregation); + onAddAggregations([aggregation]); } }} onClose={onClose} diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx index c2e3a1e9a66..14244f74eed 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AddAggregationButton/AddAggregationButton.tsx @@ -13,12 +13,12 @@ const STAGE_INDEX = -1; interface AddAggregationButtonProps { query: Lib.Query; - onAddAggregation: (aggregation: Lib.Aggregable) => void; + onAddAggregations: (aggregation: Lib.Aggregable[]) => void; } export function AddAggregationButton({ query, - onAddAggregation, + onAddAggregations, }: AddAggregationButtonProps) { const [isOpened, setIsOpened] = useState(false); const hasAggregations = Lib.aggregations(query, STAGE_INDEX).length > 0; @@ -53,8 +53,12 @@ export function AddAggregationButton({ stageIndex={STAGE_INDEX} operators={operators} hasExpressionInput={false} + onAdd={aggregations => { + onAddAggregations(aggregations); + setIsOpened(false); + }} onSelect={aggregation => { - onAddAggregation(aggregation); + onAddAggregations([aggregation]); setIsOpened(false); }} /> diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx index 693b64917cf..787694324d1 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/AggregationItem/AggregationItem.tsx @@ -13,6 +13,7 @@ interface AggregationItemProps { query: Lib.Query; aggregation: Lib.AggregationClause; aggregationIndex: number; + onAdd: (aggregations: Lib.Aggregable[]) => void; onUpdate: (nextAggregation: Lib.Aggregable) => void; onRemove: () => void; } @@ -21,6 +22,7 @@ export function AggregationItem({ query, aggregation, aggregationIndex, + onAdd, onUpdate, onRemove, }: AggregationItemProps) { @@ -52,6 +54,7 @@ export function AggregationItem({ clauseIndex={aggregationIndex} operators={operators} hasExpressionInput={false} + onAdd={onAdd} onSelect={nextAggregation => { onUpdate(nextAggregation); setIsOpened(false); diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx index 3e2478da953..400ac4dba79 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/SummarizeSidebar/SummarizeSidebar.tsx @@ -40,9 +40,12 @@ export function SummarizeSidebar({ const aggregations = Lib.aggregations(query, STAGE_INDEX); const hasAggregations = aggregations.length > 0; - const handleAddAggregation = useCallback( - (aggregation: Lib.Aggregable) => { - const nextQuery = Lib.aggregate(query, STAGE_INDEX, aggregation); + const handleAddAggregations = useCallback( + (aggregations: Lib.Aggregable[]) => { + const nextQuery = aggregations.reduce( + (query, aggregation) => Lib.aggregate(query, STAGE_INDEX, aggregation), + query, + ); onQueryChange(nextQuery); }, [query, onQueryChange], @@ -131,6 +134,7 @@ export function SummarizeSidebar({ query={query} aggregation={aggregation} aggregationIndex={aggregationIndex} + onAdd={handleAddAggregations} onUpdate={nextAggregation => handleUpdateAggregation(aggregation, nextAggregation) } @@ -139,7 +143,7 @@ export function SummarizeSidebar({ ))} <AddAggregationButton query={query} - onAddAggregation={handleAddAggregation} + onAddAggregations={handleAddAggregations} /> </AggregationsContainer> {hasAggregations && ( diff --git a/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx b/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx index 9ee47732393..ea85d58df1a 100644 --- a/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx +++ b/frontend/src/metabase/styled-components/containers/GlobalStyles/GlobalStyles.tsx @@ -2,7 +2,7 @@ import { css, Global, useTheme } from "@emotion/react"; import { baseStyle, getRootStyle } from "metabase/css/core/base.styled"; import { defaultFontFiles } from "metabase/css/core/fonts.styled"; -import { alpha, color } from "metabase/lib/colors"; +import { alpha, color, lighten } from "metabase/lib/colors"; import { getSitePath } from "metabase/lib/dom"; import { useSelector } from "metabase/lib/redux"; import { aceEditorStyles } from "metabase/query_builder/components/NativeQueryEditor/NativeQueryEditor.styled"; @@ -23,6 +23,7 @@ export const GlobalStyles = (): JSX.Element => { --mb-color-brand: ${color("brand")}; --mb-color-brand-alpha-04: ${alpha("brand", 0.04)}; --mb-color-brand-alpha-88: ${alpha("brand", 0.88)}; + --mb-color-brand-lighter: ${lighten("brand", 0.598)}; --mb-color-focus: ${color("focus")}; --mb-color-bg-dark: ${color("bg-dark")}; --mb-color-bg-light: ${color("bg-light")}; -- GitLab