diff --git a/frontend/src/metabase/entities/databases.js b/frontend/src/metabase/entities/databases.js index 1f0fc2fdb8bc4a1be040eb03aee9c2473f153ed1..b387183a80d914d2103f720ce55e00c1a167c97e 100644 --- a/frontend/src/metabase/entities/databases.js +++ b/frontend/src/metabase/entities/databases.js @@ -11,7 +11,7 @@ import { MetabaseApi } from "metabase/services"; import { DatabaseSchema } from "metabase/schema"; import Fields from "metabase/entities/fields"; -import { getFields } from "metabase/selectors/metadata"; +import { getMetadata, getFields } from "metabase/selectors/metadata"; import { createSelector } from "reselect"; // OBJECT ACTIONS @@ -57,6 +57,8 @@ const Databases = createEntity({ }, selectors: { + getObject: (state, { entityId }) => getMetadata(state).database(entityId), + getHasSampleDataset: state => _.any(Databases.selectors.getList(state), db => db.is_sample), getIdfields: createSelector( diff --git a/frontend/src/metabase/entities/fields.js b/frontend/src/metabase/entities/fields.js index 500c9db9dc0fdeffa99f78e1869c49371dba06c3..d7fcb83ee5bf8648eb02dc603c2d4e2b78fcd0e0 100644 --- a/frontend/src/metabase/entities/fields.js +++ b/frontend/src/metabase/entities/fields.js @@ -12,6 +12,8 @@ import { assocIn, updateIn } from "icepick"; import { FieldSchema } from "metabase/schema"; import { MetabaseApi } from "metabase/services"; +import { getMetadata } from "metabase/selectors/metadata"; + import { field_visibility_types, field_special_types, @@ -40,6 +42,10 @@ export default createEntity({ path: "/api/field", schema: FieldSchema, + selectors: { + getObject: (state, { entityId }) => getMetadata(state).field(entityId), + }, + // ACTION CREATORS objectActions: { diff --git a/frontend/src/metabase/entities/metrics.js b/frontend/src/metabase/entities/metrics.js index 0dfd89c2cb138e86213048a737ec814f3a608bb3..a23eb29ccd5345b83533a7aa0cef761fe385ec2e 100644 --- a/frontend/src/metabase/entities/metrics.js +++ b/frontend/src/metabase/entities/metrics.js @@ -4,6 +4,8 @@ import { MetricSchema } from "metabase/schema"; import { color } from "metabase/lib/colors"; import * as Urls from "metabase/lib/urls"; +import { getMetadata } from "metabase/selectors/metadata"; + const Metrics = createEntity({ name: "metrics", nameOne: "metric", @@ -30,6 +32,10 @@ const Metrics = createEntity({ getIcon: metric => "sum", }, + selectors: { + getObject: (state, { entityId }) => getMetadata(state).metric(entityId), + }, + form: { fields: [{ name: "name" }, { name: "description", type: "text" }], }, diff --git a/frontend/src/metabase/entities/segments.js b/frontend/src/metabase/entities/segments.js index 21fb5615ec0051119458243cceaafcbfe9645892..fb1a6e30a6010f903e1036796c3f0c1342238fcd 100644 --- a/frontend/src/metabase/entities/segments.js +++ b/frontend/src/metabase/entities/segments.js @@ -6,6 +6,8 @@ import { SegmentSchema } from "metabase/schema"; import { color } from "metabase/lib/colors"; import * as Urls from "metabase/lib/urls"; +import { getMetadata } from "metabase/selectors/metadata"; + const Segments = createEntity({ name: "segments", nameOne: "segment", @@ -24,6 +26,10 @@ const Segments = createEntity({ delete: null, }, + selectors: { + getObject: (state, { entityId }) => getMetadata(state).segment(entityId), + }, + objectSelectors: { getName: segment => segment && segment.name, getUrl: segment => diff --git a/frontend/src/metabase/entities/tables.js b/frontend/src/metabase/entities/tables.js index 6417822dc1333a4bea9f2382416d368696ca16ce..a06c7a8bb924f4994af8b151e838375314f54e30 100644 --- a/frontend/src/metabase/entities/tables.js +++ b/frontend/src/metabase/entities/tables.js @@ -176,6 +176,8 @@ const Tables = createEntity({ }, selectors: { + getObject: (state, { entityId }) => getMetadata(state).table(entityId), + getTable: createSelector( // we wrap getMetadata to handle a circular dep issue [state => getMetadata(state), (state, props) => props.entityId], diff --git a/frontend/src/metabase/lib/entities.js b/frontend/src/metabase/lib/entities.js index 9f5bc72c6fa74469e021aa85a872f70497f4292d..7381846b6e3d11fec33bd07897a61a51c1089bed 100644 --- a/frontend/src/metabase/lib/entities.js +++ b/frontend/src/metabase/lib/entities.js @@ -9,6 +9,7 @@ import { withRequestState, withCachedDataAndRequestState, } from "metabase/lib/redux"; +import createCachedSelector from "re-reselect"; import { addUndo } from "metabase/redux/undo"; @@ -458,10 +459,12 @@ export function createEntity(def: EntityDefinition): Entity { const getEntityId = (state, props) => (props.params && props.params.entityId) || props.entityId; - const getObject = createSelector( + const getObject = createCachedSelector( [getEntities, getEntityId], (entities, entityId) => denormalize(entityId, entity.schema, entities), - ); + )((state, { entityId }) => + typeof entityId === "object" ? JSON.stringify(entityId) : entityId, + ); // must stringify objects // LIST SELECTORS @@ -479,8 +482,13 @@ export function createEntity(def: EntityDefinition): Entity { ); const getList = createSelector( - [getEntities, getEntityIds], - (entities, entityIds) => denormalize(entityIds, [entity.schema], entities), + [state => state, getEntityIds], + // delegate to getObject + (state, entityIds) => + entityIds && + entityIds.map(entityId => + entity.selectors.getObject(state, { entityId }), + ), ); // REQUEST STATE SELECTORS diff --git a/frontend/src/metabase/store.js b/frontend/src/metabase/store.js index da4b8fc0aa881b40429f576fdd4a522c15dc5024..b7e4ac6d0cce976a02eb4bea35673e83f4b533d4 100644 --- a/frontend/src/metabase/store.js +++ b/frontend/src/metabase/store.js @@ -13,7 +13,10 @@ import { DEBUG } from "metabase/lib/debug"; * Provides the same functionality as redux-thunk and augments the dispatch method with * `dispatch.action(type, payload)` which creates an action that adheres to Flux Standard Action format. */ -const thunkWithDispatchAction = ({ dispatch, getState }) => next => action => { +export const thunkWithDispatchAction = ({ + dispatch, + getState, +}) => next => action => { if (typeof action === "function") { const dispatchAugmented = Object.assign(dispatch, { action: (type, payload) => dispatch({ type, payload }), diff --git a/frontend/test/metabase/entities/entities.unit.spec.js b/frontend/test/metabase/entities/entities.unit.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..14a6976cc8814258a3114dc3735d8c69a72c798b --- /dev/null +++ b/frontend/test/metabase/entities/entities.unit.spec.js @@ -0,0 +1,70 @@ +import { createEntity, combineEntities } from "metabase/lib/entities"; +import requestsReducer from "metabase/redux/requests"; +import { combineReducers, applyMiddleware, createStore, compose } from "redux"; + +import promise from "redux-promise"; +import { thunkWithDispatchAction } from "metabase/store"; + +const widgets = createEntity({ + name: "widgets", + api: { + get: ({ id }) => ({ id: id, name: "object" + id }), + }, +}); + +const entities = combineEntities([widgets]); + +const reducer = combineReducers({ + entities: entities.reducer, + requests: (state, action) => + requestsReducer(entities.requestsReducer(state, action), action), +}); + +const initialState = { + entities: { + widgets: { + 1: { name: "foo " }, + 2: { name: "bar" }, + }, + }, +}; + +describe("entities", () => { + let store; + beforeEach(() => { + store = createStore( + reducer, + initialState, + compose(applyMiddleware(thunkWithDispatchAction, promise)), + ); + }); + describe("getObject", () => { + it("should return an object", () => { + expect( + widgets.selectors.getObject(initialState, { entityId: 1 }), + ).toEqual({ name: "foo " }); + }); + it("should cache the object", () => { + const a1 = widgets.selectors.getObject(initialState, { entityId: 1 }); + const a2 = widgets.selectors.getObject(initialState, { entityId: 1 }); + expect(a1).toBe(a2); + }); + it("should cache multiple objects", () => { + const a1 = widgets.selectors.getObject(initialState, { entityId: 1 }); + const b1 = widgets.selectors.getObject(initialState, { entityId: 2 }); + const a2 = widgets.selectors.getObject(initialState, { entityId: 1 }); + const b2 = widgets.selectors.getObject(initialState, { entityId: 2 }); + expect(a1).toBe(a2); + expect(b1).toBe(b2); + }); + }); + describe("fetch", () => { + it("should fetch an entity", async () => { + await store.dispatch(widgets.actions.fetch({ id: 3 })); + const object = widgets.selectors.getObject(store.getState(), { + entityId: 3, + }); + expect(object).toEqual({ id: 3, name: "object3" }); + }); + }); +}); diff --git a/package.json b/package.json index b22dea9f8ce86ad1ebff03481762c3491c75a0f6..fc9786df4967a4673f41d88390bd53ee4d2bcdb5 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "number-to-locale-string": "^1.0.1", "password-generator": "^2.0.1", "prop-types": "^15.5.7", + "re-reselect": "^3.4.0", "react": "15", "react-addons-shallow-compare": "^15.2.1", "react-ansi-style": "^1.0.0", diff --git a/yarn.lock b/yarn.lock index 4b2d1aa3bba083a40aa1332448c06cba0ac81df4..88c0a5f235bb0cdd535cbdb83d552c5a1a9b33dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11194,6 +11194,11 @@ rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +re-reselect@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/re-reselect/-/re-reselect-3.4.0.tgz#0f2303f3c84394f57f0cd31fea08a1ca4840a7cd" + integrity sha512-JsecfN+JlckncVXTWFWjn0Vk6uInl8GSf4eEd9tTk5qXHlgqkPdILpnYpgZcISXNYAzvfvsCZviaDk8AxyS5sg== + react-addons-shallow-compare@^15.2.1: version "15.6.2" resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.2.tgz#198a00b91fc37623db64a28fd17b596ba362702f"