diff --git a/frontend/src/component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate.tsx b/frontend/src/component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate.tsx
index ef52aff4de..77ab70e4d8 100644
--- a/frontend/src/component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate.tsx
+++ b/frontend/src/component/releases/ReleasePlanTemplate/CreateReleasePlanTemplate.tsx
@@ -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 (
- <>
-
-
-
-
-
-
- Cancel
-
-
-
-
- >
+
+
+
+
+ Cancel
+
+
+
);
};
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/EditReleasePlanTemplate.tsx b/frontend/src/component/releases/ReleasePlanTemplate/EditReleasePlanTemplate.tsx
index ccdf3ff2fa..1a4796f9f3 100644
--- a/frontend/src/component/releases/ReleasePlanTemplate/EditReleasePlanTemplate.tsx
+++ b/frontend/src/component/releases/ReleasePlanTemplate/EditReleasePlanTemplate.tsx
@@ -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 (
- <>
-
-
-
-
- {template.milestones.map((milestone) => (
-
-
-
- {milestone.name}
-
-
-
- ))}
-
-
-
- Cancel
-
-
-
-
- >
+
+
+
+
+ Cancel
+
+
+
);
};
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx
new file mode 100644
index 0000000000..e185de66da
--- /dev/null
+++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx
@@ -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 (
+
+
+
+
+ {editMode && (
+
+ 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 && (
+ <>
+ setEditMode(true)}
+ >
+ {milestone.name}
+
+ setEditMode(true)}
+ />
+ >
+ )}
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx
new file mode 100644
index 0000000000..50d4cbe4f1
--- /dev/null
+++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneList.tsx
@@ -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
+ >;
+ 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) => (
+
+ ))}
+ }
+ onClick={() =>
+ setMilestones((prev) => [
+ ...prev,
+ {
+ name: `Milestone ${prev.length + 1}`,
+ sortOrder: prev.length,
+ },
+ ])
+ }
+ >
+ Add milestone
+
+ >
+ );
+};
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx
new file mode 100644
index 0000000000..7395c0dcbe
--- /dev/null
+++ b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx
@@ -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 (
+
+
+
+ Cancel
+
+
+
+ );
+};
diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx
index 5fdc695159..c7164e1911 100644
--- a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx
+++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx
@@ -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>;
description: string;
setDescription: React.Dispatch>;
+ milestones: IReleasePlanMilestonePayload[];
+ setMilestones: React.Dispatch<
+ React.SetStateAction
+ >;
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 = ({
+export const TemplateForm: React.FC = ({
name,
setName,
description,
setDescription,
+ milestones,
+ setMilestones,
errors,
clearErrors,
+ formTitle,
+ formDescription,
+ handleSubmit,
+ children,
}) => {
+ const [addStrategyOpen, setAddStrategyOpen] = useState(false);
+
return (
- <>
-
- What would you like to call your template?
-
- setName(e.target.value)}
- error={Boolean(errors.name)}
- errorText={errors.name}
- onFocus={() => clearErrors()}
- autoFocus
- />
-
- What's the purpose of this template?
-
- setDescription(e.target.value)}
- error={Boolean(errors.description)}
- errorText={errors.description}
- onFocus={() => clearErrors()}
- />
- >
+ }
+ >
+
+
+ What would you like to call your template?
+
+ setName(e.target.value)}
+ error={Boolean(errors.name)}
+ errorText={errors.name}
+ onFocus={() => clearErrors()}
+ autoFocus
+ />
+
+ What's the purpose of this template?
+
+ setDescription(e.target.value)}
+ error={Boolean(errors.description)}
+ errorText={errors.description}
+ onFocus={() => clearErrors()}
+ />
+
+
+ {children}
+
+ {}}
+ open={addStrategyOpen}
+ >
+ {
+ setAddStrategyOpen(false);
+ }}
+ />
+
+
+
);
};
diff --git a/frontend/src/component/releases/hooks/useTemplateForm.ts b/frontend/src/component/releases/hooks/useTemplateForm.ts
index 49ed900e06..a43bff25b6 100644
--- a/frontend/src/component/releases/hooks/useTemplateForm.ts
+++ b/frontend/src/component/releases/hooks/useTemplateForm.ts
@@ -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,
diff --git a/frontend/src/interfaces/releasePlans.ts b/frontend/src/interfaces/releasePlans.ts
index a869714599..8ad6d74015 100644
--- a/frontend/src/interfaces/releasePlans.ts
+++ b/frontend/src/interfaces/releasePlans.ts
@@ -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;
}