Skip to content

Commit

Permalink
feat: rework frontend validation for release plan templates (#9055)
Browse files Browse the repository at this point in the history
  • Loading branch information
daveleek authored Jan 3, 2025
1 parent 3c16616 commit 7893d3f
Show file tree
Hide file tree
Showing 4 changed files with 182 additions and 100 deletions.
115 changes: 27 additions & 88 deletions frontend/src/component/releases/ReleasePlanTemplate/MilestoneCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Input from 'component/common/Input/Input';
import {
Box,
Button,
Expand All @@ -10,9 +9,7 @@ import {
AccordionSummary,
AccordionDetails,
IconButton,
useTheme,
} from '@mui/material';
import Edit from '@mui/icons-material/Edit';
import Delete from '@mui/icons-material/DeleteOutlined';
import type {
IReleasePlanMilestonePayload,
Expand All @@ -22,23 +19,17 @@ import { type DragEventHandler, type RefObject, useState } from 'react';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenuCards';
import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem';
import { MilestoneCardName } from './MilestoneCardName';

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 }) => ({
const StyledMilestoneCard = styled(Card, {
shouldForwardProp: (prop) => prop !== 'hasError',
})<{ hasError: boolean }>(({ theme, hasError }) => ({
marginTop: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`,
border: `1px solid ${hasError ? theme.palette.error.border : theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium,
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
Expand All @@ -56,15 +47,6 @@ const StyledGridItem = styled(Grid)(({ theme }) => ({
alignItems: 'center',
}));

const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
}));

const StyledMilestoneCardTitle = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.bodySize,
}));

const StyledAddStrategyButton = styled(Button)(({ theme }) => ({}));

const StyledAccordion = styled(Accordion)(({ theme }) => ({
Expand Down Expand Up @@ -131,6 +113,7 @@ interface IMilestoneCardProps {
) => void;
errors: { [key: string]: string };
clearErrors: () => void;
removable: boolean;
onDeleteMilestone: () => void;
}

Expand All @@ -140,16 +123,15 @@ export const MilestoneCard = ({
showAddStrategyDialog,
errors,
clearErrors,
removable,
onDeleteMilestone,
}: IMilestoneCardProps) => {
const [editMode, setEditMode] = useState(false);
const [anchor, setAnchor] = useState<Element>();
const [dragItem, setDragItem] = useState<{
id: string;
index: number;
height: number;
} | null>(null);
const theme = useTheme();
const isPopoverOpen = Boolean(anchor);
const popoverId = isPopoverOpen
? 'MilestoneStrategyMenuPopover'
Expand Down Expand Up @@ -261,43 +243,21 @@ export const MilestoneCard = ({

if (!milestone.strategies || milestone.strategies.length === 0) {
return (
<StyledMilestoneCard>
<StyledMilestoneCard
hasError={
Boolean(errors?.[milestone.id]) ||
Boolean(errors?.[`${milestone.id}_name`])
}
>
<StyledMilestoneCardBody>
<Grid container>
<StyledGridItem item xs={8} md={9}>
{editMode && (
<StyledInput
label=''
value={milestone.name}
onChange={(e) =>
milestoneNameChanged(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)}
/>
</>
)}
<MilestoneCardName
milestone={milestone}
errors={errors}
clearErrors={clearErrors}
milestoneNameChanged={milestoneNameChanged}
/>
</StyledGridItem>
<StyledMilestoneActionGrid item xs={4} md={3}>
<Button
Expand All @@ -310,6 +270,7 @@ export const MilestoneCard = ({
<StyledIconButton
title='Remove milestone'
onClick={onDeleteMilestone}
disabled={!removable}
>
<Delete />
</StyledIconButton>
Expand Down Expand Up @@ -342,35 +303,12 @@ export const MilestoneCard = ({
<StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />}
>
{editMode && (
<StyledInput
label=''
value={milestone.name}
onChange={(e) => milestoneNameChanged(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)} />
</>
)}
<MilestoneCardName
milestone={milestone}
errors={errors}
clearErrors={clearErrors}
milestoneNameChanged={milestoneNameChanged}
/>
</StyledAccordionSummary>
<StyledAccordionDetails>
{milestone.strategies.map((strg, index) => (
Expand Down Expand Up @@ -403,6 +341,7 @@ export const MilestoneCard = ({
variant='text'
color='primary'
onClick={onDeleteMilestone}
disabled={!removable}
>
<Delete /> Remove milestone
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { IReleasePlanMilestonePayload } from 'interfaces/releasePlans';
import Edit from '@mui/icons-material/Edit';
import { styled } from '@mui/material';
import { useState } from 'react';
import Input from 'component/common/Input/Input';

const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
}));

const StyledMilestoneCardTitle = styled('span')(({ theme }) => ({
fontWeight: theme.fontWeight.bold,
fontSize: theme.fontSizes.bodySize,
}));

const StyledEditIcon = styled(Edit, {
shouldForwardProp: (prop) => prop !== 'hasError',
})<{ hasError: boolean }>(({ theme, hasError = false }) => ({
cursor: 'pointer',
marginTop: theme.spacing(-0.25),
marginLeft: theme.spacing(0.5),
height: theme.spacing(2.5),
width: theme.spacing(2.5),
color: hasError ? theme.palette.error.main : theme.palette.text.secondary,
}));

interface IMilestoneCardNameProps {
milestone: IReleasePlanMilestonePayload;
errors: { [key: string]: string };
clearErrors: () => void;
milestoneNameChanged: (name: string) => void;
}

export const MilestoneCardName = ({
milestone,
errors,
clearErrors,
milestoneNameChanged,
}: IMilestoneCardNameProps) => {
const [editMode, setEditMode] = useState(false);
return (
<>
{editMode && (
<StyledInput
label=''
value={milestone.name}
onChange={(e) => milestoneNameChanged(e.target.value)}
error={Boolean(errors?.[`${milestone.id}_name`])}
errorText={errors?.[`${milestone.id}_name`]}
onFocus={() => clearErrors()}
onBlur={() => setEditMode(false)}
autoFocus
onKeyDownCapture={(e) => {
if (e.code === 'Enter') {
e.preventDefault();
e.stopPropagation();
setEditMode(false);
}
}}
/>
)}
{!editMode && (
<>
<StyledMilestoneCardTitle
onClick={(ev) => {
setEditMode(true);
ev.preventDefault();
ev.stopPropagation();
}}
>
{milestone.name}
</StyledMilestoneCardTitle>
<StyledEditIcon
hasError={Boolean(errors?.[`${milestone.id}_name`])}
onClick={(ev) => {
setEditMode(true);
ev.preventDefault();
ev.stopPropagation();
}}
/>
</>
)}
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {
IReleasePlanMilestoneStrategy,
} from 'interfaces/releasePlans';
import { MilestoneCard } from './MilestoneCard';
import { styled, Button } from '@mui/material';
import { styled, Button, FormHelperText } from '@mui/material';
import Add from '@mui/icons-material/Add';
import { v4 as uuidv4 } from 'uuid';

Expand Down Expand Up @@ -46,15 +46,22 @@ export const MilestoneList = ({
return (
<>
{milestones.map((milestone) => (
<MilestoneCard
key={milestone.id}
milestone={milestone}
milestoneChanged={milestoneChanged}
showAddStrategyDialog={openAddStrategyForm}
errors={errors}
clearErrors={clearErrors}
onDeleteMilestone={onDeleteMilestone(milestone.id)}
/>
<>
<MilestoneCard
key={milestone.id}
milestone={milestone}
milestoneChanged={milestoneChanged}
showAddStrategyDialog={openAddStrategyForm}
errors={errors}
clearErrors={clearErrors}
removable={milestones.length > 1}
onDeleteMilestone={onDeleteMilestone(milestone.id)}
/>

<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
</>
))}
<StyledAddMilestoneButton
variant='text'
Expand Down
55 changes: 53 additions & 2 deletions frontend/src/component/releases/hooks/useTemplateForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,62 @@ export const useTemplateForm = (
}, [initialMilestones.length]);

const validate = () => {
let valid = true;

if (name.length === 0) {
setErrors((prev) => ({ ...prev, name: 'Name can not be empty.' }));
return false;
valid = false;
}

if (milestones.length === 0) {
setErrors((prev) => ({
...prev,
milestones: 'At least one milestone is required.',
}));
valid = false;
}

const milestoneNames = milestones.filter(
(m) => !m.name || m.name.length === 0,
);
if (milestoneNames && milestoneNames.length > 0) {
setErrors((prev) => ({
...prev,
...Object.assign(
{},
...milestoneNames.map((mst) => ({
[mst.id]: 'Milestone must have a valid name.',
})),
),
...Object.assign(
{},
...milestoneNames.map((mst) => ({
[`${mst.id}_name`]: 'Milestone must have a valid name.',
})),
),
}));
valid = false;
}

const emptyMilestones = milestones.filter(
(m) => !m.strategies || m.strategies.length === 0,
);
if (emptyMilestones && emptyMilestones.length > 0) {
setErrors((prev) => ({
...prev,
milestones:
'All milestones must have at least one strategy each.',
...Object.assign(
{},
...emptyMilestones.map((mst) => ({
[mst.id]: 'Milestone must have at least one strategy.',
})),
),
}));
valid = false;
}
return true;

return valid;
};

const clearErrors = () => {
Expand Down

0 comments on commit 7893d3f

Please sign in to comment.