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