1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

Feat/user profile (#274)

* chore: update changelog

* feat: user profile

* feat: onOutsideClick hook

* feat: tune user profile

* fix: refactor to button

* feat: mobile view

* fix: update tests

* feat: add link to docs
This commit is contained in:
Fredrik Strand Oseberg 2021-04-23 13:49:42 +02:00 committed by GitHub
parent 99c4b7e36d
commit 05334337c2
19 changed files with 570 additions and 130 deletions

View File

@ -466,4 +466,4 @@ The latest version of this document is always available in
## [2.1.0] - 2017-01-20
- Adjust header #51 #52
- Adjust header #51 #52

View File

@ -62,6 +62,7 @@
"react-dnd": "^14.0.2",
"react-dnd-html5-backend": "^14.0.0",
"react-dom": "^17.0.2",
"react-outside-click-handler": "^1.3.0",
"react-redux": "^7.2.3",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.3",
@ -71,8 +72,8 @@
"redux-mock-store": "^1.5.4",
"redux-thunk": "^2.3.0",
"sass": "^1.32.8",
"typescript": "^4.2.3",
"swr": "^0.5.5",
"typescript": "^4.2.3",
"web-vitals": "^1.0.1"
},
"jest": {

View File

@ -20,11 +20,13 @@ export const useCommonStyles = makeStyles(theme => ({
margin: '1rem 0',
backgroundColor: theme.palette.division.main,
height: '3px',
width: '100%',
},
largeDivider: {
margin: '2rem 0',
backgroundColor: theme.palette.division.main,
height: '3px',
width: '100%',
},
bold: {
fontWeight: 'bold',
@ -37,6 +39,12 @@ export const useCommonStyles = makeStyles(theme => ({
display: 'flex',
flexDirection: 'column',
},
itemsCenter: {
alignItems: 'center',
},
justifyCenter: {
justifyContent: 'center',
},
flexWrap: {
flexWrap: 'wrap',
},

View File

@ -473,10 +473,10 @@ exports[`renders correctly with with variants 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-16 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-18 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-18 PrivateNotchedOutline-legendNotched-19"
className="PrivateNotchedOutline-legendLabelled-20 PrivateNotchedOutline-legendNotched-21"
>
<span>
Stickiness

View File

@ -140,10 +140,10 @@ exports[`renders correctly with one feature 1`] = `
</svg>
<fieldset
aria-hidden={true}
className="PrivateNotchedOutline-root-14 MuiOutlinedInput-notchedOutline"
className="PrivateNotchedOutline-root-16 MuiOutlinedInput-notchedOutline"
>
<legend
className="PrivateNotchedOutline-legendLabelled-16"
className="PrivateNotchedOutline-legendLabelled-18"
>
<span>
Project
@ -175,7 +175,7 @@ exports[`renders correctly with one feature 1`] = `
>
<span
aria-disabled={false}
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-18 MuiSwitch-switchBase MuiSwitch-colorSecondary"
className="MuiButtonBase-root MuiIconButton-root PrivateSwitchBase-root-20 MuiSwitch-switchBase MuiSwitch-colorSecondary"
onBlur={[Function]}
onDragLeave={[Function]}
onFocus={[Function]}
@ -194,7 +194,7 @@ exports[`renders correctly with one feature 1`] = `
>
<input
checked={false}
className="PrivateSwitchBase-input-21 MuiSwitch-input"
className="PrivateSwitchBase-input-23 MuiSwitch-input"
disabled={false}
onChange={[Function]}
type="checkbox"
@ -318,7 +318,7 @@ exports[`renders correctly with one feature 1`] = `
</div>
<hr />
<div
className="MuiPaper-root makeStyles-tabNav-22 MuiPaper-elevation1 MuiPaper-rounded"
className="MuiPaper-root makeStyles-tabNav-24 MuiPaper-elevation1 MuiPaper-rounded"
>
<div
className="MuiTabs-root"
@ -366,7 +366,7 @@ exports[`renders correctly with one feature 1`] = `
Activation
</span>
<span
className="PrivateTabIndicator-root-23 PrivateTabIndicator-colorPrimary-24 MuiTabs-indicator"
className="PrivateTabIndicator-root-25 PrivateTabIndicator-colorPrimary-26 MuiTabs-indicator"
style={Object {}}
/>
</button>

View File

@ -3,12 +3,19 @@ import PropTypes from 'prop-types';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import { useTheme } from '@material-ui/core/styles';
import { Route } from 'react-router-dom';
import { AppBar, Container, Typography, IconButton } from '@material-ui/core';
import {
AppBar,
Container,
Typography,
IconButton,
Tooltip,
} from '@material-ui/core';
import { DrawerMenu } from '../drawer';
import MenuIcon from '@material-ui/icons/Menu';
import Breadcrumb from '../breadcrumb';
import ShowUserContainer from '../../user/show-user-container';
import UserProfile from '../../user/UserProfile';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import HelpIcon from '@material-ui/icons/Help';
import { useStyles } from './styles';
@ -31,20 +38,36 @@ const Header = ({ uiConfig, init }) => {
<React.Fragment>
<AppBar className={styles.header} position="static">
<Container className={styles.container}>
<IconButton className={styles.drawerButton} onClick={toggleDrawer}>
<IconButton
className={styles.drawerButton}
onClick={toggleDrawer}
>
<MenuIcon />
</IconButton>
<ConditionallyRender
condition={!smallScreen}
show={
<Typography variant="h1" className={styles.headerTitle}>
<Typography
variant="h1"
className={styles.headerTitle}
>
<Route path="/:path" component={Breadcrumb} />
</Typography>
}
/>
<div className={styles.userContainer}>
<ShowUserContainer />
<Tooltip title="Go to the documentation">
<a
href="https://docs.getunleash.io/"
target="_blank"
rel="noopener noreferrer"
className={styles.docsLink}
>
<HelpIcon className={styles.docsIcon} />
</a>
</Tooltip>
<UserProfile />
</div>
<DrawerMenu
links={links}

View File

@ -23,5 +23,18 @@ export const useStyles = makeStyles(theme => ({
},
userContainer: {
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
},
docsLink: {
color: '#fff',
textDecoration: 'none',
padding: '0.25rem 0.8rem',
display: 'flex',
alignItems: 'center',
},
docsIcon: {
height: '25px',
width: '25px',
},
}));

View File

@ -0,0 +1,21 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
width: '100%',
transform: 'translateY(-30px)',
},
form: {
width: '100%',
'& > *': {
width: '100%',
},
},
button: {
width: '150px',
marginTop: '1.15rem',
[theme.breakpoints.down('sm')]: {
width: '100px',
},
},
}));

View File

@ -0,0 +1,163 @@
import { SyntheticEvent, useState } from 'react';
import { Button, TextField, Typography } from '@material-ui/core';
import classnames from 'classnames';
import { useStyles } from './EditProfile.styles';
import { useCommonStyles } from '../../../../common.styles';
import PasswordChecker from '../../common/ResetPasswordForm/PasswordChecker/PasswordChecker';
import PasswordMatcher from '../../common/ResetPasswordForm/PasswordMatcher/PasswordMatcher';
import { headers } from '../../../../store/api-helper';
import { Alert } from '@material-ui/lab';
import ConditionallyRender from '../../../common/ConditionallyRender';
import useLoading from '../../../../hooks/useLoading';
import {
BAD_REQUEST,
NOT_FOUND,
OK,
UNAUTHORIZED,
} from '../../../../constants/statusCodes';
interface IEditProfileProps {
setEditingProfile: React.Dispatch<React.SetStateAction<boolean>>;
setUpdatedPassword: React.Dispatch<React.SetStateAction<boolean>>;
}
const EditProfile = ({
setEditingProfile,
setUpdatedPassword,
}: IEditProfileProps) => {
const styles = useStyles();
const commonStyles = useCommonStyles();
const [loading, setLoading] = useState(false);
const [validPassword, setValidPassword] = useState(false);
const [error, setError] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const ref = useLoading(loading);
const submit = async (e: SyntheticEvent) => {
e.preventDefault();
if (!validPassword || password !== confirmPassword) {
setError(
'Password is not valid, or your passwords do not match. Please provide a password with length over 10 characters, an uppercase letter, a lowercase letter, a number and a symbol.'
);
} else {
setLoading(true);
setError('');
try {
const res = await fetch('api/admin/user/change-password', {
headers,
body: JSON.stringify({ password, confirmPassword }),
method: 'POST',
credentials: 'include',
});
handleResponse(res);
} catch (e) {
setError(e);
}
}
setLoading(false);
};
const handleResponse = (res: Response) => {
if (res.status === BAD_REQUEST) {
setError(
'Password could not be accepted. Please make sure you are inputting a valid password.'
);
}
if (res.status === UNAUTHORIZED) {
setError('You are not authorized to make this request.');
}
if (res.status === NOT_FOUND) {
setError(
'The resource you requested could not be found on the server.'
);
}
if (res.status === OK) {
setEditingProfile(false);
setUpdatedPassword(true);
}
};
return (
<div className={styles.container} ref={ref}>
<Typography
variant="body1"
style={{ fontWeight: 'bold' }}
data-loading
>
Update password
</Typography>
<ConditionallyRender
condition={Boolean(error)}
show={
<Alert data-loading severity="error">
{error}
</Alert>
}
/>
<form
className={classnames(
styles.form,
commonStyles.contentSpacingY
)}
>
<PasswordChecker
password={password}
callback={setValidPassword}
data-loading
/>
<TextField
data-loading
variant="outlined"
size="small"
label="Password"
type="password"
name="password"
value={password}
autoComplete="on"
onChange={e => setPassword(e.target.value)}
/>
<TextField
data-loading
variant="outlined"
size="small"
label="Confirm password"
type="password"
name="confirmPassword"
value={confirmPassword}
autoComplete="on"
onChange={e => setConfirmPassword(e.target.value)}
/>
<PasswordMatcher
data-loading
started={Boolean(password && confirmPassword)}
matchingPasswords={password === confirmPassword}
/>
<Button
data-loading
variant="contained"
color="primary"
className={styles.button}
type="submit"
onClick={submit}
>
Save
</Button>
<Button
data-loading
className={styles.button}
type="submit"
onClick={() => setEditingProfile(false)}
>
Cancel
</Button>
</form>
</div>
);
};
export default EditProfile;

View File

@ -0,0 +1,93 @@
import { useEffect, useState } from 'react';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import OutsideClickHandler from 'react-outside-click-handler';
import { Avatar, Button } from '@material-ui/core';
import KeyboardArrowDownIcon from '@material-ui/icons/KeyboardArrowDown';
import { useStyles } from './UserProfile.styles';
import { useCommonStyles } from '../../../common.styles';
import UserProfileContent from './UserProfileContent/UserProfileContent';
const UserProfile = ({
profile,
location,
fetchUser,
updateSettingLocation,
logoutUser,
}) => {
const [showProfile, setShowProfile] = useState(false);
const styles = useStyles();
const commonStyles = useCommonStyles();
const [possibleLocales, setPossibleLocales] = useState([
{ value: 'en-US', image: 'en-US' },
{ value: 'en-GB', image: 'en-GB' },
{ value: 'nb-NO', image: 'nb-NO' },
{ value: 'sv-SE', image: 'sv-SE' },
{ value: 'da-DK', image: 'da-DK' },
{ value: 'en-IN', image: 'en-IN' },
{ value: 'de', image: 'de_DE' },
{ value: 'cs', image: 'cs_CZ' },
{ value: 'pt-BR', image: 'pt_BR' },
{ value: 'fr-FR', image: 'fr-FR' },
]);
useEffect(() => {
fetchUser();
const locale = navigator.language || navigator.userLanguage;
let found = possibleLocales.find(l => l.value === locale);
if (!found) {
setPossibleLocales(prev => ({
...prev,
value: locale,
image: 'unknown-locale',
}));
}
/* eslint-disable-next-line*/
}, []);
const email = profile ? profile.email : '';
const imageUrl = email ? profile.imageUrl : 'unknown-user.png';
return (
<OutsideClickHandler onOutsideClick={() => setShowProfile(false)}>
<div className={styles.profileContainer}>
<Button
className={classnames(
commonStyles.flexRow,
commonStyles.itemsCenter,
styles.button
)}
onClick={() => setShowProfile(prev => !prev)}
tabIndex="1"
role="button"
disableRipple
>
<Avatar alt="user image" src={imageUrl} />
<KeyboardArrowDownIcon />
</Button>
<UserProfileContent
showProfile={showProfile}
imageUrl={imageUrl}
profile={profile}
updateSettingLocation={updateSettingLocation}
possibleLocales={possibleLocales}
logoutUser={logoutUser}
location={location}
/>
</div>
</OutsideClickHandler>
);
};
UserProfile.propTypes = {
profile: PropTypes.object,
location: PropTypes.object,
fetchUser: PropTypes.func.isRequired,
updateSettingLocation: PropTypes.func.isRequired,
};
export default UserProfile;

View File

@ -0,0 +1,14 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles({
userProfileMenu: {
display: 'flex',
},
profileContainer: {
position: 'relative',
},
button: {
color: '#fff',
padding: '0.2rem 1rem',
},
});

View File

@ -0,0 +1,154 @@
import { useState } from 'react';
import ConditionallyRender from '../../../common/ConditionallyRender';
import {
Paper,
Avatar,
Typography,
Button,
FormControl,
Select,
InputLabel,
} from '@material-ui/core';
import classnames from 'classnames';
import { useStyles } from './UserProfileContent.styles';
import { useCommonStyles } from '../../../../common.styles';
import { Alert } from '@material-ui/lab';
import EditProfile from '../EditProfile/EditProfile';
import legacyStyles from '../../user.module.scss';
const UserProfileContent = ({
showProfile,
profile,
possibleLocales,
updateSettingLocation,
imageUrl,
location,
logoutUser,
}) => {
const commonStyles = useCommonStyles();
const [currentLocale, setCurrentLocale] = useState(location.locale);
const [updatedPassword, setUpdatedPassword] = useState(false);
const [edititingProfile, setEditingProfile] = useState(false);
const styles = useStyles();
const setLocale = value => {
updateSettingLocation('locale', value);
};
const profileAvatarClasses = classnames(styles.avatar, {
[styles.editingAvatar]: edititingProfile,
});
const profileEmailClasses = classnames(styles.profileEmail, {
[styles.editingEmail]: edititingProfile,
});
const handleChange = e => {
const { value } = e.target;
setCurrentLocale(value);
setLocale(value);
};
return (
<ConditionallyRender
condition={showProfile}
show={
<Paper
className={classnames(
styles.profile,
commonStyles.flexColumn,
commonStyles.itemsCenter,
commonStyles.contentSpacingY
)}
>
<Avatar
alt="user image"
src={imageUrl}
className={profileAvatarClasses}
/>
<Typography variant="body1" className={profileEmailClasses}>
{profile?.email}
</Typography>
<ConditionallyRender
condition={updatedPassword}
show={
<Alert onClose={() => setUpdatedPassword(false)}>
Successfully updated password.
</Alert>
}
/>
<ConditionallyRender
condition={!edititingProfile}
show={
<>
<Button
variant="contained"
onClick={() => setEditingProfile(true)}
>
Update password
</Button>
<div className={commonStyles.divider} />
<div className={legacyStyles.showUserSettings}>
<FormControl
variant="outlined"
size="small"
style={{
width: '100%',
minWidth: '120px',
}}
>
<InputLabel
htmlFor="locale-select"
style={{ backgroundColor: '#fff' }}
>
Date/Time formatting
</InputLabel>
<Select
id="locale-select"
native
value={currentLocale || ''}
onChange={handleChange}
MenuProps={{
style: {
zIndex: 9999,
},
}}
>
{possibleLocales.map(locale => {
return (
<option
key={locale.value}
value={locale.value}
>
{locale.value}
</option>
);
})}
</Select>
</FormControl>
</div>
<div className={commonStyles.divider} />
<Button
variant="contained"
color="primary"
onClick={logoutUser}
>
Logout
</Button>
</>
}
elseShow={
<EditProfile
setEditingProfile={setEditingProfile}
setUpdatedPassword={setUpdatedPassword}
/>
}
/>
</Paper>
}
/>
);
};
export default UserProfileContent;

View File

@ -0,0 +1,29 @@
import { makeStyles } from '@material-ui/styles';
export const useStyles = makeStyles(theme => ({
profile: {
position: 'absolute',
zIndex: '5000',
minWidth: '300px',
right: 0,
padding: '1.5rem',
[theme.breakpoints.down('sm')]: {
width: '100%',
padding: '1rem',
},
},
avatar: {
width: '40px',
height: '40px',
transition: 'transform 0.4s ease',
},
editingAvatar: {
transform: 'translateX(-102px) translateY(-9px)',
},
profileEmail: {
transition: 'transform 0.4s ease',
},
editingEmail: {
transform: 'translateX(10px) translateY(-60px)',
},
}));

View File

@ -1,10 +1,11 @@
import { connect } from 'react-redux';
import ShowUserComponent from './show-user-component';
import { fetchUser } from '../../store/user/actions';
import { updateSettingForGroup } from '../../store/settings/actions';
import UserProfile from './UserProfile';
import { fetchUser, logoutUser } from '../../../store/user/actions';
import { updateSettingForGroup } from '../../../store/settings/actions';
const mapDispatchToProps = {
fetchUser,
logoutUser,
updateSettingLocation: updateSettingForGroup('location'),
};
@ -13,4 +14,4 @@ const mapStateToProps = state => ({
location: state.settings ? state.settings.toJS().location : {},
});
export default connect(mapStateToProps, mapDispatchToProps)(ShowUserComponent);
export default connect(mapStateToProps, mapDispatchToProps)(UserProfile);

View File

@ -5,8 +5,8 @@ export const useStyles = makeStyles(theme => ({
border: '1px solid #f1f1f1',
borderRadius: '3px',
right: '100px',
color: '#44606e',
maxWidth: '350px',
color: '#44606e',
},
headerContainer: { display: 'flex', padding: '0.5rem' },
divider: {

View File

@ -1,104 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './user.module.scss';
import { MenuItem, Avatar, Typography, Icon } from '@material-ui/core';
import DropdownMenu from '../common/DropdownMenu/DropdownMenu';
export default class ShowUserComponent extends React.Component {
static propTypes = {
profile: PropTypes.object,
location: PropTypes.object,
fetchUser: PropTypes.func.isRequired,
updateSettingLocation: PropTypes.func.isRequired,
};
possibleLocales = [
{ value: 'en-US', image: 'en-US' },
{ value: 'en-GB', image: 'en-GB' },
{ value: 'nb-NO', image: 'nb-NO' },
{ value: 'sv-SE', image: 'sv-SE' },
{ value: 'da-DK', image: 'da-DK' },
{ value: 'en-IN', image: 'en-IN' },
{ value: 'de', image: 'de_DE' },
{ value: 'cs', image: 'cs_CZ' },
{ value: 'pt-BR', image: 'pt_BR' },
{ value: 'fr-FR', image: 'fr-FR' },
];
componentDidMount() {
this.props.fetchUser();
// find default locale and add it in choices if not present
const locale = navigator.language || navigator.userLanguage;
let found = this.possibleLocales.find(l => l.value === locale);
if (!found) {
this.possibleLocales.push({
value: locale,
image: 'unknown-locale',
});
}
}
getLocale() {
return (
(this.props.location && this.props.location.locale) ||
navigator.language ||
navigator.userLanguage
);
}
setLocale(locale) {
this.props.updateSettingLocation('locale', locale.value);
}
render() {
const email = this.props.profile ? this.props.profile.email : '';
const locale = this.getLocale();
let foundLocale = this.possibleLocales.find(l => l.value === locale);
const imageUrl = email
? this.props.profile.imageUrl
: 'unknown-user.png';
const imageLocale = foundLocale
? `${foundLocale.image}.png`
: `unknown-locale.png`;
return (
<div className={styles.showUserSettings}>
<DropdownMenu
className={styles.dropdown}
startIcon={
<Icon
component={'img'}
alt={locale}
src={imageLocale}
className={styles.labelFlag}
/>
}
renderOptions={() =>
this.possibleLocales.map(i => (
<MenuItem
key={i.value}
onClick={() => this.setLocale(i)}
>
<div className={styles.showLocale}>
<img
src={`${i.image}.png`}
title={i.value}
alt={i.value}
/>
<Typography variant="p">
{i.value}
</Typography>
</div>
</MenuItem>
))
}
label="Locale"
/>
<Avatar
alt="user image"
src={imageUrl}
className={styles.avatar}
/>
</div>
);
}
}

View File

@ -14,22 +14,23 @@
border-radius: 2px;
width: 30px;
height: 18px;
z-index: 6000;
margin: 0 10px;
}
.avatar {
width: 30px;
height: 30px;
border-radius: 5px;
margin-left: 5px;
}
.showUserSettings {
display: flex;
align-items: center;
width: 100%;
}
.dropdown {
color: #fff;
color: #000;
font-weight: normal;
}
}

View File

@ -251,4 +251,4 @@ UsersList.propTypes = {
location: PropTypes.object.isRequired,
};
export default UsersList;
export default UsersList;

View File

@ -2406,7 +2406,7 @@ aggregate-error@^3.0.0:
clean-stack "^2.0.0"
indent-string "^4.0.0"
airbnb-prop-types@^2.16.0:
airbnb-prop-types@^2.15.0, airbnb-prop-types@^2.16.0:
version "2.16.0"
resolved "https://registry.yarnpkg.com/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz#b96274cefa1abb14f623f804173ee97c13971dc2"
integrity sha512-7WHOFolP/6cS96PhKNrslCLMYAI8yB1Pp6u6XmxozQOiZbsI5ycglZr5cHhBFfuRcQQjzCMith5ZPZdYiJCxUg==
@ -3720,6 +3720,11 @@ console-browserify@^1.1.0:
resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336"
integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==
"consolidated-events@^1.1.1 || ^2.0.0":
version "2.0.2"
resolved "https://registry.yarnpkg.com/consolidated-events/-/consolidated-events-2.0.2.tgz#da8d8f8c2b232831413d9e190dc11669c79f4a91"
integrity sha512-2/uRVMdRypf5z/TW/ncD/66l75P5hH2vM/GR8Jf8HLc2xnfJtmina6F6du8+v4Z2vTrMo7jC+W1tmEEuuELgkQ==
constants-browserify@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
@ -4466,6 +4471,13 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
document.contains@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/document.contains/-/document.contains-1.0.2.tgz#4260abad67a6ae9e135c1be83d68da0db169d5f0"
integrity sha512-YcvYFs15mX8m3AO1QNQy3BlIpSMfNRj3Ujk2BEJxsZG+HZf7/hZ6jr7mDpXrF8q+ff95Vef5yjhiZxm8CGJr6Q==
dependencies:
define-properties "^1.1.3"
dom-accessibility-api@^0.5.4:
version "0.5.4"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166"
@ -9852,6 +9864,17 @@ react-is@^16.12.0, react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-outside-click-handler@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/react-outside-click-handler/-/react-outside-click-handler-1.3.0.tgz#3831d541ac059deecd38ec5423f81e80ad60e115"
integrity sha512-Te/7zFU0oHpAnctl//pP3hEAeobfeHMyygHB8MnjP6sX5OR8KHT1G3jmLsV3U9RnIYo+Yn+peJYWu+D5tUS8qQ==
dependencies:
airbnb-prop-types "^2.15.0"
consolidated-events "^1.1.1 || ^2.0.0"
document.contains "^1.0.1"
object.values "^1.1.0"
prop-types "^15.7.2"
react-redux@^7.2.3:
version "7.2.3"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.2.3.tgz#4c084618600bb199012687da9e42123cca3f0be9"