From e122df47d3c626cd76618885eefef725c87c4cee Mon Sep 17 00:00:00 2001 From: Nemanja Glumac <31325167+nemanjaglumac@users.noreply.github.com> Date: Wed, 7 Aug 2024 00:18:36 +0200 Subject: [PATCH] MS1: Open notebook data source in new tab (#46228) * WIP Open notebook data source in new tab * WIP * Implement the new functionality with `pickerInfo` * Use destructured value * Add the simplest possible E2E test as PoC * Smoke test * Add basic coverage for questions * Add basic coverage for models * Source question has been archived scenario * Add basic permissions test * Expand to the user without wite query permissions * Fix title * Add another permission scenario * Add E2E coverage for a source being native * Add coverage for the native model * Address review comment re: `pickerInfo` * Extract `getUrl` to utils * Add support for the middle/scroll click * Add unit tests * Add a negative test case * Fix title --- .../notebook-link-to-data-source.cy.spec.ts | 457 ++++++++++++++++++ .../NotebookDataPicker/NotebookDataPicker.tsx | 32 +- .../notebook/NotebookDataPicker/utils.ts | 46 ++ .../steps/DataStep/DataStep.unit.spec.tsx | 55 ++- 4 files changed, 587 insertions(+), 3 deletions(-) create mode 100644 e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts create mode 100644 frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/utils.ts diff --git a/e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts b/e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts new file mode 100644 index 00000000000..6838364bec8 --- /dev/null +++ b/e2e/test/scenarios/question/notebook-link-to-data-source.cy.spec.ts @@ -0,0 +1,457 @@ +import { + ADMIN_PERSONAL_COLLECTION_ID, + ORDERS_COUNT_QUESTION_ID, + ORDERS_MODEL_ID, +} from "e2e/support/cypress_sample_instance_data"; +import { + createNativeQuestion, + createQuestion, + getNotebookStep, + openNotebook, + openReviewsTable, + popover, + restore, + tableInteractive, + visitModel, + visitQuestion, + visualize, + type NativeQuestionDetails, +} from "e2e/support/helpers"; + +// https://docs.cypress.io/api/cypress-api/platform +const macOSX = Cypress.platform === "darwin"; + +const clickConfig = { + metaKey: macOSX, + ctrlKey: !macOSX, +}; + +describe("scenarios > notebook > link to data source", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + + cy.on("window:before:load", win => { + // prevent Cypress opening in a new window/tab and spy on this method + cy.stub(win, "open").callsFake(url => { + expect(win.open).to.be.calledOnce; + // replace the current page with the linked data source upon ctrl/meta click + win.location.replace(url); + }); + }); + }); + + it("smoke test", () => { + openReviewsTable({ mode: "notebook" }); + + cy.log("Normal click on the data source still opens the entity picker"); + getNotebookStep("data").findByText("Reviews").click(); + cy.findByTestId("entity-picker-modal").within(() => { + cy.findByText("Pick your starting data").should("be.visible"); + cy.findByLabelText("Close").click(); + }); + + cy.log("Meta/Ctrl click on the fields picker behaves as a regular click"); + getNotebookStep("data").findByTestId("fields-picker").click(clickConfig); + popover().within(() => { + cy.findByText("Select none").click(); + }); + // Regular click on the fields picker again to close the popover + getNotebookStep("data").findByTestId("fields-picker").click(); + // Mid-test sanity-check assertion + visualize(); + cy.findAllByTestId("header-cell") + .should("have.length", 1) + .and("have.text", "ID"); + + cy.log( + "Deselecting columns should have no effect on the linked data source in new tab/window", + ); + openNotebook(); + getNotebookStep("data").findByText("Reviews").click(clickConfig); + cy.wait("@dataset"); // already intercepted in `visualize()` + + cy.log("Make sure Reviews table is rendered in a simple mode"); + cy.findAllByTestId("header-cell").should("contain", "Reviewer"); + tableInteractive().should("contain", "xavier"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing 1,112 rows", + ); + + cy.findByTestId("qb-save-button").should("be.enabled"); + }); + + context("questions", () => { + it("should open the source table from a simple question", () => { + visitQuestion(ORDERS_COUNT_QUESTION_ID); + openNotebook(); + getNotebookStep("data").findByText("Orders").click(clickConfig); + + cy.log("Make sure Orders table is rendered in a simple mode"); + cy.findAllByTestId("header-cell").should("contain", "Subtotal"); + tableInteractive().should("contain", "37.65"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing first 2,000 rows", + ); + + cy.findByTestId("qb-save-button").should("be.enabled"); + }); + + it("should open the source question from a nested question", () => { + createQuestion( + { + name: "Nested question based on a question", + query: { "source-table": `card__${ORDERS_COUNT_QUESTION_ID}` }, + }, + { visitQuestion: true }, + ); + + openNotebook(); + getNotebookStep("data").findByText("Orders, Count").click(clickConfig); + + cy.log("Make sure the source question rendered in a simple mode"); + cy.location("pathname").should( + "eq", + `/question/${ORDERS_COUNT_QUESTION_ID}-orders-count`, + ); + cy.findAllByTestId("header-cell") + .should("have.length", "1") + .and("have.text", "Count"); + tableInteractive().should("contain", "18,760"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing 1 row", + ); + + // Question is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + + it("should open the source question from a nested question where the source is native question", () => { + const source = { + name: "Native source", + native: { + query: "select 1 as foo", + "template-tags": {}, + }, + }; + + createNativeQuestion(source).then(({ body: sourceQuestion }) => { + createQuestion( + { + name: "Nested question based on a native question", + query: { "source-table": `card__${sourceQuestion.id}` }, + }, + { visitQuestion: true }, + ); + + openNotebook(); + getNotebookStep("data").findByText(source.name).click(clickConfig); + + cy.log("Make sure the source question rendered in a simple mode"); + cy.location("pathname").should( + "eq", + `/question/${sourceQuestion.id}-native-source`, + ); + + cy.findAllByTestId("header-cell") + .should("have.length", "1") + .and("have.text", "FOO"); + cy.get("#main-data-grid") + .findByTestId("cell-data") + .should("have.text", "1"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing 1 row", + ); + + // Question is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + }); + + it("should open the source model from a nested question", () => { + createQuestion( + { + name: "Nested question based on a model", + query: { "source-table": `card__${ORDERS_MODEL_ID}` }, + }, + { visitQuestion: true }, + ); + + openNotebook(); + getNotebookStep("data").findByText("Orders Model").click(clickConfig); + + cy.log("Make sure the source model is rendered in a simple mode"); + cy.location("pathname").should( + "eq", + `/model/${ORDERS_MODEL_ID}-orders-model`, + ); + cy.findAllByTestId("header-cell").should("contain", "Subtotal"); + tableInteractive().should("contain", "37.65"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing first 2,000 rows", + ); + + // Model is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + + it("should open the source model from a nested question where the source is native model", () => { + const source: NativeQuestionDetails = { + name: "Native source", + native: { + query: "select 1 as foo", + "template-tags": {}, + }, + type: "model", + }; + + createNativeQuestion(source).then(({ body: sourceQuestion }) => { + createQuestion( + { + name: "Nested question based on a native question", + query: { "source-table": `card__${sourceQuestion.id}` }, + }, + { visitQuestion: true }, + ); + + openNotebook(); + getNotebookStep("data") + .findByText(sourceQuestion.name) + .click(clickConfig); + + cy.log("Make sure the source model rendered in a simple mode"); + cy.location("pathname").should( + "eq", + `/model/${sourceQuestion.id}-native-source`, + ); + + cy.findByTestId("scalar-value").should("have.text", "1"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing 1 row", + ); + + // Model is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + }); + + it('should open the "trash" if the source question has been archived', () => { + createQuestion({ + name: "Nested question based on a question", + query: { "source-table": `card__${ORDERS_COUNT_QUESTION_ID}` }, + }).then(({ body: nestedQuestion }) => { + cy.log("Move the source question to the trash"); + cy.request("PUT", `/api/card/${ORDERS_COUNT_QUESTION_ID}`, { + archived: true, + }); + + visitQuestion(nestedQuestion.id); + }); + + openNotebook(); + getNotebookStep("data").findByText("Orders, Count").click(clickConfig); + + cy.log('Make sure the source question opens in the "trash"'); + cy.location("pathname").should( + "eq", + `/question/${ORDERS_COUNT_QUESTION_ID}-orders-count`, + ); + cy.findByTestId("archive-banner").should( + "contain", + "This question is in the trash", + ); + }); + }); + + context("models", () => { + it("should open the underlying model", () => { + visitModel(ORDERS_MODEL_ID); + openNotebook(); + getNotebookStep("data").findByText("Orders Model").click(clickConfig); + + cy.log("Make sure the source model is rendered in a simple mode"); + cy.location("pathname").should( + "eq", + `/model/${ORDERS_MODEL_ID}-orders-model`, + ); + cy.findAllByTestId("header-cell").should("contain", "Subtotal"); + tableInteractive().should("contain", "37.65"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing first 2,000 rows", + ); + + // Model is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + + it("should open the underlying native model", () => { + const model: NativeQuestionDetails = { + name: "Native model", + native: { + query: "select 1 as foo", + "template-tags": {}, + }, + type: "model", + }; + + createNativeQuestion(model).then(({ body: { id, name } }) => { + visitModel(id); + + openNotebook(); + getNotebookStep("data").findByText(name).click(clickConfig); + + cy.log("Make sure the source model rendered in a simple mode"); + cy.location("pathname").should("eq", `/model/${id}-native-model`); + + cy.findByTestId("scalar-value").should("have.text", "1"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing 1 row", + ); + + // Model is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + }); + + it("should open the nested model (based on a question) as the data source", () => { + createQuestion( + { + name: "Nested model based on a question", + query: { "source-table": `card__${ORDERS_COUNT_QUESTION_ID}` }, + type: "model", + }, + { visitQuestion: true, wrapId: true, idAlias: "nestedModelId" }, + ); + + openNotebook(); + getNotebookStep("data") + .findByText("Nested model based on a question") + .click(clickConfig); + + cy.log("Make sure the source model is rendered in a simple mode"); + cy.get("@nestedModelId").then(id => { + cy.location("pathname").should( + "eq", + `/model/${id}-nested-model-based-on-a-question`, + ); + cy.findByTestId("scalar-value").should("have.text", "18,760"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing 1 row", + ); + + // Model is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + }); + + it("should open the nested model (based on a model) as the data source", () => { + createQuestion( + { + name: "Nested model based on a model", + query: { "source-table": `card__${ORDERS_MODEL_ID}` }, + type: "model", + }, + { visitQuestion: true, wrapId: true, idAlias: "nestedModelId" }, + ); + + openNotebook(); + getNotebookStep("data") + .findByText("Nested model based on a model") + .click(clickConfig); + + cy.log("Make sure the source model is rendered in a simple mode"); + cy.get("@nestedModelId").then(id => { + cy.location("pathname").should( + "eq", + `/model/${id}-nested-model-based-on-a-model`, + ); + cy.findAllByTestId("header-cell").should("contain", "Subtotal"); + tableInteractive().should("contain", "37.65"); + cy.findByTestId("question-row-count").should( + "have.text", + "Showing first 2,000 rows", + ); + + // Model is not dirty + cy.findByTestId("qb-save-button").should("not.exist"); + }); + }); + }); + + context("permissions", () => { + it("shouldn't show the source question if it lives in a collection that user can't see", () => { + createQuestion({ + name: "Nested question based on a question", + query: { "source-table": `card__${ORDERS_COUNT_QUESTION_ID}` }, + }).then(({ body: nestedQuestion }) => { + cy.log("Move the source question to admin's personal collection"); + cy.request("PUT", `/api/card/${ORDERS_COUNT_QUESTION_ID}`, { + collection_id: ADMIN_PERSONAL_COLLECTION_ID, + }); + + cy.signInAsNormalUser(); + visitQuestion(nestedQuestion.id); + + cy.log("We should not even show the notebook icon"); + cy.findByTestId("qb-header-action-panel") + .icon("notebook") + .should("not.exist"); + + cy.log( + "Even if user opens the notebook link directly, they should not see the source question. We open the entity picker instead", + ); + cy.visit(`/question/${nestedQuestion.id}/notebook`); + cy.findByTestId("entity-picker-modal").within(() => { + cy.findByText("Pick your starting data").should("be.visible"); + cy.findByLabelText("Close").click(); + }); + + getNotebookStep("data").should("contain", "Pick your starting data"); + + cy.log( + "The same should be true for a user that additionally doesn't have write query permissions", + ); + cy.signIn("nodata"); + visitQuestion(nestedQuestion.id); + cy.findByTestId("qb-header-action-panel") + .icon("notebook") + .should("not.exist"); + + cy.visit(`/question/${nestedQuestion.id}/notebook`); + cy.findByTestId("entity-picker-modal").within(() => { + cy.findByText("Pick your starting data").should("be.visible"); + cy.findByLabelText("Close").click(); + }); + + getNotebookStep("data").should("contain", "Pick your starting data"); + }); + }); + + it("user with the curate collection permissions but without write query permissions shouldn't be able to see/open the source question", () => { + createQuestion({ + name: "Nested question based on a question", + query: { "source-table": `card__${ORDERS_COUNT_QUESTION_ID}` }, + }).then(({ body: nestedQuestion }) => { + cy.signIn("nodata"); + visitQuestion(nestedQuestion.id); + + cy.log("We should not even show the notebook icon"); + cy.findByTestId("qb-header-action-panel") + .icon("notebook") + .should("not.exist"); + + // TODO update the following once metabase##46398 is fixed + // cy.visit(`/question/${nestedQuestion.id}/notebook`); + }); + }); + }); +}); diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/NotebookDataPicker.tsx b/frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/NotebookDataPicker.tsx index 3f2ad0be5ec..ca736ba7208 100644 --- a/frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/NotebookDataPicker.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/NotebookDataPicker.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, type MouseEvent } from "react"; import { useLatest } from "react-use"; import { @@ -16,6 +16,8 @@ import type { DatabaseId, TableId } from "metabase-types/api"; import { NotebookCell } from "../NotebookCell"; +import { getUrl, openInNewTab } from "./utils"; + interface NotebookDataPickerProps { title: string; query: Lib.Query; @@ -66,6 +68,31 @@ export function NotebookDataPicker({ onChangeRef.current?.(table, metadataProvider); }; + const handleClick = (event: MouseEvent<HTMLButtonElement>) => { + const isCtrlOrMetaClick = + (event.ctrlKey || event.metaKey) && event.button === 0; + + if (isCtrlOrMetaClick) { + const url = getUrl({ query, table, stageIndex }); + + openInNewTab(url); + } else { + setIsOpen(true); + } + }; + + const handleAuxClick = (event: MouseEvent<HTMLButtonElement>) => { + const isMiddleClick = event.button === 1; + + if (isMiddleClick) { + const url = getUrl({ query, table, stageIndex }); + + openInNewTab(url); + } else { + setIsOpen(true); + } + }; + return ( <> <UnstyledButton @@ -74,7 +101,8 @@ export function NotebookDataPicker({ fw="inherit" p={NotebookCell.CONTAINER_PADDING} disabled={isDisabled} - onClick={() => setIsOpen(true)} + onClick={handleClick} + onAuxClick={handleAuxClick} > <Group spacing="xs"> {tableInfo && <Icon name={getTableIcon(tableInfo)} />} diff --git a/frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/utils.ts b/frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/utils.ts new file mode 100644 index 00000000000..a85daa29878 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/notebook/NotebookDataPicker/utils.ts @@ -0,0 +1,46 @@ +import * as Urls from "metabase/lib/urls"; +import * as Lib from "metabase-lib"; + +type Props = { + query: Lib.Query; + table?: Lib.TableMetadata | Lib.CardMetadata; + stageIndex: number; +}; + +export const getUrl = ({ + query, + table, + stageIndex, +}: Props): string | undefined => { + if (!table) { + return; + } + + const pickerInfo = Lib.pickerInfo(query, table); + const tableInfo = Lib.displayInfo(query, stageIndex, table); + + if (!pickerInfo || !tableInfo) { + return; + } + + const { isModel, cardId, tableId, databaseId } = pickerInfo; + + if (cardId) { + const payload = { + id: cardId, + name: tableInfo.displayName, + }; + + return isModel ? Urls.model(payload) : Urls.question(payload); + } else { + return Urls.tableRowsQuery(databaseId, tableId); + } +}; + +export const openInNewTab = (link?: string) => { + if (!link) { + return; + } + + window.open(link, "_blank"); +}; diff --git a/frontend/src/metabase/query_builder/components/notebook/steps/DataStep/DataStep.unit.spec.tsx b/frontend/src/metabase/query_builder/components/notebook/steps/DataStep/DataStep.unit.spec.tsx index 49bc1788023..84c4ed7acd8 100644 --- a/frontend/src/metabase/query_builder/components/notebook/steps/DataStep/DataStep.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/notebook/steps/DataStep/DataStep.unit.spec.tsx @@ -1,3 +1,4 @@ +import { fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createMockMetadata } from "__support__/metadata"; @@ -52,6 +53,8 @@ const setup = ( step = createMockNotebookStep(), { readOnly = false }: { readOnly?: boolean } = {}, ) => { + const mockWindowOpen = jest.spyOn(window, "open").mockImplementation(); + const updateQuery = jest.fn(); setupDatabasesEndpoints([createSampleDatabase()]); setupSearchEndpoints([]); @@ -89,7 +92,7 @@ const setup = ( return Lib.displayInfo(nextQuery, 0, column); }; - return { getNextQuery, getNextTableName, getNextColumn }; + return { getNextQuery, getNextTableName, getNextColumn, mockWindowOpen }; }; const setupEmptyQuery = () => { @@ -290,4 +293,54 @@ describe("DataStep", () => { expect(screen.queryByLabelText("Pick columns")).not.toBeInTheDocument(); }); }); + + describe("link to data source", () => { + it("meta click should open the data source in a new window", () => { + const { mockWindowOpen } = setup(); + + const dataSource = screen.getByText("Orders"); + fireEvent.click(dataSource, { metaKey: true }); + + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + mockWindowOpen.mockClear(); + }); + + it("ctrl click should open the data source in a new window", () => { + const { mockWindowOpen } = setup(); + + const dataSource = screen.getByText("Orders"); + fireEvent.click(dataSource, { ctrlKey: true }); + + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + mockWindowOpen.mockClear(); + }); + + it("middle click should open the data source in a new window", () => { + const { mockWindowOpen } = setup(); + + const dataSource = screen.getByText("Orders"); + const middleClick = new MouseEvent("auxclick", { + bubbles: true, + button: 1, + }); + + fireEvent(dataSource, middleClick); + + expect(mockWindowOpen).toHaveBeenCalledTimes(1); + mockWindowOpen.mockClear(); + }); + + it("regular click should open the entity picker", async () => { + const { mockWindowOpen } = setup(); + + const dataSource = screen.getByText("Orders"); + + fireEvent.click(dataSource); + + expect( + await screen.findByTestId("entity-picker-modal"), + ).toBeInTheDocument(); + expect(mockWindowOpen).not.toHaveBeenCalled(); + }); + }); }); -- GitLab