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; }