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

Chore(1-3390)/playground strategy execution: constraints (#9532)

Implements the new design for playground constraints. They're not in use
in segments yet, and strategy parameters have not been touched. This PR
establishes a pattern that we can follow for strategies and parameters
later.


![image](https://github.com/user-attachments/assets/c23d538d-f27f-43f2-8e29-fa8044c11d48)

The PR also includes a change in how the constraint item organizes its
children: it now takes care adding padding and spacing itself, instead
of the children doing that. It looks right most places, but segments
aren't quite right anymore. However, as this is behind a flag, I'd
rather fix that in a separate PR.

---------

Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>
This commit is contained in:
Thomas Heartman 2025-03-17 14:30:11 +01:00 committed by GitHub
parent 37aeb62d66
commit cf1ba8fcc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 241 additions and 100 deletions

View File

@ -15,6 +15,16 @@ const StyledListItem = styled('li')(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`, border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusMedium, borderRadius: theme.shape.borderRadiusMedium,
background: theme.palette.background.default, background: theme.palette.background.default,
padding: theme.spacing(2, 3),
display: 'flex',
flexFlow: 'column',
gap: theme.spacing(1),
'&:has(>.MuiAccordion-root)': {
// todo: look at this later. MUI accordions rely heavily on their
// padding, but it doesn't collapse with the surrounding padding here,
// so they become super chunky otherwise.
paddingBlock: 0,
},
})); }));
const StyledAnd = styled('div')(({ theme }) => ({ const StyledAnd = styled('div')(({ theme }) => ({

View File

@ -12,7 +12,6 @@ const StyledContainer = styled('div')(({ theme }) => ({
gap: theme.spacing(1), gap: theme.spacing(1),
alignItems: 'center', alignItems: 'center',
fontSize: theme.typography.body2.fontSize, fontSize: theme.typography.body2.fontSize,
margin: theme.spacing(2, 3),
})); }));
const StyledContent = styled('div')(({ theme }) => ({ const StyledContent = styled('div')(({ theme }) => ({

View File

@ -26,14 +26,14 @@ const StyledAccordion = styled(Accordion)(({ theme }) => ({
boxShadow: 'none', boxShadow: 'none',
margin: 0, margin: 0,
padding: 0, padding: 0,
'::before': {
display: 'none',
},
})); }));
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({ const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
padding: 0, padding: 0,
fontSize: theme.typography.body2.fontSize, fontSize: theme.typography.body2.fontSize,
'.MuiAccordionSummary-content, .Mui-expanded.MuiAccordionSummary-content': {
margin: 0,
},
})); }));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({

View File

@ -1,7 +1,7 @@
import { import {
PlaygroundResultStrategyLists, PlaygroundResultStrategyLists,
WrappedPlaygroundResultStrategyList, WrappedPlaygroundResultStrategyList,
} from './StrategyList/playgroundResultStrategyLists'; } from './StrategyList/PlaygroundResultStrategyLists';
import type { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi'; import type { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { Alert } from '@mui/material'; import { Alert } from '@mui/material';

View File

@ -4,11 +4,9 @@ import type {
PlaygroundStrategySchema, PlaygroundStrategySchema,
PlaygroundRequestSchema, PlaygroundRequestSchema,
} from 'openapi'; } from 'openapi';
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
import { objectId } from 'utils/objectId'; import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { DisabledStrategyExecution } from './StrategyExecution/DisabledStrategyExecution';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer'; import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
import { StrategyExecution } from './StrategyExecution/StrategyExecution';
interface IFeatureStrategyItemProps { interface IFeatureStrategyItemProps {
strategy: PlaygroundStrategySchema; strategy: PlaygroundStrategySchema;
@ -45,23 +43,7 @@ export const FeatureStrategyItem = ({
/> />
} }
> >
{/* todo: use new strategy execution components */} <StrategyExecution strategyResult={strategy} input={input} />
<ConditionallyRender
condition={Boolean(strategy.disabled)}
show={
<DisabledStrategyExecution
strategyResult={strategy}
input={input}
/>
}
elseShow={
<StrategyExecution
strategyResult={strategy}
input={input}
percentageFill={theme.palette.background.elevation2}
/>
}
/>
</StrategyItemContainer> </StrategyItemContainer>
); );
}; };

View File

@ -4,7 +4,7 @@ import type {
PlaygroundStrategySchema, PlaygroundStrategySchema,
PlaygroundRequestSchema, PlaygroundRequestSchema,
} from 'openapi'; } from 'openapi';
import { StrategyExecution } from './StrategyExecution/StrategyExecution'; import { StrategyExecution } from './StrategyExecution/LegacyStrategyExecution';
import { objectId } from 'utils/objectId'; import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { DisabledStrategyExecution } from './StrategyExecution/DisabledStrategyExecution'; import { DisabledStrategyExecution } from './StrategyExecution/DisabledStrategyExecution';

View File

@ -1,59 +1,79 @@
import { Fragment, type VFC } from 'react'; import type { FC } from 'react';
import type { import type {
PlaygroundConstraintSchema, PlaygroundConstraintSchema,
PlaygroundRequestSchema, PlaygroundRequestSchema,
} from 'openapi'; } from 'openapi';
import { objectId } from 'utils/objectId'; import { ConstraintItem } from 'component/common/ConstraintsList/ConstraintItem/ConstraintItem';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import CheckCircle from '@mui/icons-material/CheckCircle';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { ConstraintAccordionView } from 'component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView'; import Cancel from '@mui/icons-material/Cancel';
import { ConstraintError } from './ConstraintError/ConstraintError';
import { ConstraintOk } from './ConstraintOk/ConstraintOk';
interface IConstraintExecutionProps { interface IConstraintExecutionProps {
constraints?: PlaygroundConstraintSchema[]; constraint?: PlaygroundConstraintSchema;
input?: PlaygroundRequestSchema; input?: PlaygroundRequestSchema;
} }
export const ConstraintExecutionWrapper = styled('div')(() => ({ const StyledContainer = styled('div', {
width: '100%', shouldForwardProp: (prop) => prop !== 'variant',
})<{ variant: 'ok' | 'error' }>(({ theme, variant }) => ({
'--font-size': theme.typography.body2.fontSize,
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
gap: theme.spacing(1),
paddingInline: theme.spacing(0.25),
color:
variant === 'ok'
? theme.palette.success.dark
: theme.palette.error.dark,
fontSize: 'var(--font-size)',
svg: {
fontSize: `calc(var(--font-size) * 1.25)`,
},
})); }));
export const ConstraintExecution: VFC<IConstraintExecutionProps> = ({ const ConstraintOk = () => {
constraints,
input,
}) => {
if (!constraints) return null;
return ( return (
<ConstraintExecutionWrapper> <StyledContainer variant='ok'>
{constraints?.map((constraint, index) => ( <CheckCircle aria-hidden='true' />
<Fragment key={objectId(constraint)}> <p>Constraint met by value in context</p>
<ConditionallyRender </StyledContainer>
condition={index > 0} );
show={<StrategySeparator text='AND' />} };
/>
<ConstraintAccordionView export const ConstraintError = ({ text }: { text: string }) => {
constraint={constraint} return (
compact <StyledContainer variant='error'>
renderAfter={ <Cancel aria-hidden='true' />
<ConditionallyRender <p>{text}</p>
condition={constraint.result} </StyledContainer>
show={<ConstraintOk />} );
elseShow={ };
<ConstraintError
input={input} export const ConstraintExecution: FC<IConstraintExecutionProps> = ({
constraint={constraint} constraint,
/> input,
} }) => {
/> if (!constraint) return null;
}
/> const errorText = () => {
</Fragment> const value = input?.context[constraint.contextName];
))}
</ConstraintExecutionWrapper> if (value) {
return `Constraint not met the value in the context: { ${value} } is not ${constraint.operator} ${constraint.contextName}`;
}
return `Constraint not met no value was specified for ${constraint.contextName}`;
};
return (
<>
<ConstraintItem {...constraint} />
{constraint.result ? (
<ConstraintOk />
) : (
<ConstraintError text={errorText()} />
)}
</>
); );
}; };

View File

@ -0,0 +1,59 @@
import { Fragment, type VFC } from 'react';
import type {
PlaygroundConstraintSchema,
PlaygroundRequestSchema,
} from 'openapi';
import { objectId } from 'utils/objectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { styled } from '@mui/material';
import { ConstraintAccordionView } from 'component/common/ConstraintAccordion/ConstraintAccordionView/ConstraintAccordionView';
import { ConstraintError } from './ConstraintError/LegacyConstraintError';
import { ConstraintOk } from './ConstraintOk/LegacyConstraintOk';
interface IConstraintExecutionProps {
constraints?: PlaygroundConstraintSchema[];
input?: PlaygroundRequestSchema;
}
export const ConstraintExecutionWrapper = styled('div')(() => ({
width: '100%',
display: 'flex',
flexDirection: 'column',
}));
export const ConstraintExecution: VFC<IConstraintExecutionProps> = ({
constraints,
input,
}) => {
if (!constraints) return null;
return (
<ConstraintExecutionWrapper>
{constraints?.map((constraint, index) => (
<Fragment key={objectId(constraint)}>
<ConditionallyRender
condition={index > 0}
show={<StrategySeparator text='AND' />}
/>
<ConstraintAccordionView
constraint={constraint}
compact
renderAfter={
<ConditionallyRender
condition={constraint.result}
show={<ConstraintOk />}
elseShow={
<ConstraintError
input={input}
constraint={constraint}
/>
}
/>
}
/>
</Fragment>
))}
</ConstraintExecutionWrapper>
);
};

View File

@ -0,0 +1,87 @@
import { Fragment, type VFC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { styled } from '@mui/material';
import type {
PlaygroundRequestSchema,
PlaygroundStrategySchema,
} from 'openapi';
import { ConstraintExecution } from './ConstraintExecution/LegacyConstraintExecution';
import { SegmentExecution } from './SegmentExecution/SegmentExecution';
import { PlaygroundResultStrategyExecutionParameters } from './StrategyExecutionParameters/StrategyExecutionParameters';
import { CustomStrategyParams } from './CustomStrategyParams/CustomStrategyParams';
import { formattedStrategyNames } from 'utils/strategyNames';
import { StyledBoxSummary } from './StrategyExecution.styles';
import { Badge } from 'component/common/Badge/Badge';
interface IStrategyExecutionProps {
strategyResult: PlaygroundStrategySchema;
percentageFill?: string;
input?: PlaygroundRequestSchema;
}
const StyledStrategyExecutionWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(0),
}));
export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
strategyResult,
input,
}) => {
const { name, constraints, segments, parameters } = strategyResult;
const hasSegments = Boolean(segments && segments.length > 0);
const hasConstraints = Boolean(constraints && constraints?.length > 0);
const hasExecutionParameters =
name !== 'default' &&
Object.keys(formattedStrategyNames).includes(name);
const hasCustomStrategyParameters =
Object.keys(parameters).length > 0 &&
strategyResult.result.evaluationStatus === 'incomplete'; // Use of custom strategy can be more explicit from the API
if (!parameters) {
return null;
}
const items = [
hasSegments && <SegmentExecution segments={segments} input={input} />,
hasConstraints && (
<ConstraintExecution constraints={constraints} input={input} />
),
hasExecutionParameters && (
<PlaygroundResultStrategyExecutionParameters
parameters={parameters}
constraints={constraints}
input={input}
/>
),
hasCustomStrategyParameters && (
<CustomStrategyParams strategyName={name} parameters={parameters} />
),
name === 'default' && (
<StyledBoxSummary sx={{ width: '100%' }}>
The standard strategy is <Badge color='success'>ON</Badge> for
all users.
</StyledBoxSummary>
),
].filter(Boolean);
return (
<StyledStrategyExecutionWrapper>
{items.map((item, index) => (
<Fragment key={index}>
<ConditionallyRender
condition={
index > 0 &&
(strategyResult.name === 'flexibleRollout'
? index < items.length
: index < items.length - 1)
}
show={<StrategySeparator text='AND' />}
/>
{item}
</Fragment>
))}
</StyledStrategyExecutionWrapper>
);
};

View File

@ -1,6 +1,6 @@
import { Fragment, type VFC } from 'react'; import { Fragment, type VFC } from 'react';
import type { PlaygroundSegmentSchema, PlaygroundRequestSchema } from 'openapi'; import type { PlaygroundSegmentSchema, PlaygroundRequestSchema } from 'openapi';
import { ConstraintExecution } from '../ConstraintExecution/ConstraintExecution'; import { ConstraintExecution } from '../ConstraintExecution/LegacyConstraintExecution';
import CancelOutlined from '@mui/icons-material/CancelOutlined'; import CancelOutlined from '@mui/icons-material/CancelOutlined';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator'; import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { styled, Typography } from '@mui/material'; import { styled, Typography } from '@mui/material';

View File

@ -1,7 +1,3 @@
import { Fragment, type VFC } from 'react';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategySeparator } from 'component/common/StrategySeparator/LegacyStrategySeparator';
import { styled } from '@mui/material';
import type { import type {
PlaygroundRequestSchema, PlaygroundRequestSchema,
PlaygroundStrategySchema, PlaygroundStrategySchema,
@ -13,6 +9,9 @@ import { CustomStrategyParams } from './CustomStrategyParams/CustomStrategyParam
import { formattedStrategyNames } from 'utils/strategyNames'; import { formattedStrategyNames } from 'utils/strategyNames';
import { StyledBoxSummary } from './StrategyExecution.styles'; import { StyledBoxSummary } from './StrategyExecution.styles';
import { Badge } from 'component/common/Badge/Badge'; import { Badge } from 'component/common/Badge/Badge';
import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsList';
import { objectId } from 'utils/objectId';
import type { FC } from 'react';
interface IStrategyExecutionProps { interface IStrategyExecutionProps {
strategyResult: PlaygroundStrategySchema; strategyResult: PlaygroundStrategySchema;
@ -20,11 +19,7 @@ interface IStrategyExecutionProps {
input?: PlaygroundRequestSchema; input?: PlaygroundRequestSchema;
} }
const StyledStrategyExecutionWrapper = styled('div')(({ theme }) => ({ export const StrategyExecution: FC<IStrategyExecutionProps> = ({
padding: theme.spacing(0),
}));
export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
strategyResult, strategyResult,
input, input,
}) => { }) => {
@ -45,9 +40,15 @@ export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
const items = [ const items = [
hasSegments && <SegmentExecution segments={segments} input={input} />, hasSegments && <SegmentExecution segments={segments} input={input} />,
hasConstraints && ( ...(hasConstraints
<ConstraintExecution constraints={constraints} input={input} /> ? constraints.map((constraint) => (
), <ConstraintExecution
key={objectId(constraint)}
constraint={constraint}
input={input}
/>
))
: []),
hasExecutionParameters && ( hasExecutionParameters && (
<PlaygroundResultStrategyExecutionParameters <PlaygroundResultStrategyExecutionParameters
parameters={parameters} parameters={parameters}
@ -66,22 +67,5 @@ export const StrategyExecution: VFC<IStrategyExecutionProps> = ({
), ),
].filter(Boolean); ].filter(Boolean);
return ( return <ConstraintsList>{items}</ConstraintsList>;
<StyledStrategyExecutionWrapper>
{items.map((item, index) => (
<Fragment key={index}>
<ConditionallyRender
condition={
index > 0 &&
(strategyResult.name === 'flexibleRollout'
? index < items.length
: index < items.length - 1)
}
show={<StrategySeparator text='AND' />}
/>
{item}
</Fragment>
))}
</StyledStrategyExecutionWrapper>
);
}; };