From 3b067c6fabd1ad12bd205759176dbffca6a1b4fe Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 4 Dec 2022 16:58:13 -0600 Subject: [PATCH] Scaffold entity store library --- app/soapbox/entity-store/actions.ts | 22 ++++++ app/soapbox/entity-store/hooks/index.ts | 2 + app/soapbox/entity-store/hooks/useEntities.ts | 72 +++++++++++++++++++ app/soapbox/entity-store/hooks/useEntity.ts | 41 +++++++++++ app/soapbox/entity-store/reducer.ts | 43 +++++++++++ app/soapbox/entity-store/types.ts | 44 ++++++++++++ app/soapbox/entity-store/utils.ts | 41 +++++++++++ app/soapbox/reducers/index.ts | 2 + package.json | 1 + yarn.lock | 5 ++ 10 files changed, 273 insertions(+) create mode 100644 app/soapbox/entity-store/actions.ts create mode 100644 app/soapbox/entity-store/hooks/index.ts create mode 100644 app/soapbox/entity-store/hooks/useEntities.ts create mode 100644 app/soapbox/entity-store/hooks/useEntity.ts create mode 100644 app/soapbox/entity-store/reducer.ts create mode 100644 app/soapbox/entity-store/types.ts create mode 100644 app/soapbox/entity-store/utils.ts diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts new file mode 100644 index 000000000..59a1afee8 --- /dev/null +++ b/app/soapbox/entity-store/actions.ts @@ -0,0 +1,22 @@ +import type { Entity } from './types'; + +const ENTITIES_IMPORT = 'ENTITIES_IMPORT'; + +/** Action to import entities into the cache. */ +function importEntities(entities: Entity[], entityType: string, listKey?: string) { + return { + type: ENTITIES_IMPORT, + entityType, + entities, + listKey, + }; +} + +/** Any action pertaining to entities. */ +type EntityAction = ReturnType; + +export { + ENTITIES_IMPORT, + importEntities, + EntityAction, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts new file mode 100644 index 000000000..07d597912 --- /dev/null +++ b/app/soapbox/entity-store/hooks/index.ts @@ -0,0 +1,2 @@ +export { useEntities } from './useEntities'; +export { useEntity } from './useEntity'; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts new file mode 100644 index 000000000..89afc23e3 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -0,0 +1,72 @@ +import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import { importEntities } from '../actions'; + +import type { Entity } from '../types'; + +type EntityPath = [entityType: string, listKey: string] + +function useEntities(path: EntityPath, url: string) { + const api = useApi(); + const dispatch = useAppDispatch(); + + const [entityType, listKey] = path; + + const cache = useAppSelector(state => state.entities.get(entityType)); + const list = cache?.lists.get(listKey); + + const entityIds = list?.ids; + + const entities: readonly TEntity[] = entityIds ? ( + Array.from(entityIds).reduce((result, id) => { + const entity = cache?.store.get(id) as TEntity | undefined; + if (entity) { + result.push(entity); + } + return result; + }, []) + ) : []; + + const isFetching = Boolean(list?.state.fetching); + const isLoading = isFetching && entities.length === 0; + const hasNextPage = Boolean(list?.state.next); + const hasPreviousPage = Boolean(list?.state.prev); + + const fetchEntities = async() => { + const { data } = await api.get(url); + dispatch(importEntities(data, entityType, listKey)); + }; + + const fetchNextPage = async() => { + const next = list?.state.next; + + if (next) { + const { data } = await api.get(next); + dispatch(importEntities(data, entityType, listKey)); + } + }; + + const fetchPreviousPage = async() => { + const prev = list?.state.prev; + + if (prev) { + const { data } = await api.get(prev); + dispatch(importEntities(data, entityType, listKey)); + } + }; + + return { + entities, + fetchEntities, + isFetching, + isLoading, + hasNextPage, + hasPreviousPage, + fetchNextPage, + fetchPreviousPage, + }; +} + +export { + useEntities, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts new file mode 100644 index 000000000..2f37df2f3 --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -0,0 +1,41 @@ +import { useState } from 'react'; + +import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; + +import { importEntities } from '../actions'; + +import type { Entity } from '../types'; + +type EntityPath = [entityType: string, entityId: string] + +function useEntity(path: EntityPath, endpoint: string) { + const api = useApi(); + const dispatch = useAppDispatch(); + + const [entityType, entityId] = path; + const entity = useAppSelector(state => state.entities.get(entityType)?.store.get(entityId)) as TEntity | undefined; + + const [isFetching, setIsFetching] = useState(false); + const isLoading = isFetching && !entity; + + const fetchEntity = () => { + setIsFetching(true); + api.get(endpoint).then(({ data }) => { + dispatch(importEntities([data], entityType)); + setIsFetching(false); + }).catch(() => { + setIsFetching(false); + }); + }; + + return { + entity, + fetchEntity, + isFetching, + isLoading, + }; +} + +export { + useEntity, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts new file mode 100644 index 000000000..a9b01b84b --- /dev/null +++ b/app/soapbox/entity-store/reducer.ts @@ -0,0 +1,43 @@ +import produce, { enableMapSet } from 'immer'; + +import { EntityAction, ENTITIES_IMPORT } from './actions'; +import { createCache, createList, updateStore, updateList } from './utils'; + +import type { Entity, EntityCache } from './types'; + +enableMapSet(); + +/** Entity reducer state. */ +type State = Map; + +/** Import entities into the cache. */ +const importEntities = ( + state: Readonly, + entityType: string, + entities: Entity[], + listKey?: string, +): State => { + return produce(state, draft => { + const cache = draft.get(entityType) ?? createCache(); + cache.store = updateStore(cache.store, entities); + + if (listKey) { + const list = cache.lists.get(listKey) ?? createList(); + cache.lists.set(listKey, updateList(list, entities)); + } + + return draft.set(entityType, cache); + }); +}; + +/** Stores various entity data and lists in a one reducer. */ +function reducer(state: Readonly = new Map(), action: EntityAction): State { + switch (action.type) { + case ENTITIES_IMPORT: + return importEntities(state, action.entityType, action.entities, action.listKey); + default: + return state; + } +} + +export default reducer; \ No newline at end of file diff --git a/app/soapbox/entity-store/types.ts b/app/soapbox/entity-store/types.ts new file mode 100644 index 000000000..d114960b7 --- /dev/null +++ b/app/soapbox/entity-store/types.ts @@ -0,0 +1,44 @@ +/** A Mastodon API entity. */ +interface Entity { + /** Unique ID for the entity (usually the primary key in the database). */ + id: string +} + +/** Store of entities by ID. */ +type EntityStore = Map + +/** List of entity IDs and fetch state. */ +interface EntityList { + /** Set of entity IDs in this list. */ + ids: Set + /** Server state for this entity list. */ + state: EntityListState +} + +/** Fetch state for an entity list. */ +interface EntityListState { + /** Next URL for pagination, if any. */ + next: string | undefined + /** Previous URL for pagination, if any. */ + prev: string | undefined + /** Error returned from the API, if any. */ + error: any + /** Whether data for this list is currently being fetched. */ + fetching: boolean +} + +/** Cache data pertaining to a paritcular entity type.. */ +interface EntityCache { + /** Map of entities of this type. */ + store: EntityStore + /** Lists of entity IDs for a particular purpose. */ + lists: Map +} + +export { + Entity, + EntityStore, + EntityList, + EntityListState, + EntityCache, +}; \ No newline at end of file diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts new file mode 100644 index 000000000..445cb44e9 --- /dev/null +++ b/app/soapbox/entity-store/utils.ts @@ -0,0 +1,41 @@ +import type { Entity, EntityStore, EntityList, EntityCache } from './types'; + +/** Insert the entities into the store. */ +const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { + return entities.reduce((store, entity) => { + return store.set(entity.id, entity); + }, new Map(store)); +}; + +/** Update the list with new entity IDs. */ +const updateList = (list: EntityList, entities: Entity[]): EntityList => { + const newIds = entities.map(entity => entity.id); + return { + ...list, + ids: new Set([...Array.from(list.ids), ...newIds]), + }; +}; + +/** Create an empty entity cache. */ +const createCache = (): EntityCache => ({ + store: new Map(), + lists: new Map(), +}); + +/** Create an empty entity list. */ +const createList = (): EntityList => ({ + ids: new Set(), + state: { + next: undefined, + prev: undefined, + fetching: false, + error: null, + }, +}); + +export { + updateStore, + updateList, + createCache, + createList, +}; \ No newline at end of file diff --git a/app/soapbox/reducers/index.ts b/app/soapbox/reducers/index.ts index c900be16c..3cffed674 100644 --- a/app/soapbox/reducers/index.ts +++ b/app/soapbox/reducers/index.ts @@ -3,6 +3,7 @@ import { combineReducers } from 'redux-immutable'; import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth'; import * as BuildConfig from 'soapbox/build-config'; +import entities from 'soapbox/entity-store/reducer'; import account_notes from './account-notes'; import accounts from './accounts'; @@ -116,6 +117,7 @@ const reducers = { rules, history, announcements, + entities, }; // Build a default state from all reducers: it has the key and `undefined` diff --git a/package.json b/package.json index d43fc9a6a..e308ff374 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "html-webpack-harddisk-plugin": "^2.0.0", "html-webpack-plugin": "^5.5.0", "http-link-header": "^1.0.2", + "immer": "^9.0.16", "immutable": "^4.0.0", "imports-loader": "^4.0.0", "intersection-observer": "^0.12.0", diff --git a/yarn.lock b/yarn.lock index 433de7543..146f783aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6707,6 +6707,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= +immer@^9.0.16: + version "9.0.16" + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.16.tgz#8e7caab80118c2b54b37ad43e05758cdefad0198" + integrity sha512-qenGE7CstVm1NrHQbMh8YaSzTZTFNP3zPqr3YU0S0UY441j4bJTg4A2Hh5KAhwgaiU6ZZ1Ar6y/2f4TblnMReQ== + immer@^9.0.7: version "9.0.12" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"