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

feat: create and edit release plan template milestones (#8768)

This commit is contained in:
David Leek 2024-11-19 13:52:07 +01:00 committed by GitHub
parent 82e752be45
commit 8935a01d90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 412 additions and 136 deletions

View File

@ -1,10 +1,9 @@
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { usePageTitle } from 'hooks/usePageTitle';
import { Button, styled } from '@mui/material';
import { TemplateForm } from './TemplateForm';
import { useTemplateForm } from '../hooks/useTemplateForm';
import { CreateButton } from 'component/common/CreateButton/CreateButton';
import { ADMIN } from '@server/types/permissions';
import { RELEASE_PLAN_TEMPLATE_CREATE } from '@server/types/permissions';
import { useNavigate } from 'react-router-dom';
import { GO_BACK } from 'constants/navigate';
import useReleasePlanTemplatesApi from 'hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi';
@ -13,12 +12,6 @@ import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const StyledButtonContainer = styled('div')(() => ({
marginTop: 'auto',
display: 'flex',
@ -31,15 +24,17 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
export const CreateReleasePlanTemplate = () => {
const releasePlansEnabled = useUiFlag('releasePlans');
usePageTitle('Create release plan template');
const { setToastApiError, setToastData } = useToast();
const navigate = useNavigate();
const { createReleasePlanTemplate } = useReleasePlanTemplatesApi();
usePageTitle('Create release plan template');
const {
name,
setName,
description,
setDescription,
milestones,
setMilestones,
errors,
clearErrors,
validate,
@ -57,7 +52,10 @@ export const CreateReleasePlanTemplate = () => {
if (isValid) {
const payload = getTemplatePayload();
try {
const template = await createReleasePlanTemplate(payload);
const template = await createReleasePlanTemplate({
...payload,
milestones,
});
scrollToTop();
setToastData({
type: 'success',
@ -75,28 +73,28 @@ export const CreateReleasePlanTemplate = () => {
}
return (
<>
<FormTemplate
title='Create release plan template'
description='Create a release plan template to make 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}
/>
<StyledButtonContainer>
<CreateButton name='template' permission={ADMIN} />
<StyledCancelButton onClick={handleCancel}>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</>
<TemplateForm
name={name}
setName={setName}
description={description}
setDescription={setDescription}
milestones={milestones}
setMilestones={setMilestones}
errors={errors}
clearErrors={clearErrors}
formTitle='Create release plan template'
formDescription='Create a release plan template to make it easier for you and your team to release features.'
handleSubmit={handleSubmit}
>
<StyledButtonContainer>
<CreateButton
name='template'
permission={RELEASE_PLAN_TEMPLATE_CREATE}
/>
<StyledCancelButton onClick={handleCancel}>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</TemplateForm>
);
};

View File

@ -2,50 +2,16 @@ 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 { Button, styled } from '@mui/material';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import { ADMIN } from '@server/types/permissions';
import { RELEASE_PLAN_TEMPLATE_UPDATE } 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',
@ -63,7 +29,7 @@ export const EditReleasePlanTemplate = () => {
useReleasePlanTemplate(templateId);
usePageTitle(`Edit template: ${template.name}`);
const navigate = useNavigate();
const { setToastApiError } = useToast();
const { setToastApiError, setToastData } = useToast();
const { updateReleasePlanTemplate } = useReleasePlanTemplatesApi();
const {
name,
@ -72,9 +38,15 @@ export const EditReleasePlanTemplate = () => {
setDescription,
errors,
clearErrors,
milestones,
setMilestones,
validate,
getTemplatePayload,
} = useTemplateForm(template.name, template.description);
} = useTemplateForm(
template.name,
template.description,
template.milestones,
);
const handleCancel = () => {
navigate('/release-management');
@ -89,9 +61,13 @@ export const EditReleasePlanTemplate = () => {
await updateReleasePlanTemplate({
...payload,
id: templateId,
milestones: template.milestones,
milestones,
});
await refetch();
setToastData({
type: 'success',
title: 'Release plan template updated',
});
navigate('/release-management');
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
@ -103,38 +79,29 @@ export const EditReleasePlanTemplate = () => {
}
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>
</>
<TemplateForm
name={name}
setName={setName}
description={description}
setDescription={setDescription}
milestones={milestones}
setMilestones={setMilestones}
errors={errors}
clearErrors={clearErrors}
formTitle={`Edit template ${template.name}`}
formDescription='Edit a release plan template that makes it easier for you and your team to release features.'
handleSubmit={handleSubmit}
loading={loading}
>
<StyledButtonContainer>
<UpdateButton
name='template'
permission={RELEASE_PLAN_TEMPLATE_UPDATE}
/>
<StyledCancelButton onClick={handleCancel}>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</TemplateForm>
);
};

View File

@ -0,0 +1,123 @@
import Input from 'component/common/Input/Input';
import { Box, Button, Card, Grid, styled } from '@mui/material';
import Edit from '@mui/icons-material/Edit';
import type { IReleasePlanMilestonePayload } from 'interfaces/releasePlans';
import { useState } from 'react';
const StyledEditIcon = styled(Edit)(({ theme }) => ({
cursor: 'pointer',
marginTop: theme.spacing(-0.25),
marginLeft: theme.spacing(0.5),
height: theme.spacing(2.5),
width: theme.spacing(2.5),
color: theme.palette.text.secondary,
}));
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(2, 2),
}));
const StyledGridItem = styled(Grid)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
}));
const StyledMilestoneCardTitle = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.bodySize,
}));
interface IMilestoneCardProps {
index: number;
milestone: IReleasePlanMilestonePayload;
milestoneNameChanged: (index: number, name: string) => void;
showAddStrategyDialog: (index: number) => void;
errors: { [key: string]: string };
clearErrors: () => void;
}
export const MilestoneCard = ({
index,
milestone,
milestoneNameChanged,
showAddStrategyDialog,
errors,
clearErrors,
}: IMilestoneCardProps) => {
const [editMode, setEditMode] = useState(false);
return (
<StyledMilestoneCard>
<StyledMilestoneCardBody>
<Grid container>
<StyledGridItem item xs={10} md={10}>
{editMode && (
<StyledInput
label=''
value={milestone.name}
onChange={(e) =>
milestoneNameChanged(index, e.target.value)
}
error={Boolean(errors?.name)}
errorText={errors?.name}
onFocus={() => clearErrors()}
onBlur={() => setEditMode(false)}
autoFocus
onKeyDownCapture={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
e.stopPropagation();
setEditMode(false);
}
}}
/>
)}
{!editMode && (
<>
<StyledMilestoneCardTitle
onClick={() => setEditMode(true)}
>
{milestone.name}
</StyledMilestoneCardTitle>
<StyledEditIcon
onClick={() => setEditMode(true)}
/>
</>
)}
</StyledGridItem>
<Grid item xs={2} md={2}>
<Button
variant='outlined'
color='primary'
onClick={() => showAddStrategyDialog(index)}
>
Add strategy
</Button>
</Grid>
</Grid>
</StyledMilestoneCardBody>
</StyledMilestoneCard>
);
};

View File

@ -0,0 +1,72 @@
import type { IReleasePlanMilestonePayload } from 'interfaces/releasePlans';
import { MilestoneCard } from './MilestoneCard';
import { styled } from '@mui/material';
import { Button } from '@mui/material';
import Add from '@mui/icons-material/Add';
interface IMilestoneListProps {
milestones: IReleasePlanMilestonePayload[];
setMilestones: React.Dispatch<
React.SetStateAction<IReleasePlanMilestonePayload[]>
>;
setAddStrategyOpen: (open: boolean) => void;
errors: { [key: string]: string };
clearErrors: () => void;
}
const StyledAddMilestoneButton = styled(Button)(({ theme }) => ({
marginTop: theme.spacing(1),
maxWidth: theme.spacing(20),
}));
export const MilestoneList = ({
milestones,
setMilestones,
setAddStrategyOpen,
errors,
clearErrors,
}: IMilestoneListProps) => {
const showAddStrategyDialog = (index: number) => {
setAddStrategyOpen(true);
};
const milestoneNameChanged = (index: number, name: string) => {
setMilestones((prev) =>
prev.map((milestone, i) =>
i === index ? { ...milestone, name } : milestone,
),
);
};
return (
<>
{milestones.map((milestone, index) => (
<MilestoneCard
key={`milestone_${index.toString()}`}
index={index}
milestone={milestone}
milestoneNameChanged={milestoneNameChanged}
showAddStrategyDialog={showAddStrategyDialog}
errors={errors}
clearErrors={clearErrors}
/>
))}
<StyledAddMilestoneButton
variant='text'
color='primary'
startIcon={<Add />}
onClick={() =>
setMilestones((prev) => [
...prev,
{
name: `Milestone ${prev.length + 1}`,
sortOrder: prev.length,
},
])
}
>
Add milestone
</StyledAddMilestoneButton>
</>
);
};

View File

@ -0,0 +1,33 @@
import { Button, styled } from '@mui/material';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
const StyledButtonContainer = styled('div')(() => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
}));
interface IReleasePlanTemplateAddStrategyFormProps {
onCancel: () => void;
}
export const ReleasePlanTemplateAddStrategyForm = ({
onCancel,
}: IReleasePlanTemplateAddStrategyFormProps) => {
return (
<FormTemplate
modal
description='Add a strategy to your release plan template.'
>
<StyledButtonContainer>
<StyledCancelButton onClick={onCancel}>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</FormTemplate>
);
};

View File

@ -1,5 +1,12 @@
import Input from 'component/common/Input/Input';
import { styled } from '@mui/material';
import { MilestoneList } from './MilestoneList';
import type { IReleasePlanMilestonePayload } from 'interfaces/releasePlans';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import ReleaseTemplateIcon from '@mui/icons-material/DashboardOutlined';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { useState } from 'react';
import { ReleasePlanTemplateAddStrategyForm } from './ReleasePlanTemplateAddStrategyForm';
const StyledInputDescription = styled('p')(({ theme }) => ({
marginBottom: theme.spacing(1),
@ -10,48 +17,98 @@ const StyledInput = styled(Input)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
interface ITemplateForm {
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
interface ITemplateFormProps {
name: string;
setName: React.Dispatch<React.SetStateAction<string>>;
description: string;
setDescription: React.Dispatch<React.SetStateAction<string>>;
milestones: IReleasePlanMilestonePayload[];
setMilestones: React.Dispatch<
React.SetStateAction<IReleasePlanMilestonePayload[]>
>;
errors: { [key: string]: string };
clearErrors: () => void;
formTitle: string;
formDescription: string;
handleSubmit: (e: React.FormEvent) => void;
loading?: boolean;
children?: React.ReactNode;
}
export const TemplateForm: React.FC<ITemplateForm> = ({
export const TemplateForm: React.FC<ITemplateFormProps> = ({
name,
setName,
description,
setDescription,
milestones,
setMilestones,
errors,
clearErrors,
formTitle,
formDescription,
handleSubmit,
children,
}) => {
const [addStrategyOpen, setAddStrategyOpen] = useState(false);
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()}
/>
</>
<FormTemplate
title={formTitle}
description={formDescription}
documentationIcon={<ReleaseTemplateIcon />}
>
<StyledForm onSubmit={handleSubmit}>
<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()}
/>
<MilestoneList
milestones={milestones}
setMilestones={setMilestones}
setAddStrategyOpen={setAddStrategyOpen}
errors={errors}
clearErrors={clearErrors}
/>
{children}
<SidebarModal
label='Add strategy to template milestone'
onClose={() => {}}
open={addStrategyOpen}
>
<ReleasePlanTemplateAddStrategyForm
onCancel={() => {
setAddStrategyOpen(false);
}}
/>
</SidebarModal>
</StyledForm>
</FormTemplate>
);
};

View File

@ -1,8 +1,16 @@
import type { IReleasePlanMilestonePayload } from 'interfaces/releasePlans';
import { useEffect, useState } from 'react';
export const useTemplateForm = (initialName = '', initialDescription = '') => {
export const useTemplateForm = (
initialName = '',
initialDescription = '',
initialMilestones: IReleasePlanMilestonePayload[] = [
{ name: 'Milestone 1', sortOrder: 0 },
],
) => {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [milestones, setMilestones] = useState(initialMilestones);
const [errors, setErrors] = useState({});
useEffect(() => {
@ -13,6 +21,10 @@ export const useTemplateForm = (initialName = '', initialDescription = '') => {
setDescription(initialDescription);
}, [initialDescription]);
useEffect(() => {
setMilestones(initialMilestones);
}, [initialMilestones.length]);
const validate = () => {
if (name.length === 0) {
setErrors((prev) => ({ ...prev, name: 'Name can not be empty.' }));
@ -37,6 +49,8 @@ export const useTemplateForm = (initialName = '', initialDescription = '') => {
setName,
description,
setDescription,
milestones,
setMilestones,
errors,
clearErrors,
validate,

View File

@ -12,7 +12,7 @@ export interface IReleasePlanTemplate {
description: string;
createdAt: string;
createdByUserId: number;
milestones: IReleasePlanMilestone[];
milestones: IReleasePlanMilestonePayload[];
}
export interface IReleasePlanMilestone {
@ -24,5 +24,17 @@ export interface IReleasePlanTemplatePayload {
id?: string;
name: string;
description: string;
milestones?: IReleasePlanMilestone[];
milestones?: IReleasePlanMilestonePayload[];
}
export interface IReleasePlanMilestonePayload {
id?: string;
name: string;
sortOrder: number;
strategies?: IReleasePlanStrategyPayload[];
}
export interface IReleasePlanStrategyPayload {
id?: string;
name: string;
}