diff --git a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx index 674c77cf7663fcc9ac8d09e627bf8679d83fa755..0cfa4a58c5e0c89d83dc5f823a04ca55e674f712 100644 --- a/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx +++ b/frontend/src/metabase/actions/containers/ActionCreator/ActionCreator.tsx @@ -15,6 +15,8 @@ import { getMetadata } from "metabase/selectors/metadata"; import type { Card, + CardId, + DatabaseId, WritebackActionId, WritebackAction, WritebackQueryAction, @@ -33,8 +35,9 @@ import CreateActionForm, { interface OwnProps { actionId?: WritebackActionId; - modelId: number; - databaseId?: number; + modelId?: CardId; + databaseId?: DatabaseId; + onSubmit?: (action: WritebackAction) => void; onClose?: () => void; } @@ -78,6 +81,7 @@ function ActionCreator({ model, onCreateAction, onUpdateAction, + onSubmit, onClose, }: Props) { const { @@ -93,33 +97,37 @@ function ActionCreator({ const [showSaveModal, setShowSaveModal] = useState(false); - const isEditable = model.canWriteActions(); + const isEditable = isNew || model.canWriteActions(); const handleCreate = async (values: CreateActionFormValues) => { if (action.type !== "query") { return; // only query action creation is supported now } - await onCreateAction({ + const reduxAction = await onCreateAction({ ...action, ...values, visualization_settings: formSettings, } as WritebackQueryAction); + const createdAction = Actions.HACK_getObjectFromAction(reduxAction); // Sync the editor state with data from save modal form handleActionChange(values); setShowSaveModal(false); + onSubmit?.(createdAction); onClose?.(); }; - const handleUpdate = () => { + const handleUpdate = async () => { if (isSavedAction(action)) { - onUpdateAction({ + const reduxAction = await onUpdateAction({ ...action, model_id: model.id(), visualization_settings: formSettings, }); + const updatedAction = Actions.HACK_getObjectFromAction(reduxAction); + onSubmit?.(updatedAction); } }; @@ -196,7 +204,7 @@ export default _.compose( entityAlias: "initialAction", }), Questions.load({ - id: (state: State, props: OwnProps) => props.modelId, + id: (state: State, props: OwnProps) => props?.modelId, entityAlias: "modelCard", }), Database.loadList(), diff --git a/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx b/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx index 3bcef9728d0073e772f163f481f2fe445892ebc6..6fc16450d9d166bcb81ddb76d3daed8b2fff29f4 100644 --- a/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx +++ b/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx @@ -1,17 +1,19 @@ import React, { ReactNode, useCallback, useMemo, useState } from "react"; import { t } from "ttag"; +import type { LocationDescriptor } from "history"; import Modal from "metabase/components/Modal"; import EntityMenu from "metabase/components/EntityMenu"; import * as Urls from "metabase/lib/urls"; +import ActionCreator from "metabase/actions/containers/ActionCreator"; import CreateCollectionModal from "metabase/collections/containers/CreateCollectionModal"; import CreateDashboardModal from "metabase/dashboard/containers/CreateDashboardModal"; -import type { CollectionId } from "metabase-types/api"; +import type { CollectionId, WritebackAction } from "metabase-types/api"; -type ModalType = "new-dashboard" | "new-collection"; +type ModalType = "new-action" | "new-dashboard" | "new-collection"; export interface NewItemMenuProps { className?: string; @@ -20,10 +22,13 @@ export interface NewItemMenuProps { triggerIcon?: string; triggerTooltip?: string; analyticsContext?: string; + hasModels: boolean; hasDataAccess: boolean; hasNativeWrite: boolean; hasDatabaseWithJsonEngine: boolean; + hasDatabaseWithActionsEnabled: boolean; onCloseNavbar: () => void; + onChangeLocation: (nextLocation: LocationDescriptor) => void; } const NewItemMenu = ({ @@ -33,10 +38,13 @@ const NewItemMenu = ({ triggerIcon, triggerTooltip, analyticsContext, + hasModels, hasDataAccess, hasNativeWrite, hasDatabaseWithJsonEngine, + hasDatabaseWithActionsEnabled, onCloseNavbar, + onChangeLocation, }: NewItemMenuProps) => { const [modal, setModal] = useState<ModalType>(); @@ -44,6 +52,14 @@ const NewItemMenu = ({ setModal(undefined); }, []); + const handleActionCreated = useCallback( + (action: WritebackAction) => { + const nextLocation = Urls.modelDetail({ id: action.model_id }, "actions"); + onChangeLocation(nextLocation); + }, + [onChangeLocation], + ); + const menuItems = useMemo(() => { const items = []; @@ -98,11 +114,22 @@ const NewItemMenu = ({ }); } + if (hasModels && hasDatabaseWithActionsEnabled && hasNativeWrite) { + items.push({ + title: t`Action`, + icon: "bolt", + action: () => setModal("new-action"), + event: `${analyticsContext};New Action Click;`, + }); + } + return items; }, [ + hasModels, hasDataAccess, hasNativeWrite, hasDatabaseWithJsonEngine, + hasDatabaseWithActionsEnabled, analyticsContext, onCloseNavbar, ]); @@ -132,6 +159,13 @@ const NewItemMenu = ({ onClose={handleModalClose} /> </Modal> + ) : modal === "new-action" ? ( + <Modal wide enableTransition={false} onClose={handleModalClose}> + <ActionCreator + onSubmit={handleActionCreated} + onClose={handleModalClose} + /> + </Modal> ) : null} </> )} diff --git a/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx b/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx index 6a67af6debba88f863180085e432fbdbf9e2d4e1..4d36c35282b98f1de260ebc32f3d7cedf68759d7 100644 --- a/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx +++ b/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx @@ -1,36 +1,54 @@ import { connect } from "react-redux"; +import { push } from "react-router-redux"; import _ from "underscore"; -import { closeNavbar } from "metabase/redux/app"; + import NewItemMenu from "metabase/components/NewItemMenu"; + import Databases from "metabase/entities/databases"; +import Search from "metabase/entities/search"; + +import { closeNavbar } from "metabase/redux/app"; import { getHasDataAccess, getHasDatabaseWithJsonEngine, getHasNativeWrite, + getHasDatabaseWithActionsEnabled, } from "metabase/selectors/data"; -import { Database } from "metabase-types/api"; -import { State } from "metabase-types/store"; + +import type { Database, CollectionItem } from "metabase-types/api"; +import type { State } from "metabase-types/store"; interface MenuDatabaseProps { databases?: Database[]; + models?: CollectionItem[]; } const mapStateToProps = ( state: State, - { databases = [] }: MenuDatabaseProps, + { databases = [], models = [] }: MenuDatabaseProps, ) => ({ + hasModels: models.length > 0, hasDataAccess: getHasDataAccess(databases), hasNativeWrite: getHasNativeWrite(databases), hasDatabaseWithJsonEngine: getHasDatabaseWithJsonEngine(databases), + hasDatabaseWithActionsEnabled: getHasDatabaseWithActionsEnabled(databases), }); const mapDispatchToProps = { onCloseNavbar: closeNavbar, + onChangeLocation: push, }; export default _.compose( Databases.loadList({ loadingAndErrorWrapper: false, }), + Search.loadList({ + // Checking if there is at least one model, + // so we can decide if "Action" option should be shown + query: { models: "dataset", limit: 1 }, + loadingAndErrorWrapper: false, + listName: "models", + }), connect(mapStateToProps, mapDispatchToProps), )(NewItemMenu); diff --git a/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.unit.spec.tsx b/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.unit.spec.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e97497e046d7b55ea82feee5295b71ca72cf0fcf --- /dev/null +++ b/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.unit.spec.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import nock from "nock"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders, screen } from "__support__/ui"; +import { setupDatabasesEndpoints } from "__support__/server-mocks"; + +import type { Database } from "metabase-types/api"; +import { createMockCard, createMockDatabase } from "metabase-types/api/mocks"; + +import NewItemMenu from "./NewItemMenu"; + +jest.mock( + "metabase/actions/containers/ActionCreator", + () => + function ActionCreator() { + return <div data-testid="mock-action-editor" />; + }, +); + +console.warn = jest.fn(); +console.error = jest.fn(); + +type SetupOpts = { + databases?: Database[]; + hasModels?: boolean; +}; + +const SAMPLE_DATABASE = createMockDatabase({ + id: 1, + engine: "postgres", + name: "Sample Database", + native_permissions: "write", + is_sample: true, + settings: null, +}); + +const DB_WITH_ACTIONS = createMockDatabase({ + id: 2, + name: "Postgres with actions", + engine: "postgres", + native_permissions: "write", + settings: { "database-enable-actions": true }, +}); + +const DB_WITHOUT_WRITE_ACCESS = createMockDatabase({ + ...DB_WITH_ACTIONS, + id: 3, + native_permissions: "none", +}); + +function setup({ + databases = [SAMPLE_DATABASE, DB_WITH_ACTIONS], + hasModels = true, +}: SetupOpts = {}) { + const scope = nock(location.origin); + const models = hasModels ? [createMockCard({ dataset: true })] : []; + + setupDatabasesEndpoints(scope, databases); + + scope.get(/\/api\/search/).reply(200, { + available_models: ["dataset"], + models: ["dataset"], + data: models, + total: models.length, + }); + + renderWithProviders(<NewItemMenu trigger={<button>New</button>} />); + userEvent.click(screen.getByText("New")); +} + +describe("NewItemMenu", () => { + afterEach(() => { + nock.cleanAll(); + }); + + describe("New Action", () => { + it("should open action editor on click", async () => { + setup(); + + userEvent.click(await screen.findByText("Action")); + const modal = screen.getByRole("dialog"); + + expect(modal).toBeVisible(); + }); + + it("should not be visible if there are no databases with actions enabled", () => { + setup({ databases: [SAMPLE_DATABASE] }); + expect(screen.queryByText("Action")).not.toBeInTheDocument(); + }); + + it("should not be visible if user has no models", () => { + setup({ hasModels: false }); + expect(screen.queryByText("Action")).not.toBeInTheDocument(); + }); + + it("should not be visible if user has no write data access", () => { + setup({ databases: [DB_WITHOUT_WRITE_ACCESS] }); + expect(screen.queryByText("Action")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/metabase/lib/urls/models.ts b/frontend/src/metabase/lib/urls/models.ts index 41be2d3ea8a7db5dcd5ede04a8a28c20291ea8f1..229685c9ac8d7b20b287039174a9952552a22a23 100644 --- a/frontend/src/metabase/lib/urls/models.ts +++ b/frontend/src/metabase/lib/urls/models.ts @@ -18,7 +18,7 @@ export function model( } export function modelDetail(card: CardOrSearchResult, tab = "") { - const baseUrl = `${model(card)}/detail`; + const baseUrl = `${model({ ...card, dataset: true })}/detail`; return tab ? `${baseUrl}/${tab}` : baseUrl; } diff --git a/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx b/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx index 88d9364e87f20f27c564bf1edbed9f51399d3fa3..d469fe1d64847104ee82451b2d3aa8e0c6692406 100644 --- a/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx +++ b/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx @@ -22,7 +22,7 @@ const NewItemButton = ({ collectionId }: NewItemButtonProps) => { </NewButton> } collectionId={collectionId} - analyticsContext={"NavBar"} + analyticsContext="NavBar" /> ); }; diff --git a/frontend/src/metabase/selectors/data.ts b/frontend/src/metabase/selectors/data.ts index be48020e22ba1f0a21661926d62ab8287716ee0a..efd0e2514ca648274861714afca700b49ae0ff20 100644 --- a/frontend/src/metabase/selectors/data.ts +++ b/frontend/src/metabase/selectors/data.ts @@ -16,3 +16,9 @@ export const getHasNativeWrite = (databases: Database[]) => { export const getHasDatabaseWithJsonEngine = (databases: Database[]) => { return databases.some(d => getEngineNativeType(d.engine) === "json"); }; + +export const getHasDatabaseWithActionsEnabled = (databases: Database[]) => { + return databases.some( + database => !!database.settings?.["database-enable-actions"], + ); +}; diff --git a/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js b/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js index 13f5433b4cb7aca35f8622086a03df170f366917..6191fd8aaa44399280bc2eced5ccad53c021315e 100644 --- a/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js +++ b/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js @@ -146,6 +146,32 @@ describe( cy.findByText("Delete").should("not.exist"); }); + it("should allow to create an action with the New button", () => { + const QUERY = "UPDATE orders SET discount = {{ discount }}"; + cy.visit("/"); + + cy.findByText("New").click(); + popover().findByText("Action").click(); + + cy.findByText("Select a database").click(); + popover().findByText("QA Postgres12").click(); + + fillActionQuery(QUERY); + cy.findByRole("button", { name: "Save" }).click(); + modal().within(() => { + cy.findByLabelText("Name").type("Discount order"); + cy.findByText("Select a model").click(); + }); + popover().findByText(SAMPLE_ORDERS_MODEL.name).click(); + cy.findByRole("button", { name: "Create" }).click(); + + cy.get("@modelId").then(modelId => { + cy.url().should("include", `/model/${modelId}/detail/actions`); + }); + cy.findByText("Discount order").should("be.visible"); + cy.findByText(QUERY).should("be.visible"); + }); + it("should allow to execute actions from the model page", () => { cy.get("@modelId").then(modelId => { createAction({