diff --git a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx index 749c0bcba4..b024f419c0 100644 --- a/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx +++ b/frontend/src/component/common/StrategyItemContainer/StrategyItemContainer.tsx @@ -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 = ({ children, orderNumber, style = {}, + description, }) => { const Icon = getFeatureStrategyIcon(strategy.name); const { uiConfig } = useUiConfig(); @@ -120,15 +137,26 @@ export const StrategyItemContainer: FC = ({ fill: theme => theme.palette.action.disabled, }} /> - + + + + {description} + + } + /> + + ( diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx index 7c70975f0e..fa9a8350f9 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx @@ -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); } diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx index 96de854ad9..88519c1438 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx @@ -222,7 +222,7 @@ export const FeatureStrategyForm = ({ } /> = ({ }) => { const { uiConfig } = useUiConfig(); - if (!uiConfig.flags.strategyTitle) { + if (!uiConfig.flags.strategyImprovements) { return null; } diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.tsx index b5c38951d2..493adc6df0 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.tsx @@ -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 = () => ( diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx index a4cbb30a65..d4baf0557c 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyItem.tsx @@ -80,7 +80,9 @@ export const StrategyItem: FC = ({ ( ({ + 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 ( + } + > + + You need project owner permissions to access this section. + + + ); + } + + return ( + }> + + 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 + + {project?.environments.map(environment => ( + + ))} + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironment.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironment.tsx new file mode 100644 index 0000000000..ee35eeda0c --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironment.tsx @@ -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 ( + + 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, + }} + > + + + + +
+ +
+
+
+
+ + + + + + + + +
+
+ ); +}; + +export default ProjectEnvironment; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy.tsx new file mode 100644 index 0000000000..1480682aba --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy.tsx @@ -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 | CreateFeatureStrategySchema + >(strategy); + + const [segments, setSegments] = useState([]); + 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(''); + 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 ( + + formatUpdateStrategyApiCode( + projectId, + environmentId, + payload, + strategyDefinition, + unleashUrl + ) + } + > + + + ); +}; + +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; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectDefaultStrategyForm.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectDefaultStrategyForm.tsx new file mode 100644 index 0000000000..d2582cf85b --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectDefaultStrategyForm.tsx @@ -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> + >; + segments: ISegment[]; + setSegments: React.Dispatch>; + 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 ( + + + } + /> + { + setStrategy(prev => ({ + ...prev, + title, + })); + }} + /> + } + /> + + + + + + + Save strategy + + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectEnvironmentDefaultStrategy.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectEnvironmentDefaultStrategy.tsx new file mode 100644 index 0000000000..650998c28d --- /dev/null +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectEnvironmentDefaultStrategy.tsx @@ -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 ( + <> + + + + + + } + > + + + + + + + } + /> + + + ); +}; + +export default ProjectEnvironmentDefaultStrategy; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx index 54cd56ff23..dd5cf9444b 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx @@ -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={} /> } /> + {Boolean(strategyImprovements) && ( + } + /> + )} } diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 751a991cf3..d1690dbec8 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -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, }; diff --git a/frontend/src/interfaces/environments.ts b/frontend/src/interfaces/environments.ts index e43f5132a6..ac9e640f2a 100644 --- a/frontend/src/interfaces/environments.ts +++ b/frontend/src/interfaces/environments.ts @@ -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 | CreateFeatureStrategySchema; } +export type ProjectEnvironmentType = { + environment: string; + defaultStrategy?: CreateFeatureStrategySchema; +}; + export interface IEnvironmentPayload { name: string; type: string; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index a979435496..d911b28cec 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -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 { diff --git a/frontend/src/utils/testIds.ts b/frontend/src/utils/testIds.ts index 4025b8d4f0..9fef658b39 100644 --- a/frontend/src/utils/testIds.ts +++ b/frontend/src/utils/testIds.ts @@ -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'; diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 5c79e39800..6655c35305 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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, }, diff --git a/src/lib/db/environment-store.ts b/src/lib/db/environment-store.ts index 63da48e623..65ce6e1d53 100644 --- a/src/lib/db/environment-store.ts +++ b/src/lib/db/environment-store.ts @@ -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' }, diff --git a/src/lib/openapi/spec/environment-project-schema.ts b/src/lib/openapi/spec/environment-project-schema.ts index 1aefdb031d..c8ba9a495c 100644 --- a/src/lib/openapi/spec/environment-project-schema.ts +++ b/src/lib/openapi/spec/environment-project-schema.ts @@ -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< diff --git a/src/lib/routes/admin-api/environments.ts b/src/lib/routes/admin-api/environments.ts index c55384ad76..8928abbdd5 100644 --- a/src/lib/routes/admin-api/environments.ts +++ b/src/lib/routes/admin-api/environments.ts @@ -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, }, ); } diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index e40444fe1e..6c0f311469 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -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, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 8f4dc22f03..34579918c7 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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( diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 312e9b95bd..ef49e46996 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -86,7 +86,7 @@ export interface IFeatureEnvironmentInfo { environment: string; enabled: boolean; strategies: IFeatureStrategy[]; - defaultStrategy?: CreateFeatureStrategySchema; + defaultStrategy?: CreateFeatureStrategySchema | null; } export interface FeatureToggleWithEnvironment extends FeatureToggle { diff --git a/src/lib/util/feature-evaluator/helpers.ts b/src/lib/util/feature-evaluator/helpers.ts index 79aa382164..12190db247 100644 --- a/src/lib/util/feature-evaluator/helpers.ts +++ b/src/lib/util/feature-evaluator/helpers.ts @@ -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), + }, + }; +} diff --git a/src/server-dev.ts b/src/server-dev.ts index 3781a926d4..44bc204350 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -39,6 +39,7 @@ process.nextTick(async () => { anonymiseEventLog: false, responseTimeWithAppNameKillSwitch: false, variantMetrics: true, + strategyImprovements: true, }, }, authentication: { diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 78bcb1fc8a..2f426070ae 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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,