Skip to content
Snippets Groups Projects
Unverified Commit d4273ccf authored by Sameer Al-Sakran's avatar Sameer Al-Sakran Committed by GitHub
Browse files

Merge pull request #8060 from metabase/reduxify-api-calls

Reduxify X-ray save and database admin API calls
parents 96d0b89a 09e73287
No related branches found
No related tags found
No related merge requests found
/* @flow weak */
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import title from "metabase/hoc/Title";
import cx from "classnames";
import { t } from "c-3po";
import MetabaseSettings from "metabase/lib/settings";
import DeleteDatabaseModal from "../components/DeleteDatabaseModal.jsx";
import DatabaseEditForms from "../components/DatabaseEditForms.jsx";
import DatabaseSchedulingForm from "../components/DatabaseSchedulingForm";
import { t } from "c-3po";
import ActionButton from "metabase/components/ActionButton.jsx";
import Breadcrumbs from "metabase/components/Breadcrumbs.jsx";
import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx";
......@@ -85,6 +87,10 @@ const mapDispatchToProps = {
@connect(mapStateToProps, mapDispatchToProps)
@title(({ database }) => database && database.name)
export default class DatabaseEditApp extends Component {
state: {
currentTab: "connection" | "scheduling",
};
constructor(props, context) {
super(props, context);
......
/* @flow weak */
import React, { Component } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { Link } from "react-router";
import { t } from "c-3po";
import cx from "classnames";
import MetabaseSettings from "metabase/lib/settings";
import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx";
import LoadingSpinner from "metabase/components/LoadingSpinner.jsx";
import { t } from "c-3po";
import FormMessage from "metabase/components/form/FormMessage";
import CreatedDatabaseModal from "../components/CreatedDatabaseModal.jsx";
import DeleteDatabaseModal from "../components/DeleteDatabaseModal.jsx";
import {
getDatabasesSorted,
hasSampleDataset,
getDeletes,
getDeletionError,
} from "../selectors";
import * as databaseActions from "../database";
import FormMessage from "metabase/components/form/FormMessage";
import Databases from "metabase/entities/databases";
import { entityListLoader } from "metabase/entities/containers/EntityListLoader";
const mapStateToProps = (state, props) => {
return {
created: props.location.query.created,
databases: getDatabasesSorted(state),
hasSampleDataset: hasSampleDataset(state),
engines: MetabaseSettings.get("engines"),
deletes: getDeletes(state),
deletionError: getDeletionError(state),
};
};
import { getDeletes, getDeletionError } from "../selectors";
import { deleteDatabase, addSampleDataset } from "../database";
const mapStateToProps = (state, props) => ({
hasSampleDataset: Databases.selectors.getHasSampleDataset(state),
created: props.location.query.created,
engines: MetabaseSettings.get("engines"),
deletes: getDeletes(state),
deletionError: getDeletionError(state),
});
const mapDispatchToProps = {
...databaseActions,
fetchDatabases: Databases.actions.fetchList,
// NOTE: still uses deleteDatabase from metabaseadmin/databases/databases.js
// rather than metabase/entities/databases since it updates deletes/deletionError
deleteDatabase: deleteDatabase,
addSampleDataset: addSampleDataset,
};
@entityListLoader({ entityType: "databases" })
@connect(mapStateToProps, mapDispatchToProps)
export default class DatabaseList extends Component {
static propTypes = {
......@@ -45,10 +51,6 @@ export default class DatabaseList extends Component {
deletionError: PropTypes.object,
};
componentWillMount() {
this.props.fetchDatabases();
}
componentWillReceiveProps(newProps) {
if (!this.props.created && newProps.created) {
this.refs.createdDatabaseModal.open();
......
import _ from "underscore";
/* @flow weak */
import { createAction } from "redux-actions";
import {
......@@ -12,6 +12,7 @@ import MetabaseAnalytics from "metabase/lib/analytics";
import MetabaseSettings from "metabase/lib/settings";
import { MetabaseApi } from "metabase/services";
import Databases from "metabase/entities/databases";
// Default schedules for db sync and deep analysis
export const DEFAULT_SCHEDULES = {
......@@ -69,22 +70,13 @@ export const CLEAR_FORM_STATE = "metabase/admin/databases/CLEAR_FORM_STATE";
export const MIGRATE_TO_NEW_SCHEDULING_SETTINGS =
"metabase/admin/databases/MIGRATE_TO_NEW_SCHEDULING_SETTINGS";
// NOTE: some but not all of these actions have been migrated to use metabase/entities/databases
export const reset = createAction(RESET);
// selectEngine (uiControl)
export const selectEngine = createAction(SELECT_ENGINE);
// fetchDatabases
export const fetchDatabases = createThunkAction(FETCH_DATABASES, function() {
return async function(dispatch, getState) {
try {
return await MetabaseApi.db_list();
} catch (error) {
console.error("error fetching databases", error);
}
};
});
// Migrates old "Enable in-depth database analysis" option to new "Let me choose when Metabase syncs and scans" option
// Migration is run as a separate action because that makes it easy to track in tests
const migrateDatabaseToNewSchedulingSettings = database => {
......@@ -112,7 +104,10 @@ export const initializeDatabase = function(databaseId) {
return async function(dispatch, getState) {
if (databaseId) {
try {
const database = await MetabaseApi.db_get({ dbId: databaseId });
const { payload } = await dispatch(
Databases.actions.fetch({ id: databaseId }, { reload: true }),
);
const database = payload.entities.databases[databaseId];
dispatch.action(INITIALIZE_DATABASE, database);
// If the new scheduling toggle isn't set, run the migration
......@@ -196,13 +191,10 @@ export const createDatabase = function(database) {
return async function(dispatch, getState) {
try {
dispatch.action(CREATE_DATABASE_STARTED, {});
const createdDatabase = await MetabaseApi.db_create(database);
const { payload } = await dispatch(Databases.actions.create(database));
const createdDatabase = payload.entities.databases[payload.result];
MetabaseAnalytics.trackEvent("Databases", "Create", database.engine);
// update the db metadata already here because otherwise there will be a gap between "Adding..." status
// and seeing the db that was just added
await dispatch(fetchDatabases());
dispatch.action(CREATE_DATABASE);
dispatch(push("/admin/databases?created=" + createdDatabase.id));
} catch (error) {
......@@ -221,7 +213,8 @@ export const updateDatabase = function(database) {
return async function(dispatch, getState) {
try {
dispatch.action(UPDATE_DATABASE_STARTED, { database });
const savedDatabase = await MetabaseApi.db_update(database);
const { payload } = await dispatch(Databases.actions.update(database));
const savedDatabase = payload.entities.databases[payload.result];
MetabaseAnalytics.trackEvent("Databases", "Update", database.engine);
dispatch.action(UPDATE_DATABASE, { database: savedDatabase });
......@@ -270,7 +263,7 @@ export const deleteDatabase = function(databaseId, isDetailView = true) {
try {
dispatch.action(DELETE_DATABASE_STARTED, { databaseId });
dispatch(push("/admin/databases/"));
await MetabaseApi.db_delete({ dbId: databaseId });
await dispatch(Databases.actions.delete({ id: databaseId }));
MetabaseAnalytics.trackEvent(
"Databases",
"Delete",
......@@ -334,18 +327,6 @@ export const discardSavedFieldValues = createThunkAction(
// reducers
const databases = handleActions(
{
[FETCH_DATABASES]: { next: (state, { payload }) => payload },
[ADD_SAMPLE_DATASET]: {
next: (state, { payload }) => (payload ? [...state, payload] : state),
},
[DELETE_DATABASE]: (state, { payload: { databaseId } }) =>
databaseId ? _.reject(state, d => d.id === databaseId) : state,
},
null,
);
const editingDatabase = handleActions(
{
[RESET]: () => null,
......@@ -420,7 +401,6 @@ const formState = handleActions(
);
export default combineReducers({
databases,
editingDatabase,
deletionError,
databaseCreationStep,
......
/* @flow weak */
import _ from "underscore";
import { createSelector } from "reselect";
// Database List
export const databases = state => state.admin.databases.databases;
export const getDatabasesSorted = createSelector([databases], databases =>
_.sortBy(databases, "name"),
);
export const hasSampleDataset = createSelector([databases], databases =>
_.some(databases, d => d.is_sample),
);
// Database Edit
export const getEditingDatabase = state =>
state.admin.databases.editingDatabase;
......@@ -21,5 +7,6 @@ export const getFormState = state => state.admin.databases.formState;
export const getDatabaseCreationStep = state =>
state.admin.databases.databaseCreationStep;
// Database List
export const getDeletes = state => state.admin.databases.deletes;
export const getDeletionError = state => state.admin.databases.deletionError;
......@@ -23,7 +23,7 @@ import Parameters from "metabase/parameters/components/Parameters";
import { getMetadata } from "metabase/selectors/metadata";
import { getUserIsAdmin } from "metabase/selectors/user";
import { DashboardApi } from "metabase/services";
import Dashboards from "metabase/entities/dashboards";
import * as Urls from "metabase/lib/urls";
import MetabaseAnalytics from "metabase/lib/analytics";
......@@ -42,7 +42,11 @@ const mapStateToProps = (state, props) => ({
dashboardId: getDashboardId(state, props),
});
@connect(mapStateToProps)
const mapDispatchToProps = {
saveDashboard: Dashboards.actions.save,
};
@connect(mapStateToProps, mapDispatchToProps)
@DashboardData
@withToast
@title(({ dashboard }) => dashboard && dashboard.name)
......@@ -59,9 +63,11 @@ class AutomaticDashboardApp extends React.Component {
}
save = async () => {
const { dashboard, triggerToast } = this.props;
const { dashboard, triggerToast, saveDashboard } = this.props;
// remove the transient id before trying to save
const newDashboard = await DashboardApi.save(dissoc(dashboard, "id"));
const { payload: newDashboard } = await saveDashboard(
dissoc(dashboard, "id"),
);
triggerToast(
<div className="flex align-center">
<Icon
......
......@@ -18,6 +18,7 @@ const Dashboards = createEntity({
api: {
favorite: POST("/api/dashboard/:id/favorite"),
unfavorite: DELETE("/api/dashboard/:id/favorite"),
save: POST("/api/dashboard/save"),
},
objectActions: {
......@@ -56,6 +57,17 @@ const Dashboards = createEntity({
},
},
actions: {
save: dashboard => async dispatch => {
const savedDashboard = await Dashboards.api.save(dashboard);
dispatch({ type: Dashboards.actionTypes.INVALIDATE_LISTS_ACTION });
return {
type: "metabase/entities/dashboards/SAVE_DASHBOARD",
payload: savedDashboard,
};
},
},
reducer: (state = {}, { type, payload, error }) => {
if (type === FAVORITE_ACTION && !error) {
return assocIn(state, [payload, "favorite"], true);
......
......@@ -3,6 +3,7 @@
import { createEntity } from "metabase/lib/entities";
import { fetchData, createThunkAction } from "metabase/lib/redux";
import { normalize } from "normalizr";
import _ from "underscore";
import { MetabaseApi } from "metabase/services";
import { DatabaseSchema } from "metabase/schema";
......@@ -11,7 +12,7 @@ import { DatabaseSchema } from "metabase/schema";
export const FETCH_DATABASE_METADATA =
"metabase/entities/database/FETCH_DATABASE_METADATA";
export default createEntity({
const Databases = createEntity({
name: "databases",
path: "/api/database",
schema: DatabaseSchema,
......@@ -37,6 +38,11 @@ export default createEntity({
),
},
selectors: {
getHasSampleDataset: state =>
_.any(Databases.selectors.getList(state), db => db.is_sample),
},
// FORM
form: {
fields: (values = {}) => [
......@@ -47,6 +53,8 @@ export default createEntity({
},
});
export default Databases;
// TODO: use the info returned by the backend
const FIELDS_BY_ENGINE = {
h2: [{ name: "details.db" }],
......
......@@ -185,6 +185,9 @@ export function createEntity(def: EntityDefinition): Entity {
const UPDATE_ACTION = `metabase/entities/${entity.name}/UPDATE`;
const DELETE_ACTION = `metabase/entities/${entity.name}/DELETE`;
const FETCH_LIST_ACTION = `metabase/entities/${entity.name}/FETCH_LIST`;
const INVALIDATE_LISTS_ACTION = `metabase/entities/${
entity.name
}/INVALIDATE_LISTS_ACTION`;
entity.actionTypes = {
CREATE: CREATE_ACTION,
......@@ -192,6 +195,7 @@ export function createEntity(def: EntityDefinition): Entity {
UPDATE: UPDATE_ACTION,
DELETE: DELETE_ACTION,
FETCH_LIST: FETCH_LIST_ACTION,
INVALIDATE_LISTS_ACTION: INVALIDATE_LISTS_ACTION,
...(entity.actionTypes || {}),
};
......@@ -480,7 +484,8 @@ export function createEntity(def: EntityDefinition): Entity {
entity.actionShouldInvalidateLists = action =>
action.type === CREATE_ACTION ||
action.type === DELETE_ACTION ||
action.type === UPDATE_ACTION;
action.type === UPDATE_ACTION ||
action.type === INVALIDATE_LISTS_ACTION;
}
entity.requestsReducer = (state, action) => {
......
import {
useSharedAdminLogin,
createTestStore,
eventually,
} from "__support__/integrated_tests";
import { click, clickButton, setInputValue } from "__support__/enzyme_utils";
import { mount } from "enzyme";
import {
FETCH_DATABASES,
initializeDatabase,
INITIALIZE_DATABASE,
DELETE_DATABASE_FAILED,
DELETE_DATABASE,
CREATE_DATABASE_STARTED,
CREATE_DATABASE_FAILED,
CREATE_DATABASE,
UPDATE_DATABASE_STARTED,
UPDATE_DATABASE_FAILED,
UPDATE_DATABASE,
......@@ -38,6 +36,8 @@ import DatabaseSchedulingForm, {
SyncOption,
} from "metabase/admin/databases/components/DatabaseSchedulingForm";
import Databases from "metabase/entities/databases";
describe("dashboard list", () => {
beforeAll(async () => {
useSharedAdminLogin();
......@@ -49,15 +49,14 @@ describe("dashboard list", () => {
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
await store.waitForActions([Databases.actionTypes.FETCH_LIST]);
const wrapper = app.find(DatabaseListApp);
expect(wrapper.length).toEqual(1);
expect(app.find(DatabaseListApp).length).toEqual(1);
});
describe("adds", () => {
it("should work and shouldn't let you accidentally add db twice", async () => {
MetabaseApi.db_create = async db => {
Databases.api.create = async db => {
await delay(10);
return { ...db, id: 10 };
};
......@@ -66,14 +65,10 @@ describe("dashboard list", () => {
store.pushPath("/admin/databases");
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
const listAppBeforeAdd = app.find(DatabaseListApp);
const addDbButton = listAppBeforeAdd
.find(".Button.Button--primary")
.first();
click(addDbButton);
await eventually(() => {
click(app.find(".Button.Button--primary").first());
});
const dbDetailsForm = app.find(DatabaseEditApp);
expect(dbDetailsForm.length).toBe(1);
......@@ -101,14 +96,14 @@ describe("dashboard list", () => {
expect(saveButton.text()).toBe("Saving...");
expect(saveButton.props().disabled).toBe(true);
await store.waitForActions([CREATE_DATABASE]);
expect(store.getPath()).toEqual("/admin/databases?created=10");
await eventually(() =>
expect(store.getPath()).toEqual("/admin/databases?created=10"),
);
expect(app.find(CreatedDatabaseModal).length).toBe(1);
});
it("should show validation error if you enable scheduling toggle and enter invalid db connection info", async () => {
MetabaseApi.db_create = async db => {
Databases.api.create = async db => {
await delay(10);
return { ...db, id: 10 };
};
......@@ -117,14 +112,10 @@ describe("dashboard list", () => {
store.pushPath("/admin/databases");
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
const listAppBeforeAdd = app.find(DatabaseListApp);
const addDbButton = listAppBeforeAdd
.find(".Button.Button--primary")
.first();
click(addDbButton);
await eventually(() => {
click(app.find(".Button.Button--primary").first());
});
const dbDetailsForm = app.find(DatabaseEditApp);
expect(dbDetailsForm.length).toBe(1);
......@@ -167,7 +158,7 @@ describe("dashboard list", () => {
});
it("should direct you to scheduling settings if you enable the toggle", async () => {
MetabaseApi.db_create = async db => {
Databases.api.create = async db => {
await delay(10);
return { ...db, id: 10 };
};
......@@ -183,14 +174,11 @@ describe("dashboard list", () => {
store.pushPath("/admin/databases");
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
const listAppBeforeAdd = app.find(DatabaseListApp);
await store.waitForActions([Databases.actionTypes.FETCH_LIST]);
const addDbButton = listAppBeforeAdd
.find(".Button.Button--primary")
.first();
click(addDbButton);
await eventually(() => {
click(app.find(".Button.Button--primary").first());
});
const dbDetailsForm = app.find(DatabaseEditApp);
expect(dbDetailsForm.length).toBe(1);
......@@ -245,14 +233,15 @@ describe("dashboard list", () => {
await store.waitForActions([CREATE_DATABASE_STARTED]);
expect(saveButton.text()).toBe("Saving...");
await store.waitForActions([CREATE_DATABASE]);
await eventually(() =>
expect(store.getPath()).toEqual("/admin/databases?created=10"),
);
expect(store.getPath()).toEqual("/admin/databases?created=10");
expect(app.find(CreatedDatabaseModal).length).toBe(1);
});
it("should show error correctly on failure", async () => {
MetabaseApi.db_create = async () => {
Databases.api.create = async () => {
await delay(10);
return Promise.reject({
status: 400,
......@@ -265,15 +254,12 @@ describe("dashboard list", () => {
store.pushPath("/admin/databases");
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
const listAppBeforeAdd = app.find(DatabaseListApp);
const addDbButton = listAppBeforeAdd
.find(".Button.Button--primary")
.first();
click(addDbButton); // ROUTER LINK
await eventually(() => {
const addDbButton = app.find(".Button.Button--primary").first();
expect(addDbButton).not.toBe(null);
click(addDbButton);
});
const dbDetailsForm = app.find(DatabaseEditApp);
expect(dbDetailsForm.length).toBe(1);
......@@ -308,43 +294,46 @@ describe("dashboard list", () => {
describe("deletes", () => {
it("should not block deletes", async () => {
MetabaseApi.db_delete = async () => await delay(10);
Databases.api.delete = async () => {
await delay(10);
};
const store = await createTestStore();
store.pushPath("/admin/databases");
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
const wrapper = app.find(DatabaseListApp);
const dbCount = wrapper.find("tr").length;
const deleteButton = wrapper.find(".Button.Button--danger").first();
let deleteButtons;
await eventually(() => {
deleteButtons = app.find(".Button.Button--danger");
expect(deleteButtons).not.toHaveLength(0);
});
click(deleteButton);
// let dbCount = deleteButtons.length;
click(deleteButtons.first());
const deleteModal = wrapper.find(".test-modal");
const deleteModal = app.find(".test-modal");
setInputValue(deleteModal.find(".Form-input"), "DELETE");
clickButton(deleteModal.find(".Button.Button--danger"));
// test that the modal is gone
expect(wrapper.find(".test-modal").length).toEqual(0);
expect(app.find(".test-modal").length).toEqual(0);
// we should now have a disabled db row during delete
expect(wrapper.find("tr.disabled").length).toEqual(1);
expect(app.find("tr.disabled").length).toEqual(1);
// db delete finishes
await store.waitForActions([DELETE_DATABASE]);
await eventually(() => {
// there should be no disabled db rows now
expect(app.find("tr.disabled").length).toEqual(0);
// there should be no disabled db rows now
expect(wrapper.find("tr.disabled").length).toEqual(0);
// we should now have one database less in the list
expect(wrapper.find("tr").length).toEqual(dbCount - 1);
// we should now have one database less in the list
// NOTE: unsure why the delete button is still present, it is not during manual testing
// expect(app.find(".Button.Button--danger").length).toEqual(dbCount - 1);
});
});
it("should show error correctly on failure", async () => {
MetabaseApi.db_delete = async () => {
Databases.api.delete = async () => {
await delay(10);
return Promise.reject({
status: 400,
......@@ -357,35 +346,36 @@ describe("dashboard list", () => {
store.pushPath("/admin/databases");
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
const wrapper = app.find(DatabaseListApp);
const dbCount = wrapper.find("tr").length;
const deleteButton = wrapper.find(".Button.Button--danger").first();
click(deleteButton);
let deleteButtons;
await eventually(() => {
deleteButtons = app.find(".Button.Button--danger");
expect(deleteButtons).not.toHaveLength(0);
});
const deleteModal = wrapper.find(".test-modal");
let dbCount = deleteButtons.length;
click(deleteButtons.first());
const deleteModal = app.find(".test-modal");
setInputValue(deleteModal.find(".Form-input"), "DELETE");
clickButton(deleteModal.find(".Button.Button--danger"));
// test that the modal is gone
expect(wrapper.find(".test-modal").length).toEqual(0);
expect(app.find(".test-modal").length).toEqual(0);
// we should now have a disabled db row during delete
expect(wrapper.find("tr.disabled").length).toEqual(1);
expect(app.find("tr.disabled").length).toEqual(1);
// db delete fails
await store.waitForActions([DELETE_DATABASE_FAILED]);
// there should be no disabled db rows now
expect(wrapper.find("tr.disabled").length).toEqual(0);
expect(app.find("tr.disabled").length).toEqual(0);
// the db count should be same as before
expect(wrapper.find("tr").length).toEqual(dbCount);
expect(app.find(".Button.Button--danger")).toHaveLength(dbCount);
expect(wrapper.find(FormMessage).text()).toBe(SERVER_ERROR_MESSAGE);
expect(app.find(FormMessage).text()).toBe(SERVER_ERROR_MESSAGE);
});
});
......@@ -397,13 +387,11 @@ describe("dashboard list", () => {
store.pushPath("/admin/databases");
const app = mount(store.getAppContainer());
await store.waitForActions([FETCH_DATABASES]);
await store.waitForActions([Databases.actionTypes.FETCH_LIST]);
const wrapper = app.find(DatabaseListApp);
const sampleDatasetEditLink = wrapper
.find('a[children="Sample Dataset"]')
.first();
click(sampleDatasetEditLink); // ROUTER LINK
await eventually(() =>
click(app.find('a[children="Sample Dataset"]').first()),
);
expect(store.getPath()).toEqual("/admin/databases/1");
await store.waitForActions([INITIALIZE_DATABASE]);
......
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