Merge branch 'entity-request' into 'develop'

EntityStore: refactor all hooks to use callbacks

See merge request soapbox-pub/soapbox!2379
This commit is contained in:
Alex Gleason 2023-03-24 14:44:47 +00:00
commit 7c1182bfb3
16 changed files with 153 additions and 130 deletions

View File

@ -1,4 +1,5 @@
import type { Entity } from '../types'; import type { Entity } from '../types';
import type { AxiosResponse } from 'axios';
import type z from 'zod'; import type z from 'zod';
type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>; type EntitySchema<TEntity extends Entity = Entity> = z.ZodType<TEntity, z.ZodTypeDef, any>;
@ -24,9 +25,23 @@ type EntitiesPath = [entityType: string, listKey: string]
/** Used to look up a single entity by its ID. */ /** Used to look up a single entity by its ID. */
type EntityPath = [entityType: string, entityId: string] 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 { export type {
EntitySchema, EntitySchema,
ExpandedEntitiesPath, ExpandedEntitiesPath,
EntitiesPath, EntitiesPath,
EntityPath, EntityPath,
EntityCallbacks,
EntityFn,
}; };

View File

@ -1,52 +1,33 @@
import { z } from 'zod'; import { z } from 'zod';
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { importEntities } from '../actions'; import { importEntities } from '../actions';
import { parseEntitiesPath } from './utils'; import { parseEntitiesPath } from './utils';
import type { Entity } from '../types'; import type { Entity } from '../types';
import type { EntitySchema, ExpandedEntitiesPath } from './types'; import type { EntityCallbacks, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
type CreateFn<Params, Result> = (params: Params) => Promise<Result> | Result;
interface UseCreateEntityOpts<TEntity extends Entity = Entity> { interface UseCreateEntityOpts<TEntity extends Entity = Entity> {
schema?: EntitySchema<TEntity> schema?: EntitySchema<TEntity>
} }
type CreateEntityResult<TEntity extends Entity = Entity, Result = unknown, Error = unknown> = function useCreateEntity<TEntity extends Entity = Entity, Data = unknown>(
{
success: true
result: Result
entity: TEntity
} | {
success: false
error: Error
}
interface EntityCallbacks<TEntity extends Entity = Entity, Error = unknown> {
onSuccess?(entity: TEntity): void
onError?(error: Error): void
}
function useCreateEntity<TEntity extends Entity = Entity, Params = any, Result = unknown>(
expandedPath: ExpandedEntitiesPath, expandedPath: ExpandedEntitiesPath,
createFn: CreateFn<Params, Result>, entityFn: EntityFn<Data>,
opts: UseCreateEntityOpts<TEntity> = {}, opts: UseCreateEntityOpts<TEntity> = {},
) { ) {
const { entityType, listKey } = parseEntitiesPath(expandedPath);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
return async function createEntity( const [isLoading, setPromise] = useLoading();
params: Params, const { entityType, listKey } = parseEntitiesPath(expandedPath);
callbacks: EntityCallbacks = {},
): Promise<CreateEntityResult<TEntity>> { async function createEntity(data: Data, callbacks: EntityCallbacks<TEntity> = {}): Promise<void> {
try { try {
const result = await createFn(params); const result = await setPromise(entityFn(data));
const schema = opts.schema || z.custom<TEntity>(); const schema = opts.schema || z.custom<TEntity>();
const entity = schema.parse(result); const entity = schema.parse(result.data);
// TODO: optimistic updating // TODO: optimistic updating
dispatch(importEntities([entity], entityType, listKey)); dispatch(importEntities([entity], entityType, listKey));
@ -54,22 +35,16 @@ function useCreateEntity<TEntity extends Entity = Entity, Params = any, Result =
if (callbacks.onSuccess) { if (callbacks.onSuccess) {
callbacks.onSuccess(entity); callbacks.onSuccess(entity);
} }
return {
success: true,
result,
entity,
};
} catch (error) { } catch (error) {
if (callbacks.onError) { if (callbacks.onError) {
callbacks.onError(error); callbacks.onError(error);
} }
}
}
return { return {
success: false, createEntity,
error, isLoading,
};
}
}; };
} }

View File

@ -1,26 +1,23 @@
import { useAppDispatch, useGetState } from 'soapbox/hooks'; import { useAppDispatch, useGetState, useLoading } from 'soapbox/hooks';
import { deleteEntities, importEntities } from '../actions'; import { deleteEntities, importEntities } from '../actions';
type DeleteFn<T> = (entityId: string) => Promise<T> | T; import type { EntityCallbacks, EntityFn } from './types';
interface EntityCallbacks {
onSuccess?(): void
}
/** /**
* Optimistically deletes an entity from the store. * Optimistically deletes an entity from the store.
* This hook should be used to globally delete an entity from all lists. * This hook should be used to globally delete an entity from all lists.
* To remove an entity from a single list, see `useDismissEntity`. * To remove an entity from a single list, see `useDismissEntity`.
*/ */
function useDeleteEntity<T = unknown>( function useDeleteEntity(
entityType: string, entityType: string,
deleteFn: DeleteFn<T>, entityFn: EntityFn<string>,
) { ) {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getState = useGetState(); const getState = useGetState();
const [isLoading, setPromise] = useLoading();
return async function deleteEntity(entityId: string, callbacks: EntityCallbacks = {}): Promise<T> { 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. // Get the entity before deleting, so we can reverse the action if the API request fails.
const entity = getState().entities[entityType]?.store[entityId]; const entity = getState().entities[entityType]?.store[entityId];
@ -28,22 +25,29 @@ function useDeleteEntity<T = unknown>(
dispatch(deleteEntities([entityId], entityType, { preserveLists: true })); dispatch(deleteEntities([entityId], entityType, { preserveLists: true }));
try { try {
const result = await deleteFn(entityId); await setPromise(entityFn(entityId));
// Success - finish deleting entity from the state. // Success - finish deleting entity from the state.
dispatch(deleteEntities([entityId], entityType)); dispatch(deleteEntities([entityId], entityType));
if (callbacks.onSuccess) { if (callbacks.onSuccess) {
callbacks.onSuccess(); callbacks.onSuccess(entityId);
} }
return result;
} catch (e) { } catch (e) {
if (entity) { if (entity) {
// If the API failed, reimport the entity. // If the API failed, reimport the entity.
dispatch(importEntities([entity], entityType)); dispatch(importEntities([entity], entityType));
} }
throw e;
if (callbacks.onError) {
callbacks.onError(e);
} }
}
}
return {
deleteEntity,
isLoading,
}; };
} }

View File

@ -1,27 +1,31 @@
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { dismissEntities } from '../actions'; import { dismissEntities } from '../actions';
import { parseEntitiesPath } from './utils'; import { parseEntitiesPath } from './utils';
import type { ExpandedEntitiesPath } from './types'; import type { EntityFn, ExpandedEntitiesPath } from './types';
type DismissFn<T> = (entityId: string) => Promise<T> | T;
/** /**
* Removes an entity from a specific list. * Removes an entity from a specific list.
* To remove an entity globally from all lists, see `useDeleteEntity`. * To remove an entity globally from all lists, see `useDeleteEntity`.
*/ */
function useDismissEntity<T = unknown>(expandedPath: ExpandedEntitiesPath, dismissFn: DismissFn<T>) { function useDismissEntity(expandedPath: ExpandedEntitiesPath, entityFn: EntityFn<string>) {
const { entityType, listKey } = parseEntitiesPath(expandedPath);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
// TODO: optimistic dismissing // TODO: optimistic dismissing
return async function dismissEntity(entityId: string): Promise<T> { async function dismissEntity(entityId: string) {
const result = await dismissFn(entityId); const result = await setPromise(entityFn(entityId));
dispatch(dismissEntities([entityId], entityType, listKey)); dispatch(dismissEntities([entityId], entityType, listKey));
return result; return result;
}
return {
dismissEntity,
isLoading,
}; };
} }

View File

@ -11,7 +11,7 @@ import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess, invalida
import { parseEntitiesPath } from './utils'; import { parseEntitiesPath } from './utils';
import type { Entity, EntityListState } from '../types'; import type { Entity, EntityListState } from '../types';
import type { EntitiesPath, EntitySchema, ExpandedEntitiesPath } from './types'; import type { EntitiesPath, EntityFn, EntitySchema, ExpandedEntitiesPath } from './types';
import type { RootState } from 'soapbox/store'; import type { RootState } from 'soapbox/store';
/** Additional options for the hook. */ /** Additional options for the hook. */
@ -32,7 +32,7 @@ function useEntities<TEntity extends Entity>(
/** Tells us where to find/store the entity in the cache. */ /** Tells us where to find/store the entity in the cache. */
expandedPath: ExpandedEntitiesPath, expandedPath: ExpandedEntitiesPath,
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */ /** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
endpoint: string | undefined, entityFn: EntityFn<void>,
/** Additional options for the hook. */ /** Additional options for the hook. */
opts: UseEntitiesOpts<TEntity> = {}, opts: UseEntitiesOpts<TEntity> = {},
) { ) {
@ -54,14 +54,14 @@ function useEntities<TEntity extends Entity>(
const next = useListState(path, 'next'); const next = useListState(path, 'next');
const prev = useListState(path, 'prev'); const prev = useListState(path, 'prev');
const fetchPage = async(url: string, overwrite = false): Promise<void> => { const fetchPage = async(req: EntityFn<void>, overwrite = false): Promise<void> => {
// Get `isFetching` state from the store again to prevent race conditions. // Get `isFetching` state from the store again to prevent race conditions.
const isFetching = selectListState(getState(), path, 'fetching'); const isFetching = selectListState(getState(), path, 'fetching');
if (isFetching) return; if (isFetching) return;
dispatch(entitiesFetchRequest(entityType, listKey)); dispatch(entitiesFetchRequest(entityType, listKey));
try { try {
const response = await api.get(url); const response = await req();
const schema = opts.schema || z.custom<TEntity>(); const schema = opts.schema || z.custom<TEntity>();
const entities = filteredArray(schema).parse(response.data); const entities = filteredArray(schema).parse(response.data);
const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']); const parsedCount = realNumberSchema.safeParse(response.headers['x-total-count']);
@ -82,20 +82,18 @@ function useEntities<TEntity extends Entity>(
}; };
const fetchEntities = async(): Promise<void> => { const fetchEntities = async(): Promise<void> => {
if (endpoint) { await fetchPage(entityFn, true);
await fetchPage(endpoint, true);
}
}; };
const fetchNextPage = async(): Promise<void> => { const fetchNextPage = async(): Promise<void> => {
if (next) { if (next) {
await fetchPage(next); await fetchPage(() => api.get(next));
} }
}; };
const fetchPreviousPage = async(): Promise<void> => { const fetchPreviousPage = async(): Promise<void> => {
if (prev) { if (prev) {
await fetchPage(prev); await fetchPage(() => api.get(prev));
} }
}; };
@ -114,7 +112,7 @@ function useEntities<TEntity extends Entity>(
if (isInvalid || isUnset || isStale) { if (isInvalid || isUnset || isStale) {
fetchEntities(); fetchEntities();
} }
}, [endpoint, isEnabled]); }, [isEnabled]);
return { return {
entities, entities,

View File

@ -1,12 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import z from 'zod'; import z from 'zod';
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useLoading } from 'soapbox/hooks';
import { importEntities } from '../actions'; import { importEntities } from '../actions';
import type { Entity } from '../types'; import type { Entity } from '../types';
import type { EntitySchema, EntityPath } from './types'; import type { EntitySchema, EntityPath, EntityFn } from './types';
/** Additional options for the hook. */ /** Additional options for the hook. */
interface UseEntityOpts<TEntity extends Entity> { interface UseEntityOpts<TEntity extends Entity> {
@ -18,10 +18,10 @@ interface UseEntityOpts<TEntity extends Entity> {
function useEntity<TEntity extends Entity>( function useEntity<TEntity extends Entity>(
path: EntityPath, path: EntityPath,
endpoint: string, entityFn: EntityFn<void>,
opts: UseEntityOpts<TEntity> = {}, opts: UseEntityOpts<TEntity> = {},
) { ) {
const api = useApi(); const [isFetching, setPromise] = useLoading();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [entityType, entityId] = path; const [entityType, entityId] = path;
@ -31,18 +31,16 @@ function useEntity<TEntity extends Entity>(
const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined); const entity = useAppSelector(state => state.entities[entityType]?.store[entityId] as TEntity | undefined);
const [isFetching, setIsFetching] = useState(false);
const isLoading = isFetching && !entity; const isLoading = isFetching && !entity;
const fetchEntity = () => { const fetchEntity = async () => {
setIsFetching(true); try {
api.get(endpoint).then(({ data }) => { const response = await setPromise(entityFn());
const entity = schema.parse(data); const entity = schema.parse(response.data);
dispatch(importEntities([entity], entityType)); dispatch(importEntities([entity], entityType));
setIsFetching(false); } catch (e) {
}).catch(() => { // do nothing
setIsFetching(false); }
});
}; };
useEffect(() => { useEffect(() => {

View File

@ -1,5 +1,3 @@
import { useState } from 'react';
import { useApi } from 'soapbox/hooks'; import { useApi } from 'soapbox/hooks';
import { useCreateEntity } from './useCreateEntity'; import { useCreateEntity } from './useCreateEntity';
@ -18,7 +16,7 @@ interface EntityActionEndpoints {
delete?: string delete?: string
} }
function useEntityActions<TEntity extends Entity = Entity, Params = any>( function useEntityActions<TEntity extends Entity = Entity, Data = any>(
expandedPath: ExpandedEntitiesPath, expandedPath: ExpandedEntitiesPath,
endpoints: EntityActionEndpoints, endpoints: EntityActionEndpoints,
opts: UseEntityActionsOpts<TEntity> = {}, opts: UseEntityActionsOpts<TEntity> = {},
@ -26,24 +24,16 @@ function useEntityActions<TEntity extends Entity = Entity, Params = any>(
const api = useApi(); const api = useApi();
const { entityType, path } = parseEntitiesPath(expandedPath); const { entityType, path } = parseEntitiesPath(expandedPath);
const [isLoading, setIsLoading] = useState<boolean>(false); const { deleteEntity, isLoading: deleteLoading } =
useDeleteEntity(entityType, (entityId) => api.delete(endpoints.delete!.replaceAll(':id', entityId)));
const deleteEntity = useDeleteEntity(entityType, (entityId) => { const { createEntity, isLoading: createLoading } =
if (!endpoints.delete) return Promise.reject(endpoints); useCreateEntity<TEntity, Data>(path, (data) => api.post(endpoints.post!, data), opts);
return api.delete(endpoints.delete.replace(':id', entityId))
.finally(() => setIsLoading(false));
});
const createEntity = useCreateEntity(path, (params: Params) => {
if (!endpoints.post) return Promise.reject(endpoints);
return api.post(endpoints.post, params)
.finally(() => setIsLoading(false));
}, opts);
return { return {
createEntity, createEntity,
deleteEntity, deleteEntity,
isLoading, isLoading: createLoading || deleteLoading,
}; };
} }

View File

@ -1,32 +1,36 @@
import { useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch, useLoading } from 'soapbox/hooks';
import { incrementEntities } from '../actions'; import { incrementEntities } from '../actions';
import { parseEntitiesPath } from './utils'; import { parseEntitiesPath } from './utils';
import type { ExpandedEntitiesPath } from './types'; import type { EntityFn, ExpandedEntitiesPath } from './types';
type IncrementFn<T> = (entityId: string) => Promise<T> | T;
/** /**
* Increases (or decreases) the `totalCount` in the entity list by the specified amount. * 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. * This only works if the API returns an `X-Total-Count` header and your components read it.
*/ */
function useIncrementEntity<T = unknown>( function useIncrementEntity(
expandedPath: ExpandedEntitiesPath, expandedPath: ExpandedEntitiesPath,
diff: number, diff: number,
incrementFn: IncrementFn<T>, entityFn: EntityFn<string>,
) { ) {
const { entityType, listKey } = parseEntitiesPath(expandedPath);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isLoading, setPromise] = useLoading();
const { entityType, listKey } = parseEntitiesPath(expandedPath);
return async function incrementEntity(entityId: string): Promise<void> { async function incrementEntity(entityId: string): Promise<void> {
dispatch(incrementEntities(entityType, listKey, diff)); dispatch(incrementEntities(entityType, listKey, diff));
try { try {
await incrementFn(entityId); await setPromise(entityFn(entityId));
} catch (e) { } catch (e) {
dispatch(incrementEntities(entityType, listKey, diff * -1)); dispatch(incrementEntities(entityType, listKey, diff * -1));
} }
}
return {
incrementEntity,
isLoading,
}; };
} }

View File

@ -12,4 +12,5 @@ function parseEntitiesPath(expandedPath: ExpandedEntitiesPath) {
}; };
} }
export { parseEntitiesPath }; export { parseEntitiesPath };

View File

@ -11,20 +11,20 @@ function useGroupMembershipRequests(groupId: string) {
const { entities, invalidate, ...rest } = useEntities( const { entities, invalidate, ...rest } = useEntities(
path, path,
`/api/v1/groups/${groupId}/membership_requests`, () => api.get(`/api/v1/groups/${groupId}/membership_requests`),
{ schema: accountSchema }, { schema: accountSchema },
); );
const authorize = useIncrementEntity(path, -1, (accountId: string) => { const { incrementEntity: authorize } = useIncrementEntity(path, -1, async (accountId: string) => {
return api const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`);
.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/authorize`) invalidate();
.then(invalidate); return response;
}); });
const reject = useIncrementEntity(path, -1, (accountId: string) => { const { incrementEntity: reject } = useIncrementEntity(path, -1, async (accountId: string) => {
return api const response = await api.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`);
.post(`/api/v1/groups/${groupId}/membership_requests/${accountId}/reject`) invalidate();
.then(invalidate); return response;
}); });
return { return {

View File

@ -2,10 +2,14 @@ import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks'; import { useEntities } from 'soapbox/entity-store/hooks';
import { GroupMember, groupMemberSchema } from 'soapbox/schemas'; import { GroupMember, groupMemberSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
function useGroupMembers(groupId: string, role: string) { function useGroupMembers(groupId: string, role: string) {
const api = useApi();
const { entities, ...result } = useEntities<GroupMember>( const { entities, ...result } = useEntities<GroupMember>(
[Entities.GROUP_MEMBERSHIPS, groupId, role], [Entities.GROUP_MEMBERSHIPS, groupId, role],
`/api/v1/groups/${groupId}/memberships?role=${role}`, () => api.get(`/api/v1/groups/${groupId}/memberships?role=${role}`),
{ schema: groupMemberSchema }, { schema: groupMemberSchema },
); );

View File

@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks'; import { useEntities } from 'soapbox/entity-store/hooks';
import { Group, groupSchema } from 'soapbox/schemas'; import { Group, groupSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
import { useFeatures } from '../useFeatures'; import { useFeatures } from '../useFeatures';
import { useGroupRelationships } from '../useGroups'; import { useGroupRelationships } from '../useGroups';
function usePopularGroups() { function usePopularGroups() {
const api = useApi();
const features = useFeatures(); const features = useFeatures();
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'popular'], [Entities.GROUPS, 'popular'],
'/api/mock/groups', // '/api/v1/truth/trends/groups' () => api.get('/api/mock/groups'), // '/api/v1/truth/trends/groups'
{ {
schema: groupSchema, schema: groupSchema,
enabled: features.groupsDiscovery, enabled: features.groupsDiscovery,

View File

@ -2,15 +2,17 @@ import { Entities } from 'soapbox/entity-store/entities';
import { useEntities } from 'soapbox/entity-store/hooks'; import { useEntities } from 'soapbox/entity-store/hooks';
import { Group, groupSchema } from 'soapbox/schemas'; import { Group, groupSchema } from 'soapbox/schemas';
import { useApi } from '../useApi';
import { useFeatures } from '../useFeatures'; import { useFeatures } from '../useFeatures';
import { useGroupRelationships } from '../useGroups'; import { useGroupRelationships } from '../useGroups';
function useSuggestedGroups() { function useSuggestedGroups() {
const api = useApi();
const features = useFeatures(); const features = useFeatures();
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS, 'suggested'], [Entities.GROUPS, 'suggested'],
'/api/mock/groups', // '/api/v1/truth/suggestions/groups' () => api.get('/api/mock/groups'), // '/api/v1/truth/suggestions/groups'
{ {
schema: groupSchema, schema: groupSchema,
enabled: features.groupsDiscovery, enabled: features.groupsDiscovery,

View File

@ -11,6 +11,7 @@ export { useGroupsPath } from './useGroupsPath';
export { useDimensions } from './useDimensions'; export { useDimensions } from './useDimensions';
export { useFeatures } from './useFeatures'; export { useFeatures } from './useFeatures';
export { useInstance } from './useInstance'; export { useInstance } from './useInstance';
export { useLoading } from './useLoading';
export { useLocale } from './useLocale'; export { useLocale } from './useLocale';
export { useOnScreen } from './useOnScreen'; export { useOnScreen } from './useOnScreen';
export { useOwnAccount } from './useOwnAccount'; export { useOwnAccount } from './useOwnAccount';

View File

@ -2,17 +2,19 @@ import { z } from 'zod';
import { Entities } from 'soapbox/entity-store/entities'; import { Entities } from 'soapbox/entity-store/entities';
import { useEntities, useEntity } from 'soapbox/entity-store/hooks'; import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
import { useApi } from 'soapbox/hooks';
import { groupSchema, Group } from 'soapbox/schemas/group'; import { groupSchema, Group } from 'soapbox/schemas/group';
import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship'; import { groupRelationshipSchema, GroupRelationship } from 'soapbox/schemas/group-relationship';
import { useFeatures } from './useFeatures'; import { useFeatures } from './useFeatures';
function useGroups() { function useGroups() {
const api = useApi();
const features = useFeatures(); const features = useFeatures();
const { entities, ...result } = useEntities<Group>( const { entities, ...result } = useEntities<Group>(
[Entities.GROUPS], [Entities.GROUPS],
'/api/v1/groups', () => api.get('/api/v1/groups'),
{ enabled: features.groups, schema: groupSchema }, { enabled: features.groups, schema: groupSchema },
); );
const { relationships } = useGroupRelationships(entities.map(entity => entity.id)); const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
@ -29,9 +31,11 @@ function useGroups() {
} }
function useGroup(groupId: string, refetch = true) { function useGroup(groupId: string, refetch = true) {
const api = useApi();
const { entity: group, ...result } = useEntity<Group>( const { entity: group, ...result } = useEntity<Group>(
[Entities.GROUPS, groupId], [Entities.GROUPS, groupId],
`/api/v1/groups/${groupId}`, () => api.get(`/api/v1/groups/${groupId}`),
{ schema: groupSchema, refetch }, { schema: groupSchema, refetch },
); );
const { entity: relationship } = useGroupRelationship(groupId); const { entity: relationship } = useGroupRelationship(groupId);
@ -43,20 +47,22 @@ function useGroup(groupId: string, refetch = true) {
} }
function useGroupRelationship(groupId: string) { function useGroupRelationship(groupId: string) {
const api = useApi();
return useEntity<GroupRelationship>( return useEntity<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, groupId], [Entities.GROUP_RELATIONSHIPS, groupId],
`/api/v1/groups/relationships?id[]=${groupId}`, () => api.get(`/api/v1/groups/relationships?id[]=${groupId}`),
{ schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) }, { schema: z.array(groupRelationshipSchema).transform(arr => arr[0]) },
); );
} }
function useGroupRelationships(groupIds: string[]) { function useGroupRelationships(groupIds: string[]) {
const api = useApi();
const q = groupIds.map(id => `id[]=${id}`).join('&'); const q = groupIds.map(id => `id[]=${id}`).join('&');
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
const { entities, ...result } = useEntities<GroupRelationship>( const { entities, ...result } = useEntities<GroupRelationship>(
[Entities.GROUP_RELATIONSHIPS, ...groupIds], [Entities.GROUP_RELATIONSHIPS, ...groupIds],
endpoint, () => api.get(`/api/v1/groups/relationships?${q}`),
{ schema: groupRelationshipSchema }, { schema: groupRelationshipSchema, enabled: groupIds.length > 0 },
); );
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => { const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {

View File

@ -0,0 +1,19 @@
import { useState } from 'react';
function useLoading() {
const [isLoading, setIsLoading] = useState<boolean>(false);
function setPromise<T>(promise: Promise<T>) {
setIsLoading(true);
promise
.then(() => setIsLoading(false))
.catch(() => setIsLoading(false));
return promise;
}
return [isLoading, setPromise] as const;
}
export { useLoading };