Merge remote-tracking branch 'soapbox/develop' into lexical

This commit is contained in:
marcin mikołajczak 2023-03-11 00:18:47 +01:00
commit dd1185a3f5
20 changed files with 572 additions and 92 deletions

View file

@ -29,6 +29,10 @@ export const getNextLink = (response: AxiosResponse): string | undefined => {
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) => {
return authType === 'app' ? getAppToken(state) : getAccessToken(state);
};

View 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,
};

View file

@ -0,0 +1,2 @@
export { useEntities } from './useEntities';
export { useEntity } from './useEntity';

View 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,
};

View 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,
};

View 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;

View 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,
};

View 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,
};

View file

@ -5,7 +5,7 @@ import { __stub } from 'soapbox/api';
import { ChatContext } from 'soapbox/contexts/chat-context';
import { StatProvider } from 'soapbox/contexts/stat-context';
import chats from 'soapbox/jest/fixtures/chats.json';
import { mockStore, render, rootState, screen, waitFor } from 'soapbox/jest/test-helpers';
import { render, screen, waitFor } from 'soapbox/jest/test-helpers';
import ChatPane from '../chat-pane';
@ -22,28 +22,28 @@ const renderComponentWithChatContext = (store = {}) => render(
);
describe('<ChatPane />', () => {
describe('when there are no chats', () => {
let store: ReturnType<typeof mockStore>;
// describe('when there are no chats', () => {
// let store: ReturnType<typeof mockStore>;
beforeEach(() => {
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
store = mockStore(state);
// beforeEach(() => {
// const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
// store = mockStore(state);
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
link: null,
});
});
});
// __stub((mock) => {
// mock.onGet('/api/v1/pleroma/chats').reply(200, [], {
// link: null,
// });
// });
// });
it('renders the blankslate', async () => {
renderComponentWithChatContext(store);
// it('renders the blankslate', async () => {
// renderComponentWithChatContext(store);
await waitFor(() => {
expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
});
});
});
// await waitFor(() => {
// expect(screen.getByTestId('chat-pane-blankslate')).toBeInTheDocument();
// });
// });
// });
describe('when the software is not Truth Social', () => {
beforeEach(() => {

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Select } from 'soapbox/components/ui';
@ -20,15 +20,7 @@ const DurationSelector = ({ onDurationChange }: IDurationSelector) => {
const [hours, setHours] = useState<number>(0);
const [minutes, setMinutes] = useState<number>(0);
const value = useMemo(() => {
const now: any = new Date();
const future: any = new Date();
now.setDate(now.getDate() + days);
now.setMinutes(now.getMinutes() + minutes);
now.setHours(now.getHours() + hours);
return Math.round((now - future) / 1000);
}, [days, hours, minutes]);
const value = (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60);
useEffect(() => {
if (days === 7) {

View file

@ -6,8 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
import GroupCard from 'soapbox/components/group-card';
import ScrollableList from 'soapbox/components/scrollable-list';
import { Button, Stack, Text } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
import { useGroups } from 'soapbox/queries/groups';
import { useAppDispatch, useAppSelector, useGroups, useFeatures } from 'soapbox/hooks';
import { PERMISSION_CREATE_GROUPS, hasPermission } from 'soapbox/utils/permissions';
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';
// 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 = () => (
<Stack space={6} alignItems='center' justifyContent='center' className='h-full p-6'>
<Stack space={2} className='max-w-sm'>

View file

@ -62,6 +62,19 @@ describe('useGroupsPath()', () => {
});
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);
await waitFor(() => {

View file

@ -5,6 +5,7 @@ export { useAppSelector } from './useAppSelector';
export { useClickOutside } from './useClickOutside';
export { useCompose } from './useCompose';
export { useDebounce } from './useDebounce';
export { useGroup, useGroups } from './useGroups';
export { useGroupsPath } from './useGroupsPath';
export { useDimensions } from './useDimensions';
export { useFeatures } from './useFeatures';

View 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 };

View file

@ -1,4 +1,4 @@
import { useGroups } from 'soapbox/queries/groups';
import { useGroups } from 'soapbox/hooks';
import { useFeatures } from './useFeatures';

View file

@ -11,8 +11,7 @@ import {
GroupMediaPanel,
SignUpPanel,
} from 'soapbox/features/ui/util/async-components';
import { useOwnAccount } from 'soapbox/hooks';
import { useGroup } from 'soapbox/queries/groups';
import { useGroup, useOwnAccount } from 'soapbox/hooks';
import { Tabs } from '../components/ui';

View file

@ -175,35 +175,35 @@ describe('useChatMessages', () => {
});
describe('useChats', () => {
let store: ReturnType<typeof mockStore>;
// let store: ReturnType<typeof mockStore>;
beforeEach(() => {
queryClient.clear();
});
describe('with a successful request', () => {
beforeEach(() => {
const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
store = mockStore(state);
// describe('with a successful request', () => {
// beforeEach(() => {
// const state = rootState.setIn(['instance', 'version'], '2.7.2 (compatible; Pleroma 2.2.0)');
// store = mockStore(state);
__stub((mock) => {
mock.onGet('/api/v1/pleroma/chats')
.reply(200, [
chat,
], {
link: '<https://example.com/api/v1/pleroma/chats?since_id=2>; rel="prev"',
});
});
});
// __stub((mock) => {
// mock.onGet('/api/v1/pleroma/chats')
// .reply(200, [
// chat,
// ], {
// link: '<https://example.com/api/v1/pleroma/chats?since_id=2>; rel="prev"',
// });
// });
// });
it('is successful', async () => {
const { result } = renderHook(() => useChats().chatsQuery, undefined, store);
// it('is successful', async () => {
// const { result } = renderHook(() => useChats().chatsQuery, undefined, store);
await waitFor(() => expect(result.current.isFetching).toBe(false));
// await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data?.length).toBe(1);
});
});
// expect(result.current.data?.length).toBe(1);
// });
// });
describe('with an unsuccessful query', () => {
beforeEach(() => {

View file

@ -3,6 +3,7 @@ import { combineReducers } from 'redux-immutable';
import { AUTH_LOGGED_OUT } from 'soapbox/actions/auth';
import * as BuildConfig from 'soapbox/build-config';
import entities from 'soapbox/entity-store/reducer';
import account_notes from './account-notes';
import accounts from './accounts';
@ -90,6 +91,7 @@ const reducers = {
custom_emojis,
domain_lists,
dropdown_menu,
entities,
filters,
group_editor,
group_memberships,

View file

@ -132,6 +132,7 @@
"html-webpack-harddisk-plugin": "^2.0.0",
"html-webpack-plugin": "^5.5.0",
"http-link-header": "^1.0.2",
"immer": "^9.0.19",
"immutable": "^4.2.1",
"imports-loader": "^4.0.0",
"intersection-observer": "^0.12.2",
@ -219,9 +220,9 @@
"@storybook/react": "^6.5.16",
"@storybook/testing-library": "^0.0.13",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.0.3",
"@testing-library/user-event": "^14.4.3",
"@typescript-eslint/eslint-plugin": "^5.15.0",
"@typescript-eslint/parser": "^5.15.0",
"babel-jest": "^29.4.1",

View file

@ -2,6 +2,11 @@
# yarn lockfile v1
"@adobe/css-tools@^4.0.1":
version "4.2.0"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.2.0.tgz#e1a84fca468f4b337816fcb7f0964beb620ba855"
integrity sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==
"@ampproject/remapping@^2.1.0":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.1.2.tgz#4edca94973ded9630d20101cd8559cedb8d8bd34"
@ -4050,16 +4055,16 @@
lz-string "^1.5.0"
pretty-format "^27.0.2"
"@testing-library/jest-dom@^5.16.4":
version "5.16.4"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz#938302d7b8b483963a3ae821f1c0808f872245cd"
integrity sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==
"@testing-library/jest-dom@^5.16.5":
version "5.16.5"
resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz#3912846af19a29b2dbf32a6ae9c31ef52580074e"
integrity sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==
dependencies:
"@adobe/css-tools" "^4.0.1"
"@babel/runtime" "^7.9.2"
"@types/testing-library__jest-dom" "^5.9.1"
aria-query "^5.0.0"
chalk "^3.0.0"
css "^3.0.0"
css.escape "^1.5.1"
dom-accessibility-api "^0.5.6"
lodash "^4.17.15"
@ -4089,10 +4094,10 @@
dependencies:
"@babel/runtime" "^7.12.5"
"@testing-library/user-event@^14.0.3":
version "14.0.3"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.0.3.tgz#463667596122c13d997f70b73426947ab71de962"
integrity sha512-zIgBG5CxfXbMsm4wBS6iQC3TBNMZk16O25i4shS9MM+eSG7PZHrsBF6LFIesUkepkZ3QKKgstB2/Nola6nvy4A==
"@testing-library/user-event@^14.4.3":
version "14.4.3"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591"
integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==
"@tootallnate/once@2":
version "2.0.0"
@ -7456,15 +7461,6 @@ css.escape@^1.5.1:
resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb"
integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=
css@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d"
integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==
dependencies:
inherits "^2.0.4"
source-map "^0.6.1"
source-map-resolve "^0.6.0"
cssesc@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
@ -10333,6 +10329,11 @@ immediate@~3.0.5:
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
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:
version "9.0.12"
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.12.tgz#2d33ddf3ee1d247deab9d707ca472c8c942a0f20"
@ -16025,14 +16026,6 @@ source-map-resolve@^0.5.0:
source-map-url "^0.4.0"
urix "^0.1.0"
source-map-resolve@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2"
integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==
dependencies:
atob "^2.1.2"
decode-uri-component "^0.2.0"
source-map-support@0.5.13:
version "0.5.13"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932"