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

feat: edit release plan template (#8723)

This commit is contained in:
David Leek 2024-11-13 09:37:47 +01:00 committed by GitHub
parent 584be706ec
commit 7feba0c4d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 408 additions and 31 deletions

View File

@ -230,20 +230,6 @@ exports[`returns all baseRoutes 1`] = `
"title": "Strategy types",
"type": "protected",
},
{
"component": [Function],
"enterprise": true,
"flag": "releasePlans",
"menu": {
"advanced": true,
"mode": [
"enterprise",
],
},
"path": "/release-management",
"title": "Release management",
"type": "protected",
},
{
"component": [Function],
"menu": {},
@ -270,6 +256,34 @@ exports[`returns all baseRoutes 1`] = `
"title": "Environments",
"type": "protected",
},
{
"component": [Function],
"enterprise": true,
"flag": "releasePlans",
"menu": {
"advanced": true,
"mode": [
"enterprise",
],
},
"path": "/release-management",
"title": "Release management",
"type": "protected",
},
{
"component": [Function],
"enterprise": true,
"flag": "releasePlans",
"menu": {
"mode": [
"enterprise",
],
},
"parent": "/release-management",
"path": "/release-management/edit/:templateId",
"title": "Edit release plan template",
"type": "protected",
},
{
"component": [Function],
"menu": {},

View File

@ -48,6 +48,7 @@ import { Signals } from 'component/signals/Signals';
import { LazyCreateProject } from '../project/Project/CreateProject/LazyCreateProject';
import { PersonalDashboard } from '../personalDashboard/PersonalDashboard';
import { ReleaseManagement } from 'component/releases/ReleaseManagement/ReleaseManagement';
import { EditReleasePlanTemplate } from 'component/releases/ReleasePlanTemplate/EditReleasePlanTemplate';
export const routes: IRoute[] = [
// Splash
@ -246,15 +247,6 @@ export const routes: IRoute[] = [
type: 'protected',
menu: { mobile: true, advanced: true },
},
{
path: '/release-management',
title: 'Release management',
component: ReleaseManagement,
type: 'protected',
menu: { advanced: true, mode: ['enterprise'] },
flag: 'releasePlans',
enterprise: true,
},
{
path: '/environments/create',
title: 'Environments',
@ -279,6 +271,27 @@ export const routes: IRoute[] = [
enterprise: true,
},
// Release management/plans
{
path: '/release-management',
title: 'Release management',
component: ReleaseManagement,
type: 'protected',
menu: { advanced: true, mode: ['enterprise'] },
flag: 'releasePlans',
enterprise: true,
},
{
path: '/release-management/edit/:templateId',
title: 'Edit release plan template',
parent: '/release-management',
component: EditReleasePlanTemplate,
type: 'protected',
menu: { mode: ['enterprise'] },
flag: 'releasePlans',
enterprise: true,
},
// Tags
{
path: '/tag-types/create',

View File

@ -2,6 +2,7 @@ import type { IReleasePlanTemplate } from 'interfaces/releasePlans';
import { ReactComponent as ReleaseTemplateIcon } from 'assets/img/releaseTemplates.svg';
import { styled, Typography } from '@mui/material';
import { ReleasePlanTemplateCardMenu } from './ReleasePlanTemplateCardMenu';
import { useNavigate } from 'react-router-dom';
const StyledTemplateCard = styled('aside')(({ theme }) => ({
height: '100%',
@ -58,8 +59,13 @@ const StyledMenu = styled('div')(({ theme }) => ({
export const ReleasePlanTemplateCard = ({
template,
}: { template: IReleasePlanTemplate }) => {
const navigate = useNavigate();
const onClick = () => {
navigate(`/release-management/edit/${template.id}`);
};
return (
<StyledTemplateCard>
<StyledTemplateCard onClick={onClick}>
<TemplateCardHeader>
<StyledCenter>
<ReleaseTemplateIcon />
@ -71,8 +77,16 @@ export const ReleasePlanTemplateCard = ({
<StyledCreatedBy>
Created by {template.createdByUserId}
</StyledCreatedBy>
<StyledMenu onClick={(e) => e.preventDefault()}>
<ReleasePlanTemplateCardMenu template={template} />
<StyledMenu
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<ReleasePlanTemplateCardMenu
template={template}
onClick={onClick}
/>
</StyledMenu>
</StyledDiv>
</TemplateCardBody>

View File

@ -16,7 +16,8 @@ import { TemplateDeleteDialog } from './TemplateDeleteDialog';
export const ReleasePlanTemplateCardMenu = ({
template,
}: { template: IReleasePlanTemplate }) => {
onClick,
}: { template: IReleasePlanTemplate; onClick: () => void }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
const { deleteReleasePlanTemplate } = useReleasePlanTemplatesApi();
@ -43,6 +44,7 @@ export const ReleasePlanTemplateCardMenu = ({
};
const handleMenuClick = (event: React.SyntheticEvent) => {
event.stopPropagation();
if (isMenuOpen) {
closeMenu();
} else {
@ -81,7 +83,7 @@ export const ReleasePlanTemplateCardMenu = ({
>
<MenuItem
onClick={() => {
closeMenu();
onClick();
}}
>
<ListItemText>Edit template</ListItemText>

View File

@ -0,0 +1,140 @@
import { useUiFlag } from 'hooks/useUiFlag';
import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useReleasePlanTemplate } from 'hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplate';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { useTemplateForm } from '../hooks/useTemplateForm';
import { TemplateForm } from './TemplateForm';
import { Box, Button, Card, styled } from '@mui/material';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import { ADMIN } from '@server/types/permissions';
import { useNavigate } from 'react-router-dom';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import useReleasePlanTemplatesApi from 'hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi';
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const StyledMilestoneCard = styled(Card)(({ theme }) => ({
marginTop: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`,
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.background.default,
'&:hover': {
backgroundColor: theme.palette.neutral.light,
},
borderRadius: theme.shape.borderRadiusMedium,
}));
const StyledMilestoneCardBody = styled(Box)(({ theme }) => ({
padding: theme.spacing(3, 2),
}));
const StyledMilestoneCardTitle = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.bodySize,
}));
const StyledButtonContainer = styled('div')(() => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
}));
const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
export const EditReleasePlanTemplate = () => {
const releasePlansEnabled = useUiFlag('releasePlans');
const templateId = useRequiredPathParam('templateId');
const { template, loading, error, refetch } =
useReleasePlanTemplate(templateId);
usePageTitle(`Edit template: ${template.name}`);
const navigate = useNavigate();
const { setToastApiError } = useToast();
const { updateReleasePlanTemplate } = useReleasePlanTemplatesApi();
const {
name,
setName,
description,
setDescription,
errors,
clearErrors,
validate,
getTemplatePayload,
} = useTemplateForm(template.name, template.description);
const handleCancel = () => {
navigate('/release-management');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
const isValid = validate();
if (isValid) {
const payload = getTemplatePayload();
try {
await updateReleasePlanTemplate({
...payload,
id: templateId,
milestones: template.milestones,
});
navigate('/release-management');
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
if (!releasePlansEnabled) {
return null;
}
return (
<>
<FormTemplate
title={`Edit template ${template.name}`}
description='Edit a release plan template that makes it easier for you and your team to release features.'
>
<StyledForm onSubmit={handleSubmit}>
<TemplateForm
name={name}
setName={setName}
description={description}
setDescription={setDescription}
errors={errors}
clearErrors={clearErrors}
/>
{template.milestones.map((milestone) => (
<StyledMilestoneCard key={milestone.id}>
<StyledMilestoneCardBody>
<StyledMilestoneCardTitle>
{milestone.name}
</StyledMilestoneCardTitle>
</StyledMilestoneCardBody>
</StyledMilestoneCard>
))}
<StyledButtonContainer>
<UpdateButton name='template' permission={ADMIN} />
<StyledCancelButton onClick={handleCancel}>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</>
);
};

View File

@ -0,0 +1,57 @@
import Input from 'component/common/Input/Input';
import { styled } from '@mui/material';
const StyledInputDescription = styled('p')(({ theme }) => ({
marginBottom: theme.spacing(1),
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
marginBottom: theme.spacing(2),
}));
interface ITemplateForm {
name: string;
setName: React.Dispatch<React.SetStateAction<string>>;
description: string;
setDescription: React.Dispatch<React.SetStateAction<string>>;
errors: { [key: string]: string };
clearErrors: () => void;
}
export const TemplateForm: React.FC<ITemplateForm> = ({
name,
setName,
description,
setDescription,
errors,
clearErrors,
}) => {
return (
<>
<StyledInputDescription>
What would you like to call your template?
</StyledInputDescription>
<StyledInput
label='Template name'
value={name}
onChange={(e) => setName(e.target.value)}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
autoFocus
/>
<StyledInputDescription>
What's the purpose of this template?
</StyledInputDescription>
<StyledInput
label='Template description (optional)'
value={description}
onChange={(e) => setDescription(e.target.value)}
error={Boolean(errors.description)}
errorText={errors.description}
onFocus={() => clearErrors()}
/>
</>
);
};

View File

@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
export const useTemplateForm = (initialName = '', initialDescription = '') => {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [errors, setErrors] = useState({});
useEffect(() => {
setName(initialName);
}, [initialName]);
useEffect(() => {
setDescription(initialDescription);
}, [initialDescription]);
const validate = () => {
if (name.length === 0) {
setErrors((prev) => ({ ...prev, name: 'Name can not be empty.' }));
return false;
}
return true;
};
const clearErrors = () => {
setErrors({});
};
const getTemplatePayload = () => {
return {
name,
description,
};
};
return {
name,
setName,
description,
setDescription,
errors,
clearErrors,
validate,
getTemplatePayload,
};
};

View File

@ -1,3 +1,4 @@
import type { IReleasePlanTemplatePayload } from 'interfaces/releasePlans';
import useAPI from '../useApi/useApi';
export const useReleasePlanTemplatesApi = () => {
@ -7,16 +8,39 @@ export const useReleasePlanTemplatesApi = () => {
});
const deleteReleasePlanTemplate = async (id: string) => {
const requestId = 'deleteReleasePlanTemplate';
const path = `api/admin/release-plan-templates/${id}`;
const req = createRequest(path, {
method: 'DELETE',
});
const req = createRequest(
path,
{
method: 'DELETE',
},
requestId,
);
return makeRequest(req.caller, req.id);
};
const updateReleasePlanTemplate = async (
template: IReleasePlanTemplatePayload,
) => {
const requestId = 'updateReleasePlanTemplate';
const path = `api/admin/release-plan-templates/${template.id}`;
const req = createRequest(
path,
{
method: 'PUT',
body: JSON.stringify(template),
},
requestId,
);
return makeRequest(req.caller, req.id);
};
return {
deleteReleasePlanTemplate,
updateReleasePlanTemplate,
};
};

View File

@ -0,0 +1,47 @@
import { useMemo } from 'react';
import useUiConfig from '../useUiConfig/useUiConfig';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import { useUiFlag } from 'hooks/useUiFlag';
import type { IReleasePlanTemplate } from 'interfaces/releasePlans';
const path = (templateId: string) =>
`api/admin/release-plan-templates/${templateId}`;
const DEFAULT_DATA: IReleasePlanTemplate = {
id: '',
name: '',
description: '',
milestones: [],
createdAt: '',
createdByUserId: 0,
};
export const useReleasePlanTemplate = (templateId: string) => {
const { isEnterprise } = useUiConfig();
const releasePlansEnabled = useUiFlag('releasePlans');
const { data, error, mutate } = useConditionalSWR<IReleasePlanTemplate>(
isEnterprise() && releasePlansEnabled,
DEFAULT_DATA,
formatApiPath(path(templateId)),
fetcher,
);
return useMemo(
() => ({
template: data ?? DEFAULT_DATA,
loading: !error && !data,
refetch: () => mutate(),
error,
}),
[data, error, mutate],
);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Release plan template'))
.then((res) => res.json());
};

View File

@ -5,3 +5,24 @@ export interface IReleasePlanTemplate {
createdAt: string;
createdByUserId: number;
}
export interface IReleasePlanTemplate {
id: string;
name: string;
description: string;
createdAt: string;
createdByUserId: number;
milestones: IReleasePlanMilestone[];
}
export interface IReleasePlanMilestone {
id: string;
name: string;
}
export interface IReleasePlanTemplatePayload {
id?: string;
name: string;
description: string;
milestones?: IReleasePlanMilestone[];
}