Merge pull request #13837 from Cylix/github-team-pull-requests

plugin-github-pull-requests-board: Add a new "Team" Filter Options to the Github Pull Requests Dashboard.
This commit is contained in:
Fredrik Adelöw
2022-10-20 10:35:07 +01:00
committed by GitHub
14 changed files with 426 additions and 99 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-github-pull-requests-board': patch
---
Add a new "Team" Filter Options to the Github Pull Requests Dashboard.
When toggling this option on, the dashboard will displays all of the PRs opened
by the members of that team on any repositories of the organization.
@@ -45,6 +45,7 @@
"@material-ui/lab": "4.0.0-alpha.57",
"@octokit/rest": "^19.0.3",
"luxon": "^3.0.0",
"p-limit": "^4.0.0",
"react-use": "^17.2.4"
},
"devDependencies": {
@@ -33,6 +33,9 @@ export const useGetPullRequestDetails = () => {
id
repository {
name
owner {
login
}
}
title
url
@@ -33,7 +33,7 @@ export const useGetPullRequestsFromRepository = () => {
const limit = pullRequestLimit ?? PULL_REQUEST_LIMIT;
const [organisation, repositoryName] = repo.split('/');
return await getPullRequestEdges(
return await getPullRequestNodes(
graphql,
repositoryName,
organisation,
@@ -45,7 +45,7 @@ export const useGetPullRequestsFromRepository = () => {
return fn.current;
};
async function getPullRequestEdges(
async function getPullRequestNodes(
graphql: (
path: string,
options?: any,
@@ -54,7 +54,7 @@ async function getPullRequestEdges(
organisation: string,
pullRequestLimit: number,
): Promise<PullRequestsNumber[]> {
const pullRequestEdges: PullRequestsNumber[] = [];
const pullRequestNodes: PullRequestsNumber[] = [];
let result: GraphQlPullRequests<PullRequestsNumber[]> | undefined = undefined;
do {
@@ -68,10 +68,8 @@ async function getPullRequestEdges(
) {
repository(name: $name, owner: $owner) {
pullRequests(states: OPEN, first: $first, after: $endCursor) {
edges {
node {
number
}
nodes {
number
}
pageInfo {
hasNextPage
@@ -94,10 +92,10 @@ async function getPullRequestEdges(
},
);
pullRequestEdges.push(...result.repository.pullRequests.edges);
pullRequestNodes.push(...result.repository.pullRequests.nodes);
if (pullRequestEdges.length >= pullRequestLimit) return pullRequestEdges;
if (pullRequestNodes.length >= pullRequestLimit) return pullRequestNodes;
} while (result.repository.pullRequests.pageInfo.hasNextPage);
return pullRequestEdges;
return pullRequestNodes;
}
@@ -0,0 +1,108 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
GraphQlUserPullRequests,
PullRequestsNumberAndOwner,
} from '../utils/types';
import { useOctokitGraphQl } from './useOctokitGraphQl';
const PULL_REQUEST_LIMIT = 10;
const GITHUB_GRAPHQL_MAX_ITEMS = 100;
export const useGetPullRequestsFromUser = () => {
const graphql =
useOctokitGraphQl<GraphQlUserPullRequests<PullRequestsNumberAndOwner[]>>();
const fn = React.useRef(
async (
userLogin: string,
organization?: string,
pullRequestLimit?: number,
): Promise<PullRequestsNumberAndOwner[]> => {
const limit = pullRequestLimit ?? PULL_REQUEST_LIMIT;
return await getPullRequestNodes(graphql, userLogin, limit, organization);
},
);
return fn.current;
};
async function getPullRequestNodes(
graphql: (
path: string,
options?: any,
) => Promise<GraphQlUserPullRequests<PullRequestsNumberAndOwner[]>>,
userLogin: string,
pullRequestLimit: number,
organization?: string,
): Promise<PullRequestsNumberAndOwner[]> {
const pullRequestNodes: PullRequestsNumberAndOwner[] = [];
let result:
| GraphQlUserPullRequests<PullRequestsNumberAndOwner[]>
| undefined = undefined;
do {
result = await graphql(
`
query ($login: String!, $first: Int, $endCursor: String) {
user(login: $login) {
pullRequests(states: OPEN, first: $first, after: $endCursor) {
nodes {
number
repository {
name
owner {
login
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`,
{
login: userLogin,
first:
pullRequestLimit > GITHUB_GRAPHQL_MAX_ITEMS
? GITHUB_GRAPHQL_MAX_ITEMS
: pullRequestLimit,
endCursor: result
? result.user.pullRequests.pageInfo.endCursor
: undefined,
},
);
pullRequestNodes.push(
...result.user.pullRequests.nodes.filter(
edge =>
!organization ||
edge?.repository?.owner?.login?.toLocaleLowerCase('en-US') ===
organization.toLocaleLowerCase('en-US'),
),
);
if (pullRequestNodes.length >= pullRequestLimit) return pullRequestNodes;
} while (result.user.pullRequests.pageInfo.hasNextPage);
return pullRequestNodes;
}
@@ -16,6 +16,7 @@
import React, { useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import FullscreenIcon from '@material-ui/icons/Fullscreen';
import PeopleIcon from '@material-ui/icons/People';
import { Progress, InfoCard } from '@backstage/core-components';
@@ -25,8 +26,9 @@ import { Wrapper } from '../Wrapper';
import { PullRequestCard } from '../PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { shouldDisplayCard } from '../../utils/functions';
import { DraftPrIcon } from '../icons/DraftPr';
import { useUserRepositories } from '../../hooks/useUserRepositories';
import { useUserRepositoriesAndTeam } from '../../hooks/useUserRepositoriesAndTeam';
/** @public */
export interface EntityTeamPullRequestsCardProps {
@@ -36,9 +38,20 @@ export interface EntityTeamPullRequestsCardProps {
const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
const { pullRequestLimit } = props;
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(
const {
loading: loadingReposAndTeam,
repositories,
teamMembers,
teamMembersOrganization,
} = useUserRepositoriesAndTeam();
const {
loading: loadingPRs,
pullRequests,
refreshPullRequests,
} = usePullRequestsByTeam(
repositories,
teamMembers,
teamMembersOrganization,
pullRequestLimit,
);
@@ -48,6 +61,11 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <PeopleIcon />,
value: 'team',
ariaLabel: 'Show PRs from your team',
},
{
icon: <DraftPrIcon />,
value: 'draft',
@@ -56,7 +74,7 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
{
icon: <FullscreenIcon />,
value: 'fullscreen',
ariaLabel: 'Info card is set to fullscreen',
ariaLabel: 'Set card to fullscreen',
},
]}
/>
@@ -64,7 +82,7 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
);
const getContent = () => {
if (loading) {
if (loadingReposAndTeam || loadingPRs) {
return <Progress />;
}
@@ -92,7 +110,14 @@ const EntityTeamPullRequestsCard = (props: EntityTeamPullRequestsCardProps) => {
},
index,
) =>
infoCardFormat.includes('draft') === isDraft && (
shouldDisplayCard(
repository,
author,
repositories,
teamMembers,
infoCardFormat,
isDraft,
) && (
<PullRequestCard
key={`pull-request-${id}-${index}`}
title={title}
@@ -15,6 +15,7 @@
*/
import React, { useState } from 'react';
import { Grid, Typography } from '@material-ui/core';
import PeopleIcon from '@material-ui/icons/People';
import { Progress, InfoCard } from '@backstage/core-components';
import { InfoCardHeader } from '../InfoCardHeader';
@@ -23,8 +24,9 @@ import { Wrapper } from '../Wrapper';
import { PullRequestCard } from '../PullRequestCard';
import { usePullRequestsByTeam } from '../../hooks/usePullRequestsByTeam';
import { PRCardFormating } from '../../utils/types';
import { shouldDisplayCard } from '../../utils/functions';
import { DraftPrIcon } from '../icons/DraftPr';
import { useUserRepositories } from '../../hooks/useUserRepositories';
import { useUserRepositoriesAndTeam } from '../../hooks/useUserRepositoriesAndTeam';
/** @public */
export interface EntityTeamPullRequestsContentProps {
@@ -36,9 +38,20 @@ const EntityTeamPullRequestsContent = (
) => {
const { pullRequestLimit } = props;
const [infoCardFormat, setInfoCardFormat] = useState<PRCardFormating[]>([]);
const { repositories } = useUserRepositories();
const { loading, pullRequests, refreshPullRequests } = usePullRequestsByTeam(
const {
loading: loadingReposAndTeam,
repositories,
teamMembers,
teamMembersOrganization,
} = useUserRepositoriesAndTeam();
const {
loading: loadingPRs,
pullRequests,
refreshPullRequests,
} = usePullRequestsByTeam(
repositories,
teamMembers,
teamMembersOrganization,
pullRequestLimit,
);
@@ -48,6 +61,11 @@ const EntityTeamPullRequestsContent = (
onClickOption={newFormats => setInfoCardFormat(newFormats)}
value={infoCardFormat}
options={[
{
icon: <PeopleIcon />,
value: 'team',
ariaLabel: 'Show PRs from your team',
},
{
icon: <DraftPrIcon />,
value: 'draft',
@@ -59,7 +77,7 @@ const EntityTeamPullRequestsContent = (
);
const getContent = () => {
if (loading) {
if (loadingReposAndTeam || loadingPRs) {
return <Progress />;
}
@@ -84,7 +102,14 @@ const EntityTeamPullRequestsContent = (
},
index,
) =>
infoCardFormat.includes('draft') === isDraft && (
shouldDisplayCard(
repository,
author,
repositories,
teamMembers,
infoCardFormat,
isDraft,
) && (
<PullRequestCard
key={`pull-request-${id}-${index}`}
title={title}
@@ -24,8 +24,16 @@ const DraftPr: FunctionComponent = () => (
width="16"
data-view-component="true"
>
<path d="M2.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.25 1a2.25 2.25 0 00-.75 4.372v5.256a2.251 2.251 0 101.5 0V5.372A2.25 2.25 0 003.25 1zm0 11a.75.75 0 100 1.5.75.75 0 000-1.5zm9.5 3a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5zm0-3a.75.75 0 100 1.5.75.75 0 000-1.5z" />
<path d="M14 7.5a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0zm0-4.25a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0z" />
<g color="rgba(0, 0, 0, 0.38)">
<path
fill="currentcolor"
d="M2.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.25 1a2.25 2.25 0 00-.75 4.372v5.256a2.251 2.251 0 101.5 0V5.372A2.25 2.25 0 003.25 1zm0 11a.75.75 0 100 1.5.75.75 0 000-1.5zm9.5 3a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5zm0-3a.75.75 0 100 1.5.75.75 0 000-1.5z"
/>
<path
fill="currentcolor"
d="M14 7.5a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0zm0-4.25a1.25 1.25 0 11-2.5 0 1.25 1.25 0 012.5 0z"
/>
</g>
</svg>
);
@@ -13,48 +13,93 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import pLimit from 'p-limit';
import { useCallback, useEffect, useState } from 'react';
import { formatPRsByReviewDecision } from '../utils/functions';
import { PullRequests, PullRequestsColumn } from '../utils/types';
import { useGetPullRequestsFromRepository } from '../api/useGetPullRequestsFromRepository';
import { useGetPullRequestsFromUser } from '../api/useGetPullRequestsFromUser';
import { useGetPullRequestDetails } from '../api/useGetPullRequestDetails';
export function usePullRequestsByTeam(
repositories: string[],
members: string[],
organization?: string,
pullRequestLimit?: number,
) {
const [pullRequests, setPullRequests] = useState<PullRequestsColumn[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const getPullRequests = useGetPullRequestsFromRepository();
const getPullRequestsFromRepository = useGetPullRequestsFromRepository();
const getPullRequestsFromUser = useGetPullRequestsFromUser();
const getPullRequestDetails = useGetPullRequestDetails();
const getPRsPerRepository = useCallback(
async (repository: string): Promise<PullRequests> => {
const pullRequestsNumbers = await getPullRequests(
const concurrencyLimit = pLimit(5);
const pullRequestsNumbers = await getPullRequestsFromRepository(
repository,
pullRequestLimit,
);
const pullRequestsWithDetails = await Promise.all(
pullRequestsNumbers.map(({ node }) =>
getPullRequestDetails(repository, node.number),
pullRequestsNumbers.map(node =>
concurrencyLimit(() =>
getPullRequestDetails(repository, node.number),
),
),
);
return pullRequestsWithDetails;
},
[getPullRequests, getPullRequestDetails, pullRequestLimit],
[getPullRequestsFromRepository, getPullRequestDetails, pullRequestLimit],
);
const getPRsPerTeamMember = useCallback(
async (
teamMember: string,
teamOrganization?: string,
): Promise<PullRequests> => {
const concurrencyLimit = pLimit(3);
const pullRequestsNumbers = await getPullRequestsFromUser(
teamMember,
teamOrganization,
pullRequestLimit,
);
const pullRequestsWithDetails = await Promise.all(
pullRequestsNumbers.map(node =>
concurrencyLimit(() =>
getPullRequestDetails(
`${node.repository.owner.login}/${node.repository.name}`,
node.number,
),
),
),
);
return pullRequestsWithDetails;
},
[getPullRequestsFromUser, getPullRequestDetails, pullRequestLimit],
);
const getPRsFromTeam = useCallback(
async (teamRepositories: string[]): Promise<PullRequests> => {
async (
teamRepositories: string[],
teamMembers: string[],
teamOrganization?: string,
): Promise<PullRequests> => {
const teamRepositoriesPromises = teamRepositories.map(repository =>
getPRsPerRepository(repository),
);
const teamPullRequests = await Promise.allSettled(
teamRepositoriesPromises,
).then(promises =>
const teamMembersPromises = teamMembers.map(teamMember =>
getPRsPerTeamMember(teamMember, teamOrganization),
);
const teamPullRequests = await Promise.allSettled([
...teamRepositoriesPromises,
...teamMembersPromises,
]).then(promises =>
promises.reduce((acc, curr) => {
if (curr.status === 'fulfilled') {
return [...acc, ...curr.value];
@@ -63,18 +108,26 @@ export function usePullRequestsByTeam(
}, [] as PullRequests),
);
return teamPullRequests;
const uniqueTeamPullRequests = teamPullRequests.filter(
(lhs, i) => teamPullRequests.findIndex(rhs => lhs.id === rhs.id) === i,
);
return uniqueTeamPullRequests;
},
[getPRsPerRepository],
[getPRsPerRepository, getPRsPerTeamMember],
);
const getAllPullRequests = useCallback(async () => {
setLoading(true);
const teamPullRequests = await getPRsFromTeam(repositories);
const teamPullRequests = await getPRsFromTeam(
repositories,
members,
organization,
);
setPullRequests(formatPRsByReviewDecision(teamPullRequests));
setLoading(false);
}, [getPRsFromTeam, repositories]);
}, [getPRsFromTeam, repositories, members, organization]);
useEffect(() => {
getAllPullRequests();
@@ -1,47 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef, useEntity } from '@backstage/plugin-catalog-react';
import { useCallback, useEffect, useState } from 'react';
import { getProjectNameFromEntity } from '../utils/functions';
export function useUserRepositories() {
const { entity: teamEntity } = useEntity();
const catalogApi = useApi(catalogApiRef);
const [repositories, setRepositories] = useState<string[]>([]);
const getRepositoriesNames = useCallback(async () => {
const entitiesList = await catalogApi.getEntities({
filter: { 'relations.ownedBy': stringifyEntityRef(teamEntity) },
});
const entitiesNames: string[] = entitiesList.items.map(componentEntity =>
getProjectNameFromEntity(componentEntity),
);
setRepositories([...new Set(entitiesNames)]);
}, [catalogApi, teamEntity]);
useEffect(() => {
getRepositoriesNames();
}, [getRepositoriesNames]);
return {
repositories,
};
}
@@ -0,0 +1,81 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef, useEntity } from '@backstage/plugin-catalog-react';
import { useCallback, useEffect, useState } from 'react';
import {
getProjectNameFromEntity,
getGithubOrganizationFromEntity,
getUserNameFromEntity,
} from '../utils/functions';
export function useUserRepositoriesAndTeam() {
const { entity: teamEntity } = useEntity();
const catalogApi = useApi(catalogApiRef);
const [loading, setLoading] = useState<boolean>(true);
const [teamData, setTeamData] = useState<{
repositories: string[];
teamMembers: string[];
}>({
repositories: [],
teamMembers: [],
});
const getTeamData = useCallback(async () => {
setLoading(true);
// get team repositories and members
const entitiesList = await catalogApi.getEntities({
filter: [
{ 'relations.ownedBy': stringifyEntityRef(teamEntity) },
{ 'relations.memberOf': stringifyEntityRef(teamEntity) },
],
});
const repositories = entitiesList.items.filter(
entity => entity.kind === 'Component',
);
const repositoriesNames: string[] = repositories
.map(componentEntity => getProjectNameFromEntity(componentEntity) ?? '')
.filter(projectName => !!projectName);
const teamMembers = entitiesList.items.filter(
entity => entity.kind === 'User',
);
const teamMembersNames: string[] = teamMembers
.map(componentEntity => getUserNameFromEntity(componentEntity) ?? '')
.filter(userName => !!userName);
setTeamData({
repositories: [...new Set(repositoriesNames)],
teamMembers: [...new Set(teamMembersNames)],
});
setLoading(false);
}, [catalogApi, teamEntity]);
useEffect(() => {
getTeamData();
}, [getTeamData]);
return {
loading,
repositories: teamData.repositories,
teamMembers: teamData.teamMembers,
teamMembersOrganization: getGithubOrganizationFromEntity(teamEntity),
};
}
@@ -21,13 +21,29 @@ import {
ReviewDecision,
PullRequestsColumn,
Author,
PRCardFormating,
Repository,
} from './types';
import { COLUMNS } from './constants';
const GITHUB_PULL_REQUESTS_ANNOTATION = 'github.com/project-slug';
const GITHUB_USER_LOGIN_ANNOTATION = 'github.com/user-login';
export const getProjectNameFromEntity = (entity: Entity): string => {
return entity?.metadata.annotations?.[GITHUB_PULL_REQUESTS_ANNOTATION] ?? '';
export const getProjectNameFromEntity = (
entity: Entity,
): string | undefined => {
return entity?.metadata.annotations?.[GITHUB_PULL_REQUESTS_ANNOTATION];
};
export const getUserNameFromEntity = (entity: Entity): string | undefined => {
return entity?.metadata.annotations?.[GITHUB_USER_LOGIN_ANNOTATION];
};
export const getGithubOrganizationFromEntity = (entity: Entity): string => {
return (
entity?.metadata?.annotations?.['github.com/team-slug']?.split('/')?.[0] ??
''
);
};
export const getApprovedReviews = (reviews: Reviews = []): Reviews => {
@@ -107,3 +123,32 @@ export const formatPRsByReviewDecision = (
{ title: COLUMNS.APPROVED, content: reviewDecisions.APPROVED },
];
};
export const shouldDisplayCard = (
repository: Repository,
author: Author,
teamRepositories: string[],
teamMembers: string[],
infoCardFormat: PRCardFormating[],
isDraft: boolean,
) => {
// hide draft PRs unless "draft" filter is toggled
if (infoCardFormat.includes('draft') !== isDraft) {
return false;
}
// when "team" filter is toggled on, only shows PR from team members
if (infoCardFormat.includes('team')) {
return teamMembers.includes(author.login);
}
const fullRepoName =
`${repository.owner.login}/${repository.name}`.toLocaleLowerCase('en-US');
const repositories = teamRepositories.map(repo =>
repo.toLocaleLowerCase('en-US'),
);
// when "team" filter is toggled off, only shows PR on team repos
return repositories.includes(fullRepoName);
};
@@ -19,22 +19,33 @@ export type GraphQlPullRequest<T> = {
};
};
export type Connection<T> = {
nodes: T;
pageInfo: {
hasNextPage: boolean;
endCursor?: string;
};
};
export type GraphQlPullRequests<T> = {
repository: {
pullRequests: {
edges: T;
pageInfo: {
hasNextPage: boolean;
endCursor?: string;
};
};
pullRequests: Connection<T>;
};
};
export type GraphQlUserPullRequests<T> = {
user: {
pullRequests: Connection<T>;
};
};
export type PullRequestsNumber = {
node: {
number: number;
};
number: number;
};
export type PullRequestsNumberAndOwner = {
number: number;
repository: Repository;
};
export type Review = {
@@ -57,11 +68,16 @@ export type Author = {
name: string;
};
export type Repository = {
name: string;
owner: {
login: string;
};
};
export type PullRequest = {
id: string;
repository: {
name: string;
};
repository: Repository;
title: string;
url: string;
lastEditedAt: string;
@@ -83,6 +99,8 @@ export type PullRequestsColumn = {
content: PullRequests;
};
export type PRCardFormating = 'compacted' | 'fullscreen' | 'draft';
export type PRCardFormating = 'compacted' | 'fullscreen' | 'draft' | 'team';
export type ReviewDecision = 'IN_PROGRESS' | 'APPROVED' | 'REVIEW_REQUIRED';
export type PullRequestsSourceType = 'TeamRepositories' | 'TeamMembers';
+1
View File
@@ -5665,6 +5665,7 @@ __metadata:
cross-fetch: ^3.1.5
luxon: ^3.0.0
msw: ^0.47.0
p-limit: ^4.0.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0