From 20a1235ccdebb3e5446033bf18a0c0df0756307e Mon Sep 17 00:00:00 2001
From: Anton Kulyk <kuliks.anton@gmail.com>
Date: Fri, 23 Dec 2022 15:05:19 +0000
Subject: [PATCH] Add model actions section to database admin page sidebar
 (#27367)

* Add action related stuff to database types

* Add utils checking actions support and state

* Ensure database's `getPlainObject` returns db type

* Add models actions section to database sidebar

* Address feedback
---
 .../src/metabase-lib/metadata/Database.ts     |  6 +-
 frontend/src/metabase-types/api/database.ts   |  2 +
 .../src/metabase-types/api/mocks/database.ts  |  1 +
 frontend/src/metabase/actions/utils.ts        |  7 ++
 .../ModelActionsSection.styled.tsx            | 24 +++++++
 .../ModelActionsSection.tsx                   | 37 ++++++++++
 .../Sidebar/ModelActionsSection/index.ts      |  1 +
 .../Sidebar/Sidebar.styled.tsx                |  4 ++
 .../DatabaseEditApp/Sidebar/Sidebar.tsx       | 51 +++++++++++---
 .../Sidebar/Sidebar.unit.spec.tsx             | 69 +++++++++++++++++++
 10 files changed, 193 insertions(+), 9 deletions(-)
 create mode 100644 frontend/src/metabase/actions/utils.ts
 create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.styled.tsx
 create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.tsx
 create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/index.ts

diff --git a/frontend/src/metabase-lib/metadata/Database.ts b/frontend/src/metabase-lib/metadata/Database.ts
index d9f8a47c2b4..d5eed628b78 100644
--- a/frontend/src/metabase-lib/metadata/Database.ts
+++ b/frontend/src/metabase-lib/metadata/Database.ts
@@ -1,6 +1,6 @@
 // eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-nocheck
-import { NativePermissions } from "metabase-types/api";
+import { Database as IDatabase, NativePermissions } from "metabase-types/api";
 import { generateSchemaId } from "metabase-lib/metadata/utils/schema";
 import { createLookupByProperty, memoizeClass } from "metabase-lib/utils";
 import Question from "../Question";
@@ -31,6 +31,10 @@ class DatabaseInner extends Base {
   // Only appears in  GET /api/database/:id
   "can-manage"?: boolean;
 
+  getPlainObject(): IDatabase {
+    return this._plainObject;
+  }
+
   // TODO Atte Keinänen 6/11/17: List all fields here (currently only in types/Database)
   displayName() {
     return this.name;
diff --git a/frontend/src/metabase-types/api/database.ts b/frontend/src/metabase-types/api/database.ts
index dfcfbeccbcf..a3c5452f932 100644
--- a/frontend/src/metabase-types/api/database.ts
+++ b/frontend/src/metabase-types/api/database.ts
@@ -7,9 +7,11 @@ export type InitialSyncStatus = "incomplete" | "complete" | "aborted";
 
 export type DatabaseSettings = {
   [key: string]: any;
+  "database-enable-actions"?: boolean;
 };
 
 export type DatabaseFeature =
+  | "actions"
   | "basic-aggregations"
   | "binning"
   | "case-sensitivity-string-filter-options"
diff --git a/frontend/src/metabase-types/api/mocks/database.ts b/frontend/src/metabase-types/api/mocks/database.ts
index 6a3874a56d0..c69cd290141 100644
--- a/frontend/src/metabase-types/api/mocks/database.ts
+++ b/frontend/src/metabase-types/api/mocks/database.ts
@@ -1,6 +1,7 @@
 import { Database, DatabaseData, DatabaseFeature } from "metabase-types/api";
 
 export const COMMON_DATABASE_FEATURES: DatabaseFeature[] = [
+  "actions",
   "basic-aggregations",
   "binning",
   "case-sensitivity-string-filter-options",
diff --git a/frontend/src/metabase/actions/utils.ts b/frontend/src/metabase/actions/utils.ts
new file mode 100644
index 00000000000..b052e29f9fc
--- /dev/null
+++ b/frontend/src/metabase/actions/utils.ts
@@ -0,0 +1,7 @@
+import type { Database } from "metabase-types/api";
+
+export const checkDatabaseSupportsActions = (database: Database) =>
+  database.features.includes("actions");
+
+export const checkDatabaseActionsEnabled = (database: Database) =>
+  !!database.settings?.["database-enable-actions"];
diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.styled.tsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.styled.tsx
new file mode 100644
index 00000000000..81b6823bc79
--- /dev/null
+++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.styled.tsx
@@ -0,0 +1,24 @@
+import styled from "@emotion/styled";
+import { color } from "metabase/lib/colors";
+
+export const ToggleContainer = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+export const Label = styled.label`
+  width: 100%;
+  cursor: pointer;
+
+  color: ${color("text-medium")};
+  font-weight: 700;
+`;
+
+export const Description = styled.p`
+  margin-top: 24px;
+
+  color: ${color("text-medium")};
+  line-height: 22px;
+`;
diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.tsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.tsx
new file mode 100644
index 00000000000..40254154362
--- /dev/null
+++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/ModelActionsSection.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { t } from "ttag";
+
+import Toggle from "metabase/core/components/Toggle";
+
+import {
+  ToggleContainer,
+  Label,
+  Description,
+} from "./ModelActionsSection.styled";
+
+interface ModelActionsSectionProps {
+  hasModelActionsEnabled: boolean;
+  onToggleModelActionsEnabled: (enabled: boolean) => void;
+}
+
+function ModelActionsSection({
+  hasModelActionsEnabled,
+  onToggleModelActionsEnabled,
+}: ModelActionsSectionProps) {
+  return (
+    <div>
+      <ToggleContainer>
+        <Label htmlFor="model-actions-toggle">{t`Model actions`}</Label>
+        <Toggle
+          id="model-actions-toggle"
+          value={hasModelActionsEnabled}
+          onChange={onToggleModelActionsEnabled}
+        />
+      </ToggleContainer>
+      <Description>{t`Allow actions from models created from this data to be run. Actions are able to read, write, and possibly delete data.`}</Description>
+      <Description>{t`Note: Your database user will need write permissions.`}</Description>
+    </div>
+  );
+}
+
+export default ModelActionsSection;
diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/index.ts b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/index.ts
new file mode 100644
index 00000000000..d77494491e5
--- /dev/null
+++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/ModelActionsSection/index.ts
@@ -0,0 +1 @@
+export { default } from "./ModelActionsSection";
diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.styled.tsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.styled.tsx
index ebed5270a8b..d60fb45e6f0 100644
--- a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.styled.tsx
+++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.styled.tsx
@@ -46,3 +46,7 @@ export const SidebarContent = styled.div`
     margin-bottom: 0;
   }
 `;
+
+export const ModelActionsSidebarContent = styled(SidebarContent)`
+  margin-top: 32px;
+`;
diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx
index a3a37b4417e..79dc01cb473 100644
--- a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx
+++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx
@@ -7,19 +7,29 @@ import ConfirmContent from "metabase/components/ConfirmContent";
 import ModalWithTrigger from "metabase/components/ModalWithTrigger";
 
 import { isSyncCompleted } from "metabase/lib/syncing";
+import {
+  checkDatabaseSupportsActions,
+  checkDatabaseActionsEnabled,
+} from "metabase/actions/utils";
 import DeleteDatabaseModal from "metabase/admin/databases/components/DeleteDatabaseModal.jsx";
 
-import type { DatabaseId } from "metabase-types/api";
+import type { Database as IDatabase, DatabaseId } from "metabase-types/api";
 import type Database from "metabase-lib/metadata/Database";
 
+import ModelActionsSection from "./ModelActionsSection";
 import ModelCachingControl from "./ModelCachingControl";
-import { SidebarRoot, SidebarContent, SidebarGroup } from "./Sidebar.styled";
+import {
+  SidebarRoot,
+  SidebarContent,
+  SidebarGroup,
+  ModelActionsSidebarContent,
+} from "./Sidebar.styled";
 
 interface DatabaseEditAppSidebarProps {
   database: Database;
   isAdmin: boolean;
   isModelPersistenceEnabled: boolean;
-  updateDatabase: (database: Database) => void;
+  updateDatabase: (database: { id: DatabaseId } & Partial<IDatabase>) => void;
   syncDatabaseSchema: (databaseId: DatabaseId) => void;
   dismissSyncSpinner: (databaseId: DatabaseId) => void;
   rescanDatabaseFields: (databaseId: DatabaseId) => void;
@@ -29,6 +39,7 @@ interface DatabaseEditAppSidebarProps {
 
 const DatabaseEditAppSidebar = ({
   database,
+  updateDatabase,
   deleteDatabase,
   syncDatabaseSchema,
   dismissSyncSpinner,
@@ -40,33 +51,47 @@ const DatabaseEditAppSidebar = ({
   const discardSavedFieldValuesModal = useRef<any>();
   const deleteDatabaseModal = useRef<any>();
 
+  const isEditingDatabase = !!database.id;
+
   const isSynced = isSyncCompleted(database);
+  const hasModelActionsSection =
+    isEditingDatabase &&
+    checkDatabaseSupportsActions(database.getPlainObject());
   const hasModelCachingSection =
     isModelPersistenceEnabled && database.supportsPersistence();
 
   const handleSyncDatabaseSchema = useCallback(
     () => syncDatabaseSchema(database.id),
-    [database, syncDatabaseSchema],
+    [database.id, syncDatabaseSchema],
   );
 
   const handleReScanFieldValues = useCallback(
     () => rescanDatabaseFields(database.id),
-    [database, rescanDatabaseFields],
+    [database.id, rescanDatabaseFields],
   );
 
   const handleDismissSyncSpinner = useCallback(
     () => dismissSyncSpinner(database.id),
-    [database, dismissSyncSpinner],
+    [database.id, dismissSyncSpinner],
+  );
+
+  const handleToggleModelActionsEnabled = useCallback(
+    (nextValue: boolean) =>
+      updateDatabase({
+        id: database.id,
+        settings: { "database-enable-actions": nextValue },
+      }),
+    [database.id, updateDatabase],
   );
 
   const handleDiscardSavedFieldValues = useCallback(
     () => discardSavedFieldValues(database.id),
-    [database, discardSavedFieldValues],
+    [database.id, discardSavedFieldValues],
   );
 
   const handleDeleteDatabase = useCallback(
     () => deleteDatabase(database.id, true),
-    [database, deleteDatabase],
+    [database.id, deleteDatabase],
   );
 
   const handleSavedFieldsModalClose = useCallback(() => {
@@ -162,6 +187,16 @@ const DatabaseEditAppSidebar = ({
           </SidebarGroup.List>
         </SidebarGroup>
       </SidebarContent>
+      {hasModelActionsSection && (
+        <ModelActionsSidebarContent>
+          <ModelActionsSection
+            hasModelActionsEnabled={checkDatabaseActionsEnabled(
+              database.getPlainObject(),
+            )}
+            onToggleModelActionsEnabled={handleToggleModelActionsEnabled}
+          />
+        </ModelActionsSidebarContent>
+      )}
     </SidebarRoot>
   );
 };
diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.tsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.tsx
index 916f14c97ae..0f6de0dd6e3 100644
--- a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.tsx
+++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.tsx
@@ -163,6 +163,75 @@ describe("DatabaseEditApp/Sidebar", () => {
     });
   });
 
+  describe("model actions control", () => {
+    it("is shown if database supports actions", () => {
+      setup();
+      expect(screen.getByLabelText(/Model actions/i)).toBeInTheDocument();
+    });
+
+    it("is shown for non-admin users", () => {
+      setup({ isAdmin: false });
+      expect(screen.getByLabelText(/Model actions/i)).toBeInTheDocument();
+    });
+
+    it("shows if actions are enabled", () => {
+      setup({
+        database: createMockDatabase({
+          settings: { "database-enable-actions": true },
+        }),
+      });
+
+      expect(screen.getByLabelText(/Model actions/i)).toBeChecked();
+    });
+
+    it("shows if actions are disabled", () => {
+      setup({
+        database: createMockDatabase({
+          settings: { "database-enable-actions": false },
+        }),
+      });
+
+      expect(screen.getByLabelText(/Model actions/i)).not.toBeChecked();
+    });
+
+    it("isn't shown if database doesn't support actions", () => {
+      const features = _.without(COMMON_DATABASE_FEATURES, "actions");
+      setup({ database: createMockDatabase({ features }) });
+
+      expect(screen.queryByText(/Model actions/i)).not.toBeInTheDocument();
+    });
+
+    it("isn't shown when adding a new database", () => {
+      setup({ database: createMockDatabase({ id: undefined }) });
+      expect(screen.queryByText(/Model actions/i)).not.toBeInTheDocument();
+    });
+
+    it("enables actions", () => {
+      const { database, updateDatabase } = setup();
+
+      userEvent.click(screen.getByLabelText(/Model actions/i));
+
+      expect(updateDatabase).toBeCalledWith({
+        id: database.id,
+        settings: { "database-enable-actions": true },
+      });
+    });
+
+    it("disables actions", () => {
+      const database = createMockDatabase({
+        settings: { "database-enable-actions": true },
+      });
+      const { updateDatabase } = setup({ database });
+
+      userEvent.click(screen.getByLabelText(/Model actions/i));
+
+      expect(updateDatabase).toBeCalledWith({
+        id: database.id,
+        settings: { "database-enable-actions": false },
+      });
+    });
+  });
+
   describe("model caching control", () => {
     it("isn't shown if model caching is turned off globally", () => {
       setup({ isModelPersistenceEnabled: false });
-- 
GitLab