Merge remote-tracking branch 'soapbox/main' into lexical
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
209
src/entity-store/__tests__/reducer.test.ts
Normal file
209
src/entity-store/__tests__/reducer.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
deleteEntities,
|
||||
dismissEntities,
|
||||
entitiesFetchFail,
|
||||
entitiesFetchRequest,
|
||||
entitiesFetchSuccess,
|
||||
importEntities,
|
||||
incrementEntities,
|
||||
} from '../actions';
|
||||
import reducer, { State } from '../reducer';
|
||||
import { createListState } from '../utils';
|
||||
|
||||
import type { EntityCache } from '../types';
|
||||
|
||||
interface TestEntity {
|
||||
id: string
|
||||
msg: string
|
||||
}
|
||||
|
||||
test('import entities', () => {
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '1', msg: 'yolo' },
|
||||
{ id: '2', msg: 'benis' },
|
||||
{ id: '3', msg: 'boop' },
|
||||
];
|
||||
|
||||
const action = importEntities(entities, 'TestEntity');
|
||||
const result = reducer(undefined, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache.store['1']!.msg).toBe('yolo');
|
||||
expect(Object.values(cache.lists).length).toBe(0);
|
||||
});
|
||||
|
||||
test('import entities into a list', () => {
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '1', msg: 'yolo' },
|
||||
{ id: '2', msg: 'benis' },
|
||||
{ id: '3', msg: 'boop' },
|
||||
];
|
||||
|
||||
const action = importEntities(entities, 'TestEntity', 'thingies');
|
||||
const result = reducer(undefined, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache.store['2']!.msg).toBe('benis');
|
||||
expect(cache.lists.thingies!.ids.size).toBe(3);
|
||||
expect(cache.lists.thingies!.state.totalCount).toBe(3);
|
||||
|
||||
// Now try adding an additional item.
|
||||
const entities2: TestEntity[] = [
|
||||
{ id: '4', msg: 'hehe' },
|
||||
];
|
||||
|
||||
const action2 = importEntities(entities2, 'TestEntity', 'thingies');
|
||||
const result2 = reducer(result, action2);
|
||||
const cache2 = result2.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache2.store['4']!.msg).toBe('hehe');
|
||||
expect(cache2.lists.thingies!.ids.size).toBe(4);
|
||||
expect(cache2.lists.thingies!.state.totalCount).toBe(4);
|
||||
|
||||
// Finally, update an item.
|
||||
const entities3: TestEntity[] = [
|
||||
{ id: '2', msg: 'yolofam' },
|
||||
];
|
||||
|
||||
const action3 = importEntities(entities3, 'TestEntity', 'thingies');
|
||||
const result3 = reducer(result2, action3);
|
||||
const cache3 = result3.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect(cache3.store['2']!.msg).toBe('yolofam');
|
||||
expect(cache3.lists.thingies!.ids.size).toBe(4); // unchanged
|
||||
expect(cache3.lists.thingies!.state.totalCount).toBe(4);
|
||||
});
|
||||
|
||||
test('fetching updates the list state', () => {
|
||||
const action = entitiesFetchRequest('TestEntity', 'thingies');
|
||||
const result = reducer(undefined, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.fetching).toBe(true);
|
||||
});
|
||||
|
||||
test('failure adds the error to the state', () => {
|
||||
const error = new Error('whoopsie');
|
||||
|
||||
const action = entitiesFetchFail('TestEntity', 'thingies', error);
|
||||
const result = reducer(undefined, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.error).toBe(error);
|
||||
});
|
||||
|
||||
test('import entities with override', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
thingies: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const entities: TestEntity[] = [
|
||||
{ id: '4', msg: 'yolo' },
|
||||
{ id: '5', msg: 'benis' },
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const action = entitiesFetchSuccess(entities, 'TestEntity', 'thingies', 'end', {
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
totalCount: 2,
|
||||
error: null,
|
||||
fetched: true,
|
||||
fetching: false,
|
||||
lastFetchedAt: now,
|
||||
invalid: false,
|
||||
}, true);
|
||||
|
||||
const result = reducer(state, action);
|
||||
const cache = result.TestEntity as EntityCache<TestEntity>;
|
||||
|
||||
expect([...cache.lists.thingies!.ids]).toEqual(['4', '5']);
|
||||
expect(cache.lists.thingies!.state.lastFetchedAt).toBe(now); // Also check that newState worked
|
||||
});
|
||||
|
||||
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(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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']);
|
||||
expect(result.TestEntity!.lists['']!.state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test('dismiss items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
yolo: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = dismissEntities(['3', '1'], 'TestEntity', 'yolo');
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.store).toMatchObject(state.TestEntity!.store);
|
||||
expect([...result.TestEntity!.lists.yolo!.ids]).toEqual(['2']);
|
||||
expect(result.TestEntity!.lists.yolo!.state.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
test('increment items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
thingies: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = incrementEntities('TestEntity', 'thingies', 1);
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(4);
|
||||
});
|
||||
|
||||
test('decrement items', () => {
|
||||
const state: State = {
|
||||
TestEntity: {
|
||||
store: { '1': { id: '1' }, '2': { id: '2' }, '3': { id: '3' } },
|
||||
lists: {
|
||||
thingies: {
|
||||
ids: new Set(['1', '2', '3']),
|
||||
state: { ...createListState(), totalCount: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const action = incrementEntities('TestEntity', 'thingies', -1);
|
||||
const result = reducer(state, action);
|
||||
|
||||
expect(result.TestEntity!.lists.thingies!.state.totalCount).toBe(2);
|
||||
});
|
||||
139
src/entity-store/actions.ts
Normal file
139
src/entity-store/actions.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { EntitiesTransaction, Entity, EntityListState, ImportPosition } from './types';
|
||||
|
||||
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' as const;
|
||||
const ENTITIES_DELETE = 'ENTITIES_DELETE' as const;
|
||||
const ENTITIES_DISMISS = 'ENTITIES_DISMISS' as const;
|
||||
const ENTITIES_INCREMENT = 'ENTITIES_INCREMENT' 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;
|
||||
const ENTITIES_INVALIDATE_LIST = 'ENTITIES_INVALIDATE_LIST' as const;
|
||||
const ENTITIES_TRANSACTION = 'ENTITIES_TRANSACTION' as const;
|
||||
|
||||
/** Action to import entities into the cache. */
|
||||
function importEntities(entities: Entity[], entityType: string, listKey?: string, pos?: ImportPosition) {
|
||||
return {
|
||||
type: ENTITIES_IMPORT,
|
||||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteEntitiesOpts {
|
||||
preserveLists?: boolean
|
||||
}
|
||||
|
||||
function deleteEntities(ids: Iterable<string>, entityType: string, opts: DeleteEntitiesOpts = {}) {
|
||||
return {
|
||||
type: ENTITIES_DELETE,
|
||||
ids,
|
||||
entityType,
|
||||
opts,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissEntities(ids: Iterable<string>, entityType: string, listKey: string) {
|
||||
return {
|
||||
type: ENTITIES_DISMISS,
|
||||
ids,
|
||||
entityType,
|
||||
listKey,
|
||||
};
|
||||
}
|
||||
|
||||
function incrementEntities(entityType: string, listKey: string, diff: number) {
|
||||
return {
|
||||
type: ENTITIES_INCREMENT,
|
||||
entityType,
|
||||
listKey,
|
||||
diff,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFetchRequest(entityType: string, listKey?: string) {
|
||||
return {
|
||||
type: ENTITIES_FETCH_REQUEST,
|
||||
entityType,
|
||||
listKey,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFetchSuccess(
|
||||
entities: Entity[],
|
||||
entityType: string,
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
) {
|
||||
return {
|
||||
type: ENTITIES_FETCH_SUCCESS,
|
||||
entityType,
|
||||
entities,
|
||||
listKey,
|
||||
pos,
|
||||
newState,
|
||||
overwrite,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesFetchFail(entityType: string, listKey: string | undefined, error: any) {
|
||||
return {
|
||||
type: ENTITIES_FETCH_FAIL,
|
||||
entityType,
|
||||
listKey,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateEntityList(entityType: string, listKey: string) {
|
||||
return {
|
||||
type: ENTITIES_INVALIDATE_LIST,
|
||||
entityType,
|
||||
listKey,
|
||||
};
|
||||
}
|
||||
|
||||
function entitiesTransaction(transaction: EntitiesTransaction) {
|
||||
return {
|
||||
type: ENTITIES_TRANSACTION,
|
||||
transaction,
|
||||
};
|
||||
}
|
||||
|
||||
/** Any action pertaining to entities. */
|
||||
type EntityAction =
|
||||
ReturnType<typeof importEntities>
|
||||
| ReturnType<typeof deleteEntities>
|
||||
| ReturnType<typeof dismissEntities>
|
||||
| ReturnType<typeof incrementEntities>
|
||||
| ReturnType<typeof entitiesFetchRequest>
|
||||
| ReturnType<typeof entitiesFetchSuccess>
|
||||
| ReturnType<typeof entitiesFetchFail>
|
||||
| ReturnType<typeof invalidateEntityList>
|
||||
| ReturnType<typeof entitiesTransaction>;
|
||||
|
||||
export {
|
||||
ENTITIES_IMPORT,
|
||||
ENTITIES_DELETE,
|
||||
ENTITIES_DISMISS,
|
||||
ENTITIES_INCREMENT,
|
||||
ENTITIES_FETCH_REQUEST,
|
||||
ENTITIES_FETCH_SUCCESS,
|
||||
ENTITIES_FETCH_FAIL,
|
||||
ENTITIES_INVALIDATE_LIST,
|
||||
ENTITIES_TRANSACTION,
|
||||
importEntities,
|
||||
deleteEntities,
|
||||
dismissEntities,
|
||||
incrementEntities,
|
||||
entitiesFetchRequest,
|
||||
entitiesFetchSuccess,
|
||||
entitiesFetchFail,
|
||||
invalidateEntityList,
|
||||
entitiesTransaction,
|
||||
};
|
||||
|
||||
export type { DeleteEntitiesOpts, EntityAction };
|
||||
26
src/entity-store/entities.ts
Normal file
26
src/entity-store/entities.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type * as Schemas from 'soapbox/schemas';
|
||||
|
||||
enum Entities {
|
||||
ACCOUNTS = 'Accounts',
|
||||
GROUPS = 'Groups',
|
||||
GROUP_MEMBERSHIPS = 'GroupMemberships',
|
||||
GROUP_MUTES = 'GroupMutes',
|
||||
GROUP_RELATIONSHIPS = 'GroupRelationships',
|
||||
GROUP_TAGS = 'GroupTags',
|
||||
PATRON_USERS = 'PatronUsers',
|
||||
RELATIONSHIPS = 'Relationships',
|
||||
STATUSES = 'Statuses'
|
||||
}
|
||||
|
||||
interface EntityTypes {
|
||||
[Entities.ACCOUNTS]: Schemas.Account
|
||||
[Entities.GROUPS]: Schemas.Group
|
||||
[Entities.GROUP_MEMBERSHIPS]: Schemas.GroupMember
|
||||
[Entities.GROUP_RELATIONSHIPS]: Schemas.GroupRelationship
|
||||
[Entities.GROUP_TAGS]: Schemas.GroupTag
|
||||
[Entities.PATRON_USERS]: Schemas.PatronUser
|
||||
[Entities.RELATIONSHIPS]: Schemas.Relationship
|
||||
[Entities.STATUSES]: Schemas.Status
|
||||
}
|
||||
|
||||
export { Entities, type EntityTypes };
|
||||
10
src/entity-store/hooks/index.ts
Normal file
10
src/entity-store/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { useEntities } from './useEntities';
|
||||
export { useEntity } from './useEntity';
|
||||
export { useEntityActions } from './useEntityActions';
|
||||
export { useEntityLookup } from './useEntityLookup';
|
||||
export { useCreateEntity } from './useCreateEntity';
|
||||
export { useDeleteEntity } from './useDeleteEntity';
|
||||
export { useDismissEntity } from './useDismissEntity';
|
||||
export { useIncrementEntity } from './useIncrementEntity';
|
||||
export { useChangeEntity } from './useChangeEntity';
|
||||
export { useTransaction } from './useTransaction';
|
||||
47
src/entity-store/hooks/types.ts
Normal file
47
src/entity-store/hooks/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Entity } from '../types';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import type z from 'zod';
|
||||
|
||||
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
|
||||
|
||||
/**
|
||||
* Tells us where to find/store the entity in the cache.
|
||||
* This value is accepted in hooks, but needs to be parsed into an `EntitiesPath`
|
||||
* before being passed to the store.
|
||||
*/
|
||||
type ExpandedEntitiesPath = [
|
||||
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
|
||||
entityType: string,
|
||||
/**
|
||||
* Name of a particular index of this entity type.
|
||||
* Multiple params get combined into one string with a `:` separator.
|
||||
*/
|
||||
...listKeys: string[],
|
||||
]
|
||||
|
||||
/** Used to look up an entity in a list. */
|
||||
type EntitiesPath = [entityType: string, listKey: string]
|
||||
|
||||
/** Used to look up a single entity by its ID. */
|
||||
type EntityPath = [entityType: string, entityId: string]
|
||||
|
||||
/** Callback functions for entity actions. */
|
||||
interface EntityCallbacks<Value, Error = unknown> {
|
||||
onSuccess?(value: Value): void
|
||||
onError?(error: Error): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed into hooks to make requests.
|
||||
* Must return an Axios response.
|
||||
*/
|
||||
type EntityFn<T> = (value: T) => Promise<AxiosResponse>
|
||||
|
||||
export type {
|
||||
EntitySchema,
|
||||
ExpandedEntitiesPath,
|
||||
EntitiesPath,
|
||||
EntityPath,
|
||||
EntityCallbacks,
|
||||
EntityFn,
|
||||
};
|
||||
105
src/entity-store/hooks/useBatchedEntities.ts
Normal file
105
src/entity-store/hooks/useBatchedEntities.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector';
|
||||
import { useGetState } from 'soapbox/hooks/useGetState';
|
||||
import { filteredArray } from 'soapbox/schemas/utils';
|
||||
|
||||
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions';
|
||||
import { selectCache, selectListState, useListState } from '../selectors';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
|
||||
import type { Entity } from '../types';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
interface UseBatchedEntitiesOpts<TEntity extends Entity> {
|
||||
schema?: EntitySchema<TEntity>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function useBatchedEntities<TEntity extends Entity>(
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
ids: string[],
|
||||
entityFn: EntityFn<string[]>,
|
||||
opts: UseBatchedEntitiesOpts<TEntity> = {},
|
||||
) {
|
||||
const getState = useGetState();
|
||||
const dispatch = useAppDispatch();
|
||||
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isFetching = useListState(path, 'fetching');
|
||||
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
||||
const isFetched = useListState(path, 'fetched');
|
||||
const isInvalid = useListState(path, 'invalid');
|
||||
const error = useListState(path, 'error');
|
||||
|
||||
/** Get IDs of entities not yet in the store. */
|
||||
const filteredIds = useAppSelector((state) => {
|
||||
const cache = selectCache(state, path);
|
||||
if (!cache) return ids;
|
||||
return ids.filter((id) => !cache.store[id]);
|
||||
});
|
||||
|
||||
const entityMap = useAppSelector((state) => selectEntityMap<TEntity>(state, path, ids));
|
||||
|
||||
async function fetchEntities() {
|
||||
const isFetching = selectListState(getState(), path, 'fetching');
|
||||
if (isFetching) return;
|
||||
|
||||
dispatch(entitiesFetchRequest(entityType, listKey));
|
||||
try {
|
||||
const response = await entityFn(filteredIds);
|
||||
const entities = filteredArray(schema).parse(response.data);
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, 'end', {
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
totalCount: undefined,
|
||||
fetching: false,
|
||||
fetched: true,
|
||||
error: null,
|
||||
lastFetchedAt: new Date(),
|
||||
invalid: false,
|
||||
}));
|
||||
} catch (e) {
|
||||
dispatch(entitiesFetchFail(entityType, listKey, e));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredIds.length && isEnabled) {
|
||||
fetchEntities();
|
||||
}
|
||||
}, [filteredIds.length]);
|
||||
|
||||
return {
|
||||
entityMap,
|
||||
isFetching,
|
||||
lastFetchedAt,
|
||||
isFetched,
|
||||
isError: !!error,
|
||||
isInvalid,
|
||||
};
|
||||
}
|
||||
|
||||
function selectEntityMap<TEntity extends Entity>(
|
||||
state: RootState,
|
||||
path: EntitiesPath,
|
||||
entityIds: string[],
|
||||
): Record<string, TEntity> {
|
||||
const cache = selectCache(state, path);
|
||||
|
||||
return entityIds.reduce<Record<string, TEntity>>((result, id) => {
|
||||
const entity = cache?.store[id];
|
||||
if (entity) {
|
||||
result[id] = entity as TEntity;
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export { useBatchedEntities };
|
||||
25
src/entity-store/hooks/useChangeEntity.ts
Normal file
25
src/entity-store/hooks/useChangeEntity.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { importEntities } from 'soapbox/entity-store/actions';
|
||||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { type Entity } from 'soapbox/entity-store/types';
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useGetState } from 'soapbox/hooks/useGetState';
|
||||
|
||||
type ChangeEntityFn<TEntity extends Entity> = (entity: TEntity) => TEntity
|
||||
|
||||
function useChangeEntity<TEntity extends Entity = Entity>(entityType: Entities) {
|
||||
const getState = useGetState();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
function changeEntity(entityId: string, change: ChangeEntityFn<TEntity>): void {
|
||||
if (!entityId) return;
|
||||
const entity = getState().entities[entityType]?.store[entityId] as TEntity | undefined;
|
||||
if (entity) {
|
||||
const newEntity = change(entity);
|
||||
dispatch(importEntities([newEntity], entityType));
|
||||
}
|
||||
}
|
||||
|
||||
return { changeEntity };
|
||||
}
|
||||
|
||||
export { useChangeEntity, type ChangeEntityFn };
|
||||
57
src/entity-store/hooks/useCreateEntity.ts
Normal file
57
src/entity-store/hooks/useCreateEntity.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useLoading } from 'soapbox/hooks/useLoading';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
|
||||
import type { Entity } from '../types';
|
||||
|
||||
interface UseCreateEntityOpts<TEntity extends Entity = Entity> {
|
||||
schema?: EntitySchema<TEntity>
|
||||
}
|
||||
|
||||
function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
entityFn: EntityFn<Data>,
|
||||
opts: UseCreateEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isSubmitting, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity, AxiosError> = {}): Promise<void> {
|
||||
try {
|
||||
const result = await setPromise(entityFn(data));
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
const entity = schema.parse(result.data);
|
||||
|
||||
// TODO: optimistic updating
|
||||
dispatch(importEntities([entity], entityType, listKey, 'start'));
|
||||
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(error);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useCreateEntity };
|
||||
56
src/entity-store/hooks/useDeleteEntity.ts
Normal file
56
src/entity-store/hooks/useDeleteEntity.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useGetState } from 'soapbox/hooks/useGetState';
|
||||
import { useLoading } from 'soapbox/hooks/useLoading';
|
||||
|
||||
import { deleteEntities, importEntities } from '../actions';
|
||||
|
||||
import type { EntityCallbacks, EntityFn } from './types';
|
||||
|
||||
/**
|
||||
* Optimistically deletes an entity from the store.
|
||||
* This hook should be used to globally delete an entity from all lists.
|
||||
* To remove an entity from a single list, see `useDismissEntity`.
|
||||
*/
|
||||
function useDeleteEntity(
|
||||
entityType: string,
|
||||
entityFn: EntityFn<string>,
|
||||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
const getState = useGetState();
|
||||
const [isSubmitting, setPromise] = useLoading();
|
||||
|
||||
async function deleteEntity(entityId: string, callbacks: EntityCallbacks<string> = {}): Promise<void> {
|
||||
// Get the entity before deleting, so we can reverse the action if the API request fails.
|
||||
const entity = getState().entities[entityType]?.store[entityId];
|
||||
|
||||
// Optimistically delete the entity from the _store_ but keep the lists in tact.
|
||||
dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
|
||||
|
||||
try {
|
||||
await setPromise(entityFn(entityId));
|
||||
|
||||
// Success - finish deleting entity from the state.
|
||||
dispatch(deleteEntities([entityId], entityType));
|
||||
|
||||
if (callbacks.onSuccess) {
|
||||
callbacks.onSuccess(entityId);
|
||||
}
|
||||
} catch (e) {
|
||||
if (entity) {
|
||||
// If the API failed, reimport the entity.
|
||||
dispatch(importEntities([entity], entityType));
|
||||
}
|
||||
|
||||
if (callbacks.onError) {
|
||||
callbacks.onError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deleteEntity,
|
||||
isSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDeleteEntity };
|
||||
33
src/entity-store/hooks/useDismissEntity.ts
Normal file
33
src/entity-store/hooks/useDismissEntity.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useLoading } from 'soapbox/hooks/useLoading';
|
||||
|
||||
import { dismissEntities } from '../actions';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntityFn, ExpandedEntitiesPath } from './types';
|
||||
|
||||
/**
|
||||
* Removes an entity from a specific list.
|
||||
* To remove an entity globally from all lists, see `useDeleteEntity`.
|
||||
*/
|
||||
function useDismissEntity(expandedPath: ExpandedEntitiesPath, entityFn: EntityFn<string>) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isLoading, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
// TODO: optimistic dismissing
|
||||
async function dismissEntity(entityId: string) {
|
||||
const result = await setPromise(entityFn(entityId));
|
||||
dispatch(dismissEntities([entityId], entityType, listKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
dismissEntity,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export { useDismissEntity };
|
||||
141
src/entity-store/hooks/useEntities.ts
Normal file
141
src/entity-store/hooks/useEntities.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useEffect } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
import { getNextLink, getPrevLink } from 'soapbox/api';
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector';
|
||||
import { useGetState } from 'soapbox/hooks/useGetState';
|
||||
import { filteredArray } from 'soapbox/schemas/utils';
|
||||
import { realNumberSchema } from 'soapbox/utils/numbers';
|
||||
|
||||
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalidateEntityList } from '../actions';
|
||||
import { selectEntities, selectListState, useListState } from '../selectors';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
|
||||
import type { Entity } from '../types';
|
||||
|
||||
/** Additional options for the hook. */
|
||||
interface UseEntitiesOpts<TEntity extends Entity> {
|
||||
/** A zod schema to parse the API entities. */
|
||||
schema?: EntitySchema<TEntity>
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
staleTime?: number
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** A hook for fetching and displaying API entities. */
|
||||
function useEntities<TEntity extends Entity>(
|
||||
/** Tells us where to find/store the entity in the cache. */
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
|
||||
entityFn: EntityFn<void>,
|
||||
/** Additional options for the hook. */
|
||||
opts: UseEntitiesOpts<TEntity> = {},
|
||||
) {
|
||||
const api = useApi();
|
||||
const dispatch = useAppDispatch();
|
||||
const getState = useGetState();
|
||||
|
||||
const { entityType, listKey, path } = parseEntitiesPath(expandedPath);
|
||||
const entities = useAppSelector(state => selectEntities<TEntity>(state, path));
|
||||
const schema = opts.schema || z.custom<TEntity>();
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isFetching = useListState(path, 'fetching');
|
||||
const lastFetchedAt = useListState(path, 'lastFetchedAt');
|
||||
const isFetched = useListState(path, 'fetched');
|
||||
const isError = !!useListState(path, 'error');
|
||||
const totalCount = useListState(path, 'totalCount');
|
||||
const isInvalid = useListState(path, 'invalid');
|
||||
|
||||
const next = useListState(path, 'next');
|
||||
const prev = useListState(path, 'prev');
|
||||
|
||||
const fetchPage = async(req: EntityFn<void>, pos: 'start' | 'end', overwrite = false): Promise<void> => {
|
||||
// Get `isFetching` state from the store again to prevent race conditions.
|
||||
const isFetching = selectListState(getState(), path, 'fetching');
|
||||
if (isFetching) return;
|
||||
|
||||
dispatch(entitiesFetchRequest(entityType, listKey));
|
||||
try {
|
||||
const response = await req();
|
||||
const entities = filteredArray(schema).parse(response.data);
|
||||
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
|
||||
const totalCount = parsedCount.success ? parsedCount.data : undefined;
|
||||
|
||||
dispatch(entitiesFetchSuccess(entities, entityType, listKey, pos, {
|
||||
next: getNextLink(response),
|
||||
prev: getPrevLink(response),
|
||||
totalCount: Number(totalCount) >= entities.length ? totalCount : undefined,
|
||||
fetching: false,
|
||||
fetched: true,
|
||||
error: null,
|
||||
lastFetchedAt: new Date(),
|
||||
invalid: false,
|
||||
}, overwrite));
|
||||
} catch (error) {
|
||||
dispatch(entitiesFetchFail(entityType, listKey, error));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEntities = async(): Promise<void> => {
|
||||
await fetchPage(entityFn, 'end', true);
|
||||
};
|
||||
|
||||
const fetchNextPage = async(): Promise<void> => {
|
||||
if (next) {
|
||||
await fetchPage(() => api.get(next), 'end');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreviousPage = async(): Promise<void> => {
|
||||
if (prev) {
|
||||
await fetchPage(() => api.get(prev), 'start');
|
||||
}
|
||||
};
|
||||
|
||||
const invalidate = () => {
|
||||
dispatch(invalidateEntityList(entityType, listKey));
|
||||
};
|
||||
|
||||
const staleTime = opts.staleTime ?? 60000;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
if (isFetching) return;
|
||||
const isUnset = !lastFetchedAt;
|
||||
const isStale = lastFetchedAt ? Date.now() >= lastFetchedAt.getTime() + staleTime : false;
|
||||
|
||||
if (isInvalid || isUnset || isStale) {
|
||||
fetchEntities();
|
||||
}
|
||||
}, [isEnabled, ...path]);
|
||||
|
||||
return {
|
||||
entities,
|
||||
fetchEntities,
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
hasNextPage: !!next,
|
||||
hasPreviousPage: !!prev,
|
||||
totalCount,
|
||||
isError,
|
||||
isFetched,
|
||||
isFetching,
|
||||
isLoading: isFetching && entities.length === 0,
|
||||
invalidate,
|
||||
/** The `X-Total-Count` from the API if available, or the length of items in the store. */
|
||||
count: typeof totalCount === 'number' ? totalCount : entities.length,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
useEntities,
|
||||
};
|
||||
78
src/entity-store/hooks/useEntity.ts
Normal file
78
src/entity-store/hooks/useEntity.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import z from 'zod';
|
||||
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector';
|
||||
import { useLoading } from 'soapbox/hooks/useLoading';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
import { selectEntity } from '../selectors';
|
||||
|
||||
import type { EntitySchema, EntityPath, EntityFn } from './types';
|
||||
import type { Entity } from '../types';
|
||||
|
||||
/** Additional options for the hook. */
|
||||
interface UseEntityOpts<TEntity extends Entity> {
|
||||
/** A zod schema to parse the API entity. */
|
||||
schema?: EntitySchema<TEntity>
|
||||
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||
refetch?: boolean
|
||||
/** A flag to potentially disable sending requests to the API. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function useEntity<TEntity extends Entity>(
|
||||
path: EntityPath,
|
||||
entityFn: EntityFn<void>,
|
||||
opts: UseEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const [isFetching, setPromise] = useLoading(true);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [entityType, entityId] = path;
|
||||
|
||||
const defaultSchema = z.custom<TEntity>();
|
||||
const schema = opts.schema || defaultSchema;
|
||||
|
||||
const entity = useAppSelector(state => selectEntity<TEntity>(state, entityType, entityId));
|
||||
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isLoading = isFetching && !entity;
|
||||
const isLoaded = !isFetching && !!entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
try {
|
||||
const response = await setPromise(entityFn());
|
||||
const entity = schema.parse(response.data);
|
||||
dispatch(importEntities([entity], entityType));
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled || error) return;
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, [isEnabled]);
|
||||
|
||||
return {
|
||||
entity,
|
||||
fetchEntity,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isLoaded,
|
||||
error,
|
||||
isUnauthorized: error instanceof AxiosError && error.response?.status === 401,
|
||||
isForbidden: error instanceof AxiosError && error.response?.status === 403,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
useEntity,
|
||||
type UseEntityOpts,
|
||||
};
|
||||
45
src/entity-store/hooks/useEntityActions.ts
Normal file
45
src/entity-store/hooks/useEntityActions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useApi } from 'soapbox/hooks/useApi';
|
||||
|
||||
import { useCreateEntity } from './useCreateEntity';
|
||||
import { useDeleteEntity } from './useDeleteEntity';
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntitySchema, ExpandedEntitiesPath } from './types';
|
||||
import type { Entity } from '../types';
|
||||
|
||||
interface UseEntityActionsOpts<TEntity extends Entity = Entity> {
|
||||
schema?: EntitySchema<TEntity>
|
||||
}
|
||||
|
||||
interface EntityActionEndpoints {
|
||||
delete?: string
|
||||
patch?: string
|
||||
post?: string
|
||||
}
|
||||
|
||||
function useEntityActions<TEntity extends Entity = Entity, Data = any>(
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
endpoints: EntityActionEndpoints,
|
||||
opts: UseEntityActionsOpts<TEntity> = {},
|
||||
) {
|
||||
const api = useApi();
|
||||
const { entityType, path } = parseEntitiesPath(expandedPath);
|
||||
|
||||
const { deleteEntity, isSubmitting: deleteSubmitting } =
|
||||
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replace(/:id/g, entityId)));
|
||||
|
||||
const { createEntity, isSubmitting: createSubmitting } =
|
||||
useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
|
||||
|
||||
const { createEntity: updateEntity, isSubmitting: updateSubmitting } =
|
||||
useCreateEntity<TEntity, Data>(path, (data) => api.patch(endpoints.patch!, data), opts);
|
||||
|
||||
return {
|
||||
createEntity,
|
||||
deleteEntity,
|
||||
updateEntity,
|
||||
isSubmitting: createSubmitting || deleteSubmitting || updateSubmitting,
|
||||
};
|
||||
}
|
||||
|
||||
export { useEntityActions };
|
||||
63
src/entity-store/hooks/useEntityLookup.ts
Normal file
63
src/entity-store/hooks/useEntityLookup.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector';
|
||||
import { useLoading } from 'soapbox/hooks/useLoading';
|
||||
|
||||
import { importEntities } from '../actions';
|
||||
import { findEntity } from '../selectors';
|
||||
import { Entity } from '../types';
|
||||
|
||||
import { EntityFn } from './types';
|
||||
import { type UseEntityOpts } from './useEntity';
|
||||
|
||||
/** Entities will be filtered through this function until it returns true. */
|
||||
type LookupFn<TEntity extends Entity> = (entity: TEntity) => boolean
|
||||
|
||||
function useEntityLookup<TEntity extends Entity>(
|
||||
entityType: string,
|
||||
lookupFn: LookupFn<TEntity>,
|
||||
entityFn: EntityFn<void>,
|
||||
opts: UseEntityOpts<TEntity> = {},
|
||||
) {
|
||||
const { schema = z.custom<TEntity>() } = opts;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const [isFetching, setPromise] = useLoading(true);
|
||||
const [error, setError] = useState<unknown>();
|
||||
|
||||
const entity = useAppSelector(state => findEntity(state, entityType, lookupFn));
|
||||
const isEnabled = opts.enabled ?? true;
|
||||
const isLoading = isFetching && !entity;
|
||||
|
||||
const fetchEntity = async () => {
|
||||
try {
|
||||
const response = await setPromise(entityFn());
|
||||
const entity = schema.parse(response.data);
|
||||
dispatch(importEntities([entity], entityType));
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) return;
|
||||
|
||||
if (!entity || opts.refetch) {
|
||||
fetchEntity();
|
||||
}
|
||||
}, [isEnabled]);
|
||||
|
||||
return {
|
||||
entity,
|
||||
fetchEntity,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isUnauthorized: error instanceof AxiosError && error.response?.status === 401,
|
||||
isForbidden: error instanceof AxiosError && error.response?.status === 403,
|
||||
};
|
||||
}
|
||||
|
||||
export { useEntityLookup };
|
||||
38
src/entity-store/hooks/useIncrementEntity.ts
Normal file
38
src/entity-store/hooks/useIncrementEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
import { useLoading } from 'soapbox/hooks/useLoading';
|
||||
|
||||
import { incrementEntities } from '../actions';
|
||||
|
||||
import { parseEntitiesPath } from './utils';
|
||||
|
||||
import type { EntityFn, ExpandedEntitiesPath } from './types';
|
||||
|
||||
/**
|
||||
* Increases (or decreases) the `totalCount` in the entity list by the specified amount.
|
||||
* This only works if the API returns an `X-Total-Count` header and your components read it.
|
||||
*/
|
||||
function useIncrementEntity(
|
||||
expandedPath: ExpandedEntitiesPath,
|
||||
diff: number,
|
||||
entityFn: EntityFn<string>,
|
||||
) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isLoading, setPromise] = useLoading();
|
||||
const { entityType, listKey } = parseEntitiesPath(expandedPath);
|
||||
|
||||
async function incrementEntity(entityId: string): Promise<void> {
|
||||
dispatch(incrementEntities(entityType, listKey, diff));
|
||||
try {
|
||||
await setPromise(entityFn(entityId));
|
||||
} catch (e) {
|
||||
dispatch(incrementEntities(entityType, listKey, diff * -1));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
incrementEntity,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export { useIncrementEntity };
|
||||
23
src/entity-store/hooks/useTransaction.ts
Normal file
23
src/entity-store/hooks/useTransaction.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { entitiesTransaction } from 'soapbox/entity-store/actions';
|
||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch';
|
||||
|
||||
import type { EntityTypes } from 'soapbox/entity-store/entities';
|
||||
import type { EntitiesTransaction, Entity } from 'soapbox/entity-store/types';
|
||||
|
||||
type Updater<TEntity extends Entity> = Record<string, (entity: TEntity) => TEntity>
|
||||
|
||||
type Changes = Partial<{
|
||||
[K in keyof EntityTypes]: Updater<EntityTypes[K]>
|
||||
}>
|
||||
|
||||
function useTransaction() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
function transaction(changes: Changes): void {
|
||||
dispatch(entitiesTransaction(changes as EntitiesTransaction));
|
||||
}
|
||||
|
||||
return { transaction };
|
||||
}
|
||||
|
||||
export { useTransaction };
|
||||
15
src/entity-store/hooks/utils.ts
Normal file
15
src/entity-store/hooks/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { EntitiesPath, ExpandedEntitiesPath } from './types';
|
||||
|
||||
function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
|
||||
const [entityType, ...listKeys] = expandedPath;
|
||||
const listKey = (listKeys || []).join(':');
|
||||
const path: EntitiesPath = [entityType, listKey];
|
||||
|
||||
return {
|
||||
entityType,
|
||||
listKey,
|
||||
path,
|
||||
};
|
||||
}
|
||||
|
||||
export { parseEntitiesPath };
|
||||
201
src/entity-store/reducer.ts
Normal file
201
src/entity-store/reducer.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { produce, enableMapSet } from 'immer';
|
||||
|
||||
import {
|
||||
ENTITIES_IMPORT,
|
||||
ENTITIES_DELETE,
|
||||
ENTITIES_DISMISS,
|
||||
ENTITIES_FETCH_REQUEST,
|
||||
ENTITIES_FETCH_SUCCESS,
|
||||
ENTITIES_FETCH_FAIL,
|
||||
EntityAction,
|
||||
ENTITIES_INVALIDATE_LIST,
|
||||
ENTITIES_INCREMENT,
|
||||
ENTITIES_TRANSACTION,
|
||||
} from './actions';
|
||||
import { createCache, createList, updateStore, updateList } from './utils';
|
||||
|
||||
import type { DeleteEntitiesOpts } from './actions';
|
||||
import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types';
|
||||
|
||||
enableMapSet();
|
||||
|
||||
/** Entity reducer state. */
|
||||
interface State {
|
||||
[entityType: string]: EntityCache | undefined
|
||||
}
|
||||
|
||||
/** Import entities into the cache. */
|
||||
const importEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
entities: Entity[],
|
||||
listKey?: string,
|
||||
pos?: ImportPosition,
|
||||
newState?: EntityListState,
|
||||
overwrite = false,
|
||||
): State => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
cache.store = updateStore(cache.store, entities);
|
||||
|
||||
if (typeof listKey === 'string') {
|
||||
let list = cache.lists[listKey] ?? createList();
|
||||
|
||||
if (overwrite) {
|
||||
list.ids = new Set();
|
||||
}
|
||||
|
||||
list = updateList(list, entities, pos);
|
||||
|
||||
if (newState) {
|
||||
list.state = newState;
|
||||
}
|
||||
|
||||
cache.lists[listKey] = list;
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
ids: Iterable<string>,
|
||||
opts: DeleteEntitiesOpts,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
|
||||
for (const id of ids) {
|
||||
delete cache.store[id];
|
||||
|
||||
if (!opts?.preserveLists) {
|
||||
for (const list of Object.values(cache.lists)) {
|
||||
if (list) {
|
||||
list.ids.delete(id);
|
||||
|
||||
if (typeof list.state.totalCount === 'number') {
|
||||
list.state.totalCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
});
|
||||
};
|
||||
|
||||
const dismissEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
ids: Iterable<string>,
|
||||
listKey: string,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
const list = cache.lists[listKey];
|
||||
|
||||
if (list) {
|
||||
for (const id of ids) {
|
||||
list.ids.delete(id);
|
||||
|
||||
if (typeof list.state.totalCount === 'number') {
|
||||
list.state.totalCount--;
|
||||
}
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const incrementEntities = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
listKey: string,
|
||||
diff: number,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
const list = cache.lists[listKey];
|
||||
|
||||
if (typeof list?.state?.totalCount === 'number') {
|
||||
list.state.totalCount += diff;
|
||||
draft[entityType] = cache;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setFetching = (
|
||||
state: State,
|
||||
entityType: string,
|
||||
listKey: string | undefined,
|
||||
isFetching: boolean,
|
||||
error?: any,
|
||||
) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
|
||||
if (typeof listKey === 'string') {
|
||||
const list = cache.lists[listKey] ?? createList();
|
||||
list.state.fetching = isFetching;
|
||||
list.state.error = error;
|
||||
cache.lists[listKey] = list;
|
||||
}
|
||||
|
||||
draft[entityType] = cache;
|
||||
});
|
||||
};
|
||||
|
||||
const invalidateEntityList = (state: State, entityType: string, listKey: string) => {
|
||||
return produce(state, draft => {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
const list = cache.lists[listKey] ?? createList();
|
||||
list.state.invalid = true;
|
||||
});
|
||||
};
|
||||
|
||||
const doTransaction = (state: State, transaction: EntitiesTransaction) => {
|
||||
return produce(state, draft => {
|
||||
for (const [entityType, changes] of Object.entries(transaction)) {
|
||||
const cache = draft[entityType] ?? createCache();
|
||||
for (const [id, change] of Object.entries(changes)) {
|
||||
const entity = cache.store[id];
|
||||
if (entity) {
|
||||
cache.store[id] = change(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Stores various entity data and lists in a one reducer. */
|
||||
function reducer(state: Readonly<State> = {}, action: EntityAction): State {
|
||||
switch (action.type) {
|
||||
case ENTITIES_IMPORT:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos);
|
||||
case ENTITIES_DELETE:
|
||||
return deleteEntities(state, action.entityType, action.ids, action.opts);
|
||||
case ENTITIES_DISMISS:
|
||||
return dismissEntities(state, action.entityType, action.ids, action.listKey);
|
||||
case ENTITIES_INCREMENT:
|
||||
return incrementEntities(state, action.entityType, action.listKey, action.diff);
|
||||
case ENTITIES_FETCH_SUCCESS:
|
||||
return importEntities(state, action.entityType, action.entities, action.listKey, action.pos, action.newState, action.overwrite);
|
||||
case ENTITIES_FETCH_REQUEST:
|
||||
return setFetching(state, action.entityType, action.listKey, true);
|
||||
case ENTITIES_FETCH_FAIL:
|
||||
return setFetching(state, action.entityType, action.listKey, false, action.error);
|
||||
case ENTITIES_INVALIDATE_LIST:
|
||||
return invalidateEntityList(state, action.entityType, action.listKey);
|
||||
case ENTITIES_TRANSACTION:
|
||||
return doTransaction(state, action.transaction);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer;
|
||||
export type { State };
|
||||
76
src/entity-store/selectors.ts
Normal file
76
src/entity-store/selectors.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import type { EntitiesPath } from './hooks/types';
|
||||
import type { Entity, EntityListState } from './types';
|
||||
import type { RootState } from 'soapbox/store';
|
||||
|
||||
/** Get cache at path from Redux. */
|
||||
const selectCache = (state: RootState, path: EntitiesPath) => state.entities[path[0]];
|
||||
|
||||
/** Get list at path from Redux. */
|
||||
const selectList = (state: RootState, path: EntitiesPath) => {
|
||||
const [, ...listKeys] = path;
|
||||
const listKey = listKeys.join(':');
|
||||
|
||||
return selectCache(state, path)?.lists[listKey];
|
||||
};
|
||||
|
||||
/** Select a particular item from a list state. */
|
||||
function selectListState<K extends keyof EntityListState>(state: RootState, path: EntitiesPath, key: K) {
|
||||
const listState = selectList(state, path)?.state;
|
||||
return listState ? listState[key] : undefined;
|
||||
}
|
||||
|
||||
/** Hook to get a particular item from a list state. */
|
||||
function useListState<K extends keyof EntityListState>(path: EntitiesPath, key: K) {
|
||||
return useAppSelector(state => selectListState(state, path, key));
|
||||
}
|
||||
|
||||
/** Get a single entity by its ID from the store. */
|
||||
function selectEntity<TEntity extends Entity>(
|
||||
state: RootState,
|
||||
entityType: string, id: string,
|
||||
): TEntity | undefined {
|
||||
return state.entities[entityType]?.store[id] as TEntity | undefined;
|
||||
}
|
||||
|
||||
/** Get list of entities from Redux. */
|
||||
function selectEntities<TEntity extends Entity>(state: RootState, path: EntitiesPath): readonly TEntity[] {
|
||||
const cache = selectCache(state, path);
|
||||
const list = selectList(state, path);
|
||||
|
||||
const entityIds = list?.ids;
|
||||
|
||||
return entityIds ? (
|
||||
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
|
||||
const entity = cache?.store[id];
|
||||
if (entity) {
|
||||
result.push(entity as TEntity);
|
||||
}
|
||||
return result;
|
||||
}, [])
|
||||
) : [];
|
||||
}
|
||||
|
||||
/** Find an entity using a finder function. */
|
||||
function findEntity<TEntity extends Entity>(
|
||||
state: RootState,
|
||||
entityType: string,
|
||||
lookupFn: (entity: TEntity) => boolean,
|
||||
) {
|
||||
const cache = state.entities[entityType];
|
||||
|
||||
if (cache) {
|
||||
return (Object.values(cache.store) as TEntity[]).find(lookupFn);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
selectCache,
|
||||
selectList,
|
||||
selectListState,
|
||||
useListState,
|
||||
selectEntities,
|
||||
selectEntity,
|
||||
findEntity,
|
||||
};
|
||||
68
src/entity-store/types.ts
Normal file
68
src/entity-store/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/** 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. */
|
||||
interface EntityStore<TEntity extends Entity = Entity> {
|
||||
[id: string]: TEntity | undefined
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Total number of items according to the API. */
|
||||
totalCount: number | undefined
|
||||
/** Error returned from the API, if any. */
|
||||
error: unknown
|
||||
/** Whether data has already been fetched */
|
||||
fetched: boolean
|
||||
/** Whether data for this list is currently being fetched. */
|
||||
fetching: boolean
|
||||
/** Date of the last API fetch for this list. */
|
||||
lastFetchedAt: Date | undefined
|
||||
/** Whether the entities should be refetched on the next component mount. */
|
||||
invalid: boolean
|
||||
}
|
||||
|
||||
/** Cache data pertaining to a paritcular entity type.. */
|
||||
interface EntityCache<TEntity extends Entity = Entity> {
|
||||
/** Map of entities of this type. */
|
||||
store: EntityStore<TEntity>
|
||||
/** Lists of entity IDs for a particular purpose. */
|
||||
lists: {
|
||||
[listKey: string]: EntityList | undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether to import items at the start or end of the list. */
|
||||
type ImportPosition = 'start' | 'end'
|
||||
|
||||
/** Map of entity mutation functions to perform at once on the store. */
|
||||
interface EntitiesTransaction {
|
||||
[entityType: string]: {
|
||||
[entityId: string]: <TEntity extends Entity>(entity: TEntity) => TEntity
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
Entity,
|
||||
EntityStore,
|
||||
EntityList,
|
||||
EntityListState,
|
||||
EntityCache,
|
||||
ImportPosition,
|
||||
EntitiesTransaction,
|
||||
};
|
||||
58
src/entity-store/utils.ts
Normal file
58
src/entity-store/utils.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Entity, EntityStore, EntityList, EntityCache, EntityListState, ImportPosition } from './types';
|
||||
|
||||
/** Insert the entities into the store. */
|
||||
const updateStore = (store: EntityStore, entities: Entity[]): EntityStore => {
|
||||
return entities.reduce<EntityStore>((store, entity) => {
|
||||
store[entity.id] = entity;
|
||||
return store;
|
||||
}, { ...store });
|
||||
};
|
||||
|
||||
/** Update the list with new entity IDs. */
|
||||
const updateList = (list: EntityList, entities: Entity[], pos: ImportPosition = 'end'): EntityList => {
|
||||
const newIds = entities.map(entity => entity.id);
|
||||
const oldIds = Array.from(list.ids);
|
||||
const ids = new Set(pos === 'start' ? [...newIds, ...oldIds] : [...oldIds, ...newIds]);
|
||||
|
||||
if (typeof list.state.totalCount === 'number') {
|
||||
const sizeDiff = ids.size - list.ids.size;
|
||||
list.state.totalCount += sizeDiff;
|
||||
}
|
||||
|
||||
return {
|
||||
...list,
|
||||
ids,
|
||||
};
|
||||
};
|
||||
|
||||
/** Create an empty entity cache. */
|
||||
const createCache = (): EntityCache => ({
|
||||
store: {},
|
||||
lists: {},
|
||||
});
|
||||
|
||||
/** Create an empty entity list. */
|
||||
const createList = (): EntityList => ({
|
||||
ids: new Set(),
|
||||
state: createListState(),
|
||||
});
|
||||
|
||||
/** Create an empty entity list state. */
|
||||
const createListState = (): EntityListState => ({
|
||||
next: undefined,
|
||||
prev: undefined,
|
||||
totalCount: 0,
|
||||
error: null,
|
||||
fetched: false,
|
||||
fetching: false,
|
||||
lastFetchedAt: undefined,
|
||||
invalid: false,
|
||||
});
|
||||
|
||||
export {
|
||||
updateStore,
|
||||
updateList,
|
||||
createCache,
|
||||
createList,
|
||||
createListState,
|
||||
};
|
||||
Reference in New Issue
Block a user