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:
parent
3c16616c36
commit
7893d3fbd3
@ -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>
|
||||||
|
@ -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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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'
|
||||||
|
@ -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 = () => {
|
||||||
|
Loading…
Reference in New Issue
Block a user