1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-17 13:46:47 +02:00

feat: rework frontend validation for release plan templates (#9055)

This commit is contained in:
David Leek 2025-01-03 13:19:15 +01:00 committed by GitHub
parent 3c16616c36
commit 7893d3fbd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 182 additions and 100 deletions

View File

@ -1,4 +1,3 @@
import Input from 'component/common/Input/Input';
import { import {
Box, Box,
Button, Button,
@ -10,9 +9,7 @@ import {
AccordionSummary, AccordionSummary,
AccordionDetails, AccordionDetails,
IconButton, IconButton,
useTheme,
} from '@mui/material'; } from '@mui/material';
import Edit from '@mui/icons-material/Edit';
import Delete from '@mui/icons-material/DeleteOutlined'; import Delete from '@mui/icons-material/DeleteOutlined';
import type { import type {
IReleasePlanMilestonePayload, IReleasePlanMilestonePayload,
@ -22,23 +19,17 @@ import { type DragEventHandler, type RefObject, useState } from 'react';
import ExpandMore from '@mui/icons-material/ExpandMore'; import ExpandMore from '@mui/icons-material/ExpandMore';
import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenuCards'; import { MilestoneStrategyMenuCards } from './MilestoneStrategyMenuCards';
import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem'; import { MilestoneStrategyDraggableItem } from './MilestoneStrategyDraggableItem';
import { MilestoneCardName } from './MilestoneCardName';
const StyledEditIcon = styled(Edit)(({ theme }) => ({ const StyledMilestoneCard = styled(Card, {
cursor: 'pointer', shouldForwardProp: (prop) => prop !== 'hasError',
marginTop: theme.spacing(-0.25), })<{ hasError: boolean }>(({ theme, hasError }) => ({
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), marginTop: theme.spacing(2),
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', justifyContent: 'space-between',
boxShadow: 'none', boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${hasError ? theme.palette.error.border : theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium, borderRadius: theme.shape.borderRadiusMedium,
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
justifyContent: 'center', justifyContent: 'center',
@ -56,15 +47,6 @@ const StyledGridItem = styled(Grid)(({ theme }) => ({
alignItems: 'center', 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 StyledAddStrategyButton = styled(Button)(({ theme }) => ({}));
const StyledAccordion = styled(Accordion)(({ theme }) => ({ const StyledAccordion = styled(Accordion)(({ theme }) => ({
@ -131,6 +113,7 @@ interface IMilestoneCardProps {
) => void; ) => void;
errors: { [key: string]: string }; errors: { [key: string]: string };
clearErrors: () => void; clearErrors: () => void;
removable: boolean;
onDeleteMilestone: () => void; onDeleteMilestone: () => void;
} }
@ -140,16 +123,15 @@ export const MilestoneCard = ({
showAddStrategyDialog, showAddStrategyDialog,
errors, errors,
clearErrors, clearErrors,
removable,
onDeleteMilestone, onDeleteMilestone,
}: IMilestoneCardProps) => { }: IMilestoneCardProps) => {
const [editMode, setEditMode] = useState(false);
const [anchor, setAnchor] = useState<Element>(); const [anchor, setAnchor] = useState<Element>();
const [dragItem, setDragItem] = useState<{ const [dragItem, setDragItem] = useState<{
id: string; id: string;
index: number; index: number;
height: number; height: number;
} | null>(null); } | null>(null);
const theme = useTheme();
const isPopoverOpen = Boolean(anchor); const isPopoverOpen = Boolean(anchor);
const popoverId = isPopoverOpen const popoverId = isPopoverOpen
? 'MilestoneStrategyMenuPopover' ? 'MilestoneStrategyMenuPopover'
@ -261,43 +243,21 @@ export const MilestoneCard = ({
if (!milestone.strategies || milestone.strategies.length === 0) { if (!milestone.strategies || milestone.strategies.length === 0) {
return ( return (
<StyledMilestoneCard> <StyledMilestoneCard
hasError={
Boolean(errors?.[milestone.id]) ||
Boolean(errors?.[`${milestone.id}_name`])
}
>
<StyledMilestoneCardBody> <StyledMilestoneCardBody>
<Grid container> <Grid container>
<StyledGridItem item xs={8} md={9}> <StyledGridItem item xs={8} md={9}>
{editMode && ( <MilestoneCardName
<StyledInput milestone={milestone}
label='' errors={errors}
value={milestone.name} clearErrors={clearErrors}
onChange={(e) => milestoneNameChanged={milestoneNameChanged}
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)}
/>
</>
)}
</StyledGridItem> </StyledGridItem>
<StyledMilestoneActionGrid item xs={4} md={3}> <StyledMilestoneActionGrid item xs={4} md={3}>
<Button <Button
@ -310,6 +270,7 @@ export const MilestoneCard = ({
<StyledIconButton <StyledIconButton
title='Remove milestone' title='Remove milestone'
onClick={onDeleteMilestone} onClick={onDeleteMilestone}
disabled={!removable}
> >
<Delete /> <Delete />
</StyledIconButton> </StyledIconButton>
@ -342,35 +303,12 @@ export const MilestoneCard = ({
<StyledAccordionSummary <StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />} expandIcon={<ExpandMore titleAccess='Toggle' />}
> >
{editMode && ( <MilestoneCardName
<StyledInput milestone={milestone}
label='' errors={errors}
value={milestone.name} clearErrors={clearErrors}
onChange={(e) => milestoneNameChanged(e.target.value)} milestoneNameChanged={milestoneNameChanged}
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)} />
</>
)}
</StyledAccordionSummary> </StyledAccordionSummary>
<StyledAccordionDetails> <StyledAccordionDetails>
{milestone.strategies.map((strg, index) => ( {milestone.strategies.map((strg, index) => (
@ -403,6 +341,7 @@ export const MilestoneCard = ({
variant='text' variant='text'
color='primary' color='primary'
onClick={onDeleteMilestone} onClick={onDeleteMilestone}
disabled={!removable}
> >
<Delete /> Remove milestone <Delete /> Remove milestone
</Button> </Button>

View File

@ -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();
}}
/>
</>
)}
</>
);
};

View File

@ -3,7 +3,7 @@ import type {
IReleasePlanMilestoneStrategy, IReleasePlanMilestoneStrategy,
} from 'interfaces/releasePlans'; } from 'interfaces/releasePlans';
import { MilestoneCard } from './MilestoneCard'; 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 Add from '@mui/icons-material/Add';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -46,6 +46,7 @@ export const MilestoneList = ({
return ( return (
<> <>
{milestones.map((milestone) => ( {milestones.map((milestone) => (
<>
<MilestoneCard <MilestoneCard
key={milestone.id} key={milestone.id}
milestone={milestone} milestone={milestone}
@ -53,8 +54,14 @@ export const MilestoneList = ({
showAddStrategyDialog={openAddStrategyForm} showAddStrategyDialog={openAddStrategyForm}
errors={errors} errors={errors}
clearErrors={clearErrors} clearErrors={clearErrors}
removable={milestones.length > 1}
onDeleteMilestone={onDeleteMilestone(milestone.id)} onDeleteMilestone={onDeleteMilestone(milestone.id)}
/> />
<FormHelperText error={Boolean(errors?.[milestone.id])}>
{errors?.[milestone.id]}
</FormHelperText>
</>
))} ))}
<StyledAddMilestoneButton <StyledAddMilestoneButton
variant='text' variant='text'

View File

@ -27,11 +27,62 @@ export const useTemplateForm = (
}, [initialMilestones.length]); }, [initialMilestones.length]);
const validate = () => { const validate = () => {
let valid = true;
if (name.length === 0) { if (name.length === 0) {
setErrors((prev) => ({ ...prev, name: 'Name can not be empty.' })); setErrors((prev) => ({ ...prev, name: 'Name can not be empty.' }));
return false; valid = false;
} }
return true;
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 valid;
}; };
const clearErrors = () => { const clearErrors = () => {