diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index fcd678e3a5e8..7d8e5143bc7e 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -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": {}, diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 6942b5829c81..21f9f99144a3 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -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', diff --git a/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCard.tsx b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCard.tsx index 7b155b5e17b7..a0ab28934c51 100644 --- a/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCard.tsx +++ b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCard.tsx @@ -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 ( - + @@ -71,8 +77,16 @@ export const ReleasePlanTemplateCard = ({ Created by {template.createdByUserId} - e.preventDefault()}> - + { + e.preventDefault(); + e.stopPropagation(); + }} + > + diff --git a/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCardMenu.tsx b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCardMenu.tsx index 08c279dcba50..8a30ad3822b8 100644 --- a/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCardMenu.tsx +++ b/frontend/src/component/releases/ReleaseManagement/ReleasePlanTemplateCardMenu.tsx @@ -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(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 = ({ > { - closeMenu(); + onClick(); }} > Edit template diff --git a/frontend/src/component/releases/ReleasePlanTemplate/EditReleasePlanTemplate.tsx b/frontend/src/component/releases/ReleasePlanTemplate/EditReleasePlanTemplate.tsx new file mode 100644 index 000000000000..ccdf3ff2faca --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/EditReleasePlanTemplate.tsx @@ -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 ( + <> + + + + + {template.milestones.map((milestone) => ( + + + + {milestone.name} + + + + ))} + + + + Cancel + + + + + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx new file mode 100644 index 000000000000..5fdc69515996 --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/TemplateForm.tsx @@ -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>; + description: string; + setDescription: React.Dispatch>; + errors: { [key: string]: string }; + clearErrors: () => void; +} + +export const TemplateForm: React.FC = ({ + name, + setName, + description, + setDescription, + errors, + clearErrors, +}) => { + 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()} + /> + + ); +}; diff --git a/frontend/src/component/releases/hooks/useTemplateForm.ts b/frontend/src/component/releases/hooks/useTemplateForm.ts new file mode 100644 index 000000000000..49ed900e06f9 --- /dev/null +++ b/frontend/src/component/releases/hooks/useTemplateForm.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi.ts b/frontend/src/hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi.ts index 355870c94a32..e1eb349a0d78 100644 --- a/frontend/src/hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi.ts +++ b/frontend/src/hooks/api/actions/useReleasePlanTemplatesApi/useReleasePlanTemplatesApi.ts @@ -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, }; }; diff --git a/frontend/src/hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplate.ts b/frontend/src/hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplate.ts new file mode 100644 index 000000000000..bfcb6aaa852f --- /dev/null +++ b/frontend/src/hooks/api/getters/useReleasePlanTemplates/useReleasePlanTemplate.ts @@ -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( + 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()); +}; diff --git a/frontend/src/interfaces/releasePlans.ts b/frontend/src/interfaces/releasePlans.ts index 099a48a48bc6..a8697145995d 100644 --- a/frontend/src/interfaces/releasePlans.ts +++ b/frontend/src/interfaces/releasePlans.ts @@ -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[]; +}