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: # -->  ### 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:
parent
7c77bc133d
commit
a8936a13c3
@ -1,10 +1,10 @@
|
|||||||
import { DragEventHandler, FC, ReactNode } from 'react';
|
import { DragEventHandler, FC, ReactNode } from 'react';
|
||||||
import { DragIndicator } from '@mui/icons-material';
|
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 { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
import {
|
import {
|
||||||
getFeatureStrategyIcon,
|
|
||||||
formatStrategyName,
|
formatStrategyName,
|
||||||
|
getFeatureStrategyIcon,
|
||||||
} from 'utils/strategyNames';
|
} from 'utils/strategyNames';
|
||||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
@ -20,13 +20,14 @@ interface IStrategyItemContainerProps {
|
|||||||
orderNumber?: number;
|
orderNumber?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DragIcon = styled(IconButton)(({ theme }) => ({
|
const DragIcon = styled(IconButton)({
|
||||||
padding: 0,
|
padding: 0,
|
||||||
cursor: 'inherit',
|
cursor: 'inherit',
|
||||||
transition: 'color 0.2s ease-in-out',
|
transition: 'color 0.2s ease-in-out',
|
||||||
}));
|
});
|
||||||
|
|
||||||
const StyledIndexLabel = styled('div')(({ theme }) => ({
|
const StyledIndexLabel = styled('div')(({ theme }) => ({
|
||||||
fontSize: theme.typography.fontSize,
|
fontSize: theme.typography.fontSize,
|
||||||
@ -39,6 +40,21 @@ const StyledIndexLabel = styled('div')(({ theme }) => ({
|
|||||||
display: 'block',
|
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, {
|
const StyledContainer = styled(Box, {
|
||||||
shouldForwardProp: prop => prop !== 'disabled',
|
shouldForwardProp: prop => prop !== 'disabled',
|
||||||
@ -78,6 +94,7 @@ export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
|
|||||||
children,
|
children,
|
||||||
orderNumber,
|
orderNumber,
|
||||||
style = {},
|
style = {},
|
||||||
|
description,
|
||||||
}) => {
|
}) => {
|
||||||
const Icon = getFeatureStrategyIcon(strategy.name);
|
const Icon = getFeatureStrategyIcon(strategy.name);
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
@ -120,15 +137,26 @@ export const StrategyItemContainer: FC<IStrategyItemContainerProps> = ({
|
|||||||
fill: theme => theme.palette.action.disabled,
|
fill: theme => theme.palette.action.disabled,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StringTruncator
|
<StyledHeaderContainer>
|
||||||
maxWidth="150"
|
<StringTruncator
|
||||||
maxLength={15}
|
maxWidth="150"
|
||||||
text={formatStrategyName(
|
maxLength={15}
|
||||||
uiConfig?.flags?.strategyTitle
|
text={formatStrategyName(
|
||||||
? strategy.title || strategy.name
|
uiConfig?.flags?.strategyImprovements
|
||||||
: strategy.name
|
? strategy.title || strategy.name
|
||||||
)}
|
: strategy.name
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(description)}
|
||||||
|
show={
|
||||||
|
<StyledDescription>
|
||||||
|
{description}
|
||||||
|
</StyledDescription>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledHeaderContainer>
|
||||||
|
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(strategy?.disabled)}
|
condition={Boolean(strategy?.disabled)}
|
||||||
show={() => (
|
show={() => (
|
||||||
|
@ -153,7 +153,7 @@ export const FeatureStrategyEdit = () => {
|
|||||||
payload
|
payload
|
||||||
);
|
);
|
||||||
|
|
||||||
if (uiConfig?.flags?.strategyTitle) {
|
if (uiConfig?.flags?.strategyImprovements) {
|
||||||
// NOTE: remove tracking when feature flag is removed
|
// NOTE: remove tracking when feature flag is removed
|
||||||
trackTitle(strategy.title);
|
trackTitle(strategy.title);
|
||||||
}
|
}
|
||||||
|
@ -222,7 +222,7 @@ export const FeatureStrategyForm = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(uiConfig?.flags?.strategyTitle)}
|
condition={Boolean(uiConfig?.flags?.strategyImprovements)}
|
||||||
show={
|
show={
|
||||||
<FeatureStrategyTitle
|
<FeatureStrategyTitle
|
||||||
title={strategy.title || ''}
|
title={strategy.title || ''}
|
||||||
|
@ -14,7 +14,7 @@ export const FeatureStrategyTitle: VFC<IFeatureStrategyTitleProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
if (!uiConfig.flags.strategyTitle) {
|
if (!uiConfig.flags.strategyImprovements) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { Fragment, useMemo, VFC } from 'react';
|
import { Fragment, useMemo, VFC } from 'react';
|
||||||
import { Box, Chip, styled } from '@mui/material';
|
import { Box, Chip, styled } from '@mui/material';
|
||||||
import { IFeatureStrategyPayload } from 'interfaces/strategy';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
|
import PercentageCircle from 'component/common/PercentageCircle/PercentageCircle';
|
||||||
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
import { StrategySeparator } from 'component/common/StrategySeparator/StrategySeparator';
|
||||||
@ -17,9 +16,11 @@ import {
|
|||||||
} from 'utils/parseParameter';
|
} from 'utils/parseParameter';
|
||||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
||||||
import { Badge } from 'component/common/Badge/Badge';
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
import { CreateFeatureStrategySchema } from 'openapi';
|
||||||
|
import { IFeatureStrategyPayload } from 'interfaces/strategy';
|
||||||
|
|
||||||
interface IStrategyExecutionProps {
|
interface IStrategyExecutionProps {
|
||||||
strategy: IFeatureStrategyPayload;
|
strategy: IFeatureStrategyPayload | CreateFeatureStrategySchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoItems: VFC = () => (
|
const NoItems: VFC = () => (
|
||||||
|
@ -80,7 +80,9 @@ export const StrategyItem: FC<IStrategyItemProps> = ({
|
|||||||
<Edit />
|
<Edit />
|
||||||
</PermissionIconButton>
|
</PermissionIconButton>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(uiConfig?.flags?.strategyDisable)}
|
condition={Boolean(
|
||||||
|
uiConfig?.flags?.strategyImprovements
|
||||||
|
)}
|
||||||
show={() => (
|
show={() => (
|
||||||
<DisableEnableStrategy
|
<DisableEnableStrategy
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
@ -8,7 +8,6 @@ import ProjectInfo from './ProjectInfo/ProjectInfo';
|
|||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { ProjectStats } from './ProjectStats/ProjectStats';
|
import { ProjectStats } from './ProjectStats/ProjectStats';
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
@ -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;
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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;
|
@ -11,10 +11,14 @@ import ProjectEnvironmentList from 'component/project/ProjectEnvironment/Project
|
|||||||
import { ChangeRequestConfiguration } from './ChangeRequestConfiguration/ChangeRequestConfiguration';
|
import { ChangeRequestConfiguration } from './ChangeRequestConfiguration/ChangeRequestConfiguration';
|
||||||
import { ProjectApiAccess } from 'component/project/Project/ProjectSettings/ProjectApiAccess/ProjectApiAccess';
|
import { ProjectApiAccess } from 'component/project/Project/ProjectSettings/ProjectApiAccess/ProjectApiAccess';
|
||||||
import { ProjectSegments } from './ProjectSegments/ProjectSegments';
|
import { ProjectSegments } from './ProjectSegments/ProjectSegments';
|
||||||
|
import { ProjectDefaultStrategySettings } from './ProjectDefaultStrategySettings/ProjectDefaultStrategySettings';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
|
||||||
export const ProjectSettings = () => {
|
export const ProjectSettings = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { strategyImprovements } = uiConfig.flags;
|
||||||
|
|
||||||
const tabs: ITab[] = [
|
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) => {
|
const onChange = (tab: ITab) => {
|
||||||
navigate(tab.id);
|
navigate(tab.id);
|
||||||
};
|
};
|
||||||
@ -65,6 +76,12 @@ export const ProjectSettings = () => {
|
|||||||
element={<ChangeRequestConfiguration />}
|
element={<ChangeRequestConfiguration />}
|
||||||
/>
|
/>
|
||||||
<Route path="api-access/*" element={<ProjectApiAccess />} />
|
<Route path="api-access/*" element={<ProjectApiAccess />} />
|
||||||
|
{Boolean(strategyImprovements) && (
|
||||||
|
<Route
|
||||||
|
path="default-strategy/*"
|
||||||
|
element={<ProjectDefaultStrategySettings />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={<Navigate replace to={tabs[0].id} />}
|
element={<Navigate replace to={tabs[0].id} />}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { BatchStaleSchema } from 'openapi';
|
import type { BatchStaleSchema, CreateFeatureStrategySchema } from 'openapi';
|
||||||
import useAPI from '../useApi/useApi';
|
import useAPI from '../useApi/useApi';
|
||||||
|
|
||||||
interface ICreatePayload {
|
interface ICreatePayload {
|
||||||
@ -261,6 +261,20 @@ const useProjectApi = () => {
|
|||||||
return makeRequest(req.caller, req.id);
|
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 {
|
return {
|
||||||
createProject,
|
createProject,
|
||||||
validateId,
|
validateId,
|
||||||
@ -279,6 +293,7 @@ const useProjectApi = () => {
|
|||||||
deleteFeature,
|
deleteFeature,
|
||||||
deleteFeatures,
|
deleteFeatures,
|
||||||
searchProjectUser,
|
searchProjectUser,
|
||||||
|
updateDefaultStrategy,
|
||||||
errors,
|
errors,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { CreateFeatureStrategySchema } from '../openapi';
|
||||||
|
import { IFeatureStrategy } from './strategy';
|
||||||
|
|
||||||
export interface IEnvironment {
|
export interface IEnvironment {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
@ -14,8 +17,14 @@ export interface IProjectEnvironment extends IEnvironment {
|
|||||||
projectVisible?: boolean;
|
projectVisible?: boolean;
|
||||||
projectApiTokenCount?: number;
|
projectApiTokenCount?: number;
|
||||||
projectEnabledToggleCount?: number;
|
projectEnabledToggleCount?: number;
|
||||||
|
defaultStrategy?: Partial<IFeatureStrategy> | CreateFeatureStrategySchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectEnvironmentType = {
|
||||||
|
environment: string;
|
||||||
|
defaultStrategy?: CreateFeatureStrategySchema;
|
||||||
|
};
|
||||||
|
|
||||||
export interface IEnvironmentPayload {
|
export interface IEnvironmentPayload {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
@ -46,11 +46,10 @@ export interface IFlags {
|
|||||||
notifications?: boolean;
|
notifications?: boolean;
|
||||||
personalAccessTokensKillSwitch?: boolean;
|
personalAccessTokensKillSwitch?: boolean;
|
||||||
demo?: boolean;
|
demo?: boolean;
|
||||||
strategyTitle?: boolean;
|
|
||||||
groupRootRoles?: boolean;
|
groupRootRoles?: boolean;
|
||||||
strategyDisable?: boolean;
|
|
||||||
googleAuthEnabled?: boolean;
|
googleAuthEnabled?: boolean;
|
||||||
variantMetrics?: boolean;
|
variantMetrics?: boolean;
|
||||||
|
strategyImprovements?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -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_DELETE_BTN_ID = 'UG_DELETE_BTN_ID';
|
||||||
export const UG_EDIT_USERS_BTN_ID = 'UG_EDIT_USERS_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 UG_REMOVE_USER_BTN_ID = 'UG_REMOVE_USER_BTN_ID';
|
||||||
|
export const PROJECT_ENVIRONMENT_ACCORDION = 'PROJECT_ENVIRONMENT_ACCORDION';
|
||||||
|
|
||||||
/* PROJECT ACCESS */
|
/* PROJECT ACCESS */
|
||||||
export const PA_ASSIGN_BUTTON_ID = 'PA_ASSIGN_BUTTON_ID';
|
export const PA_ASSIGN_BUTTON_ID = 'PA_ASSIGN_BUTTON_ID';
|
||||||
|
@ -82,8 +82,7 @@ exports[`should create default config 1`] = `
|
|||||||
"personalAccessTokensKillSwitch": false,
|
"personalAccessTokensKillSwitch": false,
|
||||||
"proPlanAutoCharge": false,
|
"proPlanAutoCharge": false,
|
||||||
"responseTimeWithAppNameKillSwitch": false,
|
"responseTimeWithAppNameKillSwitch": false,
|
||||||
"strategyDisable": false,
|
"strategyImprovements": false,
|
||||||
"strategyTitle": false,
|
|
||||||
"strictSchemaValidation": false,
|
"strictSchemaValidation": false,
|
||||||
"variantMetrics": false,
|
"variantMetrics": false,
|
||||||
},
|
},
|
||||||
@ -106,8 +105,7 @@ exports[`should create default config 1`] = `
|
|||||||
"personalAccessTokensKillSwitch": false,
|
"personalAccessTokensKillSwitch": false,
|
||||||
"proPlanAutoCharge": false,
|
"proPlanAutoCharge": false,
|
||||||
"responseTimeWithAppNameKillSwitch": false,
|
"responseTimeWithAppNameKillSwitch": false,
|
||||||
"strategyDisable": false,
|
"strategyImprovements": false,
|
||||||
"strategyTitle": false,
|
|
||||||
"strictSchemaValidation": false,
|
"strictSchemaValidation": false,
|
||||||
"variantMetrics": false,
|
"variantMetrics": false,
|
||||||
},
|
},
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
import NotFoundError from '../error/notfound-error';
|
import NotFoundError from '../error/notfound-error';
|
||||||
import { IEnvironmentStore } from '../types/stores/environment-store';
|
import { IEnvironmentStore } from '../types/stores/environment-store';
|
||||||
import { snakeCaseKeys } from '../util/snakeCase';
|
import { snakeCaseKeys } from '../util/snakeCase';
|
||||||
|
import { CreateFeatureStrategySchema } from '../openapi';
|
||||||
|
|
||||||
interface IEnvironmentsTable {
|
interface IEnvironmentsTable {
|
||||||
name: string;
|
name: string;
|
||||||
@ -30,6 +31,7 @@ interface IEnvironmentsWithCountsTable extends IEnvironmentsTable {
|
|||||||
interface IEnvironmentsWithProjectCountsTable extends IEnvironmentsTable {
|
interface IEnvironmentsWithProjectCountsTable extends IEnvironmentsTable {
|
||||||
project_api_token_count?: string;
|
project_api_token_count?: string;
|
||||||
project_enabled_toggle_count?: string;
|
project_enabled_toggle_count?: string;
|
||||||
|
project_default_strategy?: CreateFeatureStrategySchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
@ -77,6 +79,9 @@ function mapRowWithProjectCounts(
|
|||||||
projectEnabledToggleCount: row.project_enabled_toggle_count
|
projectEnabledToggleCount: row.project_enabled_toggle_count
|
||||||
? parseInt(row.project_enabled_toggle_count, 10)
|
? parseInt(row.project_enabled_toggle_count, 10)
|
||||||
: 0,
|
: 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',
|
'(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 },
|
{ 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([
|
.orderBy([
|
||||||
{ column: 'sort_order', order: 'asc' },
|
{ column: 'sort_order', order: 'asc' },
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { createFeatureStrategySchema } from './create-feature-strategy-schema';
|
||||||
|
|
||||||
export const environmentProjectSchema = {
|
export const environmentProjectSchema = {
|
||||||
$id: '#/components/schemas/environmentProjectSchema',
|
$id: '#/components/schemas/environmentProjectSchema',
|
||||||
@ -50,8 +51,17 @@ export const environmentProjectSchema = {
|
|||||||
description:
|
description:
|
||||||
'The number of features enabled in this environment for this project',
|
'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;
|
} as const;
|
||||||
|
|
||||||
export type EnvironmentProjectSchema = FromSchema<
|
export type EnvironmentProjectSchema = FromSchema<
|
||||||
|
@ -237,9 +237,9 @@ export class EnvironmentsController extends Controller {
|
|||||||
environmentsProjectSchema.$id,
|
environmentsProjectSchema.$id,
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
environments: await this.service.getProjectEnvironments(
|
environments: (await this.service.getProjectEnvironments(
|
||||||
req.params.projectId,
|
req.params.projectId,
|
||||||
),
|
)) as any,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -73,7 +73,10 @@ import {
|
|||||||
} from '../util/validators/constraint-types';
|
} from '../util/validators/constraint-types';
|
||||||
import { IContextFieldStore } from 'lib/types/stores/context-field-store';
|
import { IContextFieldStore } from 'lib/types/stores/context-field-store';
|
||||||
import { SetStrategySortOrderSchema } from 'lib/openapi/spec/set-strategy-sort-order-schema';
|
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 { AccessService } from './access-service';
|
||||||
import { User } from '../server-impl';
|
import { User } from '../server-impl';
|
||||||
import NoAccessError from '../error/no-access-error';
|
import NoAccessError from '../error/no-access-error';
|
||||||
@ -1071,7 +1074,7 @@ class FeatureToggleService {
|
|||||||
environment,
|
environment,
|
||||||
enabled: envMetadata.enabled,
|
enabled: envMetadata.enabled,
|
||||||
strategies,
|
strategies,
|
||||||
defaultStrategy,
|
defaultStrategy: defaultStrategy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1284,9 +1287,24 @@ class FeatureToggleService {
|
|||||||
featureName,
|
featureName,
|
||||||
environment,
|
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) {
|
if (strategies.length === 0) {
|
||||||
await this.unprotectedCreateStrategy(
|
await this.unprotectedCreateStrategy(
|
||||||
getDefaultStrategy(featureName),
|
strategy,
|
||||||
{
|
{
|
||||||
environment,
|
environment,
|
||||||
projectId: project,
|
projectId: project,
|
||||||
|
@ -56,12 +56,8 @@ const flags = {
|
|||||||
),
|
),
|
||||||
migrationLock: parseEnvVarBoolean(process.env.MIGRATION_LOCK, false),
|
migrationLock: parseEnvVarBoolean(process.env.MIGRATION_LOCK, false),
|
||||||
demo: parseEnvVarBoolean(process.env.UNLEASH_DEMO, false),
|
demo: parseEnvVarBoolean(process.env.UNLEASH_DEMO, false),
|
||||||
strategyTitle: parseEnvVarBoolean(
|
strategyImprovements: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_STRATEGY_TITLE,
|
process.env.UNLEASH_STRATEGY_IMPROVEMENTS,
|
||||||
false,
|
|
||||||
),
|
|
||||||
strategyDisable: parseEnvVarBoolean(
|
|
||||||
process.env.UNLEASH_STRATEGY_DISABLE,
|
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
googleAuthEnabled: parseEnvVarBoolean(
|
googleAuthEnabled: parseEnvVarBoolean(
|
||||||
|
@ -86,7 +86,7 @@ export interface IFeatureEnvironmentInfo {
|
|||||||
environment: string;
|
environment: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
strategies: IFeatureStrategy[];
|
strategies: IFeatureStrategy[];
|
||||||
defaultStrategy?: CreateFeatureStrategySchema;
|
defaultStrategy?: CreateFeatureStrategySchema | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureToggleWithEnvironment extends FeatureToggle {
|
export interface FeatureToggleWithEnvironment extends FeatureToggle {
|
||||||
|
@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -39,6 +39,7 @@ process.nextTick(async () => {
|
|||||||
anonymiseEventLog: false,
|
anonymiseEventLog: false,
|
||||||
responseTimeWithAppNameKillSwitch: false,
|
responseTimeWithAppNameKillSwitch: false,
|
||||||
variantMetrics: true,
|
variantMetrics: true,
|
||||||
|
strategyImprovements: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
@ -1673,6 +1673,10 @@ The provider you choose for your addon dictates what properties the \`parameters
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Describes a project's configuration in a given environment.",
|
"description": "Describes a project's configuration in a given environment.",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"defaultStrategy": {
|
||||||
|
"$ref": "#/components/schemas/createFeatureStrategySchema",
|
||||||
|
"description": "The strategy configuration to add when enabling a feature environment by default",
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"description": "\`true\` if the environment is enabled for the project, otherwise \`false\`",
|
"description": "\`true\` if the environment is enabled for the project, otherwise \`false\`",
|
||||||
"example": true,
|
"example": true,
|
||||||
|
Loading…
Reference in New Issue
Block a user