ydb-embedded-ui 3.4.1 → 3.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +31 -10
  3. package/dist/components/CriticalActionDialog/CriticalActionDialog.scss +1 -1
  4. package/dist/components/CriticalActionDialog/{CriticalActionDialog.js → CriticalActionDialog.tsx} +21 -16
  5. package/dist/components/CriticalActionDialog/index.ts +1 -0
  6. package/dist/containers/App/Content.js +1 -1
  7. package/dist/containers/Nodes/Nodes.scss +4 -0
  8. package/dist/containers/Nodes/Nodes.tsx +2 -1
  9. package/dist/containers/Storage/PDisk/PDisk.scss +15 -0
  10. package/dist/containers/Storage/PDisk/PDisk.tsx +43 -13
  11. package/dist/containers/Storage/StorageGroups/StorageGroups.tsx +2 -2
  12. package/dist/containers/Storage/StorageNodes/StorageNodes.scss +8 -2
  13. package/dist/containers/Storage/StorageNodes/StorageNodes.tsx +3 -1
  14. package/dist/containers/Storage/VDisk/VDisk.tsx +2 -2
  15. package/dist/containers/Storage/VDiskPopup/VDiskPopup.tsx +2 -2
  16. package/dist/containers/Tablet/Tablet.tsx +121 -0
  17. package/dist/containers/Tablet/TabletControls/TabletControls.tsx +159 -0
  18. package/dist/containers/Tablet/TabletControls/index.ts +1 -0
  19. package/dist/containers/Tablet/TabletInfo/TabletInfo.tsx +80 -0
  20. package/dist/containers/Tablet/TabletInfo/index.ts +1 -0
  21. package/dist/containers/Tablet/TabletTable/TabletTable.tsx +59 -0
  22. package/dist/containers/Tablet/TabletTable/index.ts +1 -0
  23. package/dist/containers/Tablet/i18n/en.json +10 -0
  24. package/dist/containers/Tablet/i18n/index.ts +11 -0
  25. package/dist/containers/Tablet/i18n/ru.json +10 -0
  26. package/dist/containers/Tablet/index.ts +1 -0
  27. package/dist/containers/Tenant/Diagnostics/Partitions/Partitions.tsx +9 -15
  28. package/dist/containers/Tenant/Diagnostics/Partitions/PartitionsWrapper.tsx +2 -0
  29. package/dist/store/reducers/storage.js +1 -0
  30. package/dist/store/reducers/topic.ts +13 -0
  31. package/dist/types/api/nodes.ts +1 -1
  32. package/dist/types/store/topic.ts +3 -2
  33. package/dist/utils/nodes.ts +7 -0
  34. package/dist/utils/storage.ts +1 -1
  35. package/package.json +5 -2
  36. package/dist/containers/Tablet/Tablet.js +0 -448
@@ -0,0 +1,159 @@
1
+ import {useEffect, useState} from 'react';
2
+ import {Button} from '@gravity-ui/uikit';
3
+
4
+ import {ETabletState, TTabletStateInfo} from '../../../types/api/tablet';
5
+ import {CriticalActionDialog} from '../../../components/CriticalActionDialog';
6
+
7
+ import i18n from '../i18n';
8
+ import {b} from '../Tablet';
9
+
10
+ enum EVisibleDialogType {
11
+ 'kill' = 'kill',
12
+ 'stop' = 'kill',
13
+ 'resume' = 'kill',
14
+ }
15
+
16
+ type VisibleDialogType = EVisibleDialogType | null;
17
+
18
+ interface TabletControlsProps {
19
+ tablet: TTabletStateInfo;
20
+ }
21
+
22
+ export const TabletControls = ({tablet}: TabletControlsProps) => {
23
+ const {TabletId, HiveId} = tablet;
24
+
25
+ const [isDialogVisible, setIsDialogVisible] = useState(false);
26
+ const [visibleDialogType, setVisibleDialogType] = useState<VisibleDialogType>(null);
27
+ const [isTabletActionsDisabled, setIsTabletActionsDisabled] = useState(false);
28
+
29
+ // Enable controls after data update
30
+ useEffect(() => {
31
+ setIsTabletActionsDisabled(false);
32
+ }, [tablet]);
33
+
34
+ const makeShowDialog = (type: VisibleDialogType) => () => {
35
+ setIsDialogVisible(true);
36
+ setVisibleDialogType(type);
37
+ };
38
+
39
+ const showKillDialog = makeShowDialog(EVisibleDialogType.kill);
40
+ const showStopDialog = makeShowDialog(EVisibleDialogType.stop);
41
+ const showResumeDialog = makeShowDialog(EVisibleDialogType.resume);
42
+
43
+ const hideDialog = () => {
44
+ setIsDialogVisible(false);
45
+ setVisibleDialogType(null);
46
+ };
47
+
48
+ const _onKillClick = () => {
49
+ setIsTabletActionsDisabled(true);
50
+ return window.api.killTablet(TabletId);
51
+ };
52
+ const _onStopClick = () => {
53
+ setIsTabletActionsDisabled(true);
54
+ return window.api.stopTablet(TabletId, HiveId);
55
+ };
56
+ const _onResumeClick = () => {
57
+ setIsTabletActionsDisabled(true);
58
+ return window.api.resumeTablet(TabletId, HiveId);
59
+ };
60
+
61
+ const hasHiveId = () => {
62
+ return HiveId && HiveId !== '0';
63
+ };
64
+
65
+ const isDisabledResume = () => {
66
+ if (isTabletActionsDisabled) {
67
+ return true;
68
+ }
69
+
70
+ return tablet.State !== ETabletState.Stopped && tablet.State !== ETabletState.Dead;
71
+ };
72
+
73
+ const isDisabledKill = () => {
74
+ return isTabletActionsDisabled;
75
+ };
76
+
77
+ const isDisabledStop = () => {
78
+ if (isTabletActionsDisabled) {
79
+ return true;
80
+ }
81
+
82
+ return tablet.State === ETabletState.Stopped || tablet.State === ETabletState.Deleted;
83
+ };
84
+
85
+ const renderDialog = () => {
86
+ if (!isDialogVisible) {
87
+ return null;
88
+ }
89
+
90
+ switch (visibleDialogType) {
91
+ case EVisibleDialogType.kill: {
92
+ return (
93
+ <CriticalActionDialog
94
+ visible={isDialogVisible}
95
+ text={i18n('dialog.kill')}
96
+ onClose={hideDialog}
97
+ onConfirm={_onKillClick}
98
+ />
99
+ );
100
+ }
101
+ case EVisibleDialogType.stop: {
102
+ return (
103
+ <CriticalActionDialog
104
+ visible={isDialogVisible}
105
+ text={i18n('dialog.stop')}
106
+ onClose={hideDialog}
107
+ onConfirm={_onStopClick}
108
+ />
109
+ );
110
+ }
111
+ case EVisibleDialogType.resume: {
112
+ return (
113
+ <CriticalActionDialog
114
+ visible={isDialogVisible}
115
+ text={i18n('dialog.resume')}
116
+ onClose={hideDialog}
117
+ onConfirm={_onResumeClick}
118
+ />
119
+ );
120
+ }
121
+ default:
122
+ return null;
123
+ }
124
+ };
125
+
126
+ return (
127
+ <div className={b('controls')}>
128
+ <Button
129
+ onClick={showKillDialog}
130
+ view="action"
131
+ disabled={isDisabledKill()}
132
+ className={b('control')}
133
+ >
134
+ {i18n('controls.kill')}
135
+ </Button>
136
+ {hasHiveId() ? (
137
+ <>
138
+ <Button
139
+ onClick={showStopDialog}
140
+ view="action"
141
+ disabled={isDisabledStop()}
142
+ className={b('control')}
143
+ >
144
+ {i18n('controls.stop')}
145
+ </Button>
146
+ <Button
147
+ onClick={showResumeDialog}
148
+ view="action"
149
+ disabled={isDisabledResume()}
150
+ className={b('control')}
151
+ >
152
+ {i18n('controls.resume')}
153
+ </Button>
154
+ </>
155
+ ) : null}
156
+ {renderDialog()}
157
+ </div>
158
+ );
159
+ };
@@ -0,0 +1 @@
1
+ export * from './TabletControls';
@@ -0,0 +1,80 @@
1
+ import {Link} from 'react-router-dom';
2
+
3
+ import {Link as UIKitLink} from '@gravity-ui/uikit';
4
+
5
+ import {ETabletState, TTabletStateInfo} from '../../../types/api/tablet';
6
+ import {InfoViewer, InfoViewerItem} from '../../../components/InfoViewer';
7
+ import routes, {createHref} from '../../../routes';
8
+ import {calcUptime} from '../../../utils';
9
+ import {getDefaultNodePath} from '../../Node/NodePages';
10
+
11
+ import {b} from '../Tablet';
12
+
13
+ interface TabletInfoProps {
14
+ tablet: TTabletStateInfo;
15
+ tenantPath: string;
16
+ }
17
+
18
+ export const TabletInfo = ({tablet, tenantPath}: TabletInfoProps) => {
19
+ const {
20
+ ChangeTime,
21
+ Generation,
22
+ FollowerId,
23
+ NodeId,
24
+ HiveId,
25
+ State,
26
+ Type,
27
+ TenantId: {SchemeShard} = {},
28
+ } = tablet;
29
+
30
+ const hasHiveId = HiveId && HiveId !== '0';
31
+ const hasUptime = State === ETabletState.Active;
32
+
33
+ const tabletInfo: InfoViewerItem[] = [{label: 'Database', value: tenantPath}];
34
+
35
+ if (hasHiveId) {
36
+ tabletInfo.push({
37
+ label: 'HiveId',
38
+ value: (
39
+ <UIKitLink href={createHref(routes.tablet, {id: HiveId})} target="_blank">
40
+ {HiveId}
41
+ </UIKitLink>
42
+ ),
43
+ });
44
+ }
45
+
46
+ if (SchemeShard) {
47
+ tabletInfo.push({
48
+ label: 'SchemeShard',
49
+ value: (
50
+ <UIKitLink href={createHref(routes.tablet, {id: SchemeShard})} target="_blank">
51
+ {SchemeShard}
52
+ </UIKitLink>
53
+ ),
54
+ });
55
+ }
56
+
57
+ tabletInfo.push({label: 'Type', value: Type}, {label: 'State', value: State});
58
+
59
+ if (hasUptime) {
60
+ tabletInfo.push({label: 'Uptime', value: calcUptime(ChangeTime)});
61
+ }
62
+
63
+ tabletInfo.push(
64
+ {label: 'Generation', value: Generation},
65
+ {
66
+ label: 'Node',
67
+ value: (
68
+ <Link className={b('link')} to={getDefaultNodePath(String(NodeId))}>
69
+ {NodeId}
70
+ </Link>
71
+ ),
72
+ },
73
+ );
74
+
75
+ if (FollowerId) {
76
+ tabletInfo.push({label: 'Follower', value: FollowerId});
77
+ }
78
+
79
+ return <InfoViewer info={tabletInfo} />;
80
+ };
@@ -0,0 +1 @@
1
+ export * from './TabletInfo';
@@ -0,0 +1,59 @@
1
+ import DataTable, {Column} from '@gravity-ui/react-data-table';
2
+
3
+ import type {ITabletPreparedHistoryItem} from '../../../types/store/tablet';
4
+ import {calcUptime} from '../../../utils';
5
+
6
+ const columns: Column<ITabletPreparedHistoryItem>[] = [
7
+ {
8
+ name: 'Generation',
9
+ align: DataTable.RIGHT,
10
+ render: ({row}) => row.generation,
11
+ },
12
+ {
13
+ name: 'Node ID',
14
+ align: DataTable.RIGHT,
15
+ sortable: false,
16
+ render: ({row}) => row.nodeId,
17
+ },
18
+ {
19
+ name: 'Change time',
20
+ align: DataTable.RIGHT,
21
+ sortable: false,
22
+ render: ({row}) => calcUptime(row.changeTime),
23
+ },
24
+ {
25
+ name: 'State',
26
+ sortable: false,
27
+ render: ({row}) => row.state,
28
+ },
29
+ {
30
+ name: 'Follower ID',
31
+ sortable: false,
32
+ render: ({row}) => {
33
+ return row.leader ? 'leader' : row.followerId;
34
+ },
35
+ },
36
+ ];
37
+
38
+ const TABLE_SETTINGS = {
39
+ displayIndices: false,
40
+ };
41
+
42
+ interface TabletTableProps {
43
+ history: ITabletPreparedHistoryItem[];
44
+ }
45
+
46
+ export const TabletTable = ({history}: TabletTableProps) => {
47
+ return (
48
+ <DataTable
49
+ theme="yandex-cloud"
50
+ data={history}
51
+ columns={columns}
52
+ settings={TABLE_SETTINGS}
53
+ initialSortOrder={{
54
+ columnId: 'Generation',
55
+ order: DataTable.DESCENDING,
56
+ }}
57
+ />
58
+ );
59
+ };
@@ -0,0 +1 @@
1
+ export * from './TabletTable';
@@ -0,0 +1,10 @@
1
+ {
2
+ "tablet.header": "Tablet",
3
+ "controls.kill": "Restart",
4
+ "controls.stop": "Stop",
5
+ "controls.resume": "Resume",
6
+ "dialog.kill": "The tablet will be restarted. Do you want to proceed?",
7
+ "dialog.stop": "The tablet will be stopped. Do you want to proceed?",
8
+ "dialog.resume": "The tablet will be resumed. Do you want to proceed?",
9
+ "emptyState": "The tablet was not found"
10
+ }
@@ -0,0 +1,11 @@
1
+ import {i18n, Lang} from '../../../utils/i18n';
2
+
3
+ import en from './en.json';
4
+ import ru from './ru.json';
5
+
6
+ const COMPONENT = 'ydb-tablet-page';
7
+
8
+ i18n.registerKeyset(Lang.En, COMPONENT, en);
9
+ i18n.registerKeyset(Lang.Ru, COMPONENT, ru);
10
+
11
+ export default i18n.keyset(COMPONENT);
@@ -0,0 +1,10 @@
1
+ {
2
+ "tablet.header": "Таблетка",
3
+ "controls.kill": "Перезапустить",
4
+ "controls.stop": "Остановить",
5
+ "controls.resume": "Запустить",
6
+ "dialog.kill": "Таблетка будет перезапущена. Вы хотите продолжить?",
7
+ "dialog.stop": "Таблетка будет остановлена. Вы хотите продолжить?",
8
+ "dialog.resume": "Таблетка будет запущена. Вы хотите продолжить?",
9
+ "emptyState": "Таблетка не найдена"
10
+ }
@@ -0,0 +1 @@
1
+ export * from './Tablet';
@@ -1,5 +1,5 @@
1
1
  import block from 'bem-cn-lite';
2
- import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2
+ import {useCallback, useEffect, useMemo, useState} from 'react';
3
3
  import {useDispatch} from 'react-redux';
4
4
  import {escapeRegExp} from 'lodash/fp';
5
5
 
@@ -53,8 +53,6 @@ export const Partitions = ({path, type, nodes, consumers}: PartitionsProps) => {
53
53
 
54
54
  const dispatch = useDispatch();
55
55
 
56
- const isFirstRenderRef = useRef(true);
57
-
58
56
  const [generalSearchValue, setGeneralSearchValue] = useState('');
59
57
  const [partitionIdSearchValue, setPartitionIdSearchValue] = useState('');
60
58
 
@@ -74,14 +72,6 @@ export const Partitions = ({path, type, nodes, consumers}: PartitionsProps) => {
74
72
  useEffect(() => {
75
73
  // Manual path control to ensure it updates with other values so no request with wrong params will be sent
76
74
  setComponentCurrentPath(path);
77
-
78
- // Do not reset selected consumer on first effect call
79
- // To enable navigating to specific consumer
80
- if (isFirstRenderRef.current) {
81
- isFirstRenderRef.current = false;
82
- } else {
83
- dispatch(setSelectedConsumer(undefined));
84
- }
85
75
  }, [dispatch, path]);
86
76
 
87
77
  const fetchConsumerData = useCallback(
@@ -90,11 +80,11 @@ export const Partitions = ({path, type, nodes, consumers}: PartitionsProps) => {
90
80
  dispatch(setDataWasNotLoaded());
91
81
  }
92
82
 
93
- if (selectedConsumer) {
83
+ if (selectedConsumer && consumers && consumers.includes(selectedConsumer)) {
94
84
  dispatch(getConsumer(componentCurrentPath, selectedConsumer));
95
85
  }
96
86
  },
97
- [dispatch, selectedConsumer, componentCurrentPath],
87
+ [dispatch, selectedConsumer, componentCurrentPath, consumers],
98
88
  );
99
89
 
100
90
  useAutofetcher(fetchConsumerData, [fetchConsumerData], autorefresh);
@@ -111,10 +101,13 @@ export const Partitions = ({path, type, nodes, consumers}: PartitionsProps) => {
111
101
  );
112
102
 
113
103
  useEffect(() => {
114
- if (consumersToSelect && consumersToSelect.length && !selectedConsumer) {
104
+ const shouldUpdateSelectedConsumer =
105
+ !selectedConsumer || (consumers && !consumers.includes(selectedConsumer));
106
+
107
+ if (consumersToSelect && consumersToSelect.length && shouldUpdateSelectedConsumer) {
115
108
  dispatch(setSelectedConsumer(consumersToSelect[0].value));
116
109
  }
117
- }, [dispatch, consumersToSelect, selectedConsumer]);
110
+ }, [dispatch, consumersToSelect, selectedConsumer, consumers]);
118
111
 
119
112
  const selectedColumns: string[] = useMemo(
120
113
  () =>
@@ -222,6 +215,7 @@ export const Partitions = ({path, type, nodes, consumers}: PartitionsProps) => {
222
215
  options={consumersToSelect}
223
216
  value={[selectedConsumer || '']}
224
217
  onUpdate={handleConsumerSelectChange}
218
+ filterable={consumers && consumers.length > 5}
225
219
  />
226
220
  <Search
227
221
  onChange={handlePartitionIdSearchChange}
@@ -6,6 +6,7 @@ import type {EPathType} from '../../../../types/api/schema';
6
6
  import {useTypedSelector} from '../../../../utils/hooks';
7
7
 
8
8
  import {
9
+ cleanTopicData,
9
10
  getTopic,
10
11
  setDataWasNotLoaded as setTopicDataWasNotLoaded,
11
12
  } from '../../../../store/reducers/topic';
@@ -61,6 +62,7 @@ export const PartitionsWrapper = ({path, type}: PartitionsWrapperProps) => {
61
62
 
62
63
  useEffect(() => {
63
64
  dispatch(setTopicDataWasNotLoaded());
65
+ dispatch(cleanTopicData());
64
66
  dispatch(setNodesDataWasNotLoaded());
65
67
 
66
68
  dispatch(getTopic(path));
@@ -313,6 +313,7 @@ export const getFlatListStorageNodes = createSelector([getStorageNodes], (storag
313
313
  }).length;
314
314
  return {
315
315
  NodeId: node.NodeId,
316
+ SystemState: systemState.SystemState,
316
317
  FQDN: systemState.Host,
317
318
  DataCenter: systemState.DataCenter,
318
319
  Rack: systemState.Rack,
@@ -18,6 +18,7 @@ import {convertBytesObjectToSpeed} from '../../utils/bytesParsers';
18
18
  export const FETCH_TOPIC = createRequestActionTypes('topic', 'FETCH_TOPIC');
19
19
 
20
20
  const SET_DATA_WAS_NOT_LOADED = 'topic/SET_DATA_WAS_NOT_LOADED';
21
+ const CLEAN_TOPIC_DATA = 'topic/CLEAN_TOPIC_DATA';
21
22
 
22
23
  const initialState = {
23
24
  loading: true,
@@ -64,6 +65,12 @@ const topic: Reducer<ITopicState, ITopicAction> = (state = initialState, action)
64
65
  wasLoaded: false,
65
66
  };
66
67
  }
68
+ case CLEAN_TOPIC_DATA: {
69
+ return {
70
+ ...state,
71
+ data: undefined,
72
+ };
73
+ }
67
74
  default:
68
75
  return state;
69
76
  }
@@ -75,6 +82,12 @@ export const setDataWasNotLoaded = () => {
75
82
  } as const;
76
83
  };
77
84
 
85
+ export const cleanTopicData = () => {
86
+ return {
87
+ type: CLEAN_TOPIC_DATA,
88
+ } as const;
89
+ };
90
+
78
91
  export function getTopic(path?: string) {
79
92
  return createApiRequest({
80
93
  request: window.api.getTopic({path}),
@@ -7,7 +7,7 @@ import {TVDiskStateInfo} from './vdisk';
7
7
  // source: https://github.com/ydb-platform/ydb/blob/main/ydb/core/viewer/protos/viewer.proto
8
8
 
9
9
  export interface TNodesInfo {
10
- Overall: EFlag;
10
+ Overall?: EFlag;
11
11
  Nodes?: TNodeInfo[];
12
12
 
13
13
  /** uint64 */
@@ -1,4 +1,4 @@
1
- import {FETCH_TOPIC, setDataWasNotLoaded} from '../../store/reducers/topic';
1
+ import {FETCH_TOPIC, cleanTopicData, setDataWasNotLoaded} from '../../store/reducers/topic';
2
2
  import type {ApiRequestAction} from '../../store/utils';
3
3
  import type {IProcessSpeedStats} from '../../utils/bytesParsers';
4
4
  import type {IResponseError} from '../api/error';
@@ -31,7 +31,8 @@ export interface ITopicState {
31
31
 
32
32
  export type ITopicAction =
33
33
  | ApiRequestAction<typeof FETCH_TOPIC, DescribeTopicResult, IResponseError>
34
- | ReturnType<typeof setDataWasNotLoaded>;
34
+ | ReturnType<typeof setDataWasNotLoaded>
35
+ | ReturnType<typeof cleanTopicData>;
35
36
 
36
37
  export interface ITopicRootStateSlice {
37
38
  topic: ITopicState;
@@ -1,3 +1,7 @@
1
+ import type {TSystemStateInfo} from '../types/api/nodes';
2
+ import type {INodesPreparedEntity} from '../types/store/nodes';
3
+ import {EFlag} from '../types/api/enums';
4
+
1
5
  export enum NodesUptimeFilterValues {
2
6
  'All' = 'All',
3
7
  'SmallUptime' = 'SmallUptime',
@@ -7,3 +11,6 @@ export const NodesUptimeFilterTitles = {
7
11
  [NodesUptimeFilterValues.All]: 'All',
8
12
  [NodesUptimeFilterValues.SmallUptime]: 'Uptime < 1h',
9
13
  };
14
+
15
+ export const isUnavailableNode = (node: INodesPreparedEntity | TSystemStateInfo) =>
16
+ !node.SystemState || node.SystemState === EFlag.Grey;
@@ -1,7 +1,7 @@
1
1
  import type {TVSlotId, TVDiskStateInfo} from '../types/api/vdisk';
2
2
  import type {IStoragePoolGroup} from '../types/store/storage';
3
3
 
4
- export const isFullVDiksData = (disk: TVDiskStateInfo | TVSlotId): disk is TVDiskStateInfo =>
4
+ export const isFullVDiskData = (disk: TVDiskStateInfo | TVSlotId): disk is TVDiskStateInfo =>
5
5
  'VDiskId' in disk;
6
6
 
7
7
  export const getUsage = (data: IStoragePoolGroup, step = 1) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ydb-embedded-ui",
3
- "version": "3.4.1",
3
+ "version": "3.4.3",
4
4
  "files": [
5
5
  "dist"
6
6
  ],
@@ -53,7 +53,9 @@
53
53
  "eject": "react-scripts eject",
54
54
  "prepublishOnly": "npm run package",
55
55
  "typecheck": "tsc --noEmit",
56
- "prepare": "husky install"
56
+ "prepare": "husky install",
57
+ "test:e2e:install": "npx playwright install --with-deps chromium",
58
+ "test:e2e": "npx playwright test --config=playwright.config.ts"
57
59
  },
58
60
  "lint-staged": {
59
61
  "*.{scss}": [
@@ -104,6 +106,7 @@
104
106
  "@gravity-ui/stylelint-config": "^1.0.1",
105
107
  "@gravity-ui/tsconfig": "^1.0.0",
106
108
  "@gravity-ui/uikit": "^3.20.2",
109
+ "@playwright/test": "^1.31.1",
107
110
  "@testing-library/jest-dom": "^5.15.0",
108
111
  "@testing-library/react": "^11.2.7",
109
112
  "@testing-library/user-event": "^12.8.3",