diff --git a/e2e/test/scenarios/visualizations-tabular/column-shortcuts/column-shortcuts.cy.spec.ts b/e2e/test/scenarios/visualizations-tabular/column-shortcuts/column-shortcuts.cy.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c87ac1ea2d2092b22f3d15e46ae36fb223423d3 --- /dev/null +++ b/e2e/test/scenarios/visualizations-tabular/column-shortcuts/column-shortcuts.cy.spec.ts @@ -0,0 +1,233 @@ +import _ from "underscore"; + +import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; +import { + describeWithSnowplow, + enterCustomColumnDetails, + getNotebookStep, + openNotebook, + openOrdersTable, + popover, + restore, + visualize, + createQuestion, +} from "e2e/support/helpers"; + +const { PEOPLE, PEOPLE_ID } = SAMPLE_DATABASE; + +const DATE_CASES = [ + { + option: "Hour of day", + value: "21", + example: "0, 1", + }, + { + option: "Day of month", + value: "11", + example: "1, 2", + }, + { + option: "Day of week", + value: "Tuesday", + example: "Monday, Tuesday", + }, + { + option: "Month of year", + value: "Feb", + example: "Jan, Feb", + }, + { + option: "Quarter of year", + value: "Q1", + example: "Q1, Q2", + }, + { + option: "Year", + value: "2,025", + example: "2023, 2024", + }, +]; + +const EMAIL_CASES = [ + { + option: "Domain", + value: "yahoo", + example: "example, online", + }, + { + option: "Host", + value: "yahoo.com", + example: "example.com, online.com", + }, +]; + +const URL_CASES = [ + { + option: "Domain", + value: "yahoo", + example: "example, online", + }, + { + option: "Subdomain", + value: "", + example: "www, maps", + }, + { + option: "Host", + value: "yahoo.com", + example: "example.com, online.com", + }, +]; + +describeWithSnowplow("extract shortcut", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + describe("date columns", () => { + describe("should add a date expression for each option", () => { + DATE_CASES.forEach(({ option, value, example }) => { + it(option, () => { + openOrdersTable({ limit: 1 }); + extractColumnAndCheck({ + column: "Created At", + option, + value, + example, + }); + }); + }); + }); + + it("should handle duplicate expression names", () => { + openOrdersTable({ limit: 1 }); + extractColumnAndCheck({ + column: "Created At", + option: "Hour of day", + newColumn: "Hour of day", + }); + extractColumnAndCheck({ + column: "Created At", + option: "Hour of day", + newColumn: "Hour of day_2", + }); + }); + + it("should be able to modify the expression in the notebook editor", () => { + openOrdersTable({ limit: 1 }); + extractColumnAndCheck({ + column: "Created At", + option: "Year", + value: "2,025", + }); + openNotebook(); + getNotebookStep("expression").findByText("Year").click(); + enterCustomColumnDetails({ name: "custom formula", formula: "+ 2" }); + popover().button("Update").click(); + visualize(); + cy.findByRole("gridcell", { name: "2,027" }).should("be.visible"); + }); + }); + + describe("email columns", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + EMAIL_CASES.forEach(({ option, value, example }) => { + it(option, () => { + createQuestion( + { + query: { + "source-table": PEOPLE_ID, + limit: 1, + fields: [["field", PEOPLE.EMAIL, null]], + }, + }, + { + visitQuestion: true, + }, + ); + + extractColumnAndCheck({ + column: "Email", + option, + value, + example, + }); + }); + }); + }); + + describe("url columns", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + // Make the Email column a URL column for these tests, to avoid having to create a new model + cy.request("PUT", `/api/field/${PEOPLE.EMAIL}`, { + semantic_type: "type/URL", + }); + }); + + URL_CASES.forEach(({ option, value, example }) => { + it(option, () => { + createQuestion( + { + query: { + "source-table": PEOPLE_ID, + limit: 1, + fields: [["field", PEOPLE.EMAIL, { "base-type": "type/String" }]], + }, + }, + { + visitQuestion: true, + }, + ); + + extractColumnAndCheck({ + column: "Email", + option, + value, + example, + }); + }); + }); + }); +}); + +function extractColumnAndCheck({ + column, + option, + newColumn = option, + value, + example, +}: { + column: string; + option: string; + value?: string; + example?: string; + newColumn?: string; +}) { + const requestAlias = _.uniqueId("dataset"); + cy.intercept("POST", "/api/dataset").as(requestAlias); + cy.findByLabelText("Add column").click(); + + popover().findByText("Extract part of column").click(); + popover().findAllByText(column).first().click(); + + if (example) { + popover().findByText(option).parent().should("contain", example); + } + + popover().findByText(option).click(); + + cy.wait(`@${requestAlias}`); + + cy.findByRole("columnheader", { name: newColumn }).should("be.visible"); + if (value) { + cy.findByRole("gridcell", { name: value }).should("be.visible"); + } +} diff --git a/e2e/test/scenarios/visualizations-tabular/table-column-settings.cy.spec.js b/e2e/test/scenarios/visualizations-tabular/table-column-settings.cy.spec.js index 65f9966f5bbd0656e7142741c803f6480122231f..671ec4e33ea3dfc6a93da71e8b8d07e68a38c327 100644 --- a/e2e/test/scenarios/visualizations-tabular/table-column-settings.cy.spec.js +++ b/e2e/test/scenarios/visualizations-tabular/table-column-settings.cy.spec.js @@ -619,12 +619,14 @@ describe("scenarios > visualizations > table column settings", () => { column: "Products → Category", columnName: "Products → Category", table: "test question", + scrollTimes: 3, }; const testData2 = { column: "Ean", columnName: "Product → Ean", table: "product", + scrollTimes: 3, }; _hideColumn(testData); diff --git a/frontend/src/metabase-lib/types.ts b/frontend/src/metabase-lib/types.ts index c9fceec95d2564e99ef5395708212ffaedead7e4..387abbe9a3ae7aeb915e3316f9369a4f4429691a 100644 --- a/frontend/src/metabase-lib/types.ts +++ b/frontend/src/metabase-lib/types.ts @@ -575,6 +575,7 @@ export interface ClickObject { seriesIndex?: number; cardId?: CardId; settings?: Record<string, unknown>; + columnShortcuts?: boolean; origin?: { row: RowValue; cols: DatasetColumn[]; diff --git a/frontend/src/metabase/query_builder/components/expressions/ExtractColumn/ExtractColumn.tsx b/frontend/src/metabase/query_builder/components/expressions/ExtractColumn/ExtractColumn.tsx index 64280a764087ca93f4a4d2483e1b8c44e951330c..a93cac3f01e69475986f939a804757563d0f4b97 100644 --- a/frontend/src/metabase/query_builder/components/expressions/ExtractColumn/ExtractColumn.tsx +++ b/frontend/src/metabase/query_builder/components/expressions/ExtractColumn/ExtractColumn.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import { t } from "ttag"; import { QueryColumnPicker } from "metabase/common/components/QueryColumnPicker"; -import { Text, Box, Stack, Button } from "metabase/ui"; +import { Text, Box, Stack, Button, Title } from "metabase/ui"; import * as Lib from "metabase-lib"; import { ExpressionWidgetHeader } from "../ExpressionWidgetHeader"; @@ -18,17 +18,22 @@ type Props = { name: string, extraction: Lib.ColumnExtraction, ) => void; - onCancel: () => void; + onCancel?: () => void; }; export function ExtractColumn({ - query, - stageIndex, + query: originalQuery, + stageIndex: originalStageIndex, onCancel, onSubmit, }: Props) { const [column, setColumn] = useState<Lib.ColumnMetadata | null>(null); + const { query, stageIndex } = Lib.asReturned( + originalQuery, + originalStageIndex, + ); + function handleSelect(column: Lib.ColumnMetadata) { setColumn(column); } @@ -81,7 +86,7 @@ function ColumnPicker({ stageIndex: number; column: Lib.ColumnMetadata | null; onSelect: (column: Lib.ColumnMetadata) => void; - onCancel: () => void; + onCancel?: () => void; }) { const extractableColumns = useMemo( () => @@ -94,11 +99,18 @@ function ColumnPicker({ return ( <> - <ExpressionWidgetHeader - title={t`Select column to extract from`} - onBack={onCancel} - /> + {onCancel && ( + <ExpressionWidgetHeader + title={t`Select column to extract from`} + onBack={onCancel} + /> + )} <Box py="sm"> + {!onCancel && ( + <Title p="md" pt="sm" pb={0} order={6}> + {t`Select column to extract from`} + </Title> + )} <QueryColumnPicker query={query} stageIndex={stageIndex} diff --git a/frontend/src/metabase/visualizations/click-actions/actions/ExtractColumn/ExtractColumn.tsx b/frontend/src/metabase/visualizations/click-actions/actions/ExtractColumn/ExtractColumn.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6919c54df4fc37b8556d63bf34d244f17c958d0 --- /dev/null +++ b/frontend/src/metabase/visualizations/click-actions/actions/ExtractColumn/ExtractColumn.tsx @@ -0,0 +1,64 @@ +import { t } from "ttag"; + +import { ExtractColumn } from "metabase/query_builder/components/expressions/ExtractColumn"; +import type { LegacyDrill } from "metabase/visualizations/types"; +import type { ClickActionPopoverProps } from "metabase/visualizations/types/click-actions"; +import * as Lib from "metabase-lib"; + +export const ExtractColumnAction: LegacyDrill = ({ question, clicked }) => { + const { isEditable } = Lib.queryDisplayInfo(question.query()); + + if ( + !clicked || + clicked.value !== undefined || + !clicked.columnShortcuts || + clicked?.extraData?.isRawTable || + !isEditable + ) { + return []; + } + + const Popover = ({ + onChangeCardAndRun, + onClose, + }: ClickActionPopoverProps) => { + const query = question.query(); + const stageIndex = -1; + + function handleSubmit( + _clause: Lib.Clause, + _name: string, + extraction: Lib.ColumnExtraction, + ) { + const newQuery = Lib.extract(query, stageIndex, extraction); + + const nextQuestion = question.setQuery(newQuery); + const nextCard = nextQuestion.card(); + + onChangeCardAndRun({ nextCard }); + onClose(); + } + + return ( + <ExtractColumn + query={query} + stageIndex={stageIndex} + onSubmit={handleSubmit} + onCancel={onClose} + /> + ); + }; + + return [ + { + name: "column-extract", + title: t`Extract part of column`, + tooltip: t`Extract part of column`, + buttonType: "horizontal", + icon: "arrow_split", + default: true, + section: "new-column", + popover: Popover, + }, + ]; +}; diff --git a/frontend/src/metabase/visualizations/click-actions/actions/ExtractColumn/index.ts b/frontend/src/metabase/visualizations/click-actions/actions/ExtractColumn/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..775b1a3e18b02f6787ed1957d0e8a40e56779c42 --- /dev/null +++ b/frontend/src/metabase/visualizations/click-actions/actions/ExtractColumn/index.ts @@ -0,0 +1 @@ +export { ExtractColumnAction } from "./ExtractColumn"; diff --git a/frontend/src/metabase/visualizations/click-actions/modes/DefaultMode.ts b/frontend/src/metabase/visualizations/click-actions/modes/DefaultMode.ts index 1607e2a4c40e043e9507b27a39ce2a2229a7339c..4ef60b8682dc9fdbaf4b8f06dd5891ac0c70719b 100644 --- a/frontend/src/metabase/visualizations/click-actions/modes/DefaultMode.ts +++ b/frontend/src/metabase/visualizations/click-actions/modes/DefaultMode.ts @@ -1,6 +1,7 @@ import type { QueryClickActionsMode } from "../../types"; import { ColumnFormattingAction } from "../actions/ColumnFormattingAction"; import { DashboardClickAction } from "../actions/DashboardClickAction"; +import { ExtractColumnAction } from "../actions/ExtractColumn"; import { HideColumnAction } from "../actions/HideColumnAction"; import { NativeQueryClickFallback } from "../actions/NativeQueryClickFallback"; @@ -11,6 +12,7 @@ export const DefaultMode: QueryClickActionsMode = { HideColumnAction, ColumnFormattingAction, DashboardClickAction, + ExtractColumnAction, ], fallback: NativeQueryClickFallback, }; diff --git a/frontend/src/metabase/visualizations/click-actions/modes/EmbeddingSdkMode.ts b/frontend/src/metabase/visualizations/click-actions/modes/EmbeddingSdkMode.ts index 53e0b694495de31bc77e57e8c74c88d4711a2ca4..c7adf1fdb4a6551a98615c083392b43707207b84 100644 --- a/frontend/src/metabase/visualizations/click-actions/modes/EmbeddingSdkMode.ts +++ b/frontend/src/metabase/visualizations/click-actions/modes/EmbeddingSdkMode.ts @@ -1,5 +1,6 @@ import type { QueryClickActionsMode } from "../../types"; import { DashboardClickAction } from "../actions/DashboardClickAction"; +import { ExtractColumnAction } from "../actions/ExtractColumn"; import { HideColumnAction } from "../actions/HideColumnAction"; import { NativeQueryClickFallback } from "../actions/NativeQueryClickFallback"; @@ -23,6 +24,6 @@ export const EmbeddingSdkMode: QueryClickActionsMode = { "drill-thru/zoom-in.geographic", "drill-thru/zoom-in.timeseries", ], - clickActions: [HideColumnAction, DashboardClickAction], + clickActions: [HideColumnAction, DashboardClickAction, ExtractColumnAction], fallback: NativeQueryClickFallback, }; diff --git a/frontend/src/metabase/visualizations/components/ClickActions/utils.ts b/frontend/src/metabase/visualizations/components/ClickActions/utils.ts index 1dde6aa0c5a5c82898e2bf76889ce999222f4c35..f4f27a9ce1f39639c555cd5c08db6e1bddafe7ee 100644 --- a/frontend/src/metabase/visualizations/components/ClickActions/utils.ts +++ b/frontend/src/metabase/visualizations/components/ClickActions/utils.ts @@ -29,6 +29,7 @@ export const SECTIONS: Record<ClickActionSection, Section> = { filter: {}, details: {}, custom: {}, + "new-column": {}, }; Object.values(SECTIONS).map((section, index) => { section.index = index; @@ -82,6 +83,9 @@ export const getSectionTitle = ( case "extract-popover": return t`Select a part to extract`; + + case "new-column": + return t`New column`; } return null; diff --git a/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.jsx b/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.jsx index 0d3f6cb4a4fe19a020bebc83336fbbd9d64d0ccd..dc18ff208f40e9b9ded9b8c1f1f3c13a31bb78c4 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.jsx +++ b/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.jsx @@ -25,7 +25,7 @@ import { } from "metabase/query_builder/selectors"; import { getIsEmbeddingSdk } from "metabase/selectors/embed"; import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider"; -import { Icon, DelayGroup } from "metabase/ui"; +import { Button as UIButton, Icon, DelayGroup } from "metabase/ui"; import { getTableCellClickedObject, getTableHeaderClickedObject, @@ -1017,6 +1017,7 @@ class TableInteractive extends Component { data: { cols, rows }, className, scrollToColumn, + question, } = this.props; if (!width || !height) { @@ -1025,6 +1026,9 @@ class TableInteractive extends Component { const headerHeight = this.props.tableHeaderHeight || HEADER_HEIGHT; const gutterColumn = this.state.showDetailShortcut ? 1 : 0; + const shortcutColumn = 1; + const query = question?.query(); + const info = query && Lib.queryDisplayInfo(query); return ( <DelayGroup> @@ -1091,6 +1095,18 @@ class TableInteractive extends Component { </div> </> )} + {shortcutColumn && ( + <ColumnShortcut + height={headerHeight - 1} + isEditable={info?.isEditable} + onClick={evt => { + this.onVisualizationClick( + { columnShortcuts: true }, + evt.target, + ); + }} + /> + )} <Grid ref={ref => (this.header = ref)} style={{ @@ -1110,16 +1126,24 @@ class TableInteractive extends Component { height={headerHeight} rowCount={1} rowHeight={headerHeight} - columnCount={cols.length + gutterColumn} + columnCount={cols.length + gutterColumn + shortcutColumn} columnWidth={this.getDisplayColumnWidth} - cellRenderer={props => - gutterColumn && props.columnIndex === 0 - ? null // we need a phantom cell to properly offset columns - : this.tableHeaderRenderer({ - ...props, - columnIndex: props.columnIndex - gutterColumn, - }) - } + cellRenderer={props => { + if (props.columnIndex === 0 && gutterColumn) { + // we need a phantom cell to properly offset gutter columns + return null; + } + + if (props.columnIndex === cols.length + gutterColumn) { + // we need a phantom cell to properly offset the shortcut column + return null; + } + + return this.tableHeaderRenderer({ + ...props, + columnIndex: props.columnIndex - gutterColumn, + }); + }} onScroll={({ scrollLeft }) => onScroll({ scrollLeft })} scrollLeft={scrollLeft} tabIndex={null} @@ -1137,18 +1161,26 @@ class TableInteractive extends Component { }} width={width} height={height - headerHeight} - columnCount={cols.length + gutterColumn} + columnCount={cols.length + gutterColumn + shortcutColumn} columnWidth={this.getDisplayColumnWidth} rowCount={rows.length} rowHeight={ROW_HEIGHT} - cellRenderer={props => - gutterColumn && props.columnIndex === 0 - ? null // we need a phantom cell to properly offset columns - : this.cellRenderer({ - ...props, - columnIndex: props.columnIndex - gutterColumn, - }) - } + cellRenderer={props => { + if (props.columnIndex === 0 && gutterColumn) { + // we need a phantom cell to properly offset gutter columns + return null; + } + + if (props.columnIndex === cols.length + gutterColumn) { + // we need a phantom cell to properly offset the shortcut column + return null; + } + + return this.cellRenderer({ + ...props, + columnIndex: props.columnIndex - gutterColumn, + }); + }} scrollTop={scrollTop} onScroll={({ scrollLeft, scrollTop }) => { this.props.onActionDismissal(); @@ -1239,3 +1271,22 @@ const DetailShortcut = forwardRef((_props, ref) => ( )); DetailShortcut.displayName = "DetailShortcut"; + +function ColumnShortcut({ height, onClick, isEditable }) { + if (!isEditable) { + return null; + } + + return ( + <div className={TableS.shortcutsWrapper} style={{ height }}> + <UIButton + variant="filled" + compact + leftIcon={<Icon name="add" />} + title={t`Add column`} + aria-label={t`Add column`} + onClick={onClick} + /> + </div> + ); +} diff --git a/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.module.css b/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.module.css index 9add690363d917a592b07cb49f385060dce80743..94b5995372a352675dc9164c5e84aaff671f93b8 100644 --- a/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.module.css +++ b/frontend/src/metabase/visualizations/components/TableInteractive/TableInteractive.module.css @@ -105,3 +105,16 @@ .TableInteractiveGutter { background-color: var(--color-bg-white); } + +.shortcutsWrapper { + position: absolute; + top: 0; + right: 0; + z-index: 50; + background: white; + padding: 0 0.5em; + box-sizing: border-box; + border-left: 1px solid var(--color-border); + display: flex; + align-items: center; +} diff --git a/frontend/src/metabase/visualizations/types/click-actions.ts b/frontend/src/metabase/visualizations/types/click-actions.ts index 43c424c809463aef52286df0a7d27906a97892a4..e7b8c59efa3e88244e93fff9f77675e35f2cf659 100644 --- a/frontend/src/metabase/visualizations/types/click-actions.ts +++ b/frontend/src/metabase/visualizations/types/click-actions.ts @@ -35,6 +35,7 @@ export type ClickActionSection = | "filter" | "info" | "records" + | "new-column" | "sort" | "standalone_filter" | "sum"