mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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 {
 | 
			
		||||
    Box,
 | 
			
		||||
    Button,
 | 
			
		||||
@ -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,
 | 
			
		||||
@ -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',
 | 
			
		||||
@ -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 }) => ({
 | 
			
		||||
@ -131,6 +113,7 @@ interface IMilestoneCardProps {
 | 
			
		||||
    ) => void;
 | 
			
		||||
    errors: { [key: string]: string };
 | 
			
		||||
    clearErrors: () => void;
 | 
			
		||||
    removable: boolean;
 | 
			
		||||
    onDeleteMilestone: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
@ -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
 | 
			
		||||
@ -310,6 +270,7 @@ export const MilestoneCard = ({
 | 
			
		||||
                            <StyledIconButton
 | 
			
		||||
                                title='Remove milestone'
 | 
			
		||||
                                onClick={onDeleteMilestone}
 | 
			
		||||
                                disabled={!removable}
 | 
			
		||||
                            >
 | 
			
		||||
                                <Delete />
 | 
			
		||||
                            </StyledIconButton>
 | 
			
		||||
@ -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) => (
 | 
			
		||||
@ -403,6 +341,7 @@ export const MilestoneCard = ({
 | 
			
		||||
                        variant='text'
 | 
			
		||||
                        color='primary'
 | 
			
		||||
                        onClick={onDeleteMilestone}
 | 
			
		||||
                        disabled={!removable}
 | 
			
		||||
                    >
 | 
			
		||||
                        <Delete /> Remove milestone
 | 
			
		||||
                    </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,
 | 
			
		||||
} 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';
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
        }
 | 
			
		||||
        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 = () => {
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user