1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-01 01:18:10 +02:00

Merge branch 'main' into refactor/create-token

This commit is contained in:
Youssef Khedher 2022-01-19 19:16:57 +01:00 committed by GitHub
commit 05f395f638
33 changed files with 1120 additions and 482 deletions

View File

@ -48,10 +48,10 @@
"@types/enzyme": "3.10.11",
"@types/enzyme-adapter-react-16": "1.0.6",
"@types/jest": "27.4.0",
"@types/node": "14.18.7",
"@types/node": "14.18.9",
"@types/react": "17.0.38",
"@types/react-dom": "17.0.11",
"@types/react-router-dom": "5.3.2",
"@types/react-router-dom": "5.3.3",
"@types/react-timeago": "4.1.3",
"@welldone-software/why-did-you-render": "6.2.3",
"array-move": "3.0.1",
@ -87,7 +87,7 @@
"redux-devtools-extension": "2.13.9",
"redux-mock-store": "1.5.4",
"redux-thunk": "2.4.1",
"sass": "1.48.0",
"sass": "1.49.0",
"swr": "1.0.1",
"typescript": "4.5.4",
"web-vitals": "2.1.3"

View File

@ -73,7 +73,6 @@ const FormTemplate: React.FC<ICreateProps> = ({
<h3 className={styles.subtitle}>
API Command{' '}
<IconButton
className={styles.iconButton}
onClick={copyCommand}
>
<FileCopy className={styles.icon} />

View File

@ -11,6 +11,15 @@ Array [
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"menu": Object {},
"parent": "/projects",
"path": "/projects/:id/edit",
"title": ":id",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
@ -263,15 +272,6 @@ Array [
"title": "Tag types",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",
"menu": Object {},
"parent": "/tags",
"path": "/tags/create",
"title": "Create",
"type": "protected",
},
Object {
"component": [Function],
"layout": "main",

View File

@ -13,12 +13,7 @@ import ApplicationView from '../../page/applications/view';
import ContextFields from '../../page/context';
import CreateContextField from '../../page/context/create';
import EditContextField from '../../page/context/edit';
import CreateProject from '../../page/project/create';
import ListTagTypes from '../../page/tag-types';
import CreateTagType from '../../page/tag-types/create';
import EditTagType from '../../page/tag-types/edit';
import ListTags from '../../page/tags';
import CreateTag from '../../page/tags/create';
import Addons from '../../page/addons';
import AddonsCreate from '../../page/addons/create';
import AddonsEdit from '../../page/addons/edit';
@ -48,6 +43,10 @@ import CreateApiToken from '../admin/api-token/CreateApiToken/CreateApiToken';
import CreateEnvironment from '../environments/CreateEnvironment/CreateEnvironment';
import EditEnvironment from '../environments/EditEnvironment/EditEnvironment';
import EditTagType from '../tagTypes/EditTagType/EditTagType';
import CreateTagType from '../tagTypes/CreateTagType/CreateTagType';
import EditProject from '../project/Project/EditProject/EditProject';
import CreateProject from '../project/Project/CreateProject/CreateProject';
export const routes = [
// Project
@ -61,6 +60,15 @@ export const routes = [
layout: 'main',
menu: {},
},
{
path: '/projects/:id/edit',
parent: '/projects',
title: ':id',
component: EditProject,
type: 'protected',
layout: 'main',
menu: {},
},
{
path: '/projects/:id/archived',
title: ':name',
@ -304,24 +312,6 @@ export const routes = [
layout: 'main',
menu: { mobile: true, advanced: true },
},
{
path: '/tags/create',
parent: '/tags',
title: 'Create',
component: CreateTag,
type: 'protected',
layout: 'main',
menu: {},
},
{
path: '/tags',
title: 'Tags',
component: ListTags,
hidden: true,
type: 'protected',
layout: 'main',
menu: {},
},
// Addons
{

View File

@ -1,31 +0,0 @@
.header {
padding: var(--card-header-padding);
margin-bottom: var(--card-margin-y);
word-break: break-all;
border-bottom: var(--default-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: var(--h1-size);
}
.supporting {
font-size: var(--caption-size);
max-width: 450px;
}
.container {
padding: var(--card-padding);
}
.formButtons {
padding-top: 1rem;
}
.formContainer {
margin-bottom: 1.5rem;
max-width: 350px;
}

View File

@ -0,0 +1,94 @@
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
import useProjectApi from '../../../../hooks/api/actions/useProjectApi/useProjectApi';
import { useHistory } from 'react-router-dom';
import ProjectForm from '../ProjectForm/ProjectForm';
import useProjectForm from '../hooks/useProjectForm';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../../hooks/useToast';
import useUser from '../../../../hooks/api/getters/useUser/useUser';
const CreateProject = () => {
/* @ts-ignore */
const { setToastData, setToastApiError } = useToast();
const { refetch } = useUser();
const { uiConfig } = useUiConfig();
const history = useHistory();
const {
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
getProjectPayload,
clearErrors,
validateIdUniqueness,
validateName,
errors,
} = useProjectForm();
const { createProject, loading } = useProjectApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
const validName = validateName();
const validId = await validateIdUniqueness();
if (validName && validId) {
const payload = getProjectPayload();
try {
await createProject(payload);
refetch();
history.push(`/projects/${projectId}`);
setToastData({
title: 'Project created',
text: 'Now you can add toggles to this project',
confetti: true,
type: 'success',
});
} catch (e: any) {
setToastApiError(e.toString());
}
}
};
const formatApiCode = () => {
return `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/projects' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`;
};
const handleCancel = () => {
history.push('/projects');
};
return (
<FormTemplate
loading={loading}
title="Create project"
description="Projects allows you to group feature toggles together in the management UI."
documentationLink="https://docs.getunleash.io/user_guide/projects"
formatApiCode={formatApiCode}
>
<ProjectForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
projectId={projectId}
setProjectId={setProjectId}
projectName={projectName}
setProjectName={setProjectName}
projectDesc={projectDesc}
setProjectDesc={setProjectDesc}
submitButtonText="Create"
clearErrors={clearErrors}
validateIdUniqueness={validateIdUniqueness}
/>
</FormTemplate>
);
};
export default CreateProject;

View File

@ -0,0 +1,93 @@
import FormTemplate from '../../../common/FormTemplate/FormTemplate';
import useProjectApi from '../../../../hooks/api/actions/useProjectApi/useProjectApi';
import { useHistory, useParams } from 'react-router-dom';
import ProjectForm from '../ProjectForm/ProjectForm';
import useProjectForm from '../hooks/useProjectForm';
import useProject from '../../../../hooks/api/getters/useProject/useProject';
import useUiConfig from '../../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../../hooks/useToast';
const EditProject = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const { id } = useParams<{ id: string }>();
const { project } = useProject(id);
const history = useHistory();
const {
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
getProjectPayload,
clearErrors,
validateIdUniqueness,
validateName,
errors,
} = useProjectForm(id, project.name, project.description);
const formatApiCode = () => {
return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/projects/${id}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectPayload(), undefined, 2)}'`;
};
const { refetch } = useProject(id);
const { editProject, loading } = useProjectApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
const payload = getProjectPayload();
const validName = validateName();
if (validName) {
try {
await editProject(id, payload);
refetch();
history.push(`/projects/${id}`);
setToastData({
title: 'Project information updated',
type: 'success',
});
} catch (e: any) {
setToastApiError(e.toString());
}
}
};
const handleCancel = () => {
history.goBack();
};
return (
<FormTemplate
loading={loading}
title="Edit project"
description="Projects allows you to group feature toggles together in the management UI."
documentationLink="https://docs.getunleash.io/user_guide/projects"
formatApiCode={formatApiCode}
>
<ProjectForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
projectId={projectId}
setProjectId={setProjectId}
projectName={projectName}
setProjectName={setProjectName}
projectDesc={projectDesc}
setProjectDesc={setProjectDesc}
submitButtonText="Edit"
clearErrors={clearErrors}
validateIdUniqueness={validateIdUniqueness}
/>
</FormTemplate>
);
};
export default EditProject;

View File

@ -13,7 +13,6 @@ import { useEffect } from 'react';
import useTabs from '../../../hooks/useTabs';
import TabPanel from '../../common/TabNav/TabPanel';
import ProjectAccess from '../access-container';
import EditProject from '../edit-project-container';
import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment';
import ProjectOverview from './ProjectOverview';
import ProjectHealth from './ProjectHealth/ProjectHealth';
@ -58,19 +57,6 @@ const Project = () => {
path: `${basePath}/environments`,
name: 'environments',
},
{
title: 'Settings',
// @ts-ignore (fix later)
component: (
<EditProject
projectId={id}
history={history}
title="Edit project"
/>
),
path: `${basePath}/settings`,
name: 'settings',
},
];
useEffect(() => {
@ -101,15 +87,6 @@ const Project = () => {
/* eslint-disable-next-line */
}, []);
const goToTabWithName = (name: string) => {
const index = tabData.findIndex(t => t.name === name);
if (index >= 0) {
const tab = tabData[index];
history.push(tab.path);
setActiveTab(index);
}
};
const renderTabs = () => {
return tabData.map((tab, index) => {
return (
@ -150,9 +127,9 @@ const Project = () => {
Project: {project?.name}{' '}
<PermissionIconButton
permission={UPDATE_PROJECT}
tooltip={'Edit description'}
tooltip="Edit"
projectId={project?.id}
onClick={() => goToTabWithName('settings')}
onClick={() => history.push(`/projects/${id}/edit`)}
data-loading
>
<Edit />

View File

@ -0,0 +1,47 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
form: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
container: {
maxWidth: '400px',
},
input: { width: '100%', marginBottom: '1rem' },
label: {
minWidth: '300px',
[theme.breakpoints.down(600)]: {
minWidth: 'auto',
},
},
buttonContainer: {
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
},
cancelButton: {
marginRight: '1.5rem',
},
inputDescription: {
marginBottom: '0.5rem',
},
formHeader: {
fontWeight: 'normal',
marginTop: '0',
},
header: {
fontWeight: 'normal',
},
permissionErrorContainer: {
position: 'relative',
},
errorMessage: {
//@ts-ignore
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
position: 'absolute',
top: '-8px',
},
}));

View File

@ -0,0 +1,103 @@
import PermissionButton from '../../../common/PermissionButton/PermissionButton';
import { CREATE_PROJECT } from '../../../providers/AccessProvider/permissions';
import Input from '../../../common/Input/Input';
import { TextField, Button } from '@material-ui/core';
import { useStyles } from './ProjectForm.style';
import React from 'react';
import { trim } from '../../../common/util';
interface IProjectForm {
projectId: string;
projectName: string;
projectDesc: string;
setProjectId: React.Dispatch<React.SetStateAction<string>>;
setProjectName: React.Dispatch<React.SetStateAction<string>>;
setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
submitButtonText: string;
clearErrors: () => void;
validateIdUniqueness: () => void;
}
const ProjectForm = ({
handleSubmit,
handleCancel,
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
errors,
submitButtonText,
validateIdUniqueness,
clearErrors,
}: IProjectForm) => {
const styles = useStyles();
return (
<form onSubmit={handleSubmit} className={styles.form}>
<h3 className={styles.formHeader}>Project Information</h3>
<div className={styles.container}>
<p className={styles.inputDescription}>
What is your project Id?
</p>
<Input
className={styles.input}
label="Project Id"
value={projectId}
onChange={e => setProjectId(trim(e.target.value))}
error={Boolean(errors.id)}
errorText={errors.id}
onFocus={() => clearErrors()}
onBlur={validateIdUniqueness}
disabled={submitButtonText === 'Edit'}
/>
<p className={styles.inputDescription}>
What is your project name?
</p>
<Input
className={styles.input}
label="Project name"
value={projectName}
onChange={e => setProjectName(e.target.value)}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
/>
<p className={styles.inputDescription}>
What is your project description?
</p>
<TextField
className={styles.input}
label="Project description"
variant="outlined"
multiline
maxRows={4}
value={projectDesc}
onChange={e => setProjectDesc(e.target.value)}
/>
</div>
<div className={styles.buttonContainer}>
<Button onClick={handleCancel} className={styles.cancelButton}>
Cancel
</Button>
<PermissionButton
onClick={handleSubmit}
permission={CREATE_PROJECT}
type="submit"
>
{submitButtonText} project
</PermissionButton>
</div>
</form>
);
};
export default ProjectForm;

View File

@ -55,7 +55,7 @@ const ProjectInfo = ({
component={Link}
className={permissionButtonClass}
data-loading
to={`/projects/${id}/settings`}
to={`/projects/${id}/edit`}
>
<Edit />
</PermissionIconButton>

View File

@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import useProjectApi from '../../../../hooks/api/actions/useProjectApi/useProjectApi';
import { IPermission } from '../../../../interfaces/project';
export interface ICheckedPermission {
[key: string]: IPermission;
}
const useProjectForm = (
initialProjectId = '',
initialProjectName = '',
initialProjectDesc = ''
) => {
const [projectId, setProjectId] = useState(initialProjectId);
const [projectName, setProjectName] = useState(initialProjectName);
const [projectDesc, setProjectDesc] = useState(initialProjectDesc);
const [errors, setErrors] = useState({});
const { validateId } = useProjectApi();
useEffect(() => {
setProjectId(initialProjectId);
}, [initialProjectId]);
useEffect(() => {
setProjectName(initialProjectName);
}, [initialProjectName]);
useEffect(() => {
setProjectDesc(initialProjectDesc);
}, [initialProjectDesc]);
const getProjectPayload = () => {
return {
id: projectId,
name: projectName,
description: projectDesc,
};
};
const NAME_EXISTS_ERROR = 'Error: A project with this id already exists.';
const validateIdUniqueness = async () => {
try {
await validateId(getProjectPayload());
return true;
} catch (e: any) {
if (e.toString().includes(NAME_EXISTS_ERROR)) {
setErrors(prev => ({
...prev,
id: 'A project with this id already exists',
}));
}
return false;
}
};
const validateName = () => {
if (projectName.length === 0) {
setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
return false;
}
return true;
};
const clearErrors = () => {
setErrors({});
};
return {
projectId,
projectName,
projectDesc,
setProjectId,
setProjectName,
setProjectDesc,
getProjectPayload,
validateName,
validateIdUniqueness,
clearErrors,
errors,
};
};
export default useProjectForm;

View File

@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import ProjectComponent from './form-project-component';
import { createProject, validateId } from './../../store/project/actions';
import { fetchUser } from './../../store/user/actions';
const mapStateToProps = () => ({
project: { id: '', name: '', description: '' },
});
const mapDispatchToProps = dispatch => ({
validateId,
submit: async project => {
await createProject(project)(dispatch);
fetchUser()(dispatch);
},
editMode: false,
});
const FormAddContainer = connect(mapStateToProps, mapDispatchToProps)(ProjectComponent);
export default FormAddContainer;

View File

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import Component from './form-project-component';
import { updateProject, validateId } from './../../store/project/actions';
const mapStateToProps = (state, props) => {
const projectBase = { id: '', name: '', description: '' };
const realProject = state.projects.toJS().find(n => n.id === props.projectId);
const project = Object.assign(projectBase, realProject);
return {
project,
};
};
const mapDispatchToProps = dispatch => ({
validateId,
submit: project => updateProject(project)(dispatch),
editMode: true,
});
const FormAddContainer = connect(mapStateToProps, mapDispatchToProps)(Component);
export default FormAddContainer;

View File

@ -1,239 +0,0 @@
import { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { TextField, Typography } from '@material-ui/core';
import styles from './Project.module.scss';
import classnames from 'classnames';
import { FormButtons, styles as commonStyles } from '../common';
import { trim } from '../common/util';
import PageContent from '../common/PageContent/PageContent';
import AccessContext from '../../contexts/AccessContext';
import ConditionallyRender from '../common/ConditionallyRender';
import { CREATE_PROJECT } from '../providers/AccessProvider/permissions';
import HeaderTitle from '../common/HeaderTitle';
import useUiConfig from '../../hooks/api/getters/useUiConfig/useUiConfig';
import { Alert } from '@material-ui/lab';
import { FormEvent } from 'react-router/node_modules/@types/react';
import useLoading from '../../hooks/useLoading';
import PermissionButton from '../common/PermissionButton/PermissionButton';
import { UPDATE_PROJECT } from '../../store/project/actions';
import useUser from '../../hooks/api/getters/useUser/useUser';
interface ProjectFormComponentProps {
editMode: boolean;
project: any;
validateId: (id: string) => Promise<void>;
history: any;
submit: (project: any) => Promise<void>;
}
const ProjectFormComponent = (props: ProjectFormComponentProps) => {
const { editMode } = props;
const { refetch } = useUser();
const { hasAccess } = useContext(AccessContext);
const [project, setProject] = useState(props.project || {});
const [errors, setErrors] = useState<any>({});
const { isOss, loading } = useUiConfig();
const ref = useLoading(loading);
useEffect(() => {
if (!project.id && props.project.id) {
setProject(props.project);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.project]);
const setValue = (field: string, value: string) => {
const p = { ...project };
p[field] = value;
setProject(p);
};
const validateId = async (id: string) => {
if (editMode) return true;
const e = { ...errors };
try {
await props.validateId(id);
e.id = undefined;
} catch (err: any) {
e.id = err.message;
}
setErrors(e);
if (e.id) return false;
return true;
};
const validateName = () => {
if (project.name.length === 0) {
setErrors({ ...errors, name: 'Name can not be empty.' });
return false;
}
return true;
};
const validate = async (id: string) => {
const validId = await validateId(id);
const validName = validateName();
return validId && validName;
};
const onCancel = (evt: Event) => {
evt.preventDefault();
if (editMode) {
props.history.push(`/projects/${project.id}`);
return;
}
props.history.push(`/projects/`);
};
const onSubmit = async (evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const valid = await validate(project.id);
if (valid) {
const query = editMode ? 'edited=true' : 'created=true';
await props.submit(project);
refetch();
props.history.push(`/projects/${project.id}?${query}`);
}
};
const submitText = editMode ? 'Update' : 'Create';
return (
<div ref={ref}>
<PageContent
headerContent={
<HeaderTitle
title={`${submitText} ${props.project?.name} project`}
/>
}
>
<ConditionallyRender
condition={isOss()}
show={
<Alert data-loading severity="error">
{submitText} project requires a paid version of
Unleash. Check out{' '}
<a
href="https://www.getunleash.io"
target="_blank"
rel="noreferrer"
>
getunleash.io
</a>{' '}
to learn more.
</Alert>
}
elseShow={
<>
<Typography
variant="subtitle1"
style={{ marginBottom: '0.5rem' }}
>
Projects allows you to group feature toggles
together in the management UI.
</Typography>
<form
data-loading
onSubmit={onSubmit}
className={classnames(
commonStyles.contentSpacing,
styles.formContainer
)}
>
<TextField
label="Project Id"
name="id"
placeholder="A-unique-key"
value={project.id}
error={!!errors.id}
helperText={errors.id}
disabled={editMode}
variant="outlined"
size="small"
onBlur={v => validateId(v.target.value)}
onChange={v =>
setValue('id', trim(v.target.value))
}
/>
<br />
<TextField
label="Name"
name="name"
placeholder="Project name"
value={project.name}
error={!!errors.name}
variant="outlined"
size="small"
helperText={errors.name}
onChange={v =>
setValue('name', v.target.value)
}
/>
<TextField
className={commonStyles.fullwidth}
placeholder="A short description"
maxRows={2}
label="Description"
error={!!errors.description}
helperText={errors.description}
variant="outlined"
size="small"
multiline
value={project.description}
onChange={v =>
setValue('description', v.target.value)
}
/>
<ConditionallyRender
condition={
hasAccess(CREATE_PROJECT) && !editMode
}
show={
<div className={styles.formButtons}>
<FormButtons
submitText={submitText}
onCancel={onCancel}
/>
</div>
}
/>
<ConditionallyRender
condition={editMode}
show={
<PermissionButton
permission={UPDATE_PROJECT}
projectId={props.project.id}
type="submit"
style={{ marginTop: '1rem' }}
>
Update project
</PermissionButton>
}
/>
</form>
</>
}
/>
</PageContent>
</div>
);
};
ProjectFormComponent.propTypes = {
project: PropTypes.object.isRequired,
validateId: PropTypes.func.isRequired,
submit: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
editMode: PropTypes.bool.isRequired,
};
export default ProjectFormComponent;

View File

@ -13,7 +13,6 @@ export const DELETE_CONTEXT_FIELD = 'DELETE_CONTEXT_FIELD';
export const CREATE_PROJECT = 'CREATE_PROJECT';
export const UPDATE_PROJECT = 'UPDATE_PROJECT';
export const DELETE_PROJECT = 'DELETE_PROJECT';
export const CREATE_TAG_TYPE = 'CREATE_TAG_TYPE';
export const DELETE_TAG_TYPE = 'DELETE_TAG_TYPE';
export const UPDATE_TAG_TYPE = 'UPDATE_TAG_TYPE';
export const CREATE_TAG = 'CREATE_TAG';

View File

@ -2,6 +2,10 @@
min-width: 100px;
}
.icon {
fill: #757575;
}
.textfield {
margin-left: 15px;
}

View File

@ -1,7 +1,6 @@
import { useContext, useEffect, useState } from 'react';
import { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { Link, useHistory } from 'react-router-dom';
import {
List,
ListItem,
@ -11,38 +10,53 @@ import {
Button,
Tooltip,
} from '@material-ui/core';
import { Add, Delete, Label } from '@material-ui/icons';
import { Add, Delete, Edit, Label } from '@material-ui/icons';
import HeaderTitle from '../../common/HeaderTitle';
import PageContent from '../../common/PageContent/PageContent';
import ConditionallyRender from '../../common/ConditionallyRender/ConditionallyRender';
import {
CREATE_TAG_TYPE,
DELETE_TAG_TYPE,
UPDATE_TAG_TYPE,
} from '../../providers/AccessProvider/permissions';
import Dialogue from '../../common/Dialogue/Dialogue';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import styles from '../TagType.module.scss';
import AccessContext from '../../../contexts/AccessContext';
import useTagTypesApi from '../../../hooks/api/actions/useTagTypesApi/useTagTypesApi';
import useTagTypes from '../../../hooks/api/getters/useTagTypes/useTagTypes';
import useToast from '../../../hooks/useToast';
import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton';
const TagTypeList = ({ tagTypes, fetchTagTypes, removeTagType }) => {
const TagTypeList = () => {
const { hasAccess } = useContext(AccessContext);
const [deletion, setDeletion] = useState({ open: false });
const history = useHistory();
const smallScreen = useMediaQuery('(max-width:700px)');
const { deleteTagType } = useTagTypesApi();
const { tagTypes, refetch } = useTagTypes();
const { setToastData, setToastApiError } = useToast();
useEffect(() => {
fetchTagTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const deleteTag = async () => {
try {
await deleteTagType(deletion.name);
refetch();
setDeletion({ open: false });
setToastData({
type: 'success',
show: true,
text: 'Successfully deleted tag type.',
});
} catch (e) {
setToastApiError(e.toString());
}
};
let header = (
<HeaderTitle
title="Tag Types"
actions={
<ConditionallyRender
condition={hasAccess(CREATE_TAG_TYPE)}
condition={hasAccess(UPDATE_TAG_TYPE)}
show={
<ConditionallyRender
condition={smallScreen}
@ -96,6 +110,7 @@ const TagTypeList = ({ tagTypes, fetchTagTypes, removeTagType }) => {
</IconButton>
</Tooltip>
);
return (
<ListItem
key={`${tagType.name}`}
@ -105,6 +120,13 @@ const TagTypeList = ({ tagTypes, fetchTagTypes, removeTagType }) => {
<Label />
</ListItemIcon>
<ListItemText primary={link} secondary={tagType.description} />
<PermissionIconButton
permission={UPDATE_TAG_TYPE}
component={Link}
to={`/tag-types/edit/${tagType.name}`}
>
<Edit className={styles.icon} />
</PermissionIconButton>
<ConditionallyRender
condition={hasAccess(DELETE_TAG_TYPE)}
show={deleteButton}
@ -124,10 +146,7 @@ const TagTypeList = ({ tagTypes, fetchTagTypes, removeTagType }) => {
<Dialogue
title="Really delete Tag type?"
open={deletion.open}
onClick={() => {
removeTagType(deletion.name);
setDeletion({ open: false });
}}
onClick={deleteTag}
onClose={() => {
setDeletion({ open: false });
}}

View File

@ -38,44 +38,10 @@ exports[`renders a list with elements correctly 1`] = `
className="MuiList-root MuiList-padding"
>
<li
className="MuiListItem-root tagListItem MuiListItem-gutters"
className="MuiListItem-root MuiListItem-gutters"
disabled={false}
>
<div
className="MuiListItemIcon-root"
>
<svg
aria-hidden={true}
className="MuiSvgIcon-root"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M17.63 5.84C17.27 5.33 16.67 5 16 5L5 5.01C3.9 5.01 3 5.9 3 7v10c0 1.1.9 1.99 2 1.99L16 19c.67 0 1.27-.33 1.63-.84L22 12l-4.37-6.16z"
/>
</svg>
</div>
<div
className="MuiListItemText-root MuiListItemText-multiline"
>
<span
className="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
<a
href="/tag-types/edit/simple"
onClick={[Function]}
>
<strong>
simple
</strong>
</a>
</span>
<p
className="MuiTypography-root MuiListItemText-secondary MuiTypography-body2 MuiTypography-colorTextSecondary MuiTypography-displayBlock"
>
Some simple description
</p>
</div>
No entries
</li>
</ul>
</div>

View File

@ -14,21 +14,24 @@ import {
UPDATE_TAG_TYPE,
DELETE_TAG_TYPE,
} from '../../providers/AccessProvider/permissions';
import UIProvider from '../../providers/UIProvider/UIProvider';
test('renders an empty list correctly', () => {
const tree = renderer.create(
<MemoryRouter>
<ThemeProvider theme={theme}>
<AccessProvider
store={createFakeStore([{ permission: ADMIN }])}
>
<TagTypesList
tagTypes={[]}
fetchTagTypes={jest.fn()}
removeTagType={jest.fn()}
history={{}}
/>
</AccessProvider>
<UIProvider>
<AccessProvider
store={createFakeStore([{ permission: ADMIN }])}
>
<TagTypesList
tagTypes={[]}
fetchTagTypes={jest.fn()}
removeTagType={jest.fn()}
history={{}}
/>
</AccessProvider>
</UIProvider>
</ThemeProvider>
</MemoryRouter>
);
@ -39,26 +42,28 @@ test('renders a list with elements correctly', () => {
const tree = renderer.create(
<ThemeProvider theme={theme}>
<MemoryRouter>
<AccessProvider
store={createFakeStore([
{ permission: CREATE_TAG_TYPE },
{ permission: UPDATE_TAG_TYPE },
{ permission: DELETE_TAG_TYPE },
])}
>
<TagTypesList
tagTypes={[
{
name: 'simple',
description: 'Some simple description',
icon: '#',
},
]}
fetchTagTypes={jest.fn()}
removeTagType={jest.fn()}
history={{}}
/>
</AccessProvider>
<UIProvider>
<AccessProvider
store={createFakeStore([
{ permission: CREATE_TAG_TYPE },
{ permission: UPDATE_TAG_TYPE },
{ permission: DELETE_TAG_TYPE },
])}
>
<TagTypesList
tagTypes={[
{
name: 'simple',
description: 'Some simple description',
icon: '#',
},
]}
fetchTagTypes={jest.fn()}
removeTagType={jest.fn()}
history={{}}
/>
</AccessProvider>
</UIProvider>
</MemoryRouter>
</ThemeProvider>
);

View File

@ -0,0 +1,91 @@
import { useHistory } from 'react-router-dom';
import useTagTypesApi from '../../../hooks/api/actions/useTagTypesApi/useTagTypesApi';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../hooks/useToast';
import FormTemplate from '../../common/FormTemplate/FormTemplate';
import PermissionButton from '../../common/PermissionButton/PermissionButton';
import { UPDATE_TAG_TYPE } from '../../providers/AccessProvider/permissions';
import useTagForm from '../hooks/useTagForm';
import TagTypeForm from '../TagTypeForm/TagTypeForm';
const CreateTagType = () => {
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const history = useHistory();
const {
tagName,
tagDesc,
setTagName,
setTagDesc,
getTagPayload,
validateNameUniqueness,
errors,
clearErrors,
} = useTagForm();
const { createTag, loading } = useTagTypesApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
const validName = await validateNameUniqueness();
if (validName) {
const payload = getTagPayload();
try {
await createTag(payload);
history.push('/tag-types');
setToastData({
title: 'Tag type created',
confetti: true,
type: 'success',
});
} catch (e: any) {
setToastApiError(e.toString());
}
}
};
const formatApiCode = () => {
return `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/tag-types' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getTagPayload(), undefined, 2)}'`;
};
const handleCancel = () => {
history.goBack();
};
return (
<FormTemplate
loading={loading}
title="Create tag type"
description="Tag types allow you to group tags together in the management UI"
documentationLink="https://docs.getunleash.io/advanced/tags"
formatApiCode={formatApiCode}
>
<TagTypeForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
tagName={tagName}
setTagName={setTagName}
tagDesc={tagDesc}
setTagDesc={setTagDesc}
mode="Create"
clearErrors={clearErrors}
>
<PermissionButton
onClick={handleSubmit}
permission={UPDATE_TAG_TYPE}
type="submit"
>
Create type
</PermissionButton>
</TagTypeForm>
</FormTemplate>
);
};
export default CreateTagType;

View File

@ -0,0 +1,89 @@
import { useHistory, useParams } from 'react-router-dom';
import useTagTypesApi from '../../../hooks/api/actions/useTagTypesApi/useTagTypesApi';
import useTagType from '../../../hooks/api/getters/useTagType/useTagType';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
import useToast from '../../../hooks/useToast';
import FormTemplate from '../../common/FormTemplate/FormTemplate';
import PermissionButton from '../../common/PermissionButton/PermissionButton';
import { UPDATE_TAG_TYPE } from '../../providers/AccessProvider/permissions';
import useTagForm from '../hooks/useTagForm';
import TagForm from '../TagTypeForm/TagTypeForm';
const EditTagType = () => {
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const history = useHistory();
const { name } = useParams<{ name: string }>();
const { tagType } = useTagType(name);
const {
tagName,
tagDesc,
setTagName,
setTagDesc,
getTagPayload,
errors,
clearErrors,
} = useTagForm(tagType?.name, tagType?.description);
const { updateTagType, loading } = useTagTypesApi();
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
const payload = getTagPayload();
try {
await updateTagType(tagName, payload);
history.push('/tag-types');
setToastData({
title: 'Tag type updated',
type: 'success',
});
} catch (e: any) {
setToastApiError(e.toString());
}
};
const formatApiCode = () => {
return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/tag-types/${name}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getTagPayload(), undefined, 2)}'`;
};
const handleCancel = () => {
history.goBack();
};
return (
<FormTemplate
loading={loading}
title="Edit tag type"
description="Tag types allow you to group tags together in the management UI"
documentationLink="https://docs.getunleash.io/"
formatApiCode={formatApiCode}
>
<TagForm
errors={errors}
handleSubmit={handleSubmit}
handleCancel={handleCancel}
tagName={tagName}
setTagName={setTagName}
tagDesc={tagDesc}
setTagDesc={setTagDesc}
mode="Edit"
clearErrors={clearErrors}
>
<PermissionButton
onClick={handleSubmit}
permission={UPDATE_TAG_TYPE}
type="submit"
>
Edit type
</PermissionButton>
</TagForm>
</FormTemplate>
);
};
export default EditTagType;

View File

@ -0,0 +1,47 @@
import { makeStyles } from '@material-ui/core/styles';
export const useStyles = makeStyles(theme => ({
container: {
maxWidth: '400px',
},
form: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
input: { width: '100%', marginBottom: '1rem' },
label: {
minWidth: '300px',
[theme.breakpoints.down(600)]: {
minWidth: 'auto',
},
},
buttonContainer: {
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
},
cancelButton: {
marginRight: '1.5rem',
},
inputDescription: {
marginBottom: '0.5rem',
},
formHeader: {
fontWeight: 'normal',
marginTop: '0',
},
header: {
fontWeight: 'normal',
},
permissionErrorContainer: {
position: 'relative',
},
errorMessage: {
//@ts-ignore
fontSize: theme.fontSizes.smallBody,
color: theme.palette.error.main,
position: 'absolute',
top: '-8px',
},
}));

View File

@ -0,0 +1,77 @@
import Input from '../../common/Input/Input';
import { TextField, Button } from '@material-ui/core';
import { useStyles } from './TagTypeForm.styles';
import React from 'react';
import { trim } from '../../common/util';
import { EDIT } from '../../../constants/misc';
interface ITagTypeForm {
tagName: string;
tagDesc: string;
setTagName: React.Dispatch<React.SetStateAction<string>>;
setTagDesc: React.Dispatch<React.SetStateAction<string>>;
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
mode: string;
clearErrors: () => void;
}
const TagTypeForm: React.FC<ITagTypeForm> = ({
children,
handleSubmit,
handleCancel,
tagName,
tagDesc,
setTagName,
setTagDesc,
errors,
mode,
clearErrors,
}) => {
const styles = useStyles();
return (
<form onSubmit={handleSubmit} className={styles.form}>
<h3 className={styles.formHeader}>Tag information</h3>
<div className={styles.container}>
<p className={styles.inputDescription}>
What is your tag name?
</p>
<Input
className={styles.input}
label="Tag name"
value={tagName}
onChange={e => setTagName(trim(e.target.value))}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
disabled={mode === EDIT}
/>
<p className={styles.inputDescription}>
What is this role for?
</p>
<TextField
className={styles.input}
label="Tag description"
variant="outlined"
multiline
maxRows={4}
value={tagDesc}
onChange={e => setTagDesc(e.target.value)}
/>
</div>
<div className={styles.buttonContainer}>
<Button onClick={handleCancel} className={styles.cancelButton}>
Cancel
</Button>
{children}
</div>
</form>
);
};
export default TagTypeForm;

View File

@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import useTagTypesApi from '../../../hooks/api/actions/useTagTypesApi/useTagTypesApi';
const useTagForm = (initialTagName = '', initialTagDesc = '') => {
const [tagName, setTagName] = useState(initialTagName);
const [tagDesc, setTagDesc] = useState(initialTagDesc);
const [errors, setErrors] = useState({});
const { validateTagName } = useTagTypesApi();
useEffect(() => {
setTagName(initialTagName);
}, [initialTagName]);
useEffect(() => {
setTagDesc(initialTagDesc);
}, [initialTagDesc]);
const getTagPayload = () => {
return {
name: tagName,
description: tagDesc,
};
};
const NAME_EXISTS_ERROR =
'There already exists a tag-type with the name simple';
const validateNameUniqueness = async () => {
if (tagName.length === 0) {
setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
return false;
}
if (tagName.length < 2) {
setErrors(prev => ({
...prev,
name: 'Tag name length must be at least 2 characters long',
}));
return false;
}
try {
await validateTagName(tagName);
return true;
} catch (e: any) {
if (e.toString().includes(NAME_EXISTS_ERROR)) {
setErrors(prev => ({
...prev,
name: NAME_EXISTS_ERROR,
}));
return false;
}
}
};
const clearErrors = () => {
setErrors({});
};
return {
tagName,
tagDesc,
setTagName,
setTagDesc,
getTagPayload,
clearErrors,
validateNameUniqueness,
errors,
};
};
export default useTagForm;

View File

@ -50,6 +50,7 @@ const TagList = ({ tags, fetchTags, removeTag }) => {
<Label />
</ListItemIcon>
<ListItemText primary={tag.value} secondary={tag.type} />
<ConditionallyRender
condition={hasAccess(DELETE_TAG)}
show={<DeleteButton tagType={tag.type} tagValue={tag.value} />}
@ -81,7 +82,7 @@ const TagList = ({ tags, fetchTags, removeTag }) => {
show={
<IconButton
aria-label="add tag"
onClick={() => history.push('/tags/create')}
onClick={() => history.push('/tag-types/create')}
>
<Add />
</IconButton>
@ -91,7 +92,9 @@ const TagList = ({ tags, fetchTags, removeTag }) => {
<Button
color="primary"
startIcon={<Add />}
onClick={() => history.push('/tags/create')}
onClick={() =>
history.push('/tag-types/create')
}
variant="contained"
>
Add new tag

View File

@ -1,10 +1,64 @@
import useAPI from '../useApi/useApi';
interface ICreatePayload {
id: string;
name: string;
description: string;
}
const useProjectApi = () => {
const { makeRequest, createRequest, errors } = useAPI({
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const createProject = async (payload: ICreatePayload) => {
const path = `api/admin/projects`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const validateId = async (payload: ICreatePayload) => {
const path = `api/admin/projects/validate`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const editProject = async (id: string, payload: ICreatePayload) => {
const path = `api/admin/projects/${id}`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const deleteProject = async (projectId: string) => {
const path = `api/admin/projects/${projectId}`;
const req = createRequest(path, { method: 'DELETE' });
@ -18,7 +72,10 @@ const useProjectApi = () => {
}
};
const addEnvironmentToProject = async (projectId: string, environment: string) => {
const addEnvironmentToProject = async (
projectId: string,
environment: string
) => {
const path = `api/admin/projects/${projectId}/environments`;
const req = createRequest(path, {
method: 'POST',
@ -32,9 +89,12 @@ const useProjectApi = () => {
} catch (e) {
throw e;
}
}
};
const removeEnvironmentFromProject = async (projectId: string, environment: string) => {
const removeEnvironmentFromProject = async (
projectId: string,
environment: string
) => {
const path = `api/admin/projects/${projectId}/environments/${environment}`;
const req = createRequest(path, { method: 'DELETE' });
@ -45,9 +105,18 @@ const useProjectApi = () => {
} catch (e) {
throw e;
}
}
};
return { deleteProject, addEnvironmentToProject, removeEnvironmentFromProject, errors };
return {
createProject,
validateId,
editProject,
deleteProject,
addEnvironmentToProject,
removeEnvironmentFromProject,
errors,
loading,
};
};
export default useProjectApi;

View File

@ -0,0 +1,75 @@
import { ITagPayload } from '../../../../interfaces/tags';
import useAPI from '../useApi/useApi';
const useTagTypesApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const createTag = async (payload: ITagPayload) => {
const path = `api/admin/tag-types`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const validateTagName = async (name: string) => {
const path = `api/admin/tag-types/validate`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify({ name }),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const updateTagType = async (tagName: string, payload: ITagPayload) => {
const path = `api/admin/tag-types/${tagName}`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const deleteTagType = async (tagName: string) => {
const path = `api/admin/tag-types/${tagName}`;
const req = createRequest(path, { method: 'DELETE' });
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
return {
createTag,
validateTagName,
updateTagType,
deleteTagType,
errors,
loading
};
};
export default useTagTypesApi;

View File

@ -0,0 +1,41 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from '../../../../utils/format-path';
import handleErrorResponses from '../httpErrorResponseHandler';
const useTagType = (name: string, options: SWRConfiguration = {}) => {
const fetcher = async () => {
const path = formatApiPath(`api/admin/tag-types/${name}`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Tag data'))
.then(res => res.json());
};
const FEATURE_CACHE_KEY = `api/admin/tag-types/${name}`;
const { data, error } = useSWR(FEATURE_CACHE_KEY, fetcher, {
...options,
});
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(FEATURE_CACHE_KEY);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
tagType: data?.tagType || { name: '', description: '' },
error,
loading,
refetch,
FEATURE_CACHE_KEY,
};
};
export default useTagType;

View File

@ -15,6 +15,8 @@ interface IToastOptions {
type: string;
persist?: boolean;
confetti?: boolean;
autoHideDuration?: number;
show?: boolean;
}
const useToast = () => {

View File

@ -8,3 +8,8 @@ export interface ITagType {
description: string;
icon: string;
}
export interface ITagPayload {
name: string;
description: string;
}

View File

@ -10,7 +10,6 @@
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
@ -18,7 +17,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"strict": true
},
"include": [
"src"

View File

@ -2037,6 +2037,11 @@
resolved "https://registry.npmjs.org/@types/history/-/history-4.7.8.tgz"
integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==
"@types/history@^4.7.11":
version "4.7.11"
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64"
integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==
"@types/hoist-non-react-statics@^3.3.0":
version "3.3.1"
resolved "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz"
@ -2112,10 +2117,10 @@
resolved "https://registry.npmjs.org/@types/node/-/node-14.14.37.tgz"
integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==
"@types/node@14.18.7":
version "14.18.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.7.tgz#bf973dbd8e156dbf860504a8811033cbd26967d1"
integrity sha512-UpLEO1iBG7esNPusSAjoZhWFK5Mfd8QfwWhHRrg5io13POn/stsBgTCba9suQaFflNA4tc0+6AFM3R6BZNng6A==
"@types/node@14.18.9":
version "14.18.9"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.9.tgz#0e5944eefe2b287391279a19b407aa98bd14436d"
integrity sha512-j11XSuRuAlft6vLDEX4RvhqC0KxNxx6QIyMXNb0vHHSNPXTPeiy3algESWmOOIzEtiEL0qiowPU3ewW9hHVa7Q==
"@types/node@^14.14.31":
version "14.17.19"
@ -2164,12 +2169,12 @@
hoist-non-react-statics "^3.3.0"
redux "^4.0.0"
"@types/react-router-dom@5.3.2":
version "5.3.2"
resolved "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.2.tgz"
integrity sha512-ELEYRUie2czuJzaZ5+ziIp9Hhw+juEw8b7C11YNA4QdLCVbQ3qLi2l4aq8XnlqM7V31LZX8dxUuFUCrzHm6sqQ==
"@types/react-router-dom@5.3.3":
version "5.3.3"
resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83"
integrity sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==
dependencies:
"@types/history" "*"
"@types/history" "^4.7.11"
"@types/react" "*"
"@types/react-router" "*"
@ -11265,10 +11270,10 @@ sass-loader@^10.0.5:
schema-utils "^3.0.0"
semver "^7.3.2"
sass@1.48.0:
version "1.48.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.48.0.tgz#b53cfccc1b8ab4be375cc54f306fda9d4711162c"
integrity sha512-hQi5g4DcfjcipotoHZ80l7GNJHGqQS5LwMBjVYB/TaT0vcSSpbgM8Ad7cgfsB2M0MinbkEQQPO9+sjjSiwxqmw==
sass@1.49.0:
version "1.49.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.49.0.tgz#65ec1b1d9a6bc1bae8d2c9d4b392c13f5d32c078"
integrity sha512-TVwVdNDj6p6b4QymJtNtRS2YtLJ/CqZriGg0eIAbAKMlN8Xy6kbv33FsEZSF7FufFFM705SQviHjjThfaQ4VNw==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"