1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

Feat: default strategy UI (#3682)

<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->
- Removed `strategyTitle` and `strategyDisable` flags. Unified under
`strategyImprovements` flag
- Implements the default strategy UI
- Bug fixes

## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

<!-- Does it close an issue? Multiple? -->
Closes #
[1-875](https://linear.app/unleash/issue/1-875/default-strategy-frontend)

<!-- (For internal contributors): Does it relate to an issue on public
roadmap? -->
<!--
Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#
-->
![Screenshot 2023-05-04 at 11 21
05](https://user-images.githubusercontent.com/104830839/236149232-84601829-1327-42af-9527-5cc15196517a.png)

### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally
important. Where should a reviewer start looking to get an overview of
the changes? Are any files particularly important? -->


## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged?
Got any questions or doubts? -->

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-05-05 14:32:44 +03:00 committed by GitHub
parent 7c77bc133d
commit a8936a13c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 925 additions and 40 deletions

View File

@ -1,10 +1,10 @@
import { DragEventHandler, FC, ReactNode } from 'react';
import { DragIndicator } from '@mui/icons-material';
import { styled, IconButton, Box, Chip } from '@mui/material';
import { Box, IconButton, styled } from '@mui/material';
import { IFeatureStrategy } from 'interfaces/strategy';
import {
getFeatureStrategyIcon,
formatStrategyName,
getFeatureStrategyIcon,
} from 'utils/strategyNames';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
@ -20,13 +20,14 @@ interface IStrategyItemContainerProps {
orderNumber?: number;
className?: string;
style?: React.CSSProperties;
description?: string;
}
const DragIcon = styled(IconButton)(({ theme }) => ({
const DragIcon = styled(IconButton)({
padding: 0,
cursor: 'inherit',
transition: 'color 0.2s ease-in-out',
}));
});
const StyledIndexLabel = styled('div')(({ theme }) => ({
fontSize: theme.typography.fontSize,
@ -39,6 +40,21 @@ const StyledIndexLabel = styled('div')(({ theme }) => ({
display: 'block',
},
}));
const StyledDescription = styled('div')(({ theme }) => ({
fontSize: theme.typography.fontSize,
fontWeight: 'normal',
color: theme.palette.text.secondary,
display: 'none',
top: theme.spacing(2.5),
[theme.breakpoints.up('md')]: {
display: 'block',
},
}));
const StyledHeaderContainer = styled('div')({
flexDirection: 'column',
justifyContent: 'center',
verticalAlign: 'middle',
});
const StyledContainer = styled(Box, {
shouldForwardProp: prop => prop !== 'disabled',
@ -78,6 +94,7 @@ export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
children,
orderNumber,
style = {},
description,
}) => {
const Icon = getFeatureStrategyIcon(strategy.name);
const { uiConfig } = useUiConfig();
@ -120,15 +137,26 @@ export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
fill: theme => theme.palette.action.disabled,
}}
/>
<StyledHeaderContainer>
<StringTruncator
maxWidth="150"
maxLength={15}
text={formatStrategyName(
uiConfig?.flags?.strategyTitle
uiConfig?.flags?.strategyImprovements
? strategy.title || strategy.name
: strategy.name
)}
/>
<ConditionallyRender
condition={Boolean(description)}
show={
<StyledDescription>
{description}
</StyledDescription>
}
/>
</StyledHeaderContainer>
<ConditionallyRender
condition={Boolean(strategy?.disabled)}
show={() => (

View File

@ -153,7 +153,7 @@ export const FeatureStrategyEdit = () => {
payload
);
if (uiConfig?.flags?.strategyTitle) {
if (uiConfig?.flags?.strategyImprovements) {
// NOTE: remove tracking when feature flag is removed
trackTitle(strategy.title);
}

View File

@ -222,7 +222,7 @@ export const FeatureStrategyForm = ({
}
/>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.strategyTitle)}
condition={Boolean(uiConfig?.flags?.strategyImprovements)}
show={
<FeatureStrategyTitle
title={strategy.title || ''}

View File

@ -14,7 +14,7 @@ export const FeatureStrategyTitle: VFC<IFeatureStrategyTitleProps> = ({
}) => {
const { uiConfig } = useUiConfig();
if (!uiConfig.flags.strategyTitle) {
if (!uiConfig.flags.strategyImprovements) {
return null;
}

View File

@ -1,6 +1,5 @@
import { Fragment, useMemo, VFC } from 'react';
import { Box, Chip, styled } from '@mui/material';
import { IFeatureStrategyPayload } from 'interfaces/strategy';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
@ -17,9 +16,11 @@ import {
} from 'utils/parseParameter';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { Badge } from 'component/common/Badge/Badge';
import { CreateFeatureStrategySchema } from 'openapi';
import { IFeatureStrategyPayload } from 'interfaces/strategy';
interface IStrategyExecutionProps {
strategy: IFeatureStrategyPayload;
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
}
const NoItems: VFC = () => (

View File

@ -80,7 +80,9 @@ export const StrategyItem: FC<IStrategyItemProps> = ({
<Edit />
</PermissionIconButton>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.strategyDisable)}
condition={Boolean(
uiConfig?.flags?.strategyImprovements
)}
show={() => (
<DisableEnableStrategy
projectId={projectId}

View File

@ -8,7 +8,6 @@ import ProjectInfo from './ProjectInfo/ProjectInfo';
import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useLastViewedProject } from 'hooks/useLastViewedProject';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ProjectStats } from './ProjectStats/ProjectStats';

View File

@ -0,0 +1,51 @@
import { useContext } from 'react';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useProject, {
useProjectNameOrId,
} from 'hooks/api/getters/useProject/useProject';
import AccessContext from 'contexts/AccessContext';
import { usePageTitle } from 'hooks/usePageTitle';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { Alert, styled } from '@mui/material';
import ProjectEnvironment from './ProjectEnvironment/ProjectEnvironment';
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
}));
export const ProjectDefaultStrategySettings = () => {
const projectId = useRequiredPathParam('projectId');
const projectName = useProjectNameOrId(projectId);
const { hasAccess } = useContext(AccessContext);
const { project } = useProject(projectId);
usePageTitle(`Project default strategy configuration ${projectName}`);
if (!hasAccess(UPDATE_PROJECT, projectId)) {
return (
<PageContent
header={<PageHeader title="Default Strategy configuration" />}
>
<Alert severity="error">
You need project owner permissions to access this section.
</Alert>
</PageContent>
);
}
return (
<PageContent header={<PageHeader title={`Default Strategy`} />}>
<StyledAlert severity="info">
Here you can customize your default strategy for each specific
environment. These will be used when you enable a toggle
environment that has no strategies defined
</StyledAlert>
{project?.environments.map(environment => (
<ProjectEnvironment
environment={environment}
key={environment.environment}
/>
))}
</PageContent>
);
};

View File

@ -0,0 +1,150 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
styled,
useTheme,
} from '@mui/material';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { PROJECT_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import { ProjectEnvironmentType } from '../../../../../../interfaces/environments';
import ProjectEnvironmentDefaultStrategy from './ProjectEnvironmentDefaultStrategy/ProjectEnvironmentDefaultStrategy';
interface IProjectEnvironmentProps {
environment: ProjectEnvironmentType;
}
const StyledProjectEnvironmentOverview = styled('div', {
shouldForwardProp: prop => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
borderRadius: theme.shape.borderRadiusLarge,
marginBottom: theme.spacing(2),
backgroundColor: enabled
? theme.palette.background.paper
: theme.palette.envAccordion.disabled,
}));
const StyledAccordion = styled(Accordion)({
boxShadow: 'none',
background: 'none',
});
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
boxShadow: 'none',
padding: theme.spacing(2, 4),
pointerEvents: 'none',
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1, 2),
},
}));
const StyledAccordionDetails = styled(AccordionDetails, {
shouldForwardProp: prop => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme }) => ({
padding: theme.spacing(3),
background: theme.palette.envAccordion.expanded,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
boxShadow: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)', // replace this with variable
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 1),
},
}));
const StyledAccordionBody = styled('div')(({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}));
const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1),
},
}));
const StyledHeader = styled('div', {
shouldForwardProp: prop => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
color: enabled ? theme.palette.text.primary : theme.palette.text.secondary,
}));
const StyledHeaderTitle = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
fontWeight: 'bold',
[theme.breakpoints.down(560)]: {
flexDirection: 'column',
textAlign: 'center',
},
}));
const StyledEnvironmentIcon = styled(EnvironmentIcon)(({ theme }) => ({
[theme.breakpoints.down(560)]: {
marginBottom: '0.5rem',
},
}));
const StyledStringTruncator = styled(StringTruncator)(({ theme }) => ({
fontSize: theme.fontSizes.bodySize,
fontWeight: theme.typography.fontWeightMedium,
[theme.breakpoints.down(560)]: {
textAlign: 'center',
},
}));
const ProjectEnvironment = ({ environment }: IProjectEnvironmentProps) => {
const { environment: name } = environment;
const description = `Default strategy configuration in the ${name} environment`;
const theme = useTheme();
const enabled = false;
return (
<StyledProjectEnvironmentOverview enabled={false}>
<StyledAccordion
expanded={true}
onChange={e => e.stopPropagation()}
data-testid={`${PROJECT_ENVIRONMENT_ACCORDION}_${name}`}
className={`environment-accordion ${
enabled ? '' : 'accordion-disabled'
}`}
style={{
outline: `2px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
}}
>
<StyledAccordionSummary>
<StyledHeader data-loading enabled={enabled}>
<StyledHeaderTitle>
<StyledEnvironmentIcon enabled />
<div>
<StyledStringTruncator
text={name}
maxWidth="100"
maxLength={15}
/>
</div>
</StyledHeaderTitle>
</StyledHeader>
</StyledAccordionSummary>
<StyledAccordionDetails enabled>
<StyledAccordionBody>
<StyledAccordionBodyInnerContainer>
<ProjectEnvironmentDefaultStrategy
environment={environment}
description={description}
/>
</StyledAccordionBodyInnerContainer>
</StyledAccordionBody>
</StyledAccordionDetails>
</StyledAccordion>
</StyledProjectEnvironmentOverview>
);
};
export default ProjectEnvironment;

View File

@ -0,0 +1,231 @@
import useToast from 'hooks/useToast';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useNavigate } from 'react-router-dom';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import React, { useEffect, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
import { ISegment } from 'interfaces/segment';
import { useFormErrors } from 'hooks/useFormErrors';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { formatStrategyName } from 'utils/strategyNames';
import { sortStrategyParameters } from 'utils/sortStrategyParameters';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { ProjectDefaultStrategyForm } from './ProjectDefaultStrategyForm';
import { CreateFeatureStrategySchema } from 'openapi';
import useProject from 'hooks/api/getters/useProject/useProject';
interface EditDefaultStrategyProps {
strategy: IFeatureStrategy | CreateFeatureStrategySchema;
}
const EditDefaultStrategy = ({ strategy }: EditDefaultStrategyProps) => {
const projectId = useRequiredPathParam('projectId');
const environmentId = useRequiredQueryParam('environmentId');
const { refetch: refetchProject } = useProject(projectId);
const [defaultStrategy, setDefaultStrategy] = useState<
Partial<IFeatureStrategy> | CreateFeatureStrategySchema
>(strategy);
const [segments, setSegments] = useState<ISegment[]>([]);
const { updateDefaultStrategy, loading } = useProjectApi();
const { strategyDefinition } = useStrategy(strategy.name);
const { setToastData, setToastApiError } = useToast();
const errors = useFormErrors();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const navigate = useNavigate();
const [previousTitle, setPreviousTitle] = useState<string>('');
const { trackEvent } = usePlausibleTracker();
const trackTitle = (title: string = '') => {
// don't expose the title, just if it was added, removed, or edited
if (title === previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'none',
on: 'edit',
},
});
}
if (previousTitle === '' && title !== '') {
trackEvent('strategyTitle', {
props: {
action: 'added',
on: 'edit',
},
});
}
if (previousTitle !== '' && title === '') {
trackEvent('strategyTitle', {
props: {
action: 'removed',
on: 'edit',
},
});
}
if (previousTitle !== '' && title !== '' && title !== previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'edited',
on: 'edit',
},
});
}
};
const {
segments: allSegments,
refetchSegments: refetchSavedStrategySegments,
} = useSegments();
useEffect(() => {
// Fill in the selected segments once they've been fetched.
if (allSegments && strategy?.segments) {
const temp: ISegment[] = [];
for (const segmentId of strategy?.segments) {
temp.push(
...allSegments.filter(segment => segment.id === segmentId)
);
}
setSegments(temp);
}
}, [JSON.stringify(allSegments), JSON.stringify(strategy.segments)]);
const segmentsToSubmit = uiConfig?.flags.SE ? segments : [];
const payload = createStrategyPayload(
defaultStrategy as any,
segmentsToSubmit
);
const onDefaultStrategyEdit = async (
payload: CreateFeatureStrategySchema
) => {
await updateDefaultStrategy(projectId, environmentId, payload);
if (uiConfig?.flags?.strategyImprovements && strategy.title) {
// NOTE: remove tracking when feature flag is removed
trackTitle(strategy.title);
}
await refetchSavedStrategySegments();
setToastData({
title: 'Default Strategy updated',
type: 'success',
confetti: true,
});
};
const onSubmit = async () => {
const path = `/projects/${projectId}/settings/default-strategy`;
try {
await onDefaultStrategyEdit(payload);
await refetchProject();
navigate(path);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
if (!strategyDefinition) {
return null;
}
if (!defaultStrategy) return null;
return (
<FormTemplate
modal
title={formatStrategyName(strategy.name ?? '')}
description={projectDefaultStrategyHelp}
documentationLink={projectDefaultStrategyDocsLink}
documentationLinkLabel={projectDefaultStrategyDocsLinkLabel}
formatApiCode={() =>
formatUpdateStrategyApiCode(
projectId,
environmentId,
payload,
strategyDefinition,
unleashUrl
)
}
>
<ProjectDefaultStrategyForm
projectId={projectId}
strategy={defaultStrategy as any}
setStrategy={setDefaultStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environmentId}
onSubmit={onSubmit}
loading={loading}
permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={false}
/>
</FormTemplate>
);
};
export const createStrategyPayload = (
strategy: CreateFeatureStrategySchema,
segments: ISegment[]
): CreateFeatureStrategySchema => ({
name: strategy.name,
title: strategy.title,
constraints: strategy.constraints ?? [],
parameters: strategy.parameters ?? {},
segments: segments.map(segment => segment.id),
disabled: strategy.disabled ?? false,
});
export const formatUpdateStrategyApiCode = (
projectId: string,
environmentId: string,
strategy: CreateFeatureStrategySchema,
strategyDefinition: IStrategy,
unleashUrl?: string
): string => {
if (!unleashUrl) {
return '';
}
// Sort the strategy parameters payload so that they match
// the order of the input fields in the form, for usability.
const sortedStrategy = {
...strategy,
parameters: sortStrategyParameters(
strategy.parameters ?? {},
strategyDefinition
),
};
const url = `${unleashUrl}/api/admin/projects/${projectId}/environments/${environmentId}/default-strategy}`;
const payload = JSON.stringify(sortedStrategy, undefined, 2);
return `curl --location --request PUT '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${payload}'`;
};
export const projectDefaultStrategyHelp = `
An activation strategy will only run when a feature toggle is enabled and provides a way to control who will get access to the feature.
If any of a feature toggle's activation strategies returns true, the user will get access.
`;
export const projectDefaultStrategyDocsLink =
'https://docs.getunleash.io/reference/activation-strategies';
export const projectDefaultStrategyDocsLinkLabel =
'Default strategy documentation';
export default EditDefaultStrategy;

View File

@ -0,0 +1,217 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, styled } from '@mui/material';
import {
IFeatureStrategy,
IFeatureStrategyParameters,
IStrategyParameter,
} from 'interfaces/strategy';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { STRATEGY_FORM_SUBMIT_ID } from 'utils/testIds';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment';
import { ISegment } from 'interfaces/segment';
import { IFormErrors } from 'hooks/useFormErrors';
import { validateParameterValue } from 'utils/validateParameterValue';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
import { FeatureStrategyConstraints } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/FeatureStrategyConstraints';
import { FeatureStrategyType } from 'component/feature/FeatureStrategy/FeatureStrategyType/FeatureStrategyType';
import { FeatureStrategyTitle } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyTitle/FeatureStrategyTitle';
import { CreateFeatureStrategySchema } from 'openapi';
interface IProjectDefaultStrategyFormProps {
projectId: string;
environmentId: string;
permission: string;
onSubmit: () => void;
onCancel?: () => void;
loading: boolean;
isChangeRequest?: boolean;
strategy: IFeatureStrategy | CreateFeatureStrategySchema;
setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>>
>;
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
errors: IFormErrors;
}
const StyledForm = styled('form')(({ theme }) => ({
display: 'grid',
gap: theme.spacing(2),
}));
const StyledHr = styled('hr')(({ theme }) => ({
width: '100%',
height: '1px',
margin: theme.spacing(2, 0),
border: 'none',
background: theme.palette.background.elevation2,
}));
const StyledButtons = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'end',
gap: theme.spacing(2),
paddingBottom: theme.spacing(10),
}));
export const ProjectDefaultStrategyForm = ({
projectId,
environmentId,
permission,
onSubmit,
onCancel,
loading,
strategy,
setStrategy,
segments,
setSegments,
errors,
}: IProjectDefaultStrategyFormProps) => {
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
const access = useHasProjectEnvironmentAccess(
permission,
projectId,
environmentId
);
const { strategyDefinition } = useStrategy(strategy?.name);
const navigate = useNavigate();
const {
uiConfig,
error: uiConfigError,
loading: uiConfigLoading,
} = useUiConfig();
if (uiConfigError) {
throw uiConfigError;
}
if (uiConfigLoading || !strategyDefinition) {
return null;
}
const findParameterDefinition = (name: string): IStrategyParameter => {
return strategyDefinition.parameters.find(parameterDefinition => {
return parameterDefinition.name === name;
})!;
};
const validateParameter = (
name: string,
value: IFeatureStrategyParameters[string]
): boolean => {
const parameter = findParameterDefinition(name);
// We don't validate groupId for the default strategy.
// it will get filled when added to a toggle
if (name !== 'groupId') {
const parameterValueError = validateParameterValue(
parameter,
value
);
if (parameterValueError) {
errors.setFormError(name, parameterValueError);
return false;
} else {
errors.removeFormError(name);
return true;
}
}
return true;
};
const validateAllParameters = (): boolean => {
return strategyDefinition.parameters
.map(parameter => parameter.name)
.map(name => validateParameter(name, strategy.parameters?.[name]))
.every(Boolean);
};
const onDefaultCancel = () => {
navigate(`/projects/${projectId}/settings/default-strategy`);
};
const onSubmitWithValidation = async (event: React.FormEvent) => {
event.preventDefault();
if (!validateAllParameters()) {
return;
} else {
onSubmit();
}
};
return (
<StyledForm onSubmit={onSubmitWithValidation}>
<ConditionallyRender
condition={Boolean(uiConfig.flags.SE)}
show={
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
}
/>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.strategyImprovements)}
show={
<FeatureStrategyTitle
title={strategy.title || ''}
setTitle={title => {
setStrategy(prev => ({
...prev,
title,
}));
}}
/>
}
/>
<FeatureStrategyConstraints
projectId={projectId}
environmentId={environmentId}
strategy={strategy as any}
setStrategy={setStrategy}
/>
<StyledHr />
<FeatureStrategyType
strategy={strategy as any}
strategyDefinition={strategyDefinition}
setStrategy={setStrategy}
validateParameter={validateParameter}
errors={errors}
hasAccess={access}
/>
<StyledHr />
<StyledButtons>
<PermissionButton
permission={permission}
projectId={projectId}
environmentId={environmentId}
variant="contained"
color="primary"
type="submit"
disabled={
loading ||
!hasValidConstraints ||
errors.hasFormErrors()
}
data-testid={STRATEGY_FORM_SUBMIT_ID}
>
Save strategy
</PermissionButton>
<Button
type="button"
color="primary"
onClick={onCancel ? onCancel : onDefaultCancel}
disabled={loading}
>
Cancel
</Button>
</StyledButtons>
</StyledForm>
);
};

View File

@ -0,0 +1,104 @@
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { StrategyItemContainer } from 'component/common/StrategyItemContainer/StrategyItemContainer';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { Link, Route, Routes, useNavigate } from 'react-router-dom';
import { Edit } from '@mui/icons-material';
import { StrategyExecution } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import { ProjectEnvironmentType } from 'interfaces/environments';
import React, { useMemo } from 'react';
import EditDefaultStrategy from './EditDefaultStrategy';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { CreateFeatureStrategySchema } from 'openapi';
interface ProjectEnvironmentDefaultStrategyProps {
environment: ProjectEnvironmentType;
description: string;
}
export const formatEditProjectEnvironmentStrategyPath = (
projectId: string,
environmentId: string
): string => {
const params = new URLSearchParams({ environmentId });
return `/projects/${projectId}/settings/default-strategy/edit?${params}`;
};
const DEFAULT_STRATEGY: CreateFeatureStrategySchema = {
name: 'flexibleRollout',
disabled: false,
constraints: [],
title: '',
parameters: {
rollout: '100',
stickiness: 'default',
groupId: '',
},
};
const ProjectEnvironmentDefaultStrategy = ({
environment,
description,
}: ProjectEnvironmentDefaultStrategyProps) => {
const navigate = useNavigate();
const projectId = useRequiredPathParam('projectId');
const { environment: environmentId, defaultStrategy } = environment;
const editStrategyPath = formatEditProjectEnvironmentStrategyPath(
projectId,
environmentId
);
const path = `/projects/${projectId}/settings/default-strategy`;
const strategy: CreateFeatureStrategySchema = useMemo(() => {
return defaultStrategy ? defaultStrategy : DEFAULT_STRATEGY;
}, [JSON.stringify(defaultStrategy)]);
const onSidebarClose = () => navigate(path);
return (
<>
<StrategyItemContainer
strategy={(strategy || DEFAULT_STRATEGY) as any}
description={description}
actions={
<>
<PermissionIconButton
permission={UPDATE_FEATURE_STRATEGY}
environmentId={environmentId}
projectId={projectId}
component={Link}
to={editStrategyPath}
tooltipProps={{
title: `Edit default strategy for "${environmentId}"`,
}}
data-testid={`STRATEGY_EDIT-${strategy.name}`}
>
<Edit />
</PermissionIconButton>
</>
}
>
<StrategyExecution strategy={strategy || DEFAULT_STRATEGY} />
</StrategyItemContainer>
<Routes>
<Route
path="edit"
element={
<SidebarModal
label="Edit feature strategy"
onClose={onSidebarClose}
open
>
<EditDefaultStrategy strategy={strategy as any} />
</SidebarModal>
}
/>
</Routes>
</>
);
};
export default ProjectEnvironmentDefaultStrategy;

View File

@ -11,10 +11,14 @@ import ProjectEnvironmentList from 'component/project/ProjectEnvironment/Project
import { ChangeRequestConfiguration } from './ChangeRequestConfiguration/ChangeRequestConfiguration';
import { ProjectApiAccess } from 'component/project/Project/ProjectSettings/ProjectApiAccess/ProjectApiAccess';
import { ProjectSegments } from './ProjectSegments/ProjectSegments';
import { ProjectDefaultStrategySettings } from './ProjectDefaultStrategySettings/ProjectDefaultStrategySettings';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
export const ProjectSettings = () => {
const location = useLocation();
const navigate = useNavigate();
const { uiConfig } = useUiConfig();
const { strategyImprovements } = uiConfig.flags;
const tabs: ITab[] = [
{
@ -39,6 +43,13 @@ export const ProjectSettings = () => {
},
];
if (Boolean(strategyImprovements)) {
tabs.push({
id: 'default-strategy',
label: 'Default strategy',
});
}
const onChange = (tab: ITab) => {
navigate(tab.id);
};
@ -65,6 +76,12 @@ export const ProjectSettings = () => {
element={<ChangeRequestConfiguration />}
/>
<Route path="api-access/*" element={<ProjectApiAccess />} />
{Boolean(strategyImprovements) && (
<Route
path="default-strategy/*"
element={<ProjectDefaultStrategySettings />}
/>
)}
<Route
path="*"
element={<Navigate replace to={tabs[0].id} />}

View File

@ -1,4 +1,4 @@
import type { BatchStaleSchema } from 'openapi';
import type { BatchStaleSchema, CreateFeatureStrategySchema } from 'openapi';
import useAPI from '../useApi/useApi';
interface ICreatePayload {
@ -261,6 +261,20 @@ const useProjectApi = () => {
return makeRequest(req.caller, req.id);
};
const updateDefaultStrategy = async (
projectId: string,
environment: string,
strategy: CreateFeatureStrategySchema
) => {
const path = `api/admin/projects/${projectId}/environments/${environment}/default-strategy`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(strategy),
});
return makeRequest(req.caller, req.id);
};
return {
createProject,
validateId,
@ -279,6 +293,7 @@ const useProjectApi = () => {
deleteFeature,
deleteFeatures,
searchProjectUser,
updateDefaultStrategy,
errors,
loading,
};

View File

@ -1,3 +1,6 @@
import { CreateFeatureStrategySchema } from '../openapi';
import { IFeatureStrategy } from './strategy';
export interface IEnvironment {
name: string;
type: string;
@ -14,8 +17,14 @@ export interface IProjectEnvironment extends IEnvironment {
projectVisible?: boolean;
projectApiTokenCount?: number;
projectEnabledToggleCount?: number;
defaultStrategy?: Partial<IFeatureStrategy> | CreateFeatureStrategySchema;
}
export type ProjectEnvironmentType = {
environment: string;
defaultStrategy?: CreateFeatureStrategySchema;
};
export interface IEnvironmentPayload {
name: string;
type: string;

View File

@ -46,11 +46,10 @@ export interface IFlags {
notifications?: boolean;
personalAccessTokensKillSwitch?: boolean;
demo?: boolean;
strategyTitle?: boolean;
groupRootRoles?: boolean;
strategyDisable?: boolean;
googleAuthEnabled?: boolean;
variantMetrics?: boolean;
strategyImprovements?: boolean;
}
export interface IVersionInfo {

View File

@ -21,6 +21,7 @@ export const UG_EDIT_BTN_ID = 'UG_EDIT_BTN_ID';
export const UG_DELETE_BTN_ID = 'UG_DELETE_BTN_ID';
export const UG_EDIT_USERS_BTN_ID = 'UG_EDIT_USERS_BTN_ID';
export const UG_REMOVE_USER_BTN_ID = 'UG_REMOVE_USER_BTN_ID';
export const PROJECT_ENVIRONMENT_ACCORDION = 'PROJECT_ENVIRONMENT_ACCORDION';
/* PROJECT ACCESS */
export const PA_ASSIGN_BUTTON_ID = 'PA_ASSIGN_BUTTON_ID';

View File

@ -82,8 +82,7 @@ exports[`should create default config 1`] = `
"personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false,
"responseTimeWithAppNameKillSwitch": false,
"strategyDisable": false,
"strategyTitle": false,
"strategyImprovements": false,
"strictSchemaValidation": false,
"variantMetrics": false,
},
@ -106,8 +105,7 @@ exports[`should create default config 1`] = `
"personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false,
"responseTimeWithAppNameKillSwitch": false,
"strategyDisable": false,
"strategyTitle": false,
"strategyImprovements": false,
"strictSchemaValidation": false,
"variantMetrics": false,
},

View File

@ -11,6 +11,7 @@ import {
import NotFoundError from '../error/notfound-error';
import { IEnvironmentStore } from '../types/stores/environment-store';
import { snakeCaseKeys } from '../util/snakeCase';
import { CreateFeatureStrategySchema } from '../openapi';
interface IEnvironmentsTable {
name: string;
@ -30,6 +31,7 @@ interface IEnvironmentsWithCountsTable extends IEnvironmentsTable {
interface IEnvironmentsWithProjectCountsTable extends IEnvironmentsTable {
project_api_token_count?: string;
project_enabled_toggle_count?: string;
project_default_strategy?: CreateFeatureStrategySchema;
}
const COLUMNS = [
@ -77,6 +79,9 @@ function mapRowWithProjectCounts(
projectEnabledToggleCount: row.project_enabled_toggle_count
? parseInt(row.project_enabled_toggle_count, 10)
: 0,
defaultStrategy: row.project_default_strategy
? (row.project_default_strategy as any)
: undefined,
};
}
@ -196,6 +201,10 @@ export default class EnvironmentStore implements IEnvironmentStore {
'(SELECT COUNT(*) FROM feature_environments INNER JOIN features on feature_environments.feature_name = features.name WHERE enabled=true AND feature_environments.environment = environments.name AND project = :projectId) as project_enabled_toggle_count',
{ projectId },
),
this.db.raw(
'(SELECT default_strategy FROM project_environments pe WHERE pe.environment_name = environments.name AND pe.project_id = :projectId) as project_default_strategy',
{ projectId },
),
)
.orderBy([
{ column: 'sort_order', order: 'asc' },

View File

@ -1,4 +1,5 @@
import { FromSchema } from 'json-schema-to-ts';
import { createFeatureStrategySchema } from './create-feature-strategy-schema';
export const environmentProjectSchema = {
$id: '#/components/schemas/environmentProjectSchema',
@ -50,8 +51,17 @@ export const environmentProjectSchema = {
description:
'The number of features enabled in this environment for this project',
},
defaultStrategy: {
description:
'The strategy configuration to add when enabling a feature environment by default',
$ref: '#/components/schemas/createFeatureStrategySchema',
},
},
components: {
schemas: {
createFeatureStrategySchema,
},
},
components: {},
} as const;
export type EnvironmentProjectSchema = FromSchema<

View File

@ -237,9 +237,9 @@ export class EnvironmentsController extends Controller {
environmentsProjectSchema.$id,
{
version: 1,
environments: await this.service.getProjectEnvironments(
environments: (await this.service.getProjectEnvironments(
req.params.projectId,
),
)) as any,
},
);
}

View File

@ -73,7 +73,10 @@ import {
} from '../util/validators/constraint-types';
import { IContextFieldStore } from 'lib/types/stores/context-field-store';
import { SetStrategySortOrderSchema } from 'lib/openapi/spec/set-strategy-sort-order-schema';
import { getDefaultStrategy } from '../util/feature-evaluator/helpers';
import {
getDefaultStrategy,
getProjectDefaultStrategy,
} from '../util/feature-evaluator/helpers';
import { AccessService } from './access-service';
import { User } from '../server-impl';
import NoAccessError from '../error/no-access-error';
@ -1071,7 +1074,7 @@ class FeatureToggleService {
environment,
enabled: envMetadata.enabled,
strategies,
defaultStrategy,
defaultStrategy: defaultStrategy,
};
}
@ -1284,9 +1287,24 @@ class FeatureToggleService {
featureName,
environment,
);
const projectEnvironmentDefaultStrategy =
await this.projectStore.getDefaultStrategy(
project,
environment,
);
const strategy =
this.flagResolver.isEnabled('strategyImprovements') &&
projectEnvironmentDefaultStrategy != null
? getProjectDefaultStrategy(
projectEnvironmentDefaultStrategy,
featureName,
)
: getDefaultStrategy(featureName);
if (strategies.length === 0) {
await this.unprotectedCreateStrategy(
getDefaultStrategy(featureName),
strategy,
{
environment,
projectId: project,

View File

@ -56,12 +56,8 @@ const flags = {
),
migrationLock: parseEnvVarBoolean(process.env.MIGRATION_LOCK, false),
demo: parseEnvVarBoolean(process.env.UNLEASH_DEMO, false),
strategyTitle: parseEnvVarBoolean(
process.env.UNLEASH_STRATEGY_TITLE,
false,
),
strategyDisable: parseEnvVarBoolean(
process.env.UNLEASH_STRATEGY_DISABLE,
strategyImprovements: parseEnvVarBoolean(
process.env.UNLEASH_STRATEGY_IMPROVEMENTS,
false,
),
googleAuthEnabled: parseEnvVarBoolean(

View File

@ -86,7 +86,7 @@ export interface IFeatureEnvironmentInfo {
environment: string;
enabled: boolean;
strategies: IFeatureStrategy[];
defaultStrategy?: CreateFeatureStrategySchema;
defaultStrategy?: CreateFeatureStrategySchema | null;
}
export interface FeatureToggleWithEnvironment extends FeatureToggle {

View File

@ -51,3 +51,28 @@ export function getDefaultStrategy(featureName: string): IStrategyConfig {
},
};
}
function resolveGroupId(
defaultStrategy: IStrategyConfig,
featureName: string,
): string {
const groupId =
defaultStrategy?.parameters?.groupId !== ''
? defaultStrategy.parameters?.groupId
: featureName;
return groupId || '';
}
export function getProjectDefaultStrategy(
defaultStrategy: IStrategyConfig,
featureName: string,
): IStrategyConfig {
return {
...defaultStrategy,
parameters: {
...defaultStrategy.parameters,
groupId: resolveGroupId(defaultStrategy, featureName),
},
};
}

View File

@ -39,6 +39,7 @@ process.nextTick(async () => {
anonymiseEventLog: false,
responseTimeWithAppNameKillSwitch: false,
variantMetrics: true,
strategyImprovements: true,
},
},
authentication: {

View File

@ -1673,6 +1673,10 @@ The provider you choose for your addon dictates what properties the \`parameters
"additionalProperties": false,
"description": "Describes a project's configuration in a given environment.",
"properties": {
"defaultStrategy": {
"$ref": "#/components/schemas/createFeatureStrategySchema",
"description": "The strategy configuration to add when enabling a feature environment by default",
},
"enabled": {
"description": "\`true\` if the environment is enabled for the project, otherwise \`false\`",
"example": true,