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:
parent
584be706ec
commit
7feba0c4d4
@ -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": {},
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
45
frontend/src/component/releases/hooks/useTemplateForm.ts
Normal file
45
frontend/src/component/releases/hooks/useTemplateForm.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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());
|
||||
};
|
@ -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[];
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user