From a3bce7a2652c59668df379d8f031deffe16c9507 Mon Sep 17 00:00:00 2001 From: Alexander Lesnenko <alxnddr@users.noreply.github.com> Date: Thu, 12 May 2022 21:01:15 +0400 Subject: [PATCH] do not show restore sample database button to non-admins (#22506) * do not show restore sample database button to non-admins * fix db list update after restoring the sample db --- .../components/DatabaseList/DatabaseList.jsx | 174 +++++++++++++++++ .../DatabaseList/DatabaseList.unit.spec.jsx | 46 +++++ .../components/DatabaseList/index.ts | 1 + .../databases/containers/DatabaseListApp.jsx | 181 ++---------------- frontend/src/metabase/entities/databases.js | 4 +- 5 files changed, 234 insertions(+), 172 deletions(-) create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.jsx create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.unit.spec.jsx create mode 100644 frontend/src/metabase/admin/databases/components/DatabaseList/index.ts diff --git a/frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.jsx b/frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.jsx new file mode 100644 index 00000000000..1fdf141f1ef --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.jsx @@ -0,0 +1,174 @@ +/* eslint-disable react/prop-types */ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import { Link } from "react-router"; +import { t } from "ttag"; + +import cx from "classnames"; +import { isSyncCompleted } from "metabase/lib/syncing"; + +import LoadingSpinner from "metabase/components/LoadingSpinner"; +import FormMessage from "metabase/components/form/FormMessage"; +import Modal from "metabase/components/Modal"; +import SyncingModal from "metabase/containers/SyncingModal"; +import { PLUGIN_FEATURE_LEVEL_PERMISSIONS } from "metabase/plugins"; + +import { + TableCellContent, + TableCellSpinner, +} from "../../containers/DatabaseListApp.styled"; + +const query = { + ...PLUGIN_FEATURE_LEVEL_PERMISSIONS.databaseDetailsQueryProps, +}; + +export default class DatabaseList extends Component { + constructor(props) { + super(props); + + props.databases.map(database => { + this["deleteDatabaseModal_" + database.id] = React.createRef(); + }); + + this.state = { + isSyncingModalOpened: (props.created && props.showSyncingModal) || false, + }; + } + + componentDidMount() { + if (this.state.isSyncingModalOpened) { + this.props.closeSyncingModal(); + } + } + + onSyncingModalClose = () => { + this.setState({ isSyncingModalOpened: false }); + }; + + static propTypes = { + databases: PropTypes.array, + hasSampleDatabase: PropTypes.bool, + engines: PropTypes.object, + deletes: PropTypes.array, + deletionError: PropTypes.object, + created: PropTypes.string, + showSyncingModal: PropTypes.bool, + closeSyncingModal: PropTypes.func, + isAdmin: PropTypes.bool, + }; + + render() { + const { + databases, + hasSampleDatabase, + isAddingSampleDatabase, + addSampleDatabaseError, + engines, + deletionError, + isAdmin, + } = this.props; + const { isSyncingModalOpened } = this.state; + + const error = deletionError || addSampleDatabaseError; + + return ( + <div className="wrapper"> + <section className="PageHeader px2 clearfix"> + {isAdmin && ( + <Link + to="/admin/databases/create" + className="Button Button--primary float-right" + >{t`Add database`}</Link> + )} + <h2 className="PageTitle">{t`Databases`}</h2> + </section> + {error && ( + <section> + <FormMessage formError={error} /> + </section> + )} + <section> + <table className="ContentTable"> + <thead> + <tr> + <th>{t`Name`}</th> + <th>{t`Engine`}</th> + </tr> + </thead> + <tbody> + {databases ? ( + [ + databases.map(database => { + const isDeleting = + this.props.deletes.indexOf(database.id) !== -1; + return ( + <tr + key={database.id} + className={cx({ disabled: isDeleting })} + > + <td> + <TableCellContent> + {!isSyncCompleted(database) && ( + <TableCellSpinner size={16} borderWidth={2} /> + )} + <Link + to={"/admin/databases/" + database.id} + className="text-bold link" + > + {database.name} + </Link> + </TableCellContent> + </td> + <td> + {engines && engines[database.engine] + ? engines[database.engine]["driver-name"] + : database.engine} + </td> + </tr> + ); + }), + ] + ) : ( + <tr> + <td colSpan={4}> + <LoadingSpinner /> + <h3>{t`Loading ...`}</h3> + </td> + </tr> + )} + </tbody> + </table> + {!hasSampleDatabase && isAdmin ? ( + <div className="pt4"> + <span + className={cx("p2 text-italic", { + "border-top": databases && databases.length > 0, + })} + > + {isAddingSampleDatabase ? ( + <span className="text-light no-decoration"> + {t`Restoring the sample database...`} + </span> + ) : ( + <a + className="text-light text-brand-hover no-decoration" + onClick={() => this.props.addSampleDatabase(query)} + > + {t`Bring the sample database back`} + </a> + )} + </span> + </div> + ) : null} + </section> + <Modal + small + isOpen={isSyncingModalOpened} + onClose={this.onSyncingModalClose} + > + <SyncingModal onClose={this.onSyncingModalClose} /> + </Modal> + </div> + ); + } +} diff --git a/frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.unit.spec.jsx b/frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.unit.spec.jsx new file mode 100644 index 00000000000..f9b8d522b52 --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/DatabaseList/DatabaseList.unit.spec.jsx @@ -0,0 +1,46 @@ +import React from "react"; +import { render, screen } from "__support__/ui"; +import DatabaseList from "./DatabaseList"; + +import { createMockDatabase } from "metabase-types/api/mocks"; + +const CREATE_SAMPLE_DATABASE_BUTTON_LABEL = "Bring the sample database back"; + +async function setup({ hasSampleDatabase, isAdmin } = {}) { + const databases = [createMockDatabase()]; + + render( + <DatabaseList + databases={databases} + hasSampleDatabase={hasSampleDatabase} + isAdmin={isAdmin} + deletes={[]} + />, + ); +} + +describe("DatabaseListApp", () => { + it("shows the restore sample database button to admins when there is no sample database", async () => { + await setup({ hasSampleDatabase: false, isAdmin: true }); + + expect( + screen.queryByText(CREATE_SAMPLE_DATABASE_BUTTON_LABEL), + ).toBeInTheDocument(); + }); + + it("does not show the restore sample database button to admins when the sample database exists", async () => { + await setup({ hasSampleDatabase: true, isAdmin: true }); + + expect( + screen.queryByText(CREATE_SAMPLE_DATABASE_BUTTON_LABEL), + ).not.toBeInTheDocument(); + }); + + it("does not show restore sample database button to non-admins", async () => { + await setup({ hasSampleDatabase: false, isAdmin: false }); + + expect( + screen.queryByText(CREATE_SAMPLE_DATABASE_BUTTON_LABEL), + ).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/metabase/admin/databases/components/DatabaseList/index.ts b/frontend/src/metabase/admin/databases/components/DatabaseList/index.ts new file mode 100644 index 00000000000..759dd5b895d --- /dev/null +++ b/frontend/src/metabase/admin/databases/components/DatabaseList/index.ts @@ -0,0 +1 @@ +export { default } from "./DatabaseList"; diff --git a/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx b/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx index 8a93e956b8e..d39c0804b1c 100644 --- a/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx +++ b/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx @@ -1,24 +1,12 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import PropTypes from "prop-types"; import { connect } from "react-redux"; -import { Link } from "react-router"; -import { t } from "ttag"; -import _ from "underscore"; -import cx from "classnames"; import MetabaseSettings from "metabase/lib/settings"; -import { isSyncCompleted, isSyncInProgress } from "metabase/lib/syncing"; +import { isSyncInProgress } from "metabase/lib/syncing"; -import LoadingSpinner from "metabase/components/LoadingSpinner"; import LoadingAndGenericErrorWrapper from "metabase/components/LoadingAndGenericErrorWrapper"; -import FormMessage from "metabase/components/form/FormMessage"; -import Modal from "metabase/components/Modal"; -import SyncingModal from "metabase/containers/SyncingModal"; import { getUserIsAdmin } from "metabase/selectors/user"; import { PLUGIN_FEATURE_LEVEL_PERMISSIONS } from "metabase/plugins"; - -import { TableCellContent, TableCellSpinner } from "./DatabaseListApp.styled"; +import DatabaseList from "../components/DatabaseList"; import Database from "metabase/entities/databases"; @@ -33,16 +21,23 @@ import { addSampleDatabase, closeSyncingModal, } from "../database"; +import _ from "underscore"; const RELOAD_INTERVAL = 2000; -const getReloadInterval = (state, props, databases = []) => { +const getReloadInterval = (_state, _props, databases = []) => { return databases.some(d => isSyncInProgress(d)) ? RELOAD_INTERVAL : 0; }; +const query = { + ...PLUGIN_FEATURE_LEVEL_PERMISSIONS.databaseDetailsQueryProps, +}; + const mapStateToProps = (state, props) => ({ isAdmin: getUserIsAdmin(state), - hasSampleDatabase: Database.selectors.getHasSampleDatabase(state), + hasSampleDatabase: Database.selectors.getHasSampleDatabase(state, { + entityQuery: query, + }), isAddingSampleDatabase: getIsAddingSampleDatabase(state), addSampleDatabaseError: getAddSampleDatabaseError(state), @@ -54,10 +49,6 @@ const mapStateToProps = (state, props) => ({ deletionError: getDeletionError(state), }); -const query = { - ...PLUGIN_FEATURE_LEVEL_PERMISSIONS.databaseDetailsQueryProps, -}; - const mapDispatchToProps = { // NOTE: still uses deleteDatabase from metabaseadmin/databases/databases.js // rather than metabase/entities/databases since it updates deletes/deletionError @@ -66,156 +57,6 @@ const mapDispatchToProps = { closeSyncingModal, }; -class DatabaseList extends Component { - constructor(props) { - super(props); - - props.databases.map(database => { - this["deleteDatabaseModal_" + database.id] = React.createRef(); - }); - - this.state = { - isSyncingModalOpened: (props.created && props.showSyncingModal) || false, - }; - } - - componentDidMount() { - if (this.state.isSyncingModalOpened) { - this.props.closeSyncingModal(); - } - } - - onSyncingModalClose = () => { - this.setState({ isSyncingModalOpened: false }); - }; - - static propTypes = { - databases: PropTypes.array, - hasSampleDatabase: PropTypes.bool, - engines: PropTypes.object, - deletes: PropTypes.array, - deletionError: PropTypes.object, - created: PropTypes.string, - showSyncingModal: PropTypes.bool, - closeSyncingModal: PropTypes.func, - }; - - render() { - const { - databases, - hasSampleDatabase, - isAddingSampleDatabase, - addSampleDatabaseError, - engines, - deletionError, - isAdmin, - } = this.props; - const { isSyncingModalOpened } = this.state; - - const error = deletionError || addSampleDatabaseError; - - return ( - <div className="wrapper"> - <section className="PageHeader px2 clearfix"> - {isAdmin && ( - <Link - to="/admin/databases/create" - className="Button Button--primary float-right" - >{t`Add database`}</Link> - )} - <h2 className="PageTitle">{t`Databases`}</h2> - </section> - {error && ( - <section> - <FormMessage formError={error} /> - </section> - )} - <section> - <table className="ContentTable"> - <thead> - <tr> - <th>{t`Name`}</th> - <th>{t`Engine`}</th> - </tr> - </thead> - <tbody> - {databases ? ( - [ - databases.map(database => { - const isDeleting = - this.props.deletes.indexOf(database.id) !== -1; - return ( - <tr - key={database.id} - className={cx({ disabled: isDeleting })} - > - <td> - <TableCellContent> - {!isSyncCompleted(database) && ( - <TableCellSpinner size={16} borderWidth={2} /> - )} - <Link - to={"/admin/databases/" + database.id} - className="text-bold link" - > - {database.name} - </Link> - </TableCellContent> - </td> - <td> - {engines && engines[database.engine] - ? engines[database.engine]["driver-name"] - : database.engine} - </td> - </tr> - ); - }), - ] - ) : ( - <tr> - <td colSpan={4}> - <LoadingSpinner /> - <h3>{t`Loading ...`}</h3> - </td> - </tr> - )} - </tbody> - </table> - {!hasSampleDatabase ? ( - <div className="pt4"> - <span - className={cx("p2 text-italic", { - "border-top": databases && databases.length > 0, - })} - > - {isAddingSampleDatabase ? ( - <span className="text-light no-decoration"> - {t`Restoring the sample database...`} - </span> - ) : ( - <a - className="text-light text-brand-hover no-decoration" - onClick={() => this.props.addSampleDatabase(query)} - > - {t`Bring the sample database back`} - </a> - )} - </span> - </div> - ) : null} - </section> - <Modal - small - isOpen={isSyncingModalOpened} - onClose={this.onSyncingModalClose} - > - <SyncingModal onClose={this.onSyncingModalClose} /> - </Modal> - </div> - ); - } -} - export default _.compose( Database.loadList({ reloadInterval: getReloadInterval, diff --git a/frontend/src/metabase/entities/databases.js b/frontend/src/metabase/entities/databases.js index 87048719ab3..8150f961e3d 100644 --- a/frontend/src/metabase/entities/databases.js +++ b/frontend/src/metabase/entities/databases.js @@ -75,8 +75,8 @@ const Databases = createEntity({ selectors: { getObject: (state, { entityId }) => getMetadata(state).database(entityId), - getHasSampleDatabase: state => - _.any(Databases.selectors.getList(state), db => db.is_sample), + getHasSampleDatabase: (state, props) => + _.any(Databases.selectors.getList(state, props), db => db.is_sample), getIdfields: createSelector( // we wrap getFields to handle a circular dep issue [state => getFields(state), (state, props) => props.databaseId], -- GitLab