Пример #1
0
export function createEntity(def: EntityDefinition): Entity {
  // $FlowFixMe
  const entity: Entity = { ...def };

  // defaults
  if (!entity.schema) {
    entity.schema = new schema.Entity(entity.name);
  }

  // API
  if (!entity.api) {
    entity.api = {};
  }
  if (entity.path) {
    const path = entity.path; // Flow not recognizing path won't be undefined
    entity.api = {
      list: GET(`${path}`),
      create: POST(`${path}`),
      get: GET(`${path}/:id`),
      update: PUT(`${path}/:id`),
      delete: DELETE(`${path}/:id`),
      ...entity.api,
    };
  }

  const getIdForQuery = entityQuery => JSON.stringify(entityQuery || null);

  const getObjectStatePath = entityId => ["entities", entity.name, entityId];
  const getListStatePath = entityQuery =>
    ["entities", entity.name + "_list"].concat(getIdForQuery(entityQuery));

  const getWritableProperties = object =>
    entity.writableProperties != null
      ? _.pick(object, "id", ...entity.writableProperties)
      : object;

  // ACTION TYPES
  const CREATE_ACTION = `metabase/entities/${entity.name}/CREATE`;
  const FETCH_ACTION = `metabase/entities/${entity.name}/FETCH`;
  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,
    FETCH: FETCH_ACTION,
    UPDATE: UPDATE_ACTION,
    DELETE: DELETE_ACTION,
    FETCH_LIST: FETCH_LIST_ACTION,
    INVALIDATE_LISTS_ACTION: INVALIDATE_LISTS_ACTION,
    ...(entity.actionTypes || {}),
  };

  entity.objectActions = {
    create: createThunkAction(
      CREATE_ACTION,
      entityObject => async (dispatch, getState) => {
        trackAction("create", entityObject, getState);
        const statePath = ["entities", entity.name, "create"];
        try {
          dispatch(setRequestState({ statePath, state: "LOADING" }));
          const result = normalize(
            await entity.api.create(getWritableProperties(entityObject)),
            entity.schema,
          );
          dispatch(setRequestState({ statePath, state: "LOADED" }));
          return result;
        } catch (error) {
          console.error(`${CREATE_ACTION} failed:`, error);
          dispatch(setRequestState({ statePath, error }));
          throw error;
        }
      },
    ),

    fetch: createThunkAction(
      FETCH_ACTION,
      (entityObject, { reload = false, properties = null } = {}) => (
        dispatch,
        getState,
      ) =>
        fetchData({
          dispatch,
          getState,
          reload,
          properties,
          requestStatePath: getObjectStatePath(entityObject.id),
          existingStatePath: getObjectStatePath(entityObject.id),
          getData: async () =>
            normalize(
              await entity.api.get({ id: entityObject.id }),
              entity.schema,
            ),
        }),
    ),

    update: createThunkAction(
      UPDATE_ACTION,
      (entityObject, updatedObject = null, { notify } = {}) => async (
        dispatch,
        getState,
      ) => {
        trackAction("update", updatedObject, getState);
        // save the original object for undo
        const originalObject = entity.selectors.getObject(getState(), {
          entityId: entityObject.id,
        });
        // If a second object is provided just take the id from the first and
        // update it with all the properties in the second
        // NOTE: this is so that the object.update(updatedObject) method on
        // the default entity wrapper class works correctly
        if (updatedObject) {
          entityObject = { id: entityObject.id, ...updatedObject };
        }
        const statePath = [...getObjectStatePath(entityObject.id), "update"];
        try {
          dispatch(setRequestState({ statePath, state: "LOADING" }));
          const result = normalize(
            await entity.api.update(getWritableProperties(entityObject)),
            entity.schema,
          );
          dispatch(setRequestState({ statePath, state: "LOADED" }));
          if (notify) {
            if (notify.undo) {
              // pick only the attributes that were updated
              // $FlowFixMe
              const undoObject = _.pick(
                originalObject,
                ...Object.keys(updatedObject || {}),
              );
              dispatch(
                addUndo({
                  actions: [
                    entity.objectActions.update(
                      entityObject,
                      undoObject,
                      // don't show an undo for the undo
                      { notify: false },
                    ),
                  ],
                  ...notify,
                }),
              );
            } else {
              dispatch(addUndo(notify));
            }
          }
          return result;
        } catch (error) {
          console.error(`${UPDATE_ACTION} failed:`, error);
          dispatch(setRequestState({ statePath, error }));
          throw error;
        }
      },
    ),

    delete: createThunkAction(
      DELETE_ACTION,
      entityObject => async (dispatch, getState) => {
        trackAction("delete", getState);
        const statePath = [...getObjectStatePath(entityObject.id), "delete"];
        try {
          dispatch(setRequestState({ statePath, state: "LOADING" }));
          await entity.api.delete({ id: entityObject.id });
          dispatch(setRequestState({ statePath, state: "LOADED" }));
          return {
            entities: { [entity.name]: { [entityObject.id]: null } },
            result: entityObject.id,
          };
        } catch (error) {
          console.error(`${DELETE_ACTION} failed:`, error);
          dispatch(setRequestState({ statePath, error }));
          throw error;
        }
      },
    ),

    // user defined object actions should override defaults
    ...(def.objectActions || {}),
  };

  // ACTION CREATORS
  entity.actions = {
    fetchList: createThunkAction(
      FETCH_LIST_ACTION,
      (entityQuery = null, { reload = false } = {}) => (dispatch, getState) =>
        fetchData({
          dispatch,
          getState,
          reload,
          requestStatePath: getListStatePath(entityQuery),
          existingStatePath: getListStatePath(entityQuery),
          getData: async () => {
            const fetched = await entity.api.list(entityQuery || {});
            let results = fetched;

            // for now at least paginated endpoints have a 'data' property that
            // contains the actual entries, if that is on the response we should
            // use that as the 'results'
            if (fetched.data) {
              results = fetched.data;
            }
            const { result, entities } = normalize(results, [entity.schema]);
            return {
              result,
              entities,
              entityQuery,
              // capture some extra details from the result just in case?
              resultDetails: {
                total: fetched.total,
                offset: fetched.offset,
                limit: fetched.limit,
              },
            };
          },
        }),
    ),

    // user defined actions should override defaults
    ...entity.objectActions,
    ...(def.actions || {}),
  };

  // HACK: the above actions return the normalizr results
  // (i.e. { entities, result }) rather than the loaded object(s), except
  // for fetch and fetchList when the data is cached, in which case it returns
  // the noralized object.
  //
  // This is a problem when we use the result of one of the actions as though
  // though the action creator was an API client.
  //
  // For now just use this function until we figure out a cleaner way to do
  // this. It will make it easy to find instances where we use the result of an
  // action, and ensures a consistent result
  //
  // NOTE: this returns the normalized object(s), nested objects defined in
  // the schema will be replaced with IDs.
  //
  // NOTE: A possible solution is to have an `updateEntities` action which is
  // dispatched by the actions with the normalized data so that we can return
  // the denormalized data from the action itself.
  //
  entity.HACK_getObjectFromAction = ({ payload }) => {
    if (payload && "entities" in payload && "result" in payload) {
      if (Array.isArray(payload.result)) {
        return payload.result.map(id => payload.entities[entity.name][id]);
      } else {
        return payload.entities[entity.name][payload.result];
      }
    } else {
      return payload;
    }
  };

  // SELECTORS

  const getEntities = state => state.entities;

  // OBJECT SELECTORS

  const getEntityId = (state, props) =>
    (props.params && props.params.entityId) || props.entityId;

  const getObject = createSelector(
    [getEntities, getEntityId],
    (entities, entityId) => denormalize(entityId, entity.schema, entities),
  );

  // LIST SELECTORS

  const getEntityQueryId = (state, props) =>
    getIdForQuery(props && props.entityQuery);

  const getEntityLists = createSelector(
    [getEntities],
    entities => entities[`${entity.name}_list`],
  );

  const getEntityIds = createSelector(
    [getEntityQueryId, getEntityLists],
    (entityQueryId, lists) => lists[entityQueryId],
  );

  const getList = createSelector(
    [getEntities, getEntityIds],
    (entities, entityIds) => denormalize(entityIds, [entity.schema], entities),
  );

  // REQUEST STATE SELECTORS

  const getStatePath = props =>
    props.entityId != null
      ? getObjectStatePath(props.entityId)
      : getListStatePath(props.entityQuery);

  const getRequestState = (state, props = {}) =>
    getIn(state, ["requests", "states", ...getStatePath(props), "fetch"]);

  const getFetchState = (state, props = {}) =>
    getIn(state, ["requests", "fetched", ...getStatePath(props)]);

  const getLoading = createSelector(
    [getRequestState],
    requestState => (requestState ? requestState.state === "LOADING" : false),
  );
  const getLoaded = createSelector(
    [getRequestState],
    requestState => (requestState ? requestState.state === "LOADED" : false),
  );
  const getFetched = createSelector(
    [getFetchState],
    fetchState => !!fetchState,
  );
  const getError = createSelector(
    [getRequestState],
    requestState => (requestState ? requestState.error : null),
  );

  entity.selectors = {
    getList,
    getObject,
    getFetched,
    getLoading,
    getLoaded,
    getError,
    ...(def.selectors || {}),
  };

  entity.objectSelectors = {
    getName(object) {
      return object.name;
    },
    getIcon(object) {
      return "unknown";
    },
    getColor(object) {
      return undefined;
    },
    ...(def.objectSelectors || {}),
  };

  // REDUCERS

  entity.reducers = {};

  entity.reducers[entity.name] = handleEntities(
    /^metabase\/entities\//,
    entity.name,
    def.reducer,
  );

  entity.reducers[entity.name + "_list"] = (
    state = {},
    { type, error, payload },
  ) => {
    if (error) {
      return state;
    }
    if (type === FETCH_LIST_ACTION) {
      if (payload.result) {
        return {
          ...state,
          [getIdForQuery(payload.entityQuery)]: payload.result,
        };
      }
      // NOTE: only add/remove from the "default" list (no entityQuery)
      // TODO: just remove this entirely?
    } else if (type === CREATE_ACTION && state[""]) {
      return { ...state, "": state[""].concat([payload.result]) };
    } else if (type === DELETE_ACTION && state[""]) {
      return {
        ...state,
        "": state[""].filter(id => id !== payload.result),
      };
    }
    return state;
  };

  // REQUEST STATE REDUCER

  // NOTE: ideally we'd only reset lists where there's a possibility the action,
  // or even better, add/remove the item from appropriate lists in the reducer
  // above. This will be difficult with pagination

  if (!entity.actionShouldInvalidateLists) {
    entity.actionShouldInvalidateLists = action =>
      action.type === CREATE_ACTION ||
      action.type === DELETE_ACTION ||
      action.type === UPDATE_ACTION ||
      action.type === INVALIDATE_LISTS_ACTION;
  }

  entity.requestsReducer = (state, action) => {
    // reset all list request states when creating, deleting, or updating
    // to force a reload
    if (entity.actionShouldInvalidateLists(action)) {
      return dissocIn(state, ["states", "entities", entity.name + "_list"]);
    }
    return state;
  };

  // OBJECT WRAPPER

  if (!entity.wrapEntity) {
    // This is the default entity wrapper class implementation
    //
    // We automatically bind all objectSelectors and objectActions functions
    //
    // If a dispatch function is passed to the constructor the actions will be
    // dispatched using it, otherwise the actions will be returned
    //
    class EntityWrapper {
      _dispatch: ?(action: any) => any;

      constructor(object, dispatch = null) {
        Object.assign(this, object);
        this._dispatch = dispatch;
      }
    }
    // object selectors
    for (const [methodName, method] of Object.entries(entity.objectSelectors)) {
      // $FlowFixMe
      EntityWrapper.prototype[methodName] = function(...args) {
        // $FlowFixMe
        return method(this, ...args);
      };
    }
    // object actions
    for (const [methodName, method] of Object.entries(entity.objectActions)) {
      // $FlowFixMe
      EntityWrapper.prototype[methodName] = function(...args) {
        if (this._dispatch) {
          // if dispatch was provided to the constructor go ahead and dispatch
          // $FlowFixMe
          return this._dispatch(method(this, ...args));
        } else {
          // otherwise just return the action
          // $FlowFixMe
          return method(this, ...args);
        }
      };
    }

    entity.wrapEntity = (object, dispatch = null) =>
      new EntityWrapper(object, dispatch);
  }

  function trackAction(action, object, getState) {
    try {
      MetabaseAnalytics.trackEvent(
        "entity actions",
        entity.name,
        action,
        entity.getAnalyticsMetadata &&
          entity.getAnalyticsMetadata(action, object, getState),
      );
    } catch (e) {
      console.warn("trackAction threw an error:", e);
    }
  }

  return entity;
}
Пример #2
0
  list: GET("/api/activity"),
  recent_views: GET("/api/activity/recent_views"),
};

export const CardApi = {
  list: GET("/api/card", (cards, { data }) =>
    // HACK: support for the "q" query param until backend implements it
    cards.filter(
      card =>
        !data.q || card.name.toLowerCase().indexOf(data.q.toLowerCase()) >= 0,
    ),
  ),
  create: POST("/api/card"),
  get: GET("/api/card/:cardId"),
  update: PUT("/api/card/:id"),
  delete: DELETE("/api/card/:cardId"),
  query: POST("/api/card/:cardId/query"),
  // isfavorite:                  GET("/api/card/:cardId/favorite"),
  favorite: POST("/api/card/:cardId/favorite"),
  unfavorite: DELETE("/api/card/:cardId/favorite"),

  listPublic: GET("/api/card/public"),
  listEmbeddable: GET("/api/card/embeddable"),
  createPublicLink: POST("/api/card/:id/public_link"),
  deletePublicLink: DELETE("/api/card/:id/public_link"),
  // related
  related: GET("/api/card/:cardId/related"),
  adHocRelated: POST("/api/card/related"),
};

export const DashboardApi = {
Пример #3
0
export function createEntity(def: EntityDefinition): Entity {
  // $FlowFixMe
  const entity: Entity = { ...def };

  // defaults
  if (!entity.schema) {
    entity.schema = new schema.Entity(entity.name);
  }
  if (!entity.getName) {
    entity.getName = object => object.name;
  }

  // API
  if (!entity.api) {
    entity.api = {
      list: GET(`${entity.path}`),
      create: POST(`${entity.path}`),
      get: GET(`${entity.path}/:id`),
      update: PUT(`${entity.path}/:id`),
      delete: DELETE(`${entity.path}/:id`),
    };
  }

  // ACITON TYPES
  const CREATE_ACTION = `metabase/entities/${entity.name}/CREATE`;
  const FETCH_ACTION = `metabase/entities/${entity.name}/FETCH`;
  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`;

  // ACTION CREATORS
  entity.actions = {
    ...(def.actions || {}),
    ...(def.objectActions || {}),

    create: createThunkAction(
      CREATE_ACTION,
      entityObject => async (dispatch, getState) => {
        const statePath = ["entities", entity.name, "create"];
        try {
          dispatch(setRequestState({ statePath, state: "LOADING" }));
          const result = normalize(
            await entity.api.create(entityObject),
            entity.schema,
          );
          dispatch(setRequestState({ statePath, state: "LOADED" }));
          return result;
        } catch (error) {
          console.error(`${CREATE_ACTION} failed:`, error);
          dispatch(setRequestState({ statePath, error }));
          throw error;
        }
      },
    ),

    fetch: createThunkAction(
      FETCH_ACTION,
      (entityObject, reload = false) => (dispatch, getState) =>
        fetchData({
          dispatch,
          getState,
          reload,
          requestStatePath: ["entities", entity.name, entityObject.id],
          existingStatePath: ["entities", entity.name, entityObject.id],
          getData: async () =>
            normalize(
              await entity.api.get({ id: entityObject.id }),
              entity.schema,
            ),
        }),
    ),

    update: createThunkAction(
      UPDATE_ACTION,
      entityObject => async (dispatch, getState) => {
        const statePath = ["entities", entity.name, entityObject.id, "update"];
        try {
          dispatch(setRequestState({ statePath, state: "LOADING" }));
          const result = normalize(
            await entity.api.update(entityObject),
            entity.schema,
          );
          dispatch(setRequestState({ statePath, state: "LOADED" }));
          return result;
        } catch (error) {
          console.error(`${UPDATE_ACTION} failed:`, error);
          dispatch(setRequestState({ statePath, error }));
          throw error;
        }
      },
    ),

    delete: createThunkAction(
      DELETE_ACTION,
      entityObject => async (dispatch, getState) => {
        const statePath = ["entities", entity.name, entityObject.id, "delete"];
        try {
          dispatch(setRequestState({ statePath, state: "LOADING" }));
          await entity.api.delete({ id: entityObject.id });
          dispatch(setRequestState({ statePath, state: "LOADED" }));
          return {
            entities: { [entity.name]: { [entityObject.id]: null } },
            result: entityObject.id,
          };
        } catch (error) {
          console.error(`${DELETE_ACTION} failed:`, error);
          dispatch(setRequestState({ statePath, error }));
          throw error;
        }
      },
    ),

    fetchList: createThunkAction(
      FETCH_LIST_ACTION,
      (query = {}, reload = false) => (dispatch, getState) =>
        fetchData({
          dispatch,
          getState,
          reload,
          requestStatePath: ["entities", entity.name + "_list"], // FIXME: different path depending on query?
          existingStatePath: ["entities", entity.name + "_list"], // FIXME: different path depending on query?
          getData: async () =>
            normalize(await entity.api.list(query), [entity.schema]),
        }),
    ),
  };

  // SELECTORS
  const getEntities = state => state.entities[entity.name];
  const getEntitiesIdsList = state => state.entities[`${entity.name}_list`];
  const getEntityId = (state, props) =>
    (props.params && props.params.entityId) || props.entityId;
  const getList = createSelector(
    [getEntities, getEntitiesIdsList],
    (entities, entityIds) => entityIds && entityIds.map(id => entities[id]),
  );
  const getObject = createSelector(
    [getEntities, getEntityId],
    (entities, entityId) => entities[entityId],
  );

  const getRequestState = (state, props = {}) => {
    const path = ["requests", "states", "entities"];
    if (props.entityId != null) {
      path.push(entity.name, props.entityId);
    } else {
      path.push(entity.name + "_list");
    }
    path.push(props.requestType || "fetch");
    return getIn(state, path);
  };
  const getLoading = createSelector(
    [getRequestState],
    requestState => (requestState ? requestState.state === "LOADING" : true),
  );
  const getError = createSelector(
    [getRequestState],
    requestState => (requestState ? requestState.error : null),
  );

  entity.selectors = {
    getEntities,
    getEntitiesIdsList,
    getList,
    getObject,
    getLoading,
    getError,
  };

  // REDUCERS

  entity.reducers = {};

  entity.reducers[entity.name] = handleEntities(
    /^metabase\/entities\//,
    entity.name,
    def.reducer,
  );

  entity.reducers[entity.name + "_list"] = (state = null, action) => {
    if (action.error) {
      return state;
    }
    if (action.type === FETCH_LIST_ACTION) {
      return action.payload.result || state;
    } else if (action.type === CREATE_ACTION) {
      return state && state.concat([action.payload.result]);
    } else if (action.type === DELETE_ACTION) {
      return state && state.filter(id => id !== action.payload.result);
    } else {
      return state;
    }
  };

  return entity;
}
Пример #4
0
import { POST, DELETE } from "metabase/lib/api";
import {
  canonicalCollectionId,
  getCollectionType,
} from "metabase/entities/collections";

const FAVORITE_ACTION = `metabase/entities/dashboards/FAVORITE`;
const UNFAVORITE_ACTION = `metabase/entities/dashboards/UNFAVORITE`;

const Dashboards = createEntity({
  name: "dashboards",
  path: "/api/dashboard",

  api: {
    favorite: POST("/api/dashboard/:id/favorite"),
    unfavorite: DELETE("/api/dashboard/:id/favorite"),
    save: POST("/api/dashboard/save"),
  },

  objectActions: {
    setArchived: ({ id }, archived, opts) =>
      Dashboards.actions.update(
        { id },
        { archived },
        undo(opts, "dashboard", archived ? "archived" : "unarchived"),
      ),

    setCollection: ({ id }, collection, opts) =>
      Dashboards.actions.update(
        { id },
        { collection_id: canonicalCollectionId(collection && collection.id) },