Skip to content
Snippets Groups Projects
Unverified Commit 5f2e3234 authored by Romeo Van Snick's avatar Romeo Van Snick Committed by GitHub
Browse files

Notebook text extraction shortcut (#41578)

* Add shortcuts to suggestions

* Add shortcuts group

* Add split icon

* Start implementing extract column modal

* Pass alwaysExpanded from prop to accordion items

* Allow disabling search on QueryColumnPicker

* Only render columns that support extraction

* Add columnExtractions helper to Lib

* WIP

* Add examples for column extraction

* Use Lib.columnExtractions to create extraction picker

* WIP: Create clause from extraction

* Use mb- prefix in color

* Add extract wrapper

* Use updated column extraction types

* WIP: extract clause

* Use JSDoc for todo

* Handle extract column submit in expression editor

* Set name based on extract expression

* Suffix column displayNames to avoid conflicts

* Rename combine test to disambiguate it from extract test

* Add e2e test for column extractions

* Correctly render button as button

* Add tests for email extractions

* Use correct font in expression button

* Remove error when selecting an extraction

* Use last expression

* Use handleExpressionChange

* Add tests for url extractions

* Rename to lastExpression

* Add issue link to the todo

* Move Button into ExtractColumn

* Use unstyled button for ExtractColumnButton
parent ddc0f98f
No related branches found
No related tags found
No related merge requests found
Showing
with 562 additions and 13 deletions
......@@ -11,7 +11,7 @@ import {
resetSnowplow,
} from "e2e/support/helpers";
describe("scenarios > question > custom column > expression shortcuts", () => {
describe("scenarios > question > custom column > expression shortcuts > combine", () => {
beforeEach(() => {
restore();
cy.signInAsNormalUser();
......
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
import {
addCustomColumn,
restore,
popover,
openOrdersTable,
expressionEditorWidget,
openTable,
} from "e2e/support/helpers";
const { ORDERS_ID, ORDERS } = SAMPLE_DATABASE;
const DATE_EXTRACTIONS = [
{
table: ORDERS_ID,
column: "Created At",
name: "Hour of day",
fn: "hour",
},
{
table: ORDERS_ID,
column: "Created At",
name: "Day of month",
fn: "day",
},
{
table: ORDERS_ID,
column: "Created At",
name: "Day of week",
fn: "weekday",
},
{
table: ORDERS_ID,
column: "Created At",
name: "Month of year",
fn: "month",
},
{
table: ORDERS_ID,
column: "Created At",
name: "Quarter of year",
fn: "quarter",
},
{
table: ORDERS_ID,
column: "Created At",
name: "Year",
fn: "year",
},
];
const EMAIL_EXTRACTIONS = [
{
table: ORDERS_ID,
column: "Email",
name: "Domain",
fn: "domain",
},
{
table: ORDERS_ID,
column: "Email",
name: "Host",
fn: "host",
},
];
const URL_EXRACTIONS = [
{
table: ORDERS_ID,
column: "Product ID",
name: "Domain",
fn: "domain",
},
{
table: ORDERS_ID,
column: "Product ID",
name: "Subdomain",
fn: "subdomain",
},
{
table: ORDERS_ID,
column: "Product ID",
name: "Host",
fn: "host",
},
];
const EXTRACTIONS = [
...EMAIL_EXTRACTIONS,
...DATE_EXTRACTIONS,
...URL_EXRACTIONS,
];
describe("scenarios > question > custom column > expression shortcuts > extract", () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
// Make the PRODUCT_ID column a URL column for these tests, to avoid having to create a new model
cy.request("PUT", `/api/field/${ORDERS.PRODUCT_ID}`, {
semantic_type: "type/URL",
});
});
for (const extraction of EXTRACTIONS) {
it(`should be possible to use the ${extraction.name} extraction on ${extraction.column}`, () => {
openTable({ mode: "notebook", limit: 1, table: extraction.table });
addCustomColumn();
selectExtractColumn();
cy.findAllByTestId("dimension-list-item")
.contains(extraction.column)
.click();
popover().findAllByRole("button").contains(extraction.name).click();
cy.findByTestId("expression-editor-textfield").should(
"contain",
`${extraction.fn}(`,
);
expressionEditorWidget()
.findByTestId("expression-name")
.should("have.value", extraction.name);
});
}
it("should be possible to create the same extraction multiple times", () => {
openOrdersTable({ mode: "notebook", limit: 5 });
addCustomColumn();
selectExtractColumn();
cy.findAllByTestId("dimension-list-item").contains("Created At").click();
popover().findAllByRole("button").contains("Hour of day").click();
expressionEditorWidget()
.findByTestId("expression-name")
.should("have.value", "Hour of day");
expressionEditorWidget().button("Done").click();
cy.findAllByTestId("notebook-cell-item").last().click();
selectExtractColumn();
cy.findAllByTestId("dimension-list-item").contains("Created At").click();
popover().findAllByRole("button").contains("Hour of day").click();
expressionEditorWidget()
.findByTestId("expression-name")
.should("have.value", "Hour of day (1)");
});
});
function selectExtractColumn() {
cy.findByTestId("expression-suggestions-list").within(() => {
cy.findByText("Extract columns").click();
});
}
import * as ML from "cljs/metabase.lib.js";
import type { ColumnExtraction, ColumnMetadata, Query } from "./types";
export function columnExtractions(
query: Query,
column: ColumnMetadata,
): ColumnExtraction[] {
return ML.column_extractions(query, column);
}
export function extract(
query: Query,
stageIndex: number,
extraction: ColumnExtraction,
): Query {
return ML.extract(query, stageIndex, extraction);
}
......@@ -9,6 +9,7 @@ export * from "./comparison";
export * from "./database";
export * from "./drills";
export * from "./expression";
export * from "./extractions";
export * from "./fields";
export * from "./filter";
export * from "./join";
......
......@@ -41,8 +41,10 @@ export interface QueryColumnPickerProps {
onSelect: (column: Lib.ColumnMetadata) => void;
onClose?: () => void;
"data-testid"?: string;
width?: number | string;
width?: string;
hasInitialFocus?: boolean;
alwaysExpanded?: boolean;
disableSearch?: boolean;
}
type Sections = {
......@@ -67,6 +69,8 @@ export function QueryColumnPicker({
"data-testid": dataTestId,
width,
hasInitialFocus = true,
alwaysExpanded,
disableSearch,
}: QueryColumnPickerProps) {
const sections: Sections[] = useMemo(
() =>
......@@ -190,7 +194,7 @@ export function QueryColumnPicker({
<StyledAccordionList
className={className}
sections={sections}
alwaysExpanded={false}
alwaysExpanded={alwaysExpanded}
onChange={handleSelectColumn}
itemIsSelected={checkIsColumnSelected}
renderItemWrapper={renderItemWrapper}
......@@ -206,10 +210,11 @@ export function QueryColumnPicker({
// Compat with E2E tests around MLv1-based components
// Prefer using a11y role selectors
itemTestId="dimension-list-item"
globalSearch
withBorders
hasInitialFocus={hasInitialFocus}
width={width}
globalSearch={!disableSearch}
searchable={!disableSearch}
/>
</DelayGroup>
);
......
......@@ -649,7 +649,8 @@ export default class AccordionList extends Component {
onChangeSearchText={this.handleChangeSearchText}
sectionIsExpanded={this.isSectionExpanded}
alwaysExpanded={
this.props.globalSearch && this.state.searchText.length > 0
this.props.alwaysExpanded ||
(this.props.globalSearch && this.state.searchText.length > 0)
}
canToggleSections={this.canToggleSections()}
toggleSection={this.toggleSection}
......
......@@ -306,6 +306,7 @@ function colorForIcon(icon: string | undefined | null) {
return { normal: color("accent1"), highlighted: color("brand-white") };
case "function":
case "combine":
case "split":
return { normal: color("brand"), highlighted: color("brand-white") };
default:
return {
......
......@@ -19,6 +19,7 @@ import { diagnose } from "metabase-lib/v1/expressions/diagnostics";
import { format } from "metabase-lib/v1/expressions/format";
import { processSource } from "metabase-lib/v1/expressions/process";
import type {
GroupName,
SuggestArgs,
Suggestion,
} from "metabase-lib/v1/expressions/suggest";
......@@ -56,7 +57,7 @@ export type SuggestionShortcut = {
shortcut: true;
name: string;
icon: IconName;
group: string;
group: GroupName;
action: () => void;
};
......@@ -165,7 +166,7 @@ function transformPropsToState(
metadata,
reportTimezone,
showMetabaseLinks,
shortcuts,
shortcuts = [],
} = props;
const expressionFromClause = clause
? Lib.legacyExpressionForExpressionClause(query, stageIndex, clause)
......@@ -616,7 +617,7 @@ class ExpressionEditorTextfield extends React.Component<
expressionPosition,
startRule = ExpressionEditorTextfield.defaultProps.startRule,
showMetabaseLinks,
shortcuts,
shortcuts = [],
} = this.props;
const { source } = this.state;
const { suggestions, helpText } = suggestWithExtras({
......
......@@ -5,7 +5,7 @@ import { t } from "ttag";
import Input from "metabase/core/components/Input/Input";
import { isNotNull } from "metabase/lib/types";
import { Button } from "metabase/ui";
import type * as Lib from "metabase-lib";
import * as Lib from "metabase-lib";
import { isExpression } from "metabase-lib/v1/expressions";
import type { Expression } from "metabase-types/api";
......@@ -24,6 +24,7 @@ import {
} from "./ExpressionWidget.styled";
import { ExpressionWidgetHeader } from "./ExpressionWidgetHeader";
import { ExpressionWidgetInfo } from "./ExpressionWidgetInfo";
import { ExtractColumn } from "./ExtractColumn";
export type ExpressionWidgetProps<Clause = Lib.ExpressionClause> = {
query: Lib.Query;
......@@ -83,6 +84,8 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
const [error, setError] = useState<string | null>(null);
const [isCombiningColumns, setIsCombiningColumns] = useState(false);
const [isExtractingColumn, setIsExtractingColumn] = useState(false);
const isValidName = withName ? name.trim().length > 0 : true;
const isValidExpression = isNotNull(expression) && isExpression(expression);
const isValidExpressionClause = isNotNull(clause);
......@@ -125,11 +128,14 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
if (isCombiningColumns) {
const handleSubmit = (name: string, clause: Lib.ExpressionClause) => {
trackColumnCombineViaShortcut(query);
setIsCombiningColumns(false);
setClause(clause);
const expression = Lib.legacyExpressionForExpressionClause(
query,
stageIndex,
clause,
);
handleExpressionChange(expression, clause);
setName(name);
setError(null);
setIsCombiningColumns(false);
};
const handleCancel = () => {
......@@ -151,6 +157,30 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
);
}
if (isExtractingColumn) {
const handleSubmit = (clause: Lib.ExpressionClause, name: string) => {
const expression = Lib.legacyExpressionForExpressionClause(
query,
stageIndex,
clause,
);
handleExpressionChange(expression, clause);
setName(name);
setIsExtractingColumn(false);
};
return (
<Container data-testid="expression-editor">
<ExtractColumn
query={query}
stageIndex={stageIndex}
onCancel={() => setIsExtractingColumn(false)}
onSubmit={handleSubmit}
/>
</Container>
);
}
return (
<Container data-testid="expression-editor">
{header}
......@@ -180,6 +210,13 @@ export const ExpressionWidget = <Clause extends object = Lib.ExpressionClause>(
group: "shortcuts",
icon: "combine",
},
!startRule && {
shortcut: true,
name: t`Extract columns`,
icon: "split",
group: "shortcuts",
action: () => setIsExtractingColumn(true),
},
].filter(Boolean)}
/>
</ExpressionFieldWrapper>
......
.button {
.inner {
align-items: flex-start;
justify-content: flex-start;
}
.example {
font-weight: normal;
}
.content,
.example {
font-family: var(--mb-default-font-family) !important;
}
&:hover {
background: var(--mb-color-brand);
.content,
.example {
color: var(--color-white);
}
}
}
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 * as Lib from "metabase-lib";
import { ExpressionWidgetHeader } from "../ExpressionWidgetHeader";
import styles from "./ExtractColumn.module.css";
import { getExample, getName } from "./util";
type Props = {
query: Lib.Query;
stageIndex: number;
onSubmit: (clause: Lib.ExpressionClause, name: string) => void;
onCancel: () => void;
};
export function ExtractColumn({
query,
stageIndex,
onCancel,
onSubmit,
}: Props) {
const [column, setColumn] = useState<Lib.ColumnMetadata | null>(null);
function handleSelect(column: Lib.ColumnMetadata) {
setColumn(column);
}
if (!column) {
return (
<ColumnPicker
query={query}
stageIndex={stageIndex}
column={column}
onCancel={onCancel}
onSelect={handleSelect}
/>
);
}
function handleSubmit(
info: Lib.ColumnExtractionInfo,
extraction: Lib.ColumnExtraction,
) {
// @todo this is a hack until Lib supports building an expression from an extraction
const newQuery = Lib.extract(query, stageIndex, extraction);
const expressions = Lib.expressions(newQuery, stageIndex);
const name = getName(query, stageIndex, info);
const lastExpression = expressions.at(-1);
if (lastExpression) {
onSubmit(lastExpression, name);
}
}
return (
<ExtractionPicker
query={query}
stageIndex={stageIndex}
column={column}
onSelect={handleSubmit}
onCancel={() => setColumn(null)}
/>
);
}
function ColumnPicker({
query,
stageIndex,
column,
onSelect,
onCancel,
}: {
query: Lib.Query;
stageIndex: number;
column: Lib.ColumnMetadata | null;
onSelect: (column: Lib.ColumnMetadata) => void;
onCancel: () => void;
}) {
const extractableColumns = useMemo(
() =>
Lib.expressionableColumns(query, stageIndex).filter(
column => Lib.columnExtractions(query, column).length > 0,
),
[query, stageIndex],
);
const columnGroups = Lib.groupColumns(extractableColumns);
return (
<>
<ExpressionWidgetHeader
title={t`Select column to extract from`}
onBack={onCancel}
/>
<Box py="sm">
<QueryColumnPicker
query={query}
stageIndex={stageIndex}
columnGroups={columnGroups}
onSelect={onSelect}
checkIsColumnSelected={item => item.column === column}
width="100%"
alwaysExpanded
disableSearch
/>
</Box>
</>
);
}
function ExtractionPicker({
query,
stageIndex,
column,
onSelect,
onCancel,
}: {
query: Lib.Query;
stageIndex: number;
column: Lib.ColumnMetadata;
onSelect: (
info: Lib.ColumnExtractionInfo,
extraction: Lib.ColumnExtraction,
) => void;
onCancel: () => void;
}) {
const info = Lib.displayInfo(query, stageIndex, column);
const extractions = useMemo(
() =>
Lib.columnExtractions(query, column).map(extraction => ({
extraction,
info: Lib.displayInfo(query, stageIndex, extraction),
})),
[query, stageIndex, column],
);
return (
<>
<ExpressionWidgetHeader
title={t`Select part of '${info.longDisplayName}' to extract`}
onBack={onCancel}
/>
<Box p="sm">
<Stack spacing={0}>
{extractions.map(extraction => (
<ExtractColumnButton
key={extraction.info.tag}
title={extraction.info.displayName}
example={getExample(extraction.info) ?? ""}
onClick={() => onSelect(extraction.info, extraction.extraction)}
/>
))}
</Stack>
</Box>
</>
);
}
function ExtractColumnButton({
title,
example,
onClick,
}: {
title: string;
example: string;
onClick: () => void;
}) {
return (
<Button
variant="unstyled"
type="button"
p="sm"
className={styles.button}
classNames={{
inner: styles.inner,
}}
onClick={onClick}
>
<Text color="text-dark" className={styles.content} weight="bold" p={0}>
{title}
</Text>
<Text color="text-light" size="sm" className={styles.example}>
{example}
</Text>
</Button>
);
}
export { ExtractColumn } from "./ExtractColumn";
import * as Lib from "metabase-lib";
export function getExample(info: Lib.ColumnExtractionInfo) {
// @todo this should eventually be moved into Lib.displayInfo
// to avoid the keys going out of sync with the MLv2-defined extractions.
//
// @see https://github.com/metabase/metabase/issues/42039
switch (info.tag) {
case "hour-of-day":
return "0, 1";
case "day-of-month":
return "1, 2";
case "day-of-week":
return "Monday, Tuesday";
case "month-of-year":
return "Jan, Feb";
case "quarter-of-year":
return "Q1, Q2";
case "year":
return "2023, 2024";
case "domain":
return "example.com, online.com";
case "host":
return "example, online";
case "subdomain":
return "www, maps";
}
return undefined;
}
function getNextName(names: string[], name: string, index: number): string {
const suffixed = index === 0 ? name : `${name} (${index})`;
if (!names.includes(suffixed)) {
return suffixed;
}
return getNextName(names, name, index + 1);
}
export function getName(
query: Lib.Query,
stageIndex: number,
info: Lib.ColumnExtractionInfo,
) {
const columnNames = Lib.returnedColumns(query, stageIndex).map(
column => Lib.displayInfo(query, stageIndex, column).displayName,
);
return getNextName(columnNames, info.displayName, 0);
}
import { createMockMetadata } from "__support__/metadata";
import type * as Lib from "metabase-lib";
import { createQuery } from "metabase-lib/test-helpers";
import {
createMockDatabase,
createMockField,
createMockTable,
} from "metabase-types/api/mocks";
import { getName } from "./util";
const DATE = createMockField({
id: 2,
name: "Date",
display_name: "Date",
semantic_type: "type/Date",
base_type: "type/String",
});
const TABLE = createMockTable({
fields: [DATE],
});
const DATABASE = createMockDatabase({
tables: [TABLE],
});
const QUERY = createQuery({
databaseId: DATABASE.id,
metadata: createMockMetadata({ databases: [DATABASE] }),
query: {
database: DATABASE.id,
type: "query",
query: {
"source-table": TABLE.id,
},
},
});
describe("getName", () => {
it("should return a plain name without suffix when no duplicates exist", () => {
expect(
getName(QUERY, -1, { displayName: "Bar" } as Lib.ColumnExtractionInfo),
).toBe("Bar");
});
it("should return a name with a suffix to avoid name clashes", () => {
expect(
getName(QUERY, -1, { displayName: "Date" } as Lib.ColumnExtractionInfo),
).toBe("Date (1)");
});
});
......@@ -309,6 +309,8 @@ import sort_component from "./sort.svg?component";
import sort_source from "./sort.svg?source";
import sort_arrows_component from "./sort_arrows.svg?component";
import sort_arrows_source from "./sort_arrows.svg?source";
import split_component from "./split.svg?component";
import split_source from "./split.svg?source";
import sql_component from "./sql.svg?component";
import sql_source from "./sql.svg?source";
import star_component from "./star.svg?component";
......@@ -907,6 +909,10 @@ export const Icons = {
component: share_component,
source: share_source,
},
split: {
component: split_component,
source: split_source,
},
sql: {
component: sql_component,
source: sql_source,
......
<svg viewBox="0 0 16 16" fill="currentcolor" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M12.2574 5.47474C11.984 5.20137 11.5408 5.20137 11.2674 5.47474C10.994 5.74811 10.994 6.19132 11.2674 6.46469L12.0874 7.28464H9.54984L9.5498 8.68464H12.0874L11.2674 9.50459C10.994 9.77796 10.994 10.2212 11.2674 10.4945C11.5408 10.7679 11.984 10.7679 12.2574 10.4945L14.2723 8.47961C14.5457 8.20625 14.5457 7.76303 14.2723 7.48966L12.2574 5.47474Z" />
<path d="M11.9494 7.28907C12.336 7.28907 12.6494 7.60247 12.6494 7.98907C12.6494 8.37567 12.336 8.68907 11.9494 8.68907L10.9107 8.68906C10.3968 8.68911 9.88965 8.81655 9.43194 9.06172C8.97409 9.30696 8.57834 9.66338 8.2798 10.1021L7.87703 10.6937C7.45318 11.3166 6.88821 11.8271 6.22867 12.1804C5.56896 12.5337 4.83484 12.7189 4.08886 12.7189H2.24973C1.86313 12.7189 1.54973 12.4055 1.54973 12.0189C1.54973 11.6323 1.86313 11.3189 2.24973 11.3189H4.08886C4.60273 11.3189 5.10992 11.1914 5.56764 10.9463C6.02548 10.701 6.42123 10.3446 6.71978 9.90584L7.12244 9.3144C7.54631 8.69148 8.11131 8.1809 8.77091 7.82761C9.43061 7.47425 10.1647 7.28912 10.9107 7.28906L11.9494 7.28907Z" />
<path d="M11.9494 8.67987C12.336 8.67987 12.6494 8.36647 12.6494 7.97987C12.6494 7.59327 12.336 7.27987 11.9494 7.27987L10.9107 7.27987C10.3968 7.27982 9.88965 7.15238 9.43194 6.90721C8.97409 6.66198 8.57834 6.30555 8.2798 5.86679L7.87703 5.27521C7.45318 4.65236 6.88821 4.14183 6.22867 3.78856C5.56896 3.4352 4.83484 3.25008 4.08886 3.25002H2.24973C1.86313 3.25002 1.54973 3.56342 1.54973 3.95002C1.54973 4.33662 1.86313 4.65002 2.24973 4.65002H4.08886C4.60273 4.65007 5.10992 4.77751 5.56764 5.02268C6.02548 5.26791 6.42123 5.62434 6.71978 6.0631L7.12244 6.65453C7.54631 7.27745 8.11131 7.78803 8.77091 8.14133C9.43061 8.49469 10.1647 8.67981 10.9107 8.67987L11.9494 8.67987Z" />
</svg>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment