diff --git a/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.jsx b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..b242b5e240b04a0b646608e8b9885ea0c5d9318d --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.jsx @@ -0,0 +1,97 @@ +import React, { useRef } from "react"; +import PropTypes from "prop-types"; +import { Box } from "grid-styled"; +import { t } from "ttag"; + +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"; + +const propTypes = { + database: PropTypes.object.isRequired, + deleteDatabase: PropTypes.func.isRequired, + syncDatabaseSchema: PropTypes.func.isRequired, + rescanDatabaseFields: PropTypes.func.isRequired, + discardSavedFieldValues: PropTypes.func.isRequired, +}; + +const DatabaseEditAppSidebar = ({ + database, + deleteDatabase, + syncDatabaseSchema, + rescanDatabaseFields, + discardSavedFieldValues, +}) => { + const discardSavedFieldValuesModal = useRef(); + const deleteDatabaseModal = useRef(); + + return ( + <Box ml={[2, 3]} width={420}> + <div className="Actions bg-light rounded p3"> + <div className="Actions-group"> + <label className="Actions-groupLabel block text-bold">{t`Actions`}</label> + <ol> + <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> + </ol> + </div> + + <div className="Actions-group"> + <label className="Actions-groupLabel block text-bold">{t`Danger Zone`}</label> + <ol> + <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> + + <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> + </Box> + ); +}; + +DatabaseEditAppSidebar.propTypes = propTypes; + +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 new file mode 100644 index 0000000000000000000000000000000000000000..3b1c82c3841ea669ee723e32d13c205a523c4e69 --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar.unit.spec.js @@ -0,0 +1,99 @@ +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 }; + 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 }; + 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("discards saved field values", () => { + const databaseId = 1; + const database = { id: databaseId }; + 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 }; + const deleteDatabase = jest.fn(); + + render(<Sidebar database={database} deleteDatabase={deleteDatabase} />); + + 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(); +}); diff --git a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx index 474ea945ccbc2056f00aa16bc320a3632fe1f6cb..25584c48008b50b0be2a26cc3bb428dd88eeffeb 100644 --- a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx +++ b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx @@ -10,14 +10,12 @@ import { Box, Flex } from "grid-styled"; import title from "metabase/hoc/Title"; -import DeleteDatabaseModal from "../components/DeleteDatabaseModal"; -import ActionButton from "metabase/components/ActionButton"; import AddDatabaseHelpCard from "metabase/components/AddDatabaseHelpCard"; import Button from "metabase/components/Button"; import Breadcrumbs from "metabase/components/Breadcrumbs"; import DriverWarning from "metabase/components/DriverWarning"; import Radio from "metabase/components/Radio"; -import ModalWithTrigger from "metabase/components/ModalWithTrigger"; +import Sidebar from "metabase/admin/databases/components/DatabaseEditApp/Sidebar/Sidebar"; import Databases from "metabase/entities/databases"; @@ -38,7 +36,6 @@ import { deleteDatabase, selectEngine, } from "../database"; -import ConfirmContent from "metabase/components/ConfirmContent"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import { getIn } from "icepick"; @@ -72,10 +69,7 @@ const mapDispatchToProps = { selectEngine, }; -type TabName = "connection" | "scheduling"; -type TabOption = { name: string, value: TabName }; - -const TABS: TabOption[] = [ +const TABS = [ { name: t`Connection`, value: "connection", @@ -92,19 +86,12 @@ const TABS: TabOption[] = [ ) @title(({ database }) => database && database.name) export default class DatabaseEditApp extends Component { - state: { - currentTab: TabName, - }; - constructor(props, context) { super(props, context); this.state = { currentTab: TABS[0].value, }; - - this.discardSavedFieldValuesModal = React.createRef(); - this.deleteDatabaseModal = React.createRef(); } static propTypes = { @@ -139,26 +126,30 @@ export default class DatabaseEditApp extends Component { render() { const { database, + deleteDatabase, + discardSavedFieldValues, selectedEngine, letUserControlSchedulingSaved, letUserControlSchedulingForm, initializeError, + rescanDatabaseFields, + syncDatabaseSchema, } = this.props; const { currentTab } = this.state; - const editingExistingDatabase = database && database.id != null; + const editingExistingDatabase = database?.id != null; const addingNewDatabase = !editingExistingDatabase; const showTabs = editingExistingDatabase && letUserControlSchedulingSaved; + const crumbs = [ + [t`Databases`, "/admin/databases"], + [addingNewDatabase ? t`Add Database` : database.name], + ]; + return ( <Box px={[3, 4, 5]} mt={[1, 2, 3]}> - <Breadcrumbs - className="py4" - crumbs={[ - [t`Databases`, "/admin/databases"], - [addingNewDatabase ? t`Add Database` : database.name], - ]} - /> + <Breadcrumbs className="py4" crumbs={crumbs} /> + <Flex pb={2}> <Box> <div className="pt0"> @@ -258,82 +249,14 @@ export default class DatabaseEditApp extends Component { </div> </Box> - {/* Sidebar Actions */} {editingExistingDatabase && ( - <Box ml={[2, 3]} width={420}> - <div className="Actions bg-light rounded p3"> - <div className="Actions-group"> - <label className="Actions-groupLabel block text-bold">{t`Actions`}</label> - <ol> - <li> - <ActionButton - actionFn={() => - this.props.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={() => - this.props.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> - </ol> - </div> - - <div className="Actions-group"> - <label className="Actions-groupLabel block text-bold">{t`Danger Zone`}</label> - <ol> - <li> - <ModalWithTrigger - ref={this.discardSavedFieldValuesModal} - triggerClasses="Button Button--danger Button--discardSavedFieldValues" - triggerElement={t`Discard saved field values`} - > - <ConfirmContent - title={t`Discard saved field values`} - onClose={() => - this.discardSavedFieldValuesModal.current.toggle() - } - onAction={() => - this.props.discardSavedFieldValues(database.id) - } - /> - </ModalWithTrigger> - </li> - - <li className="mt2"> - <ModalWithTrigger - ref={this.deleteDatabaseModal} - triggerClasses="Button Button--deleteDatabase Button--danger" - triggerElement={t`Remove this database`} - > - <DeleteDatabaseModal - database={database} - onClose={() => - this.deleteDatabaseModal.current.toggle() - } - onDelete={() => - this.props.deleteDatabase(database.id, true) - } - /> - </ModalWithTrigger> - </li> - </ol> - </div> - </div> - </Box> + <Sidebar + database={database} + deleteDatabase={deleteDatabase} + discardSavedFieldValues={discardSavedFieldValues} + rescanDatabaseFields={rescanDatabaseFields} + syncDatabaseSchema={syncDatabaseSchema} + /> )} </Flex> </Box>