From 048659306211583c6b6378f70bc3ee9222587c8c Mon Sep 17 00:00:00 2001 From: Anton Kulyk <kuliks.anton@gmail.com> Date: Fri, 23 Dec 2022 10:04:29 +0000 Subject: [PATCH] Clean up admin database page sidebar (#27364) * Clean up tests * Port `DatabaseFeature` type to `metabase-types/api` * Add more tests * Extract styled components * Remove redundant class names * Remove `"Actions"` CSS class * Convert `Sidebar` to TypeScript * Extract callbacks * Remove redundant CSS classes * Sort imports * Extract more styled components * That wasn't supposed to be here --- frontend/src/metabase-types/api/database.ts | 15 +- .../src/metabase-types/api/mocks/database.ts | 16 +- .../DatabaseEditApp/Sidebar/Sidebar.jsx | 138 ---------- .../Sidebar/Sidebar.styled.tsx | 37 +++ .../DatabaseEditApp/Sidebar/Sidebar.tsx | 169 ++++++++++++ .../Sidebar/Sidebar.unit.spec.js | 161 ----------- .../Sidebar/Sidebar.unit.spec.tsx | 251 ++++++++++++++++++ frontend/src/metabase/css/admin.css | 13 - .../database-details-permissions.cy.spec.js | 2 +- 9 files changed, 487 insertions(+), 315 deletions(-) delete mode 100644 frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.jsx create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx delete mode 100644 frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.js create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.tsx diff --git a/frontend/src/metabase-types/api/database.ts b/frontend/src/metabase-types/api/database.ts index 154abdcbeaf..dfcfbeccbcf 100644 --- a/frontend/src/metabase-types/api/database.ts +++ b/frontend/src/metabase-types/api/database.ts @@ -9,16 +9,29 @@ export type DatabaseSettings = { [key: string]: any; }; +export type DatabaseFeature = + | "basic-aggregations" + | "binning" + | "case-sensitivity-string-filter-options" + | "expression-aggregations" + | "expressions" + | "foreign-keys" + | "native-parameters" + | "nested-queries" + | "standard-deviation-aggregations" + | "persist-models" + | "persist-models-enabled"; + export interface Database extends DatabaseData { id: DatabaseId; is_saved_questions: boolean; + features: DatabaseFeature[]; creator_id?: number; created_at: string; timezone?: string; native_permissions: NativePermissions; initial_sync_status: InitialSyncStatus; - // appears in frontend/src/metabase/writeback/utils.ts settings?: DatabaseSettings | null; // Only appears in GET /api/database/:id diff --git a/frontend/src/metabase-types/api/mocks/database.ts b/frontend/src/metabase-types/api/mocks/database.ts index 674b1052b9d..6a3874a56d0 100644 --- a/frontend/src/metabase-types/api/mocks/database.ts +++ b/frontend/src/metabase-types/api/mocks/database.ts @@ -1,4 +1,17 @@ -import { Database, DatabaseData } from "metabase-types/api"; +import { Database, DatabaseData, DatabaseFeature } from "metabase-types/api"; + +export const COMMON_DATABASE_FEATURES: DatabaseFeature[] = [ + "basic-aggregations", + "binning", + "case-sensitivity-string-filter-options", + "expression-aggregations", + "expressions", + "foreign-keys", + "native-parameters", + "nested-queries", + "standard-deviation-aggregations", + "persist-models", +]; export const createMockDatabase = (opts?: Partial<Database>): Database => ({ ...createMockDatabaseData(opts), @@ -10,6 +23,7 @@ export const createMockDatabase = (opts?: Partial<Database>): Database => ({ timezone: "UTC", native_permissions: "write", initial_sync_status: "complete", + features: COMMON_DATABASE_FEATURES, ...opts, }); diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.jsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.jsx deleted file mode 100644 index e1d1a9b4e90..00000000000 --- a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useRef } from "react"; -import PropTypes from "prop-types"; -import { t } from "ttag"; - -import { isSyncCompleted } from "metabase/lib/syncing"; -import DeleteDatabaseModal from "metabase/admin/databases/components/DeleteDatabaseModal.jsx"; -import ActionButton from "metabase/components/ActionButton"; -import ModalWithTrigger from "metabase/components/ModalWithTrigger"; -import ConfirmContent from "metabase/components/ConfirmContent"; -import Button from "metabase/core/components/Button"; - -import ModelCachingControl from "./ModelCachingControl"; -import { SidebarRoot } from "./Sidebar.styled"; - -const propTypes = { - database: PropTypes.object.isRequired, - updateDatabase: PropTypes.func.isRequired, - deleteDatabase: PropTypes.func.isRequired, - syncDatabaseSchema: PropTypes.func.isRequired, - dismissSyncSpinner: PropTypes.func.isRequired, - rescanDatabaseFields: PropTypes.func.isRequired, - discardSavedFieldValues: PropTypes.func.isRequired, - isAdmin: PropTypes.bool, - isWritebackEnabled: PropTypes.bool, - isModelPersistenceEnabled: PropTypes.bool, -}; - -const DatabaseEditAppSidebar = ({ - database, - deleteDatabase, - syncDatabaseSchema, - dismissSyncSpinner, - rescanDatabaseFields, - discardSavedFieldValues, - isAdmin, - isWritebackEnabled, - isModelPersistenceEnabled, -}) => { - const discardSavedFieldValuesModal = useRef(); - const deleteDatabaseModal = useRef(); - - return ( - <SidebarRoot> - <div className="Actions bg-light rounded p3"> - <div className="Actions-group"> - <label className="Actions-groupLabel block text-bold">{t`Actions`}</label> - <ol> - {!isSyncCompleted(database) && ( - <li> - <Button disabled borderless>{t`Syncing database…`}</Button> - </li> - )} - <li> - <ActionButton - actionFn={() => syncDatabaseSchema(database.id)} - className="Button Button--syncDbSchema" - normalText={t`Sync database schema now`} - activeText={t`Starting…`} - failedText={t`Failed to sync`} - successText={t`Sync triggered!`} - /> - </li> - <li className="mt2"> - <ActionButton - actionFn={() => rescanDatabaseFields(database.id)} - className="Button Button--rescanFieldValues" - normalText={t`Re-scan field values now`} - activeText={t`Starting…`} - failedText={t`Failed to start scan`} - successText={t`Scan triggered!`} - /> - </li> - {database["initial_sync_status"] !== "complete" && ( - <li className="mt2"> - <ActionButton - actionFn={() => dismissSyncSpinner(database.id)} - className="Button Button--dismissSyncSpinner" - normalText={t`Dismiss sync spinner manually`} - activeText={t`Dismissing…`} - failedText={t`Failed to dismiss sync spinner`} - successText={t`Sync spinners dismissed!`} - /> - </li> - )} - {isModelPersistenceEnabled && database.supportsPersistence() && ( - <li className="mt2"> - <ModelCachingControl database={database} /> - </li> - )} - </ol> - </div> - - <div className="Actions-group"> - <label className="Actions-groupLabel block text-bold">{t`Danger Zone`}</label> - <ol> - {isSyncCompleted(database) && ( - <li> - <ModalWithTrigger - ref={discardSavedFieldValuesModal} - triggerClasses="Button Button--danger Button--discardSavedFieldValues" - triggerElement={t`Discard saved field values`} - > - <ConfirmContent - title={t`Discard saved field values`} - onClose={() => - discardSavedFieldValuesModal.current.toggle() - } - onAction={() => discardSavedFieldValues(database.id)} - /> - </ModalWithTrigger> - </li> - )} - - {isAdmin && ( - <li className="mt2"> - <ModalWithTrigger - ref={deleteDatabaseModal} - triggerClasses="Button Button--deleteDatabase Button--danger" - triggerElement={t`Remove this database`} - > - <DeleteDatabaseModal - database={database} - onClose={() => deleteDatabaseModal.current.toggle()} - onDelete={() => deleteDatabase(database.id, true)} - /> - </ModalWithTrigger> - </li> - )} - </ol> - </div> - </div> - </SidebarRoot> - ); -}; - -DatabaseEditAppSidebar.propTypes = propTypes; - -export default DatabaseEditAppSidebar; 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 d074e1be01e..ebed5270a8b 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 @@ -1,4 +1,5 @@ import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; import { breakpointMinSmall } from "metabase/styled-components/theme"; export const SidebarRoot = styled.div` @@ -9,3 +10,39 @@ export const SidebarRoot = styled.div` margin-left: 2rem; } `; + +const _SidebarGroup = styled.div` + margin-bottom: 2em; +`; + +const SidebarGroupName = styled.span` + display: block; + + font-size: 1em; + font-weight: bold; + + margin-bottom: 1em; +`; + +const SidebarGroupList = styled.ol``; + +const SidebarGroupListItem = styled.li<{ hasMarginTop?: boolean }>` + ${({ hasMarginTop = true }) => hasMarginTop && "margin-top: 1rem;"} +`; + +export const SidebarGroup = Object.assign(_SidebarGroup, { + Name: SidebarGroupName, + List: SidebarGroupList, + ListItem: SidebarGroupListItem, +}); + +export const SidebarContent = styled.div` + padding: 1.5rem; + + background-color: ${color("bg-light")}; + border-radius: 8px; + + ${SidebarGroup}:last-child { + margin-bottom: 0; + } +`; diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx new file mode 100644 index 00000000000..a3a37b4417e --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.tsx @@ -0,0 +1,169 @@ +import React, { useCallback, useRef } from "react"; +import { t } from "ttag"; + +import Button from "metabase/core/components/Button"; +import ActionButton from "metabase/components/ActionButton"; +import ConfirmContent from "metabase/components/ConfirmContent"; +import ModalWithTrigger from "metabase/components/ModalWithTrigger"; + +import { isSyncCompleted } from "metabase/lib/syncing"; +import DeleteDatabaseModal from "metabase/admin/databases/components/DeleteDatabaseModal.jsx"; + +import type { DatabaseId } from "metabase-types/api"; +import type Database from "metabase-lib/metadata/Database"; + +import ModelCachingControl from "./ModelCachingControl"; +import { SidebarRoot, SidebarContent, SidebarGroup } from "./Sidebar.styled"; + +interface DatabaseEditAppSidebarProps { + database: Database; + isAdmin: boolean; + isModelPersistenceEnabled: boolean; + updateDatabase: (database: Database) => void; + syncDatabaseSchema: (databaseId: DatabaseId) => void; + dismissSyncSpinner: (databaseId: DatabaseId) => void; + rescanDatabaseFields: (databaseId: DatabaseId) => void; + discardSavedFieldValues: (databaseId: DatabaseId) => void; + deleteDatabase: (databaseId: DatabaseId, isDetailView: boolean) => void; +} + +const DatabaseEditAppSidebar = ({ + database, + deleteDatabase, + syncDatabaseSchema, + dismissSyncSpinner, + rescanDatabaseFields, + discardSavedFieldValues, + isAdmin, + isModelPersistenceEnabled, +}: DatabaseEditAppSidebarProps) => { + const discardSavedFieldValuesModal = useRef<any>(); + const deleteDatabaseModal = useRef<any>(); + + const isSynced = isSyncCompleted(database); + const hasModelCachingSection = + isModelPersistenceEnabled && database.supportsPersistence(); + + const handleSyncDatabaseSchema = useCallback( + () => syncDatabaseSchema(database.id), + [database, syncDatabaseSchema], + ); + + const handleReScanFieldValues = useCallback( + () => rescanDatabaseFields(database.id), + [database, rescanDatabaseFields], + ); + + const handleDismissSyncSpinner = useCallback( + () => dismissSyncSpinner(database.id), + [database, dismissSyncSpinner], + ); + + const handleDiscardSavedFieldValues = useCallback( + () => discardSavedFieldValues(database.id), + [database, discardSavedFieldValues], + ); + + const handleDeleteDatabase = useCallback( + () => deleteDatabase(database.id, true), + [database, deleteDatabase], + ); + + const handleSavedFieldsModalClose = useCallback(() => { + discardSavedFieldValuesModal.current.close(); + }, []); + + const handleDeleteDatabaseModalClose = useCallback(() => { + deleteDatabaseModal.current.close(); + }, []); + + return ( + <SidebarRoot> + <SidebarContent data-testid="database-actions-panel"> + <SidebarGroup> + <SidebarGroup.Name>{t`Actions`}</SidebarGroup.Name> + <SidebarGroup.List> + {!isSynced && ( + <SidebarGroup.ListItem hasMarginTop={false}> + <Button disabled borderless>{t`Syncing database…`}</Button> + </SidebarGroup.ListItem> + )} + <SidebarGroup.ListItem hasMarginTop={false}> + <ActionButton + actionFn={handleSyncDatabaseSchema} + normalText={t`Sync database schema now`} + activeText={t`Starting…`} + failedText={t`Failed to sync`} + successText={t`Sync triggered!`} + /> + </SidebarGroup.ListItem> + <SidebarGroup.ListItem> + <ActionButton + actionFn={handleReScanFieldValues} + normalText={t`Re-scan field values now`} + activeText={t`Starting…`} + failedText={t`Failed to start scan`} + successText={t`Scan triggered!`} + /> + </SidebarGroup.ListItem> + {!isSynced && ( + <SidebarGroup.ListItem> + <ActionButton + actionFn={handleDismissSyncSpinner} + normalText={t`Dismiss sync spinner manually`} + activeText={t`Dismissing…`} + failedText={t`Failed to dismiss sync spinner`} + successText={t`Sync spinners dismissed!`} + /> + </SidebarGroup.ListItem> + )} + {hasModelCachingSection && ( + <SidebarGroup.ListItem> + <ModelCachingControl database={database} /> + </SidebarGroup.ListItem> + )} + </SidebarGroup.List> + </SidebarGroup> + <SidebarGroup> + <SidebarGroup.Name>{t`Danger Zone`}</SidebarGroup.Name> + <SidebarGroup.List> + {isSyncCompleted(database) && ( + <SidebarGroup.ListItem hasMarginTop={false}> + <ModalWithTrigger + triggerElement={ + <Button danger>{t`Discard saved field values`}</Button> + } + ref={discardSavedFieldValuesModal} + > + <ConfirmContent + title={t`Discard saved field values`} + onClose={handleSavedFieldsModalClose} + onAction={handleDiscardSavedFieldValues} + /> + </ModalWithTrigger> + </SidebarGroup.ListItem> + )} + {isAdmin && ( + <SidebarGroup.ListItem> + <ModalWithTrigger + triggerElement={ + <Button danger>{t`Remove this database`}</Button> + } + ref={deleteDatabaseModal} + > + <DeleteDatabaseModal + database={database} + onClose={handleDeleteDatabaseModalClose} + onDelete={handleDeleteDatabase} + /> + </ModalWithTrigger> + </SidebarGroup.ListItem> + )} + </SidebarGroup.List> + </SidebarGroup> + </SidebarContent> + </SidebarRoot> + ); +}; + +export default DatabaseEditAppSidebar; diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.js b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.js deleted file mode 100644 index 8f2affd55ca..00000000000 --- a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; - -import Sidebar from "./Sidebar"; - -it("syncs database schema", () => { - const databaseId = 1; - const database = { - id: databaseId, - initial_sync_status: "complete", - supportsPersistence: () => true, - isPersisted: () => false, - }; - const syncDatabaseSchema = jest.fn(); - - render( - <Sidebar database={database} syncDatabaseSchema={syncDatabaseSchema} />, - ); - - const syncButton = screen.getByText("Sync database schema now"); - - fireEvent.click(syncButton); - - expect(syncDatabaseSchema).toHaveBeenCalledWith(databaseId); -}); - -it("rescans database field values", () => { - const databaseId = 1; - const database = { - id: databaseId, - initial_sync_status: "complete", - supportsPersistence: () => true, - isPersisted: () => false, - }; - const rescanDatabaseFields = jest.fn(); - - render( - <Sidebar database={database} rescanDatabaseFields={rescanDatabaseFields} />, - ); - - const rescanButton = screen.getByText("Re-scan field values now"); - - fireEvent.click(rescanButton); - - expect(rescanDatabaseFields).toHaveBeenCalledWith(databaseId); -}); - -it("can cancel sync and just forgets about initial sync (#20863)", () => { - const databaseId = 1; - const database = { - id: databaseId, - initial_sync_status: "incomplete", - supportsPersistence: () => true, - isPersisted: () => false, - }; - const dismissSyncSpinner = jest.fn(); - - render( - <Sidebar database={database} dismissSyncSpinner={dismissSyncSpinner} />, - ); - - const dismissButton = screen.getByText("Dismiss sync spinner manually"); - fireEvent.click(dismissButton); - expect(dismissSyncSpinner).toHaveBeenCalledWith(databaseId); -}); - -it("discards saved field values", () => { - const databaseId = 1; - const database = { - id: databaseId, - initial_sync_status: "complete", - supportsPersistence: () => true, - isPersisted: () => false, - }; - const discardSavedFieldValues = jest.fn(); - - render( - <Sidebar - database={database} - discardSavedFieldValues={discardSavedFieldValues} - />, - ); - - const discardButton = screen.getByText("Discard saved field values"); - - fireEvent.click(discardButton); - - expect(screen.getAllByText("Discard saved field values").length).toBe(2); - - const cancelButton = screen.getByText("Cancel"); - - fireEvent.click(cancelButton); - - fireEvent.click(discardButton); - - const yesButton = screen.getByText("Yes"); - - fireEvent.click(yesButton); - - expect(discardSavedFieldValues).toHaveBeenCalledWith(databaseId); -}); - -it("removes database", () => { - const databaseId = 1; - const name = "DB Name"; - const database = { - id: databaseId, - name, - supportsPersistence: () => true, - isPersisted: () => false, - }; - const deleteDatabase = jest.fn(); - - render( - <Sidebar database={database} deleteDatabase={deleteDatabase} isAdmin />, - ); - - const removeDBButton = screen.getByText("Remove this database"); - - fireEvent.click(removeDBButton); - - screen.getByText(`Delete the ${name} database?`); - - const cancelButton = screen.getByText("Cancel"); - - fireEvent.click(cancelButton); - - fireEvent.click(removeDBButton); - - const input = screen.getByRole("textbox"); - - userEvent.type(input, name); - - const deleteButton = screen.getByText("Delete"); - - fireEvent.click(deleteButton); - - expect(deleteDatabase).toHaveBeenCalled(); -}); - -it("does not allow to remove databases for non-admins", () => { - const database = { id: 1, name: "DB Name" }; - render(<Sidebar database={database} deleteDatabase={jest.fn()} />); - expect(screen.queryByText("Remove this database")).toBeNull(); -}); - -it("shows loading indicator when a sync is in progress", () => { - const databaseId = 1; - const database = { - id: databaseId, - initial_sync_status: "incomplete", - supportsPersistence: () => true, - isPersisted: () => false, - }; - - render(<Sidebar database={database} />); - - const statusButton = screen.getByText("Syncing database…"); - expect(statusButton).toBeInTheDocument(); -}); 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 new file mode 100644 index 00000000000..916f14c97ae --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.tsx @@ -0,0 +1,251 @@ +import React from "react"; +import _ from "underscore"; +import { + getByRole, + getByText, + render, + screen, + waitForElementToBeRemoved, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import MetabaseSettings from "metabase/lib/settings"; +import Utils from "metabase/lib/utils"; + +import type { InitialSyncStatus } from "metabase-types/api"; + +import { + createMockDatabase, + COMMON_DATABASE_FEATURES, +} from "metabase-types/api/mocks"; +import Database from "metabase-lib/metadata/Database"; + +import Sidebar from "./Sidebar"; + +const NOT_SYNCED_DB_STATUSES: InitialSyncStatus[] = ["aborted", "incomplete"]; + +function getModal() { + return document.querySelector(".Modal") as HTMLElement; +} + +function setup({ + database = createMockDatabase(), + isAdmin = true, + isModelPersistenceEnabled = false, +} = {}) { + // Using mockResolvedValue since `ActionButton` component + // the Sidebar is using is expecting these callbacks to be async + const updateDatabase = jest.fn().mockResolvedValue({}); + const syncDatabaseSchema = jest.fn().mockResolvedValue({}); + const rescanDatabaseFields = jest.fn().mockResolvedValue({}); + const discardSavedFieldValues = jest.fn().mockResolvedValue({}); + const dismissSyncSpinner = jest.fn().mockResolvedValue({}); + const deleteDatabase = jest.fn().mockResolvedValue({}); + + const utils = render( + <Sidebar + database={new Database(database)} + isAdmin={isAdmin} + isModelPersistenceEnabled={isModelPersistenceEnabled} + updateDatabase={updateDatabase} + syncDatabaseSchema={syncDatabaseSchema} + rescanDatabaseFields={rescanDatabaseFields} + discardSavedFieldValues={discardSavedFieldValues} + dismissSyncSpinner={dismissSyncSpinner} + deleteDatabase={deleteDatabase} + />, + ); + + return { + ...utils, + database, + updateDatabase, + syncDatabaseSchema, + rescanDatabaseFields, + discardSavedFieldValues, + dismissSyncSpinner, + deleteDatabase, + }; +} + +function mockMetabaseSettings() { + const original = MetabaseSettings.get.bind(MetabaseSettings); + const spy = jest.spyOn(MetabaseSettings, "get"); + spy.mockImplementation(key => { + if (key === "site-uuid") { + return Utils.uuid(); + } + return original(key); + }); +} + +describe("DatabaseEditApp/Sidebar", () => { + beforeAll(() => { + mockMetabaseSettings(); + }); + + it("syncs database schema", () => { + const { database, syncDatabaseSchema } = setup(); + userEvent.click(screen.getByText(/Sync database schema now/i)); + expect(syncDatabaseSchema).toHaveBeenCalledWith(database.id); + }); + + it("re-scans database field values", () => { + const { database, rescanDatabaseFields } = setup(); + userEvent.click(screen.getByText(/Re-scan field values now/i)); + expect(rescanDatabaseFields).toHaveBeenCalledWith(database.id); + }); + + describe("sync indicator", () => { + it("isn't shown for a fully synced database", () => { + setup({ + database: createMockDatabase({ initial_sync_status: "complete" }), + }); + + expect(screen.queryByText(/Syncing database…/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Dismiss sync spinner manually/i), + ).not.toBeInTheDocument(); + }); + + NOT_SYNCED_DB_STATUSES.forEach(initial_sync_status => { + it(`is shown for a database with "${initial_sync_status}" sync status`, () => { + setup({ database: createMockDatabase({ initial_sync_status }) }); + + expect(screen.getByText(/Syncing database…/i)).toBeInTheDocument(); + expect( + screen.getByText(/Dismiss sync spinner manually/i), + ).toBeInTheDocument(); + }); + + it(`can be dismissed for a database with "${initial_sync_status}" sync status (#20863)`, () => { + const database = createMockDatabase({ initial_sync_status }); + const { dismissSyncSpinner } = setup({ database }); + + userEvent.click(screen.getByText(/Dismiss sync spinner manually/i)); + + expect(dismissSyncSpinner).toHaveBeenCalledWith(database.id); + }); + }); + }); + + describe("discarding field values", () => { + it("discards field values", () => { + const { database, discardSavedFieldValues } = setup(); + + userEvent.click(screen.getByText(/Discard saved field values/i)); + userEvent.click(getByRole(getModal(), "button", { name: "Yes" })); + + expect(discardSavedFieldValues).toHaveBeenCalledWith(database.id); + }); + + it("allows to cancel confirmation modal", async () => { + const { discardSavedFieldValues } = setup(); + + userEvent.click(screen.getByText(/Discard saved field values/i)); + userEvent.click(getByRole(getModal(), "button", { name: "Cancel" })); + await waitForElementToBeRemoved(() => getModal()); + + expect(getModal()).not.toBeInTheDocument(); + expect(discardSavedFieldValues).not.toBeCalled(); + }); + + NOT_SYNCED_DB_STATUSES.forEach(initial_sync_status => { + it(`is hidden for databases with "${initial_sync_status}" sync status`, () => { + setup({ + database: createMockDatabase({ initial_sync_status }), + }); + + expect( + screen.queryByText(/Discard saved field values/i), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("model caching control", () => { + it("isn't shown if model caching is turned off globally", () => { + setup({ isModelPersistenceEnabled: false }); + + expect( + screen.queryByText(/Turn model caching on/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/Turn model caching off/i), + ).not.toBeInTheDocument(); + }); + + it("isn't shown if database doesn't support model caching", () => { + setup({ + isModelPersistenceEnabled: true, + database: createMockDatabase({ + features: _.without(COMMON_DATABASE_FEATURES, "persist-models"), + }), + }); + + expect( + screen.queryByText(/Turn model caching on/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/Turn model caching off/i), + ).not.toBeInTheDocument(); + }); + + it("offers to enable caching when it's enabled on the instance and supported by a database", () => { + setup({ isModelPersistenceEnabled: true }); + expect(screen.getByText(/Turn model caching on/i)).toBeInTheDocument(); + expect( + screen.queryByText(/Turn model caching off/i), + ).not.toBeInTheDocument(); + }); + + it("offers to disable caching when it's enabled for a database", () => { + setup({ + isModelPersistenceEnabled: true, + database: createMockDatabase({ + features: [...COMMON_DATABASE_FEATURES, "persist-models-enabled"], + }), + }); + expect(screen.getByText(/Turn model caching off/i)).toBeInTheDocument(); + expect( + screen.queryByText(/Turn model caching on/i), + ).not.toBeInTheDocument(); + }); + }); + + describe("database removal", () => { + it("isn't shown for non-admins", () => { + setup({ isAdmin: false }); + expect( + screen.queryByText(/Remove this database/i), + ).not.toBeInTheDocument(); + }); + + it("removes database", async () => { + const { database, deleteDatabase } = setup({ isAdmin: true }); + userEvent.click(screen.getByText(/Remove this database/i)); + const modal = getModal(); + + // Fill in database name to confirm deletion + userEvent.type(getByRole(modal, "textbox"), database.name); + userEvent.click(getByRole(modal, "button", { name: "Delete" })); + await waitForElementToBeRemoved(() => getModal()); + + expect(getModal()).not.toBeInTheDocument(); + expect(deleteDatabase).toHaveBeenCalled(); + }); + + it("allows to dismiss confirmation modal", async () => { + const { database, deleteDatabase } = setup({ isAdmin: true }); + userEvent.click(screen.getByText(/Remove this database/i)); + const modal = getModal(); + + getByText(modal, `Delete the ${database.name} database?`); + userEvent.click(getByRole(modal, "button", { name: "Cancel" })); + await waitForElementToBeRemoved(() => getModal()); + + expect(getModal()).not.toBeInTheDocument(); + expect(deleteDatabase).not.toBeCalled(); + }); + }); +}); diff --git a/frontend/src/metabase/css/admin.css b/frontend/src/metabase/css/admin.css index 14bf1e9a924..da271ff9cbd 100644 --- a/frontend/src/metabase/css/admin.css +++ b/frontend/src/metabase/css/admin.css @@ -12,19 +12,6 @@ transition: background 0.2s linear; } -.Actions-group { - margin-bottom: 2em; -} - -.Actions-group:last-child { - margin-bottom: 0; -} - -.Actions-groupLabel { - font-size: 1em; - margin-bottom: 1em; -} - .ContentTable { width: 100%; border-collapse: collapse; diff --git a/frontend/test/metabase/scenarios/permissions/database-details-permissions.cy.spec.js b/frontend/test/metabase/scenarios/permissions/database-details-permissions.cy.spec.js index 7e16b63ae9b..3537c6441aa 100644 --- a/frontend/test/metabase/scenarios/permissions/database-details-permissions.cy.spec.js +++ b/frontend/test/metabase/scenarios/permissions/database-details-permissions.cy.spec.js @@ -56,7 +56,7 @@ describeEE( cy.findByText("Sample Database").click(); - cy.get(".Actions") + cy.findByTestId("database-actions-panel") .should("contain", "Sync database schema now") .and("contain", "Re-scan field values now") .and("contain", "Discard saved field values") -- GitLab