Merge branch 'group-entities' into 'develop'
EntityStore: Groups See merge request soapbox-pub/soapbox!2333
This commit is contained in:
commit
a0c1bd84c9
17 changed files with 515 additions and 15 deletions
|
@ -29,6 +29,10 @@ export const getNextLink = (response: AxiosResponse): string | undefined => {
|
||||||
return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
|
return getLinks(response).refs.find(link => link.rel === 'next')?.uri;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPrevLink = (response: AxiosResponse): string | undefined => {
|
||||||
|
return getLinks(response).refs.find(link => link.rel === 'prev')?.uri;
|
||||||
|
};
|
||||||
|
|
||||||
const getToken = (state: RootState, authType: string) => {
|
const getToken = (state: RootState, authType: string) => {
|
||||||
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
|
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
|
||||||
};
|
};
|
||||||
|
|
62
app/soapbox/entity-store/actions.ts
Normal file
62
app/soapbox/entity-store/actions.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import type { Entity, EntityListState } from './types';
|
||||||
|
|
||||||
|
const ENTITIES_IMPORT = 'ENTITIES_IMPORT' 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;
|
||||||
|
|
||||||
|
/** Action to import entities into the cache. */
|
||||||
|
function importEntities(entities: Entity[], entityType: string, listKey?: string) {
|
||||||
|
return {
|
||||||
|
type: ENTITIES_IMPORT,
|
||||||
|
entityType,
|
||||||
|
entities,
|
||||||
|
listKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitiesFetchRequest(entityType: string, listKey?: string) {
|
||||||
|
return {
|
||||||
|
type: ENTITIES_FETCH_REQUEST,
|
||||||
|
entityType,
|
||||||
|
listKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitiesFetchSuccess(entities: Entity[], entityType: string, listKey?: string, newState?: EntityListState) {
|
||||||
|
return {
|
||||||
|
type: ENTITIES_FETCH_SUCCESS,
|
||||||
|
entityType,
|
||||||
|
entities,
|
||||||
|
listKey,
|
||||||
|
newState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitiesFetchFail(entityType: string, listKey: string | undefined, error: any) {
|
||||||
|
return {
|
||||||
|
type: ENTITIES_FETCH_FAIL,
|
||||||
|
entityType,
|
||||||
|
listKey,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any action pertaining to entities. */
|
||||||
|
type EntityAction =
|
||||||
|
ReturnType<typeof importEntities>
|
||||||
|
| ReturnType<typeof entitiesFetchRequest>
|
||||||
|
| ReturnType<typeof entitiesFetchSuccess>
|
||||||
|
| ReturnType<typeof entitiesFetchFail>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
ENTITIES_IMPORT,
|
||||||
|
ENTITIES_FETCH_REQUEST,
|
||||||
|
ENTITIES_FETCH_SUCCESS,
|
||||||
|
ENTITIES_FETCH_FAIL,
|
||||||
|
importEntities,
|
||||||
|
entitiesFetchRequest,
|
||||||
|
entitiesFetchSuccess,
|
||||||
|
entitiesFetchFail,
|
||||||
|
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';
|
132
app/soapbox/entity-store/hooks/useEntities.ts
Normal file
132
app/soapbox/entity-store/hooks/useEntities.ts
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { getNextLink, getPrevLink } from 'soapbox/api';
|
||||||
|
import { useApi, useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import { entitiesFetchFail, entitiesFetchRequest, entitiesFetchSuccess } from '../actions';
|
||||||
|
|
||||||
|
import type { Entity } from '../types';
|
||||||
|
import type { RootState } from 'soapbox/store';
|
||||||
|
|
||||||
|
/** Tells us where to find/store the entity in the cache. */
|
||||||
|
type EntityPath = [
|
||||||
|
/** Name of the entity type for use in the global cache, eg `'Notification'`. */
|
||||||
|
entityType: string,
|
||||||
|
/** Name of a particular index of this entity type. You can use empty-string (`''`) if you don't need separate lists. */
|
||||||
|
listKey: string,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Additional options for the hook. */
|
||||||
|
interface UseEntitiesOpts<TEntity> {
|
||||||
|
/** A parser function that returns the desired type, or undefined if validation fails. */
|
||||||
|
parser?: (entity: unknown) => TEntity | undefined
|
||||||
|
/**
|
||||||
|
* 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 hook for fetching and displaying API entities. */
|
||||||
|
function useEntities<TEntity extends Entity>(
|
||||||
|
/** Tells us where to find/store the entity in the cache. */
|
||||||
|
path: EntityPath,
|
||||||
|
/** API route to GET, eg `'/api/v1/notifications'`. If undefined, nothing will be fetched. */
|
||||||
|
endpoint: string | undefined,
|
||||||
|
/** Additional options for the hook. */
|
||||||
|
opts: UseEntitiesOpts<TEntity> = {},
|
||||||
|
) {
|
||||||
|
const api = useApi();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [entityType, listKey] = path;
|
||||||
|
|
||||||
|
const defaultParser = (entity: unknown) => entity as TEntity;
|
||||||
|
const parseEntity = opts.parser || defaultParser;
|
||||||
|
|
||||||
|
const cache = useAppSelector(state => state.entities[entityType]);
|
||||||
|
const list = cache?.lists[listKey];
|
||||||
|
|
||||||
|
const entityIds = list?.ids;
|
||||||
|
|
||||||
|
const entities: readonly TEntity[] = entityIds ? (
|
||||||
|
Array.from(entityIds).reduce<TEntity[]>((result, id) => {
|
||||||
|
const entity = parseEntity(cache?.store[id] as unknown);
|
||||||
|
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 fetchPage = async(url: string): Promise<void> => {
|
||||||
|
// Get `isFetching` state from the store again to prevent race conditions.
|
||||||
|
const isFetching = dispatch((_, getState: () => RootState) => Boolean(getState().entities[entityType]?.lists[listKey]?.state.fetching));
|
||||||
|
if (isFetching) return;
|
||||||
|
|
||||||
|
dispatch(entitiesFetchRequest(entityType, listKey));
|
||||||
|
try {
|
||||||
|
const response = await api.get(url);
|
||||||
|
dispatch(entitiesFetchSuccess(response.data, entityType, listKey, {
|
||||||
|
next: getNextLink(response),
|
||||||
|
prev: getPrevLink(response),
|
||||||
|
fetching: false,
|
||||||
|
error: null,
|
||||||
|
lastFetchedAt: new Date(),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
dispatch(entitiesFetchFail(entityType, listKey, error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchEntities = async(): Promise<void> => {
|
||||||
|
if (endpoint) {
|
||||||
|
await fetchPage(endpoint);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchNextPage = async(): Promise<void> => {
|
||||||
|
const next = list?.state.next;
|
||||||
|
|
||||||
|
if (next) {
|
||||||
|
await fetchPage(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPreviousPage = async(): Promise<void> => {
|
||||||
|
const prev = list?.state.prev;
|
||||||
|
|
||||||
|
if (prev) {
|
||||||
|
await fetchPage(prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const staleTime = opts.staleTime ?? 60000;
|
||||||
|
const lastFetchedAt = list?.state.lastFetchedAt;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isFetching && (!lastFetchedAt || lastFetchedAt.getTime() + staleTime <= Date.now())) {
|
||||||
|
fetchEntities();
|
||||||
|
}
|
||||||
|
}, [endpoint]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entities,
|
||||||
|
fetchEntities,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
hasNextPage,
|
||||||
|
hasPreviousPage,
|
||||||
|
fetchNextPage,
|
||||||
|
fetchPreviousPage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useEntities,
|
||||||
|
};
|
63
app/soapbox/entity-store/hooks/useEntity.ts
Normal file
63
app/soapbox/entity-store/hooks/useEntity.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { useEffect, 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]
|
||||||
|
|
||||||
|
/** Additional options for the hook. */
|
||||||
|
interface UseEntityOpts<TEntity> {
|
||||||
|
/** A parser function that returns the desired type, or undefined if validation fails. */
|
||||||
|
parser?: (entity: unknown) => TEntity | undefined
|
||||||
|
/** Whether to refetch this entity every time the hook mounts, even if it's already in the store. */
|
||||||
|
refetch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function useEntity<TEntity extends Entity>(
|
||||||
|
path: EntityPath,
|
||||||
|
endpoint: string,
|
||||||
|
opts: UseEntityOpts<TEntity> = {},
|
||||||
|
) {
|
||||||
|
const api = useApi();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const [entityType, entityId] = path;
|
||||||
|
|
||||||
|
const defaultParser = (entity: unknown) => entity as TEntity;
|
||||||
|
const parseEntity = opts.parser || defaultParser;
|
||||||
|
|
||||||
|
const entity = useAppSelector(state => parseEntity(state.entities[entityType]?.store[entityId]));
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!entity || opts.refetch) {
|
||||||
|
fetchEntity();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entity,
|
||||||
|
fetchEntity,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useEntity,
|
||||||
|
};
|
81
app/soapbox/entity-store/reducer.ts
Normal file
81
app/soapbox/entity-store/reducer.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import produce, { enableMapSet } from 'immer';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ENTITIES_IMPORT,
|
||||||
|
ENTITIES_FETCH_REQUEST,
|
||||||
|
ENTITIES_FETCH_SUCCESS,
|
||||||
|
ENTITIES_FETCH_FAIL,
|
||||||
|
EntityAction,
|
||||||
|
} from './actions';
|
||||||
|
import { createCache, createList, updateStore, updateList } from './utils';
|
||||||
|
|
||||||
|
import type { Entity, EntityCache, EntityListState } 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,
|
||||||
|
newState?: EntityListState,
|
||||||
|
): 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()) };
|
||||||
|
list = updateList(list, entities);
|
||||||
|
if (newState) {
|
||||||
|
list.state = newState;
|
||||||
|
}
|
||||||
|
cache.lists[listKey] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft[entityType] = cache;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFetching = (
|
||||||
|
state: State,
|
||||||
|
entityType: string,
|
||||||
|
listKey: string | undefined,
|
||||||
|
isFetching: boolean,
|
||||||
|
) => {
|
||||||
|
return produce(state, draft => {
|
||||||
|
const cache = draft[entityType] ?? createCache();
|
||||||
|
|
||||||
|
if (typeof listKey === 'string') {
|
||||||
|
const list = cache.lists[listKey] ?? createList();
|
||||||
|
list.state.fetching = isFetching;
|
||||||
|
cache.lists[listKey] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft[entityType] = cache;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
case ENTITIES_FETCH_SUCCESS:
|
||||||
|
return importEntities(state, action.entityType, action.entities, action.listKey, action.newState);
|
||||||
|
case ENTITIES_FETCH_REQUEST:
|
||||||
|
return setFetching(state, action.entityType, action.listKey, true);
|
||||||
|
case ENTITIES_FETCH_FAIL:
|
||||||
|
return setFetching(state, action.entityType, action.listKey, false);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default reducer;
|
50
app/soapbox/entity-store/types.ts
Normal file
50
app/soapbox/entity-store/types.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
/** 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 {
|
||||||
|
[id: string]: Entity | 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
|
||||||
|
/** Error returned from the API, if any. */
|
||||||
|
error: any
|
||||||
|
/** Whether data for this list is currently being fetched. */
|
||||||
|
fetching: boolean
|
||||||
|
/** Date of the last API fetch for this list. */
|
||||||
|
lastFetchedAt: Date | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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: {
|
||||||
|
[listKey: string]: EntityList | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Entity,
|
||||||
|
EntityStore,
|
||||||
|
EntityList,
|
||||||
|
EntityListState,
|
||||||
|
EntityCache,
|
||||||
|
};
|
43
app/soapbox/entity-store/utils.ts
Normal file
43
app/soapbox/entity-store/utils.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
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) => {
|
||||||
|
store[entity.id] = entity;
|
||||||
|
return store;
|
||||||
|
}, { ...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: {},
|
||||||
|
lists: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Create an empty entity list. */
|
||||||
|
const createList = (): EntityList => ({
|
||||||
|
ids: new Set(),
|
||||||
|
state: {
|
||||||
|
next: undefined,
|
||||||
|
prev: undefined,
|
||||||
|
fetching: false,
|
||||||
|
error: null,
|
||||||
|
lastFetchedAt: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
updateStore,
|
||||||
|
updateList,
|
||||||
|
createCache,
|
||||||
|
createList,
|
||||||
|
};
|
|
@ -6,8 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
|
||||||
import GroupCard from 'soapbox/components/group-card';
|
import GroupCard from 'soapbox/components/group-card';
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
import { Button, Stack, Text } from 'soapbox/components/ui';
|
import { Button, Stack, Text } from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useGroups, useFeatures } from 'soapbox/hooks';
|
||||||
import { useGroups } from 'soapbox/queries/groups';
|
|
||||||
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
|
||||||
|
|
||||||
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
import PlaceholderGroupCard from '../placeholder/components/placeholder-group-card';
|
||||||
|
@ -16,16 +15,6 @@ import TabBar, { TabItems } from './components/tab-bar';
|
||||||
|
|
||||||
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
import type { Group as GroupEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
// const getOrderedGroups = createSelector([
|
|
||||||
// (state: RootState) => state.groups.items,
|
|
||||||
// (state: RootState) => state.group_relationships,
|
|
||||||
// ], (groups, group_relationships) => ({
|
|
||||||
// groups: (groups.toList().filter((item: GroupEntity | false) => !!item) as ImmutableList<GroupEntity>)
|
|
||||||
// .map((item) => item.set('relationship', group_relationships.get(item.id) || null))
|
|
||||||
// .filter((item) => item.relationship?.member)
|
|
||||||
// .sort((a, b) => a.display_name.localeCompare(b.display_name)),
|
|
||||||
// }));
|
|
||||||
|
|
||||||
const EmptyMessage = () => (
|
const EmptyMessage = () => (
|
||||||
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
|
||||||
<Stack space={2} className='max-w-sm'>
|
<Stack space={2} className='max-w-sm'>
|
||||||
|
|
|
@ -62,6 +62,19 @@ describe('useGroupsPath()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should default to the discovery page', async () => {
|
test('should default to the discovery page', async () => {
|
||||||
|
const store = {
|
||||||
|
entities: {
|
||||||
|
Groups: {
|
||||||
|
store: {
|
||||||
|
'1': normalizeGroup({}),
|
||||||
|
},
|
||||||
|
lists: {
|
||||||
|
'': new Set(['1']),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const { result } = renderHook(useGroupsPath, undefined, store);
|
const { result } = renderHook(useGroupsPath, undefined, store);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
|
@ -5,6 +5,7 @@ export { useAppSelector } from './useAppSelector';
|
||||||
export { useClickOutside } from './useClickOutside';
|
export { useClickOutside } from './useClickOutside';
|
||||||
export { useCompose } from './useCompose';
|
export { useCompose } from './useCompose';
|
||||||
export { useDebounce } from './useDebounce';
|
export { useDebounce } from './useDebounce';
|
||||||
|
export { useGroup, useGroups } from './useGroups';
|
||||||
export { useGroupsPath } from './useGroupsPath';
|
export { useGroupsPath } from './useGroupsPath';
|
||||||
export { useDimensions } from './useDimensions';
|
export { useDimensions } from './useDimensions';
|
||||||
export { useFeatures } from './useFeatures';
|
export { useFeatures } from './useFeatures';
|
||||||
|
|
53
app/soapbox/hooks/useGroups.ts
Normal file
53
app/soapbox/hooks/useGroups.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { useEntities, useEntity } from 'soapbox/entity-store/hooks';
|
||||||
|
import { normalizeGroup, normalizeGroupRelationship } from 'soapbox/normalizers';
|
||||||
|
|
||||||
|
import type { Group, GroupRelationship } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
function useGroups() {
|
||||||
|
const { entities, ...result } = useEntities<Group>(['Group', ''], '/api/v1/groups', { parser: parseGroup });
|
||||||
|
const { relationships } = useGroupRelationships(entities.map(entity => entity.id));
|
||||||
|
|
||||||
|
const groups = entities.map((group) => group.set('relationship', relationships[group.id] || null));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
groups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGroup(groupId: string, refetch = true) {
|
||||||
|
const { entity: group, ...result } = useEntity<Group>(['Group', groupId], `/api/v1/groups/${groupId}`, { parser: parseGroup, refetch });
|
||||||
|
const { entity: relationship } = useGroupRelationship(groupId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
group: group?.set('relationship', relationship || null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGroupRelationship(groupId: string) {
|
||||||
|
return useEntity<GroupRelationship>(['GroupRelationship', groupId], `/api/v1/groups/relationships?id[]=${groupId}`, { parser: parseGroupRelationship });
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGroupRelationships(groupIds: string[]) {
|
||||||
|
const q = groupIds.map(id => `id[]=${id}`).join('&');
|
||||||
|
const endpoint = groupIds.length ? `/api/v1/groups/relationships?${q}` : undefined;
|
||||||
|
const { entities, ...result } = useEntities<GroupRelationship>(['GroupRelationship', q], endpoint, { parser: parseGroupRelationship });
|
||||||
|
|
||||||
|
const relationships = entities.reduce<Record<string, GroupRelationship>>((map, relationship) => {
|
||||||
|
map[relationship.id] = relationship;
|
||||||
|
return map;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
relationships,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: normalizers currently don't have the desired API.
|
||||||
|
// TODO: rewrite normalizers as Zod parsers.
|
||||||
|
const parseGroup = (entity: unknown) => entity ? normalizeGroup(entity as Record<string, any>) : undefined;
|
||||||
|
const parseGroupRelationship = (entity: unknown) => entity ? normalizeGroupRelationship(entity as Record<string, any>) : undefined;
|
||||||
|
|
||||||
|
export { useGroup, useGroups };
|
|
@ -1,4 +1,4 @@
|
||||||
import { useGroups } from 'soapbox/queries/groups';
|
import { useGroups } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { useFeatures } from './useFeatures';
|
import { useFeatures } from './useFeatures';
|
||||||
|
|
||||||
|
|
|
@ -11,8 +11,7 @@ import {
|
||||||
GroupMediaPanel,
|
GroupMediaPanel,
|
||||||
SignUpPanel,
|
SignUpPanel,
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
import { useOwnAccount } from 'soapbox/hooks';
|
import { useGroup, useOwnAccount } from 'soapbox/hooks';
|
||||||
import { useGroup } from 'soapbox/queries/groups';
|
|
||||||
|
|
||||||
import { Tabs } from '../components/ui';
|
import { Tabs } from '../components/ui';
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { combineReducers } from 'redux-immutable';
|
||||||
|
|
||||||
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
|
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
|
||||||
import * as BuildConfig from 'soapbox/build-config';
|
import * as BuildConfig from 'soapbox/build-config';
|
||||||
|
import entities from 'soapbox/entity-store/reducer';
|
||||||
|
|
||||||
import account_notes from './account-notes';
|
import account_notes from './account-notes';
|
||||||
import accounts from './accounts';
|
import accounts from './accounts';
|
||||||
|
@ -90,6 +91,7 @@ const reducers = {
|
||||||
custom_emojis,
|
custom_emojis,
|
||||||
domain_lists,
|
domain_lists,
|
||||||
dropdown_menu,
|
dropdown_menu,
|
||||||
|
entities,
|
||||||
filters,
|
filters,
|
||||||
group_editor,
|
group_editor,
|
||||||
group_memberships,
|
group_memberships,
|
||||||
|
|
|
@ -124,6 +124,7 @@
|
||||||
"html-webpack-harddisk-plugin": "^2.0.0",
|
"html-webpack-harddisk-plugin": "^2.0.0",
|
||||||
"html-webpack-plugin": "^5.5.0",
|
"html-webpack-plugin": "^5.5.0",
|
||||||
"http-link-header": "^1.0.2",
|
"http-link-header": "^1.0.2",
|
||||||
|
"immer": "^9.0.19",
|
||||||
"immutable": "^4.2.1",
|
"immutable": "^4.2.1",
|
||||||
"imports-loader": "^4.0.0",
|
"imports-loader": "^4.0.0",
|
||||||
"intersection-observer": "^0.12.2",
|
"intersection-observer": "^0.12.2",
|
||||||
|
|
|
@ -10206,6 +10206,11 @@ immediate@~3.0.5:
|
||||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||||
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
|
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
|
||||||
|
|
||||||
|
immer@^9.0.19:
|
||||||
|
version "9.0.19"
|
||||||
|
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.19.tgz#67fb97310555690b5f9cd8380d38fc0aabb6b38b"
|
||||||
|
integrity sha512-eY+Y0qcsB4TZKwgQzLaE/lqYMlKhv5J9dyd2RhhtGhNo2njPXDqU9XPfcNfa3MIDsdtZt5KlkIsirlo4dHsWdQ==
|
||||||
|
|
||||||
immer@^9.0.7:
|
immer@^9.0.7:
|
||||||
version "9.0.12"
|
version "9.0.12"
|
||||||
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"
|
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"
|
||||||
|
|
Loading…
Reference in a new issue