1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-14 01:16:17 +02:00

Constraint values list (#9592)

This commit is contained in:
Tymoteusz Czech 2025-03-24 14:26:58 +01:00 committed by GitHub
parent e018ee2f34
commit dd62b3dbcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 257 additions and 81 deletions

View File

@ -1,12 +1,14 @@
import type { FC } from 'react'; import type { ComponentProps, FC } from 'react';
import { import { StrategyEvaluationItem } from '../StrategyEvaluationItem/StrategyEvaluationItem';
StrategyEvaluationItem,
type StrategyEvaluationItemProps,
} from '../StrategyEvaluationItem/StrategyEvaluationItem';
import type { ConstraintSchema } from 'openapi'; import type { ConstraintSchema } from 'openapi';
import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription'; import { formatOperatorDescription } from 'component/common/ConstraintAccordion/ConstraintOperator/formatOperatorDescription';
import { StrategyEvaluationChip } from '../StrategyEvaluationChip/StrategyEvaluationChip'; import { StrategyEvaluationChip } from '../StrategyEvaluationChip/StrategyEvaluationChip';
import { styled, Tooltip } from '@mui/material'; import { styled, Tooltip } from '@mui/material';
import { Truncator } from 'component/common/Truncator/Truncator';
import { ValuesList } from '../ValuesList/ValuesList';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatConstraintValue } from 'utils/formatConstraintValue';
import { useConstraintTooltips } from './hooks/useConstraintTooltips';
import { ReactComponent as CaseSensitiveIcon } from 'assets/icons/case-sensitive.svg'; import { ReactComponent as CaseSensitiveIcon } from 'assets/icons/case-sensitive.svg';
import { isCaseSensitive } from './isCaseSensitive'; import { isCaseSensitive } from './isCaseSensitive';
@ -52,32 +54,45 @@ const StyledOperatorGroup = styled('div')(({ theme }) => ({
gap: theme.spacing(0.5), gap: theme.spacing(0.5),
})); }));
const StyledConstraintName = styled('div')(({ theme }) => ({
maxWidth: '150px',
paddingRight: theme.spacing(0.5),
overflow: 'hidden',
}));
export const ConstraintItemHeader: FC< export const ConstraintItemHeader: FC<
ConstraintSchema & Pick<StrategyEvaluationItemProps, 'onSetTruncated'> ConstraintSchema & Pick<ComponentProps<typeof ValuesList>, 'onSetTruncated'>
> = ({ > = ({ onSetTruncated, ...constraint }) => {
caseInsensitive, const { caseInsensitive, contextName, inverted, operator, value, values } =
contextName, constraint;
inverted, const { locationSettings } = useLocationSettings();
operator, const items = value
value, ? [
values, formatConstraintValue(constraint, locationSettings) || '',
onSetTruncated, ...(values || []),
}) => { ]
const items = value ? [value, ...(values || [])] : values || []; : values || [];
const tooltips = useConstraintTooltips(contextName, values || []);
return ( return (
<StrategyEvaluationItem <StrategyEvaluationItem type='Constraint'>
type='Constraint' <StyledConstraintName>
values={items} <Truncator lines={2} title={contextName} arrow>
onSetTruncated={onSetTruncated} {contextName}
> </Truncator>
{contextName} </StyledConstraintName>
<StyledOperatorGroup> <StyledOperatorGroup>
<Operator label={operator} inverted={inverted} /> <Operator label={operator} inverted={inverted} />
{isCaseSensitive(operator, caseInsensitive) ? ( {isCaseSensitive(operator, caseInsensitive) ? (
<CaseSensitive /> <CaseSensitive />
) : null} ) : null}
</StyledOperatorGroup> </StyledOperatorGroup>
<ValuesList
values={items}
onSetTruncated={onSetTruncated}
tooltips={tooltips}
/>
</StrategyEvaluationItem> </StrategyEvaluationItem>
); );
}; };

View File

@ -0,0 +1,99 @@
import { vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useConstraintTooltips } from './useConstraintTooltips';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
vi.mock('hooks/api/getters/useUnleashContext/useUnleashContext', () => ({
default: vi.fn(),
}));
describe('useConstraintTooltips', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('returns tooltip mapping for legal values with descriptions', () => {
(
useUnleashContext as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
context: [
{
name: 'contextA',
description: 'Test context A',
createdAt: '2021-01-01',
sortOrder: 1,
stickiness: false,
legalValues: [
{ value: 'value1', description: 'Tooltip 1' },
{ value: 'value2', description: 'Tooltip 2' },
{ value: 'value3' }, // No description provided
],
},
],
});
const { result } = renderHook(() =>
useConstraintTooltips('contextA', [
'value1',
'value2',
'value3',
'nonExisting',
]),
);
expect(result.current).toEqual({
value1: 'Tooltip 1',
value2: 'Tooltip 2',
});
});
it('returns an empty object when the context is not found', () => {
(
useUnleashContext as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
context: [
{
name: 'otherContext',
description: 'Other context',
createdAt: '2021-01-01',
sortOrder: 1,
stickiness: false,
legalValues: [
{ value: 'value1', description: 'Tooltip 1' },
],
},
],
});
const { result } = renderHook(() =>
useConstraintTooltips('contextA', ['value1']),
);
expect(result.current).toEqual({});
});
it('returns an empty object when no values are provided', () => {
(
useUnleashContext as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
context: [
{
name: 'contextA',
description: 'Test context A',
createdAt: '2021-01-01',
sortOrder: 1,
stickiness: false,
legalValues: [
{ value: 'value1', description: 'Tooltip 1' },
],
},
],
});
const { result } = renderHook(() =>
useConstraintTooltips('contextA', []),
);
expect(result.current).toEqual({});
});
});

View File

@ -0,0 +1,27 @@
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import { useMemo } from 'react';
export const useConstraintTooltips = (
contextName: string,
values: string[],
) => {
const { context } = useUnleashContext();
const contextDefinition = useMemo(
() => context.find(({ name }) => name === contextName),
[contextName, context],
);
return useMemo<Record<string, string>>(
() =>
Object.fromEntries(
values
?.map((item) => [
item,
contextDefinition?.legalValues?.find(
({ value }) => value === item,
)?.description,
])
.filter(([_, tooltip]) => !!tooltip) || [],
),
[context, values],
);
};

View File

@ -1,16 +1,11 @@
import { styled } from '@mui/material';
import {
Truncator,
type TruncatorProps,
} from 'component/common/Truncator/Truncator';
import { disabledStrategyClassName } from 'component/common/StrategyItemContainer/disabled-strategy-utils';
import type { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import { styled } from '@mui/material';
import { disabledStrategyClassName } from 'component/common/StrategyItemContainer/disabled-strategy-utils';
export type StrategyEvaluationItemProps = { export type StrategyEvaluationItemProps = {
type?: ReactNode; type?: ReactNode;
children?: ReactNode; children?: ReactNode;
values?: string[]; };
} & Pick<TruncatorProps, 'onSetTruncated'>;
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -38,35 +33,17 @@ const StyledType = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
width: theme.spacing(10), width: theme.spacing(10),
})); }));
/** /**
* Abstract building block for a list of constraints, segments and other items inside a strategy * Abstract building block for a list of constraints, segments and other items inside a strategy
*/ */
export const StrategyEvaluationItem: FC<StrategyEvaluationItemProps> = ({ export const StrategyEvaluationItem: FC<StrategyEvaluationItemProps> = ({
type, type,
children, children,
values, }) => {
onSetTruncated, return (
}) => ( <StyledContainer>
<StyledContainer> <StyledType>{type}</StyledType>
<StyledType>{type}</StyledType> <StyledContent>{children}</StyledContent>
<StyledContent> </StyledContainer>
{children} );
{values && values?.length === 1 ? ( };
<Truncator
title={values[0]}
arrow
lines={2}
onSetTruncated={() => onSetTruncated?.(false)}
>
{values[0]}
</Truncator>
) : null}
{values && values?.length > 1 ? (
<Truncator title='' lines={2} onSetTruncated={onSetTruncated}>
{values.join(', ')}
</Truncator>
) : null}
</StyledContent>
</StyledContainer>
);

View File

@ -0,0 +1,66 @@
import type { FC } from 'react';
import { styled, Tooltip } from '@mui/material';
import {
Truncator,
type TruncatorProps,
} from 'component/common/Truncator/Truncator';
export type ValuesListProps = {
values?: string[];
tooltips?: Record<string, string | undefined>;
} & Pick<TruncatorProps, 'onSetTruncated'>;
const StyledValuesContainer = styled('div')({
flex: '1 1 0',
});
const StyledValueItem = styled('span')(({ theme }) => ({
padding: theme.spacing(0.25),
display: 'inline-block',
span: {
background: theme.palette.background.elevation2,
borderRadius: theme.shape.borderRadiusLarge,
display: 'inline-block',
padding: theme.spacing(0.25, 1),
},
}));
const StyledSingleValue = styled('div')(({ theme }) => ({
padding: theme.spacing(0.25, 1),
background: theme.palette.background.elevation2,
borderRadius: theme.shape.borderRadiusLarge,
}));
export const ValuesList: FC<ValuesListProps> = ({
values,
tooltips,
onSetTruncated,
}) => (
<StyledValuesContainer>
{values && values?.length === 1 ? (
<StyledSingleValue>
<Truncator
title={values[0]}
arrow
lines={2}
onSetTruncated={() => onSetTruncated?.(false)}
>
<Tooltip title={tooltips?.[values[0]] || ''}>
<span>{values[0]}</span>
</Tooltip>
</Truncator>
</StyledSingleValue>
) : null}
{values && values?.length > 1 ? (
<Truncator title='' lines={2} onSetTruncated={onSetTruncated}>
{values.map((value) => (
<Tooltip title={tooltips?.[value] || ''} key={value}>
<StyledValueItem>
<span>{value}</span>
</StyledValueItem>
</Tooltip>
))}
</Truncator>
) : null}
</StyledValuesContainer>
);

View File

@ -45,8 +45,7 @@ const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
})); }));
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({ const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
borderTop: `1px dashed ${theme.palette.divider}`, padding: theme.spacing(0.5, 3, 2.5),
padding: theme.spacing(1.5, 3, 2.5),
})); }));
const StyledLink = styled(Link)({ const StyledLink = styled(Link)({

View File

@ -13,6 +13,7 @@ import type {
StrategySchemaParametersItem, StrategySchemaParametersItem,
} from 'openapi'; } from 'openapi';
import type { IFeatureStrategyPayload } from 'interfaces/strategy'; import type { IFeatureStrategyPayload } from 'interfaces/strategy';
import { ValuesList } from 'component/common/ConstraintsList/ValuesList/ValuesList';
export const useCustomStrategyParameters = ( export const useCustomStrategyParameters = (
strategy: Pick< strategy: Pick<
@ -48,14 +49,11 @@ export const useCustomStrategyParameters = (
} }
return ( return (
<StrategyEvaluationItem <StrategyEvaluationItem key={key} type={typeItem}>
key={key}
type={typeItem}
values={values}
>
{values.length === 1 {values.length === 1
? 'has 1 item:' ? 'has 1 item:'
: `has ${values.length} items:`} : `has ${values.length} items:`}
<ValuesList values={values} />
</StrategyEvaluationItem> </StrategyEvaluationItem>
); );
} }
@ -82,12 +80,11 @@ export const useCustomStrategyParameters = (
const value = parseParameterString(parameters[name]); const value = parseParameterString(parameters[name]);
return ( return (
<StrategyEvaluationItem <StrategyEvaluationItem key={key} type={typeItem}>
key={key}
type={typeItem}
values={value === '' ? undefined : [value]}
>
{value === '' ? 'is an empty string' : 'is set to'} {value === '' ? 'is an empty string' : 'is set to'}
<ValuesList
values={value === '' ? undefined : [value]}
/>
</StrategyEvaluationItem> </StrategyEvaluationItem>
); );
} }
@ -95,12 +92,9 @@ export const useCustomStrategyParameters = (
case 'number': { case 'number': {
const value = parseParameterNumber(parameters[name]); const value = parseParameterNumber(parameters[name]);
return ( return (
<StrategyEvaluationItem <StrategyEvaluationItem key={key} type={typeItem}>
key={key}
type={typeItem}
values={[`${value}`]}
>
is a number set to is a number set to
<ValuesList values={[`${value}`]} />
</StrategyEvaluationItem> </StrategyEvaluationItem>
); );
} }

View File

@ -1,8 +1,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { parseParameterStrings } from 'utils/parseParameter';
import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem'; import { StrategyEvaluationItem } from 'component/common/ConstraintsList/StrategyEvaluationItem/StrategyEvaluationItem';
import type { FeatureStrategySchema } from 'openapi'; import type { FeatureStrategySchema } from 'openapi';
import { RolloutParameter } from '../RolloutParameter/RolloutParameter'; import { RolloutParameter } from '../RolloutParameter/RolloutParameter';
import { ValuesList } from 'component/common/ConstraintsList/ValuesList/ValuesList';
import { parseParameterStrings } from 'utils/parseParameter';
export const useStrategyParameters = ( export const useStrategyParameters = (
strategy: Partial< strategy: Partial<
@ -36,11 +37,9 @@ export const useStrategyParameters = (
if (['userids', 'hostnames', 'ips'].includes(type)) { if (['userids', 'hostnames', 'ips'].includes(type)) {
return ( return (
<StrategyEvaluationItem <StrategyEvaluationItem key={key} type={key}>
key={key} <ValuesList values={parseParameterStrings(value)} />
type={key} </StrategyEvaluationItem>
values={parseParameterStrings(value)}
/>
); );
} }

View File

@ -1,10 +1,10 @@
import type { IConstraint } from 'interfaces/strategy';
import { formatDateYMDHMS } from 'utils/formatDate'; import { formatDateYMDHMS } from 'utils/formatDate';
import type { ILocationSettings } from 'hooks/useLocationSettings'; import type { ILocationSettings } from 'hooks/useLocationSettings';
import { CURRENT_TIME_CONTEXT_FIELD } from 'utils/operatorsForContext'; import { CURRENT_TIME_CONTEXT_FIELD } from 'utils/operatorsForContext';
import type { ConstraintSchema } from 'openapi';
export const formatConstraintValue = ( export const formatConstraintValue = (
constraint: IConstraint, constraint: Pick<ConstraintSchema, 'value' | 'contextName'>,
locationSettings: ILocationSettings, locationSettings: ILocationSettings,
): string | undefined => { ): string | undefined => {
if ( if (