Scaffold entity store library
This commit is contained in:
parent
0d42fe1c96
commit
3b067c6fab
10 changed files with 273 additions and 0 deletions
22
app/soapbox/entity-store/actions.ts
Normal file
22
app/soapbox/entity-store/actions.ts
Normal file
|
@ -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<typeof importEntities>;
|
||||
|
||||
export {
|
||||
ENTITIES_IMPORT,
|
||||
importEntities,
|
||||
EntityAction,
|
||||
};
|
2
app/soapbox/entity-store/hooks/index.ts
Normal file
2
app/soapbox/entity-store/hooks/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { useEntities } from './useEntities';
|
||||
export { useEntity } from './useEntity';
|
72
app/soapbox/entity-store/hooks/useEntities.ts
Normal file
72
app/soapbox/entity-store/hooks/useEntities.ts
Normal file
|
@ -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<TEntity extends Entity>(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<TEntity[]>((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,
|
||||
};
|
41
app/soapbox/entity-store/hooks/useEntity.ts
Normal file
41
app/soapbox/entity-store/hooks/useEntity.ts
Normal file
|
@ -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<TEntity extends Entity>(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,
|
||||
};
|
43
app/soapbox/entity-store/reducer.ts
Normal file
43
app/soapbox/entity-store/reducer.ts
Normal file
|
@ -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<string, EntityCache>;
|
||||
|
||||
/** Import entities into the cache. */
|
||||
const importEntities = (
|
||||
state: Readonly<State>,
|
||||
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<State> = 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;
|
44
app/soapbox/entity-store/types.ts
Normal file
44
app/soapbox/entity-store/types.ts
Normal file
|
@ -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<string, Entity>
|
||||
|
||||
/** List of entity IDs and fetch state. */
|
||||
interface EntityList {
|
||||
/** Set of entity IDs in this list. */
|
||||
ids: Set<string>
|
||||
/** 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<string, EntityList>
|
||||
}
|
||||
|
||||
export {
|
||||
Entity,
|
||||
EntityStore,
|
||||
EntityList,
|
||||
EntityListState,
|
||||
EntityCache,
|
||||
};
|
41
app/soapbox/entity-store/utils.ts
Normal file
41
app/soapbox/entity-store/utils.ts
Normal file
|
@ -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<EntityStore>((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,
|
||||
};
|
|
@ -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`
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue