diff --git a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx index 6cff6b53b031b85a48dd128efc2e5b0b61f02fee..2f22372c58d32451bd9ea7c467a56b450fb344a5 100644 --- a/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx +++ b/frontend/src/metabase/models/components/ModelDetailPage/ModelActionDetails/ModelActionDetails.tsx @@ -9,6 +9,7 @@ import Link from "metabase/core/components/Link"; import Actions from "metabase/entities/actions"; import { parseTimestamp } from "metabase/lib/time"; import * as Urls from "metabase/lib/urls"; +import { useConfirmation } from "metabase/hooks/use-confirmation"; import type { Card, WritebackAction } from "metabase-types/api"; import type { Dispatch, State } from "metabase-types/store"; @@ -36,6 +37,7 @@ interface OwnProps { interface DispatchProps { onEnableImplicitActions: () => void; onArchiveAction: (action: WritebackAction) => void; + onDeleteAction: (action: WritebackAction) => void; } interface ActionsLoaderProps { @@ -50,6 +52,8 @@ function mapDispatchToProps(dispatch: Dispatch, { model }: OwnProps) { dispatch(Actions.actions.enableImplicitActionsForModel(model.id())), onArchiveAction: (action: WritebackAction) => dispatch(Actions.objectActions.setArchived(action, true)), + onDeleteAction: (action: WritebackAction) => + dispatch(Actions.actions.delete({ id: action.id })), }; } @@ -58,26 +62,58 @@ function ModelActionDetails({ actions, onEnableImplicitActions, onArchiveAction, + onDeleteAction, }: Props) { + const { show: askConfirmation, modalContent: confirmationModal } = + useConfirmation(); + const database = model.database(); const hasActionsEnabled = database != null && database.hasActionsEnabled(); const canWrite = model.canWriteActions(); - const hasImplicitActions = actions.some(action => action.type === "implicit"); const actionsSorted = useMemo( () => _.sortBy(actions, mostRecentFirst), [actions], ); + const implicitActions = useMemo( + () => actions.filter(action => action.type === "implicit"), + [actions], + ); + + const onDeleteImplicitActions = useCallback(() => { + askConfirmation({ + title: t`Disable basic actions?`, + message: t`Disabling basic actions will also remove any buttons that use these actions. Are you sure you want to continue?`, + confirmButtonText: t`Disable`, + onConfirm: () => { + implicitActions.forEach(action => { + onDeleteAction(action); + }); + }, + }); + }, [implicitActions, askConfirmation, onDeleteAction]); + const menuItems = useMemo(() => { - return [ - { + const items = []; + const hasImplicitActions = implicitActions.length > 0; + + if (hasImplicitActions) { + items.push({ + title: t`Disable basic actions`, + icon: "bolt", + action: onDeleteImplicitActions, + }); + } else { + items.push({ title: t`Create basic actions`, icon: "bolt", action: onEnableImplicitActions, - }, - ]; - }, [onEnableImplicitActions]); + }); + } + + return items; + }, [implicitActions, onEnableImplicitActions, onDeleteImplicitActions]); const renderActionListItem = useCallback( (action: WritebackAction) => { @@ -104,13 +140,11 @@ function ModelActionDetails({ {canWrite && ( <ActionsHeader> <Button as={Link} to={newActionUrl}>{t`New action`}</Button> - {!hasImplicitActions && ( - <ActionMenu - triggerIcon="ellipsis" - items={menuItems} - triggerProps={ACTION_MENU_TRIGGER_PROPS} - /> - )} + <ActionMenu + triggerIcon="ellipsis" + items={menuItems} + triggerProps={{ "aria-label": t`Actions menu` }} + /> </ActionsHeader> )} {database && !hasActionsEnabled && ( @@ -128,6 +162,7 @@ function ModelActionDetails({ onCreateClick={onEnableImplicitActions} /> )} + {confirmationModal} </Root> ); } @@ -160,10 +195,6 @@ function mostRecentFirst(action: WritebackAction) { return -createdAt.unix(); } -const ACTION_MENU_TRIGGER_PROPS = { - "data-testid": "new-action-menu", -}; - export default _.compose( Actions.loadList({ query: (state: State, { model }: OwnProps) => ({ diff --git a/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.unit.spec.tsx b/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.unit.spec.tsx index dfeb9e70870076d69071e35436ed59ca276031d3..b381ddad4652aa2c32339bd4a1ed3406056b0653 100644 --- a/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.unit.spec.tsx +++ b/frontend/src/metabase/models/containers/ModelDetailPage/ModelDetailPage.unit.spec.tsx @@ -23,6 +23,7 @@ import { import { checkNotNull } from "metabase/core/utils/types"; import { ActionsApi } from "metabase/services"; +import Actions from "metabase/entities/actions"; import Models from "metabase/entities/questions"; import { ModalRoute } from "metabase/hoc/ModalRoute"; import { getMetadata } from "metabase/selectors/metadata"; @@ -605,7 +606,7 @@ describe("ModelDetailPage", () => { const action = createMockQueryAction({ model_id: model.id() }); await setupActions({ model, actions: [action] }); - userEvent.click(screen.getByTestId("new-action-menu")); + userEvent.click(screen.getByLabelText("Actions menu")); userEvent.click(screen.getByText("Create basic actions")); await waitFor(() => { @@ -668,12 +669,11 @@ describe("ModelDetailPage", () => { actions: createMockImplicitCUDActions(model.id()), }); + userEvent.click(screen.getByLabelText("Actions menu")); + expect( screen.queryByText(/Create basic action/i), ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("new-action-menu"), - ).not.toBeInTheDocument(); }); it("allows to archive a query action", async () => { @@ -709,6 +709,32 @@ describe("ModelDetailPage", () => { expect(screen.queryByText("Archive")).not.toBeInTheDocument(); }); + + it("allows to disable implicit actions", async () => { + const deleteActionSpy = jest.spyOn(Actions.actions, "delete"); + const model = getModel(); + const actions = createMockImplicitCUDActions(model.id()); + await setupActions({ model, actions }); + + userEvent.click(screen.getByLabelText("Actions menu")); + userEvent.click(screen.getByText("Disable basic actions")); + userEvent.click(screen.getByRole("button", { name: "Disable" })); + + actions.forEach(action => { + expect(deleteActionSpy).toHaveBeenCalledWith({ id: action.id }); + }); + }); + + it("doesn't allow to disable implicit actions if they don't exist", async () => { + const model = getModel(); + await setupActions({ model, actions: [] }); + + userEvent.click(screen.getByLabelText("Actions menu")); + + expect( + screen.queryByText("Disable basic actions"), + ).not.toBeInTheDocument(); + }); }); describe("read-only permissions", () => { @@ -750,9 +776,7 @@ describe("ModelDetailPage", () => { expect( screen.queryByText("Create basic actions"), ).not.toBeInTheDocument(); - expect( - screen.queryByTestId("new-action-menu"), - ).not.toBeInTheDocument(); + expect(screen.queryByTestId("actions-menu")).not.toBeInTheDocument(); }); it("doesn't allow to edit actions", async () => { diff --git a/frontend/test/__support__/server-mocks/action.ts b/frontend/test/__support__/server-mocks/action.ts index 74f1461e7fb03727621b6cb405a7b845b99e0f82..026a8cb6a57b31cab9de34300e710a2a6eef4096 100644 --- a/frontend/test/__support__/server-mocks/action.ts +++ b/frontend/test/__support__/server-mocks/action.ts @@ -8,6 +8,7 @@ import { export function setupActionEndpoints(scope: Scope, action: WritebackAction) { scope.get(`/api/action/${action.id}`).reply(200, action); scope.put(`/api/action/${action.id}`).reply(200, action); + scope.delete(`/api/action/${action.id}`).reply(200, action); } export function setupActionsEndpoints( 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 33dc6ea3e6a68579ca857cb519df1db9353be2ce..91d18ed00c46b113db129a057d84c35f0fc64228 100644 --- a/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js +++ b/frontend/test/metabase/scenarios/models/model-actions.cy.spec.js @@ -130,6 +130,17 @@ describe("scenarios > models > actions", () => { }); cy.findByRole("listitem", { name: "Delete Order" }).should("not.exist"); + + cy.findByLabelText("Actions menu").click(); + popover().findByText("Disable basic actions").click(); + modal().within(() => { + cy.findByText("Disable basic actions?").should("be.visible"); + cy.button("Disable").click(); + }); + cy.findByLabelText("Action list").should("not.exist"); + cy.findByText("Create").should("not.exist"); + cy.findByText("Update").should("not.exist"); + cy.findByText("Delete").should("not.exist"); }); it("should allow to execute actions from the model page", () => {