diff --git a/frontend/src/metabase-lib/metadata/Database.ts b/frontend/src/metabase-lib/metadata/Database.ts index d9f8a47c2b4156bf8c5c581d4434f121da835282..d5eed628b784ae1cd7847610c7b9d794b8d39aa8 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 dfcfbeccbcf4a58db4bf800014de9f96d7194c79..a3c5452f93230dc10562d24c5a353a2cada52167 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 6a3874a56d0ed6835cc5980d0f7913d84dd7c909..c69cd290141424ac07bd81796e587fb59db1ab15 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 0000000000000000000000000000000000000000..b052e29f9fc6225a34e60c4858f3afbda9b1ed02 --- /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 0000000000000000000000000000000000000000..81b6823bc79a6a4f5b9ef1156e99a489548ccb0c --- /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 0000000000000000000000000000000000000000..4025415436287760e5d9956225f3b58522ca85e4 --- /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 0000000000000000000000000000000000000000..d77494491e59f8e451d6af4dde6f5f4ef6b3c4cf --- /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 ebed5270a8b7f0bd9790316abe0b909c6611b135..d60fb45e6f082637664c7f6f38e789c01dea1036 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 a3a37b4417eada8af53b5255ee92a24e9e9c1f77..79dc01cb473fa9791db1304f8da73271876ec696 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 916f14c97ae338f0ed2585a0681534c609c36b37..0f6de0dd6e3056027ff0d3cbaf49e3aedff37563 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 });