Skip to content
Snippets Groups Projects
Unverified Commit 04865930 authored by Anton Kulyk's avatar Anton Kulyk Committed by GitHub
Browse files

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
parent c6925a88
No related branches found
No related tags found
No related merge requests found
......@@ -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
......
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,
});
......
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;
}
`;
import React, { useRef } from "react";
import PropTypes from "prop-types";
import React, { useCallback, useRef } from "react";
import { t } from "ttag";
import { isSyncCompleted } from "metabase/lib/syncing";
import DeleteDatabaseModal from "metabase/admin/databases/components/DeleteDatabaseModal.jsx";
import Button from "metabase/core/components/Button";
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 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 } 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,
};
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,
......@@ -33,106 +35,135 @@ const DatabaseEditAppSidebar = ({
rescanDatabaseFields,
discardSavedFieldValues,
isAdmin,
isWritebackEnabled,
isModelPersistenceEnabled,
}) => {
const discardSavedFieldValuesModal = useRef();
const deleteDatabaseModal = useRef();
}: 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>
<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>
<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>
</li>
</SidebarGroup.ListItem>
)}
<li>
<SidebarGroup.ListItem hasMarginTop={false}>
<ActionButton
actionFn={() => syncDatabaseSchema(database.id)}
className="Button Button--syncDbSchema"
actionFn={handleSyncDatabaseSchema}
normalText={t`Sync database schema now`}
activeText={t`Starting…`}
failedText={t`Failed to sync`}
successText={t`Sync triggered!`}
/>
</li>
<li className="mt2">
</SidebarGroup.ListItem>
<SidebarGroup.ListItem>
<ActionButton
actionFn={() => rescanDatabaseFields(database.id)}
className="Button Button--rescanFieldValues"
actionFn={handleReScanFieldValues}
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">
</SidebarGroup.ListItem>
{!isSynced && (
<SidebarGroup.ListItem>
<ActionButton
actionFn={() => dismissSyncSpinner(database.id)}
className="Button Button--dismissSyncSpinner"
actionFn={handleDismissSyncSpinner}
normalText={t`Dismiss sync spinner manually`}
activeText={t`Dismissing…`}
failedText={t`Failed to dismiss sync spinner`}
successText={t`Sync spinners dismissed!`}
/>
</li>
</SidebarGroup.ListItem>
)}
{isModelPersistenceEnabled && database.supportsPersistence() && (
<li className="mt2">
{hasModelCachingSection && (
<SidebarGroup.ListItem>
<ModelCachingControl database={database} />
</li>
</SidebarGroup.ListItem>
)}
</ol>
</div>
<div className="Actions-group">
<label className="Actions-groupLabel block text-bold">{t`Danger Zone`}</label>
<ol>
</SidebarGroup.List>
</SidebarGroup>
<SidebarGroup>
<SidebarGroup.Name>{t`Danger Zone`}</SidebarGroup.Name>
<SidebarGroup.List>
{isSyncCompleted(database) && (
<li>
<SidebarGroup.ListItem hasMarginTop={false}>
<ModalWithTrigger
triggerElement={
<Button danger>{t`Discard saved field values`}</Button>
}
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)}
onClose={handleSavedFieldsModalClose}
onAction={handleDiscardSavedFieldValues}
/>
</ModalWithTrigger>
</li>
</SidebarGroup.ListItem>
)}
{isAdmin && (
<li className="mt2">
<SidebarGroup.ListItem>
<ModalWithTrigger
triggerElement={
<Button danger>{t`Remove this database`}</Button>
}
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)}
onClose={handleDeleteDatabaseModalClose}
onDelete={handleDeleteDatabase}
/>
</ModalWithTrigger>
</li>
</SidebarGroup.ListItem>
)}
</ol>
</div>
</div>
</SidebarGroup.List>
</SidebarGroup>
</SidebarContent>
</SidebarRoot>
);
};
DatabaseEditAppSidebar.propTypes = propTypes;
export default DatabaseEditAppSidebar;
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();
});
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();
});
});
});
......@@ -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;
......
......@@ -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")
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment