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