diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx
index 392e231846..b99121bab8 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader.tsx
@@ -1,4 +1,4 @@
-import type { FC, PropsWithChildren } from 'react';
+import { useMemo, type FC, type PropsWithChildren } from 'react';
import {
AccordionSummary,
type AccordionSummaryProps,
@@ -7,12 +7,16 @@ import {
import ExpandMore from '@mui/icons-material/ExpandMore';
import { Truncator } from 'component/common/Truncator/Truncator';
import { useId } from 'hooks/useId';
+import { EnvironmentStrategySuggestion } from './EnvironmentStrategySuggestion/EnvironmentStrategySuggestion.js';
+import type { IFeatureStrategy } from 'interfaces/strategy';
+import { useProjectEnvironments } from 'hooks/api/getters/useProjectEnvironments/useProjectEnvironments';
const StyledAccordionSummary = styled(AccordionSummary, {
- shouldForwardProp: (prop) => prop !== 'expandable',
+ shouldForwardProp: (prop) => prop !== 'expandable' && prop !== 'empty',
})<{
expandable?: boolean;
-}>(({ theme, expandable }) => ({
+ empty?: boolean;
+}>(({ theme, expandable, empty }) => ({
boxShadow: 'none',
padding: theme.spacing(0.5, 3, 0.5, 2),
display: 'flex',
@@ -27,9 +31,26 @@ const StyledAccordionSummary = styled(AccordionSummary, {
':focus-within': {
background: 'none',
},
+ ...(empty && {
+ padding: 0,
+ alignItems: 'normal',
+ '.MuiAccordionSummary-content': {
+ marginBottom: '0px',
+ paddingBottom: '0px',
+ flexDirection: 'column',
+ },
+
+ '.MuiAccordionSummary-expandIconWrapper': {
+ width: '0px',
+ },
+ }),
}));
-const StyledHeader = styled('header')(({ theme }) => ({
+const StyledHeader = styled('header', {
+ shouldForwardProp: (prop) => prop !== 'empty',
+})<{
+ empty?: boolean;
+}>(({ theme, empty }) => ({
display: 'flex',
columnGap: theme.spacing(1),
paddingRight: theme.spacing(1),
@@ -37,6 +58,9 @@ const StyledHeader = styled('header')(({ theme }) => ({
color: theme.palette.text.primary,
alignItems: 'center',
minHeight: theme.spacing(8),
+ ...(empty && {
+ padding: theme.spacing(0, 8, 0, 2),
+ }),
}));
const StyledHeaderTitle = styled('hgroup')(({ theme }) => ({
@@ -79,9 +103,12 @@ type EnvironmentMetadata = {
};
type EnvironmentHeaderProps = {
+ projectId: string;
+ featureId: string;
environmentId: string;
expandable?: boolean;
environmentMetadata?: EnvironmentMetadata;
+ hasActivations?: boolean;
} & AccordionSummaryProps;
const MetadataChip = ({
@@ -110,19 +137,53 @@ const MetadataChip = ({
return {text};
};
+const DEFAULT_STRATEGY: Omit = {
+ name: 'flexibleRollout',
+ disabled: false,
+ constraints: [],
+ title: '',
+ parameters: {
+ rollout: '100',
+ stickiness: 'default',
+ groupId: '',
+ },
+};
+
export const environmentAccordionSummaryClassName =
'environment-accordion-summary';
export const EnvironmentHeader: FC<
PropsWithChildren
> = ({
+ projectId,
+ featureId,
environmentId,
children,
expandable = true,
environmentMetadata,
+ hasActivations = false,
...props
}) => {
const id = useId();
+ const { environments } = useProjectEnvironments(projectId);
+ const defaultStrategy = environments.find(
+ (env) => env.name === environmentId,
+ )?.defaultStrategy;
+
+ const strategy: Omit = useMemo(() => {
+ const baseDefaultStrategy = {
+ ...DEFAULT_STRATEGY,
+ ...defaultStrategy,
+ };
+ return {
+ ...baseDefaultStrategy,
+ disabled: false,
+ constraints: baseDefaultStrategy.constraints ?? [],
+ title: baseDefaultStrategy.title ?? '',
+ parameters: baseDefaultStrategy.parameters ?? {},
+ };
+ }, [JSON.stringify(defaultStrategy)]);
+
return (
-
+
Environment
@@ -149,6 +211,14 @@ export const EnvironmentHeader: FC<
{children}
+ {!hasActivations && (
+
+ )}
);
};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentStrategySuggestion/EnvironmentStrategySuggestion.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentStrategySuggestion/EnvironmentStrategySuggestion.tsx
new file mode 100644
index 0000000000..b91b6f2c86
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentStrategySuggestion/EnvironmentStrategySuggestion.tsx
@@ -0,0 +1,115 @@
+import { Box, styled } from '@mui/material';
+import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
+import { Link, useNavigate } from 'react-router-dom';
+import { StrategyExecution } from '../../EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution.js';
+import PermissionButton from 'component/common/PermissionButton/PermissionButton.js';
+import { usePlausibleTracker } from 'hooks/usePlausibleTracker.js';
+import { formatCreateStrategyPath } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.js';
+import { UPDATE_FEATURE } from '@server/types/permissions.js';
+import type { IFeatureStrategy } from 'interfaces/strategy.js';
+
+const StyledSuggestion = styled('div')(({ theme }) => ({
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ padding: theme.spacing(0.5, 3),
+ background: theme.palette.secondary.light,
+ borderBottomLeftRadius: theme.shape.borderRadiusLarge,
+ borderBottomRightRadius: theme.shape.borderRadiusLarge,
+ color: theme.palette.primary.main,
+ fontSize: theme.fontSizes.smallerBody,
+}));
+
+const StyledBold = styled('b')(({ theme }) => ({
+ fontWeight: theme.typography.fontWeightBold,
+}));
+
+const StyledSpan = styled('span')(({ theme }) => ({
+ fontWeight: theme.typography.fontWeightBold,
+ textDecoration: 'underline',
+}));
+
+const TooltipHeader = styled('div')(({ theme }) => ({
+ fontWeight: theme.typography.fontWeightBold,
+}));
+
+const TooltipDescription = styled('div')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallerBody,
+ paddingBottom: theme.spacing(1.5),
+}));
+
+const StyledBox = styled(Box)(({ theme }) => ({
+ padding: theme.spacing(1.5),
+}));
+
+type DefaultStrategySuggestionProps = {
+ projectId: string;
+ featureId: string;
+ environmentId: string;
+ strategy: Omit;
+};
+
+export const EnvironmentStrategySuggestion = ({
+ projectId,
+ featureId,
+ environmentId,
+ strategy,
+}: DefaultStrategySuggestionProps) => {
+ const { trackEvent } = usePlausibleTracker();
+ const navigate = useNavigate();
+ const editDefaultStrategyPath = `/projects/${projectId}/settings/default-strategy`;
+ const createStrategyPath = formatCreateStrategyPath(
+ projectId,
+ featureId,
+ environmentId,
+ 'flexibleRollout',
+ true,
+ );
+
+ const openStrategyCreationModal = () => {
+ trackEvent('suggestion-strategy-add', {
+ props: {
+ buttonTitle: 'flexibleRollout',
+ },
+ });
+ navigate(createStrategyPath);
+ };
+
+ return (
+
+ Suggestion:
+ Add the
+
+ Default strategy
+
+ Defined per project, per environment
+
+ here
+
+
+
+
+ }
+ maxWidth='200'
+ arrow
+ >
+ default strategy
+
+ for this project
+ openStrategyCreationModal()}
+ >
+ Apply
+
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/LegacyEnvironmentHeader/LegacyEnvironmentHeader.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/LegacyEnvironmentHeader/LegacyEnvironmentHeader.tsx
new file mode 100644
index 0000000000..f516fcbd49
--- /dev/null
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/LegacyEnvironmentHeader/LegacyEnvironmentHeader.tsx
@@ -0,0 +1,154 @@
+import type { FC, PropsWithChildren } from 'react';
+import {
+ AccordionSummary,
+ type AccordionSummaryProps,
+ styled,
+} from '@mui/material';
+import ExpandMore from '@mui/icons-material/ExpandMore';
+import { Truncator } from 'component/common/Truncator/Truncator';
+import { useId } from 'hooks/useId';
+
+const StyledAccordionSummary = styled(AccordionSummary, {
+ shouldForwardProp: (prop) => prop !== 'expandable',
+})<{
+ expandable?: boolean;
+}>(({ theme, expandable }) => ({
+ boxShadow: 'none',
+ padding: theme.spacing(0.5, 3, 0.5, 2),
+ display: 'flex',
+ alignItems: 'center',
+ borderRadius: theme.shape.borderRadiusLarge,
+ pointerEvents: 'auto',
+ opacity: 1,
+ '&&&': {
+ cursor: expandable ? 'pointer' : 'default',
+ },
+
+ ':focus-within': {
+ background: 'none',
+ },
+}));
+
+const StyledHeader = styled('header')(({ theme }) => ({
+ display: 'flex',
+ columnGap: theme.spacing(1),
+ paddingRight: theme.spacing(1),
+ width: '100%',
+ color: theme.palette.text.primary,
+ alignItems: 'center',
+ minHeight: theme.spacing(8),
+}));
+
+const StyledHeaderTitle = styled('hgroup')(({ theme }) => ({
+ display: 'flex',
+ flexFlow: 'row wrap',
+ flex: 1,
+ columnGap: theme.spacing(1),
+}));
+
+const StyledHeaderTitleLabel = styled('p')(({ theme }) => ({
+ width: '100%',
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.text.secondary,
+}));
+
+const StyledTruncator = styled(Truncator)(({ theme }) => ({
+ fontSize: theme.typography.h2.fontSize,
+ fontWeight: theme.typography.fontWeightMedium,
+}));
+
+const StyledStrategyCount = styled('p')(({ theme }) => ({
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.info.contrastText,
+ backgroundColor: theme.palette.info.light,
+ whiteSpace: 'nowrap',
+ width: 'min-content',
+ borderRadius: theme.shape.borderRadiusExtraLarge,
+ padding: theme.spacing(0.5, 1),
+}));
+
+const NeutralStrategyCount = styled(StyledStrategyCount)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallerBody,
+ color: theme.palette.text.secondary,
+ backgroundColor: theme.palette.neutral.light,
+}));
+
+type EnvironmentMetadata = {
+ strategyCount: number;
+ releasePlanCount: number;
+};
+
+type EnvironmentHeaderProps = {
+ environmentId: string;
+ expandable?: boolean;
+ environmentMetadata?: EnvironmentMetadata;
+} & AccordionSummaryProps;
+
+const MetadataChip = ({
+ strategyCount,
+ releasePlanCount,
+}: EnvironmentMetadata) => {
+ if (strategyCount === 0 && releasePlanCount === 0) {
+ return 0 strategies added;
+ }
+
+ const releasePlanText = releasePlanCount > 0 ? 'Release plan' : undefined;
+
+ const strategyText = () => {
+ switch (strategyCount) {
+ case 0:
+ return undefined;
+ case 1:
+ return `1 strategy`;
+ default:
+ return `${strategyCount} strategies`;
+ }
+ };
+
+ const text = `${[releasePlanText, strategyText()].filter(Boolean).join(', ')} added`;
+
+ return {text};
+};
+
+export const environmentAccordionSummaryClassName =
+ 'environment-accordion-summary';
+
+export const LegacyEnvironmentHeader: FC<
+ PropsWithChildren
+> = ({
+ environmentId,
+ children,
+ expandable = true,
+ environmentMetadata,
+ ...props
+}) => {
+ const id = useId();
+ return (
+
+ }
+ id={id}
+ aria-controls={`environment-accordion-${id}-content`}
+ expandable={expandable}
+ tabIndex={expandable ? 0 : -1}
+ className={environmentAccordionSummaryClassName}
+ >
+
+
+ Environment
+
+ {environmentId}
+
+ {environmentMetadata ? (
+
+ ) : null}
+
+ {children}
+
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx
index 4559fddcfc..6fe0306615 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx
@@ -29,6 +29,8 @@ const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({
const StyledAccordion = styled(Accordion)(({ theme }) => ({
boxShadow: 'none',
background: 'none',
+ borderRadius: theme.shape.borderRadiusLarge,
+
[`&:has(.${environmentAccordionSummaryClassName}:focus-visible)`]: {
background: theme.palette.table.headerHover,
},
@@ -97,7 +99,10 @@ export const FeatureOverviewEnvironment = ({
releasePlanCount: environment.releasePlans?.length ?? 0,
}}
environmentId={environment.name}
+ projectId={projectId}
+ featureId={featureId}
expandable={hasActivations}
+ hasActivations={hasActivations}
>
({
+ borderRadius: theme.shape.borderRadiusLarge,
+ backgroundColor: theme.palette.background.paper,
+ border: `1px solid ${theme.palette.divider}`,
+}));
+
+const StyledAccordion = styled(Accordion)(({ theme }) => ({
+ boxShadow: 'none',
+ background: 'none',
+ [`&:has(.${environmentAccordionSummaryClassName}:focus-visible)`]: {
+ background: theme.palette.table.headerHover,
+ },
+}));
+
+const NewStyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
+ padding: 0,
+ background: theme.palette.background.elevation1,
+ borderBottomLeftRadius: theme.shape.borderRadiusLarge,
+ borderBottomRightRadius: theme.shape.borderRadiusLarge,
+ boxShadow: theme.boxShadows.accordionFooter,
+}));
+
+const StyledAccordionFooter = styled('footer')(({ theme }) => ({
+ padding: theme.spacing(2, 3, 3),
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+}));
+
+const StyledEnvironmentAccordionContainer = styled('div')(({ theme }) => ({
+ width: '100%',
+ position: 'relative',
+}));
+
+type FeatureOverviewEnvironmentProps = {
+ environment: IFeatureEnvironment & {
+ releasePlans?: IReleasePlan[];
+ };
+ metrics?: Pick;
+ otherEnvironments?: string[];
+ onToggleEnvOpen?: (isOpen: boolean) => void;
+};
+
+export const LegacyFeatureOverviewEnvironment = ({
+ environment,
+ metrics = { yes: 0, no: 0 },
+ otherEnvironments = [],
+ onToggleEnvOpen = () => {},
+}: FeatureOverviewEnvironmentProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const projectId = useRequiredPathParam('projectId');
+ const featureId = useRequiredPathParam('featureId');
+ const { isOss } = useUiConfig();
+ const hasActivations = Boolean(
+ environment?.enabled ||
+ (environment?.strategies && environment?.strategies.length > 0) ||
+ (environment?.releasePlans && environment?.releasePlans.length > 0),
+ );
+
+ return (
+
+ {
+ const state = isOpen ? !isOpen : hasActivations;
+ onToggleEnvOpen(state);
+ setIsOpen(state);
+ }}
+ >
+
+
+ {!hasActivations ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {isOss() && environment?.type === 'production' ? (
+
+ ) : null}
+
+
+
+
+ );
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironments.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironments.tsx
index def3bf2d1d..272dafa65c 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironments.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironments.tsx
@@ -1,10 +1,12 @@
import type { ComponentProps, FC } from 'react';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
+import { LegacyFeatureOverviewEnvironment } from './FeatureOverviewEnvironment/LegacyFeatureOverviewEnvironment/LegacyFeatureOverviewEnvironment.tsx';
import { FeatureOverviewEnvironment } from './FeatureOverviewEnvironment/FeatureOverviewEnvironment.tsx';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import { getFeatureMetrics } from 'utils/getFeatureMetrics';
import { useReleasePlans } from 'hooks/api/getters/useReleasePlans/useReleasePlans';
+import { useUiFlag } from 'hooks/useUiFlag';
type FeatureOverviewEnvironmentsProps = {
hiddenEnvironments?: string[];
@@ -12,7 +14,7 @@ type FeatureOverviewEnvironmentsProps = {
};
const FeatureOverviewWithReleasePlans: FC<
- ComponentProps
+ ComponentProps
> = ({ environment, ...props }) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
@@ -21,9 +23,20 @@ const FeatureOverviewWithReleasePlans: FC<
featureId,
environment?.name,
);
+ const envAddStrategySuggestionEnabled = useUiFlag(
+ 'envAddStrategySuggestion',
+ );
+ if (envAddStrategySuggestionEnabled) {
+ return (
+
+ );
+ }
return (
-
diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironment.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironment.tsx
index 5019e7a9f2..aa3979dcb6 100644
--- a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironment.tsx
+++ b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironment.tsx
@@ -2,7 +2,7 @@ import { Accordion, AccordionDetails, styled } from '@mui/material';
import { PROJECT_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import type { ProjectEnvironmentType } from '../../../../../../interfaces/environments.ts';
import { ProjectEnvironmentDefaultStrategy } from './ProjectEnvironmentDefaultStrategy/ProjectEnvironmentDefaultStrategy.tsx';
-import { EnvironmentHeader } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentHeader';
+import { LegacyEnvironmentHeader } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/LegacyEnvironmentHeader/LegacyEnvironmentHeader';
interface IProjectEnvironmentProps {
environment: ProjectEnvironmentType;
@@ -35,7 +35,10 @@ export const ProjectEnvironment = ({
onChange={(e) => e.stopPropagation()}
data-testid={`${PROJECT_ENVIRONMENT_ACCORDION}_${name}`}
>
-
+
;
@@ -287,6 +288,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_MILESTONE_PROGRESSION,
false,
),
+ envAddStrategySuggestion: parseEnvVarBoolean(
+ process.env.UNLEASH_EXPERIMENTAL_ENV_ADD_STRATEGY_SUGGESTION,
+ false,
+ ),
};
export const defaultExperimentalOptions: IExperimentalOptions = {
diff --git a/src/server-dev.ts b/src/server-dev.ts
index 165f20128e..ddf48063f8 100644
--- a/src/server-dev.ts
+++ b/src/server-dev.ts
@@ -59,6 +59,7 @@ process.nextTick(async () => {
flagsUiFilterRefactor: true,
trafficBillingDisplay: true,
milestoneProgression: true,
+ envAddStrategySuggestion: true,
},
},
authentication: {