From c39b4cd1b08b99f49cebf769e5af97dc3399bed3 Mon Sep 17 00:00:00 2001 From: David Leek Date: Mon, 6 Oct 2025 09:02:15 +0200 Subject: [PATCH] feat: add a suggestion banner at the bottom of empty feature-environments (#10725) --- .../EnvironmentHeader/EnvironmentHeader.tsx | 80 ++++++++- .../EnvironmentStrategySuggestion.tsx | 115 +++++++++++++ .../LegacyEnvironmentHeader.tsx | 154 ++++++++++++++++++ .../FeatureOverviewEnvironment.tsx | 5 + .../LegacyFeatureOverviewEnvironment.tsx | 147 +++++++++++++++++ .../FeatureOverviewEnvironments.tsx | 17 +- .../ProjectEnvironment/ProjectEnvironment.tsx | 7 +- frontend/src/hooks/usePlausibleTracker.ts | 1 + frontend/src/interfaces/uiConfig.ts | 1 + src/lib/types/experimental.ts | 7 +- src/server-dev.ts | 1 + 11 files changed, 525 insertions(+), 10 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/EnvironmentStrategySuggestion/EnvironmentStrategySuggestion.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/LegacyEnvironmentHeader/LegacyEnvironmentHeader.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/LegacyFeatureOverviewEnvironment/LegacyFeatureOverviewEnvironment.tsx 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: {