From 11c6ccf640e1ebb4eae0810c18c2b9aa38856a3c Mon Sep 17 00:00:00 2001 From: David Leek Date: Tue, 26 Nov 2024 15:23:36 +0100 Subject: [PATCH] feat: strategy types, constraints, segments --- .../MilestoneStrategyConstraints.tsx | 66 +++++++++ .../MilestoneStrategySegment.tsx | 98 ++++++++++++++ .../MilestoneStrategySegmentList.tsx | 80 +++++++++++ .../MilestoneStrategyType.tsx | 59 +++++++++ .../MilestoneStrategyTypeFlexible.tsx | 111 ++++++++++++++++ .../ReleasePlanTemplateAddStrategyForm.tsx | 125 +++++++++++++++++- 6 files changed, 532 insertions(+), 7 deletions(-) create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyConstraints.tsx create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegment.tsx create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegmentList.tsx create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyType.tsx create mode 100644 frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyTypeFlexible.tsx diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyConstraints.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyConstraints.tsx new file mode 100644 index 000000000000..65f2033ff827 --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyConstraints.tsx @@ -0,0 +1,66 @@ +import { FeatureStrategyConstraintAccordionList } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraintAccordionList/FeatureStrategyConstraintAccordionList'; +import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans'; +import type { IConstraint } from 'interfaces/strategy'; +import { useEffect } from 'react'; + +interface IMilestoneStrategyConstraintsProps { + strategy: Omit; + setStrategy: React.Dispatch< + React.SetStateAction> + >; +} + +const filterConstraints = (constraint: any) => { + if ( + constraint.hasOwnProperty('values') && + (!constraint.hasOwnProperty('value') || constraint.value === '') + ) { + return constraint.values && constraint.values.length > 0; + } + + if (constraint.hasOwnProperty('value')) { + return constraint.value !== ''; + } +}; + +export const MilestoneStrategyConstraints = ({ + strategy, + setStrategy, +}: IMilestoneStrategyConstraintsProps) => { + useEffect(() => { + return () => { + if (!strategy.constraints) { + return; + } + + // If the component is unmounting we want to remove all constraints that do not have valid single value or + // valid multivalues + setStrategy((prev) => ({ + ...prev, + constraints: prev.constraints?.filter(filterConstraints), + })); + }; + }, []); + + const constraints = strategy.constraints || []; + + const setConstraints = (value: React.SetStateAction) => { + setStrategy((prev) => { + return { + ...prev, + constraints: + value instanceof Function + ? value(prev.constraints || []) + : value, + }; + }); + }; + + return ( + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegment.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegment.tsx new file mode 100644 index 000000000000..33edc0bda79e --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegment.tsx @@ -0,0 +1,98 @@ +import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits'; +import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; +import type { ISegment } from 'interfaces/segment'; +import { Box, styled, Typography } from '@mui/material'; +import { SegmentDocsStrategyWarning } from 'component/segments/SegmentDocs'; +import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; +import { + AutocompleteBox, + type IAutocompleteBoxOption, +} from 'component/common/AutocompleteBox/AutocompleteBox'; +import { MilestoneStrategySegmentList } from './MilestoneStrategySegmentList'; + +const StyledHelpIconBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), +})); + +interface IMilestoneStrategySegmentProps { + segments: ISegment[]; + setSegments: React.Dispatch>; +} + +export const MilestoneStrategySegment = ({ + segments: selectedSegments, + setSegments: setSelectedSegments, +}: IMilestoneStrategySegmentProps) => { + const { segments: allSegments } = useSegments(); + const { strategySegmentsLimit } = useSegmentLimits(); + + const atStrategySegmentsLimit: boolean = Boolean( + strategySegmentsLimit && + selectedSegments.length >= strategySegmentsLimit, + ); + + if (!allSegments || allSegments.length === 0) { + return null; + } + + const unusedSegments = allSegments.filter((segment) => { + return !selectedSegments.find((selected) => selected.id === segment.id); + }); + + const autocompleteOptions = unusedSegments.map((segment) => ({ + value: String(segment.id), + label: segment.name, + })); + + const onChange = ([option]: IAutocompleteBoxOption[]) => { + const selectedSegment = allSegments.find((segment) => { + return String(segment.id) === option.value; + }); + if (selectedSegment) { + setSelectedSegments((prev) => [...prev, selectedSegment]); + } + }; + + return ( + <> + + Segments + + + Segments are reusable sets of constraints that + can be defined once and reused across feature + toggle configurations. You can create a segment + on the global or the project level. Read more + about segments{' '} + + here + + + + } + /> + + {atStrategySegmentsLimit && } + + + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegmentList.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegmentList.tsx new file mode 100644 index 000000000000..5324407a1f5c --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategySegmentList.tsx @@ -0,0 +1,80 @@ +import { Fragment, useState } from 'react'; +import type { ISegment } from 'interfaces/segment'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { FeatureStrategySegmentChip } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegmentChip'; +import { SegmentItem } from 'component/common/SegmentItem/SegmentItem'; +import { styled } from '@mui/material'; + +const StyledList = styled('div')(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), +})); + +const StyledSelectedSegmentsLabel = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, +})); + +const StyledAnd = styled('p')(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + padding: theme.spacing(0.75, 1), + display: 'block', + marginTop: 'auto', + marginBottom: 'auto', + alignItems: 'center', + borderRadius: theme.shape.borderRadius, + lineHeight: 1, + color: theme.palette.text.primary, + backgroundColor: theme.palette.background.elevation2, +})); + +type IMilestoneStrategySegmentListProps = { + segments: ISegment[]; + setSegments: React.Dispatch>; +}; + +export const MilestoneStrategySegmentList = ({ + segments, + setSegments, +}: IMilestoneStrategySegmentListProps) => { + const [preview, setPreview] = useState(); + const lastSegmentIndex = segments.length - 1; + + if (segments.length === 0) { + console.log('segments.length === 0'); + return null; + } + + return ( + <> + 0} + show={ + + Selected Segments + + } + /> + + {segments.map((segment, i) => ( + + + AND} + /> + + ))} + + } + /> + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyType.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyType.tsx new file mode 100644 index 000000000000..dcac2eb701e1 --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyType.tsx @@ -0,0 +1,59 @@ +import type { IFormErrors } from 'hooks/useFormErrors'; +import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans'; +import type { IStrategy } from 'interfaces/strategy'; +import { MilestoneStrategyTypeFlexible } from './MilestoneStrategyTypeFlexible'; +import GeneralStrategy from 'component/feature/StrategyTypes/GeneralStrategy/GeneralStrategy'; +import UserWithIdStrategy from 'component/feature/StrategyTypes/UserWithIdStrategy/UserWithId'; +import DefaultStrategy from 'component/feature/StrategyTypes/DefaultStrategy/DefaultStrategy'; + +interface IMilestoneStrategyTypeProps { + strategy: Omit; + strategyDefinition?: IStrategy; + parameters: IReleasePlanMilestoneStrategy['parameters']; + updateParameter: (field: string, value: string) => void; + errors: IFormErrors; +} +export const MilestoneStrategyType = ({ + strategy, + strategyDefinition, + parameters, + updateParameter, + errors, +}: IMilestoneStrategyTypeProps) => { + if (!strategyDefinition) { + return null; + } + + switch (strategy.name) { + case 'default': + return ; + case 'flexibleRollout': + return ( + + ); + case 'userWithId': + return ( + + ); + default: + return ( + + ); + } +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyTypeFlexible.tsx b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyTypeFlexible.tsx new file mode 100644 index 000000000000..07b3d48a0da8 --- /dev/null +++ b/frontend/src/component/releases/ReleasePlanTemplate/MilestoneStrategyTypeFlexible.tsx @@ -0,0 +1,111 @@ +import { Box, styled } from '@mui/material'; +import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; +import RolloutSlider from 'component/feature/StrategyTypes/RolloutSlider/RolloutSlider'; +import type { IFormErrors } from 'hooks/useFormErrors'; +import type { IFeatureStrategyParameters } from 'interfaces/strategy'; +import { useMemo } from 'react'; +import { + parseParameterNumber, + parseParameterString, +} from 'utils/parseParameter'; +import Input from 'component/common/Input/Input'; + +interface IMilestoneStrategyTypeFlexibleProps { + parameters: IFeatureStrategyParameters; + updateParameter: (field: string, value: string) => void; + editable: boolean; + errors?: IFormErrors; +} + +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + backgroundColor: theme.palette.background.elevation1, + padding: theme.spacing(2), + borderRadius: `${theme.shape.borderRadiusMedium}px`, +})); + +const StyledOuterBox = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(1), + display: 'flex', + width: '100%', + justifyContent: 'space-between', +})); + +const StyledInnerBox1 = styled(Box)(({ theme }) => ({ + width: '50%', + marginRight: theme.spacing(0.5), +})); + +const StyledInnerBox2 = styled(Box)(({ theme }) => ({ + width: '50%', + marginLeft: theme.spacing(0.5), +})); + +const DEFAULT_STICKINESS = 'default'; + +export const MilestoneStrategyTypeFlexible = ({ + parameters, + updateParameter, + editable, + errors, +}: IMilestoneStrategyTypeFlexibleProps) => { + const updateRollout = (e: Event, value: number | number[]) => { + updateParameter('rollout', value.toString()); + }; + + const rollout = + parameters.rollout !== undefined + ? parseParameterNumber(parameters.rollout) + : 100; + + const stickiness = useMemo(() => { + if (!parameters.stickiness) { + updateParameter('stickiness', DEFAULT_STICKINESS); + } + + return parseParameterString(parameters.stickiness); + }, [parameters.stickiness]); + + const groupId = parseParameterString(parameters.groupId); + + return ( + + + + + + updateParameter('stickiness', e.target.value) + } + /> + + + + updateParameter( + 'groupId', + parseParameterString(e.target.value), + ) + } + error={Boolean(errors?.getFormError('groupId'))} + helperText={errors?.getFormError('groupId')} + /> + + + + ); +}; diff --git a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx index 2f3089d77dcb..6f3b86ef06fd 100644 --- a/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx +++ b/frontend/src/component/releases/ReleasePlanTemplate/ReleasePlanTemplateAddStrategyForm.tsx @@ -1,11 +1,28 @@ -import { Box, Button, styled, Tab, Tabs, Typography } from '@mui/material'; +import { + Box, + Button, + styled, + Alert, + Link, + Tab, + Tabs, + Typography, + Divider, +} from '@mui/material'; import { Badge } from 'component/common/Badge/Badge'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import type { IReleasePlanMilestoneStrategy } from 'interfaces/releasePlans'; +import type { ISegment } from 'interfaces/segment'; import { useState } from 'react'; -import { formatStrategyName } from 'utils/strategyNames'; +import { BuiltInStrategies, formatStrategyName } from 'utils/strategyNames'; import { MilestoneStrategyTitle } from './MilestoneStrategyTitle'; +import { MilestoneStrategyType } from './MilestoneStrategyType'; +import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; +import { useFormErrors } from 'hooks/useFormErrors'; import produce from 'immer'; +import { MilestoneStrategySegment } from './MilestoneStrategySegment'; +import { MilestoneStrategyConstraints } from './MilestoneStrategyConstraints'; +import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation'; const StyledCancelButton = styled(Button)(({ theme }) => ({ marginLeft: theme.spacing(3), @@ -34,6 +51,15 @@ const StyledTitle = styled('h1')(({ theme }) => ({ paddingBottom: theme.spacing(2), })); +const StyledAlertBox = styled(Box)(({ theme }) => ({ + paddingLeft: theme.spacing(6), + paddingRight: theme.spacing(6), + '& > *': { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})); + const StyledTabs = styled(Tabs)(({ theme }) => ({ borderTop: `1px solid ${theme.palette.divider}`, borderBottom: `1px solid ${theme.palette.divider}`, @@ -46,6 +72,10 @@ const StyledTab = styled(Tab)(({ theme }) => ({ width: '100px', })); +const StyledBadge = styled(Badge)(({ theme }) => ({ + marginLeft: theme.spacing(1), +})); + const StyledContentDiv = styled('div')(({ theme }) => ({ position: 'relative', display: 'flex', @@ -58,11 +88,34 @@ const StyledContentDiv = styled('div')(({ theme }) => ({ height: '100%', })); +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'flex', + position: 'relative', + marginTop: theme.spacing(3.5), +})); + const StyledTargetingHeader = styled('div')(({ theme }) => ({ color: theme.palette.text.secondary, marginTop: theme.spacing(1.5), })); +const StyledDivider = styled(Divider)(({ theme }) => ({ + width: '100%', +})); + +const StyledDividerContent = styled(Box)(({ theme }) => ({ + padding: theme.spacing(0.75, 1), + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallerBody, + backgroundColor: theme.palette.background.elevation2, + borderRadius: theme.shape.borderRadius, + width: '45px', + position: 'absolute', + top: '-10px', + left: 'calc(50% - 45px)', + lineHeight: 1, +})); + interface IReleasePlanTemplateAddStrategyFormProps { milestoneId: string | undefined; onCancel: () => void; @@ -81,10 +134,25 @@ export const ReleasePlanTemplateAddStrategyForm = ({ }: IReleasePlanTemplateAddStrategyFormProps) => { const [addStrategy, setAddStrategy] = useState(strategy); const [activeTab, setActiveTab] = useState(0); + const [segments, setSegments] = useState([]); + const { strategyDefinition } = useStrategy(strategy?.name); + const hasValidConstraints = useConstraintsValidation(strategy?.constraints); + const errors = useFormErrors(); + if (!strategy || !addStrategy || !strategyDefinition) { + return null; + } + const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { setActiveTab(newValue); }; + const getTargetingCount = () => { + const constraintCount = addStrategy?.constraints?.length || 0; + const segmentCount = segments?.length || 0; + + return constraintCount + segmentCount; + }; + const updateParameter = (name: string, value: string) => { setAddStrategy( produce((draft) => { @@ -108,10 +176,6 @@ export const ReleasePlanTemplateAddStrategyForm = ({ onAddStrategy(milestoneId, addStrategy); }; - if (!strategy) { - return null; - } - return ( + {!BuiltInStrategies.includes(strategy.name || 'default') && ( + + + Custom strategies are deprecated. We recommend not + adding them to any templates going forward and using the + predefined strategies like Gradual rollout with{' '} + + constraints + {' '} + instead. + + + )} - Targeting} /> + + Targeting + {getTargetingCount()} + + } + /> {activeTab === 0 && ( @@ -140,6 +230,14 @@ export const ReleasePlanTemplateAddStrategyForm = ({ updateParameter('title', title) } /> + + )} {activeTab === 1 && ( @@ -147,6 +245,18 @@ export const ReleasePlanTemplateAddStrategyForm = ({ Segmentation and constraints allow you to set filters on your strategies, so that they will only + + + + AND + + be evaluated for users and applications that match the specified preconditions. @@ -158,6 +268,7 @@ export const ReleasePlanTemplateAddStrategyForm = ({ variant='contained' color='primary' type='submit' + disabled={!hasValidConstraints || errors.hasFormErrors()} onClick={addStrategyToMilestone} > Save strategy