diff --git a/app/soapbox/entity-store/__tests__/reducer.test.ts b/app/soapbox/entity-store/__tests__/reducer.test.ts index 0da25f25e..df0ec8e57 100644 --- a/app/soapbox/entity-store/__tests__/reducer.test.ts +++ b/app/soapbox/entity-store/__tests__/reducer.test.ts @@ -1,5 +1,6 @@ -import { entitiesFetchFail, entitiesFetchRequest, importEntities } from '../actions'; -import reducer from '../reducer'; +import { deleteEntities, entitiesFetchFail, entitiesFetchRequest, importEntities } from '../actions'; +import reducer, { State } from '../reducer'; +import { createListState } from '../utils'; import type { EntityCache } from '../types'; @@ -76,4 +77,24 @@ test('failure adds the error to the state', () => { const result = reducer(undefined, action); expect(result.TestEntity!.lists.thingies!.state.error).toBe(error); +}); + +test('deleting items', () => { + const state: State = { + TestEntity: { + store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } }, + lists: { + '': { + ids: new Set(['1', '2', '3']), + state: createListState(), + }, + }, + }, + }; + + const action = deleteEntities(['3', '1'], 'TestEntity'); + const result = reducer(state, action); + + expect(result.TestEntity!.store).toMatchObject({ '2': { id: '2' } }); + expect([...result.TestEntity!.lists['']!.ids]).toEqual(['2']); }); \ No newline at end of file diff --git a/app/soapbox/entity-store/actions.ts b/app/soapbox/entity-store/actions.ts index d5e78c1f9..4a01942b7 100644 --- a/app/soapbox/entity-store/actions.ts +++ b/app/soapbox/entity-store/actions.ts @@ -1,6 +1,7 @@ import type { Entity, EntityListState } from './types'; const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const; +const ENTITIES_DELETE = 'ENTITIES_DELETE' as const; const ENTITIES_FETCH_REQUEST = 'ENTITIES_FETCH_REQUEST' as const; const ENTITIES_FETCH_SUCCESS = 'ENTITIES_FETCH_SUCCESS' as const; const ENTITIES_FETCH_FAIL = 'ENTITIES_FETCH_FAIL' as const; @@ -15,6 +16,14 @@ function importEntities(entities: Entity[], entityType: string, listKey?: string }; } +function deleteEntities(ids: Iterable, entityType: string) { + return { + type: ENTITIES_DELETE, + ids, + entityType, + }; +} + function entitiesFetchRequest(entityType: string, listKey?: string) { return { type: ENTITIES_FETCH_REQUEST, @@ -45,16 +54,19 @@ function entitiesFetchFail(entityType: string, listKey: string | undefined, erro /** Any action pertaining to entities. */ type EntityAction = ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType; export { ENTITIES_IMPORT, + ENTITIES_DELETE, ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, importEntities, + deleteEntities, entitiesFetchRequest, entitiesFetchSuccess, entitiesFetchFail, diff --git a/app/soapbox/entity-store/hooks/index.ts b/app/soapbox/entity-store/hooks/index.ts index 07d597912..af27c8f3e 100644 --- a/app/soapbox/entity-store/hooks/index.ts +++ b/app/soapbox/entity-store/hooks/index.ts @@ -1,2 +1,3 @@ export { useEntities } from './useEntities'; -export { useEntity } from './useEntity'; \ No newline at end of file +export { useEntity } from './useEntity'; +export { useEntityActions } from './useEntityActions'; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/types.ts b/app/soapbox/entity-store/hooks/types.ts new file mode 100644 index 000000000..89992c12d --- /dev/null +++ b/app/soapbox/entity-store/hooks/types.ts @@ -0,0 +1,6 @@ +import type { Entity } from '../types'; +import type z from 'zod'; + +type EntitySchema = z.ZodType; + +export type { EntitySchema }; \ No newline at end of file diff --git a/app/soapbox/entity-store/hooks/useEntities.ts b/app/soapbox/entity-store/hooks/useEntities.ts index 3b84f07ca..3d3fddb48 100644 --- a/app/soapbox/entity-store/hooks/useEntities.ts +++ b/app/soapbox/entity-store/hooks/useEntities.ts @@ -8,6 +8,7 @@ import { filteredArray } from 'soapbox/schemas/utils'; import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions'; import type { Entity, EntityListState } from '../types'; +import type { EntitySchema } from './types'; import type { RootState } from 'soapbox/store'; /** Tells us where to find/store the entity in the cache. */ @@ -25,7 +26,7 @@ type EntityPath = [ /** Additional options for the hook. */ interface UseEntitiesOpts { /** A zod schema to parse the API entities. */ - schema?: z.ZodType + schema?: EntitySchema /** * Time (milliseconds) until this query becomes stale and should be refetched. * It is 1 minute by default, and can be set to `Infinity` to opt-out of automatic fetching. diff --git a/app/soapbox/entity-store/hooks/useEntity.ts b/app/soapbox/entity-store/hooks/useEntity.ts index 92f20560e..1dad1ff1e 100644 --- a/app/soapbox/entity-store/hooks/useEntity.ts +++ b/app/soapbox/entity-store/hooks/useEntity.ts @@ -6,13 +6,14 @@ import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { importEntities } from '../actions'; import type { Entity } from '../types'; +import type { EntitySchema } from './types'; type EntityPath = [entityType: string, entityId: string] /** Additional options for the hook. */ -interface UseEntityOpts { +interface UseEntityOpts { /** A zod schema to parse the API entity. */ - schema?: z.ZodType + schema?: EntitySchema /** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */ refetch?: boolean } diff --git a/app/soapbox/entity-store/hooks/useEntityActions.ts b/app/soapbox/entity-store/hooks/useEntityActions.ts new file mode 100644 index 000000000..c1e40f37e --- /dev/null +++ b/app/soapbox/entity-store/hooks/useEntityActions.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; + +import { useApi, useAppDispatch } from 'soapbox/hooks'; + +import { importEntities } from '../actions'; + +import type { Entity } from '../types'; +import type { EntitySchema } from './types'; +import type { AxiosResponse } from 'axios'; + +type EntityPath = [entityType: string, listKey?: string] + +interface UseEntityActionsOpts { + schema?: EntitySchema +} + +interface CreateEntityResult { + response: AxiosResponse + entity: TEntity +} + +interface DeleteEntityResult { + response: AxiosResponse +} + +interface EntityActionEndpoints { + post?: string + delete?: string +} + +function useEntityActions( + path: EntityPath, + endpoints: EntityActionEndpoints, + opts: UseEntityActionsOpts = {}, +) { + const api = useApi(); + const dispatch = useAppDispatch(); + const [entityType, listKey] = path; + + function createEntity(params: P): Promise> { + if (!endpoints.post) return Promise.reject(endpoints); + + return api.post(endpoints.post, params).then((response) => { + const schema = opts.schema || z.custom(); + const entity = schema.parse(response.data); + + // TODO: optimistic updating + dispatch(importEntities([entity], entityType, listKey)); + + return { + response, + entity, + }; + }); + } + + function deleteEntity(entityId: string): Promise { + if (!endpoints.delete) return Promise.reject(endpoints); + return api.delete(endpoints.delete.replaceAll(':id', entityId)).then((response) => { + + return { + response, + }; + }); + } + + return { + createEntity: endpoints.post ? createEntity : undefined, + deleteEntity: endpoints.delete ? deleteEntity : undefined, + }; +} + +export { useEntityActions }; \ No newline at end of file diff --git a/app/soapbox/entity-store/reducer.ts b/app/soapbox/entity-store/reducer.ts index bb1dcdd1d..a7f82e6e6 100644 --- a/app/soapbox/entity-store/reducer.ts +++ b/app/soapbox/entity-store/reducer.ts @@ -2,6 +2,7 @@ import produce, { enableMapSet } from 'immer'; import { ENTITIES_IMPORT, + ENTITIES_DELETE, ENTITIES_FETCH_REQUEST, ENTITIES_FETCH_SUCCESS, ENTITIES_FETCH_FAIL, @@ -43,6 +44,26 @@ const importEntities = ( }); }; +const deleteEntities = ( + state: State, + entityType: string, + ids: Iterable, +) => { + return produce(state, draft => { + const cache = draft[entityType] ?? createCache(); + + for (const id of ids) { + delete cache.store[id]; + + for (const list of Object.values(cache.lists)) { + list?.ids.delete(id); + } + } + + draft[entityType] = cache; + }); +}; + const setFetching = ( state: State, entityType: string, @@ -69,6 +90,8 @@ function reducer(state: Readonly = {}, action: EntityAction): State { switch (action.type) { case ENTITIES_IMPORT: return importEntities(state, action.entityType, action.entities, action.listKey); + case ENTITIES_DELETE: + return deleteEntities(state, action.entityType, action.ids); case ENTITIES_FETCH_SUCCESS: return importEntities(state, action.entityType, action.entities, action.listKey, action.newState); case ENTITIES_FETCH_REQUEST: @@ -80,4 +103,5 @@ function reducer(state: Readonly = {}, action: EntityAction): State { } } -export default reducer; \ No newline at end of file +export default reducer; +export type { State }; \ No newline at end of file diff --git a/app/soapbox/entity-store/utils.ts b/app/soapbox/entity-store/utils.ts index 6e96a9ec6..9d56ceb42 100644 --- a/app/soapbox/entity-store/utils.ts +++ b/app/soapbox/entity-store/utils.ts @@ -1,4 +1,4 @@ -import type { Entity, EntityStore, EntityList, EntityCache } from './types'; +import type { Entity, EntityStore, EntityList, EntityCache, EntityListState } from './types'; /** Insert the entities into the store. */ const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => { @@ -26,14 +26,17 @@ const createCache = (): EntityCache => ({ /** Create an empty entity list. */ const createList = (): EntityList => ({ ids: new Set(), - state: { - next: undefined, - prev: undefined, - error: null, - fetched: false, - fetching: false, - lastFetchedAt: undefined, - }, + state: createListState(), +}); + +/** Create an empty entity list state. */ +const createListState = (): EntityListState => ({ + next: undefined, + prev: undefined, + error: null, + fetched: false, + fetching: false, + lastFetchedAt: undefined, }); export { @@ -41,4 +44,5 @@ export { updateList, createCache, createList, + createListState, }; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c47b1a33f..c61cdf0f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "strict": true, "module": "es2022", "lib": ["es2019", "es6", "dom", "webworker"], - "target": "es5", + "target": "es2015", "jsx": "react", "allowJs": true, "moduleResolution": "node",