1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

feat: add a suggestion banner at the bottom of empty feature-environments (#10725)

This commit is contained in:
David Leek 2025-10-06 09:02:15 +02:00 committed by GitHub
parent c65a336783
commit c39b4cd1b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 525 additions and 10 deletions

View File

@ -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 <StyledStrategyCount>{text}</StyledStrategyCount>;
};
const DEFAULT_STRATEGY: Omit<IFeatureStrategy, 'id'> = {
name: 'flexibleRollout',
disabled: false,
constraints: [],
title: '',
parameters: {
rollout: '100',
stickiness: 'default',
groupId: '',
},
};
export const environmentAccordionSummaryClassName =
'environment-accordion-summary';
export const EnvironmentHeader: FC<
PropsWithChildren<EnvironmentHeaderProps>
> = ({
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<IFeatureStrategy, 'id'> = useMemo(() => {
const baseDefaultStrategy = {
...DEFAULT_STRATEGY,
...defaultStrategy,
};
return {
...baseDefaultStrategy,
disabled: false,
constraints: baseDefaultStrategy.constraints ?? [],
title: baseDefaultStrategy.title ?? '',
parameters: baseDefaultStrategy.parameters ?? {},
};
}, [JSON.stringify(defaultStrategy)]);
return (
<StyledAccordionSummary
{...props}
@ -136,8 +197,9 @@ export const EnvironmentHeader: FC<
expandable={expandable}
tabIndex={expandable ? 0 : -1}
className={environmentAccordionSummaryClassName}
empty={!hasActivations}
>
<StyledHeader data-loading>
<StyledHeader empty={!hasActivations} data-loading>
<StyledHeaderTitle>
<StyledHeaderTitleLabel>Environment</StyledHeaderTitleLabel>
<StyledTruncator component='h2'>
@ -149,6 +211,14 @@ export const EnvironmentHeader: FC<
</StyledHeaderTitle>
{children}
</StyledHeader>
{!hasActivations && (
<EnvironmentStrategySuggestion
projectId={projectId}
featureId={featureId}
environmentId={environmentId}
strategy={strategy}
/>
)}
</StyledAccordionSummary>
);
};

View File

@ -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<IFeatureStrategy, 'id'>;
};
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 (
<StyledSuggestion>
<StyledBold>Suggestion:</StyledBold>
&nbsp;Add the&nbsp;
<HtmlTooltip
title={
<StyledBox>
<TooltipHeader>Default strategy</TooltipHeader>
<TooltipDescription>
Defined per project, per environment&nbsp;
<Link
to={editDefaultStrategyPath}
title='Project default strategies'
>
here
</Link>
</TooltipDescription>
<StrategyExecution strategy={strategy} />
</StyledBox>
}
maxWidth='200'
arrow
>
<StyledSpan>default strategy</StyledSpan>
</HtmlTooltip>
&nbsp;for this project&nbsp;
<PermissionButton
size='small'
permission={UPDATE_FEATURE}
projectId={projectId}
variant='text'
onClick={() => openStrategyCreationModal()}
>
Apply
</PermissionButton>
</StyledSuggestion>
);
};

View File

@ -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 <NeutralStrategyCount>0 strategies added</NeutralStrategyCount>;
}
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 <StyledStrategyCount>{text}</StyledStrategyCount>;
};
export const environmentAccordionSummaryClassName =
'environment-accordion-summary';
export const LegacyEnvironmentHeader: FC<
PropsWithChildren<EnvironmentHeaderProps>
> = ({
environmentId,
children,
expandable = true,
environmentMetadata,
...props
}) => {
const id = useId();
return (
<StyledAccordionSummary
{...props}
expandIcon={
<ExpandMore
sx={{ visibility: expandable ? 'visible' : 'hidden' }}
/>
}
id={id}
aria-controls={`environment-accordion-${id}-content`}
expandable={expandable}
tabIndex={expandable ? 0 : -1}
className={environmentAccordionSummaryClassName}
>
<StyledHeader data-loading>
<StyledHeaderTitle>
<StyledHeaderTitleLabel>Environment</StyledHeaderTitleLabel>
<StyledTruncator component='h2'>
{environmentId}
</StyledTruncator>
{environmentMetadata ? (
<MetadataChip {...environmentMetadata} />
) : null}
</StyledHeaderTitle>
{children}
</StyledHeader>
</StyledAccordionSummary>
);
};

View File

@ -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}
>
<FeatureOverviewEnvironmentToggle
environment={environment}

View File

@ -0,0 +1,147 @@
import { Accordion, AccordionDetails, styled } from '@mui/material';
import type {
IFeatureEnvironment,
IFeatureEnvironmentMetrics,
} from 'interfaces/featureToggle';
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { UpgradeChangeRequests } from '../UpgradeChangeRequests/UpgradeChangeRequests.tsx';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import {
environmentAccordionSummaryClassName,
LegacyEnvironmentHeader,
} from '../EnvironmentHeader/LegacyEnvironmentHeader/LegacyEnvironmentHeader.tsx';
import FeatureOverviewEnvironmentMetrics from '../EnvironmentHeader/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics.tsx';
import { FeatureOverviewEnvironmentToggle } from '../EnvironmentHeader/FeatureOverviewEnvironmentToggle/FeatureOverviewEnvironmentToggle.tsx';
import { useState } from 'react';
import type { IReleasePlan } from 'interfaces/releasePlans';
import { EnvironmentAccordionBody } from '../EnvironmentAccordionBody/EnvironmentAccordionBody.tsx';
import { Box } from '@mui/material';
import { ReleaseTemplatesFeedback } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/ReleaseTemplatesFeedback/ReleaseTemplatesFeedback';
const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({
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<IFeatureEnvironmentMetrics, 'yes' | 'no'>;
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 (
<StyledFeatureOverviewEnvironment>
<StyledAccordion
TransitionProps={{ mountOnEnter: true, unmountOnExit: true }}
data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${environment.name}`}
expanded={isOpen && hasActivations}
onChange={() => {
const state = isOpen ? !isOpen : hasActivations;
onToggleEnvOpen(state);
setIsOpen(state);
}}
>
<LegacyEnvironmentHeader
environmentMetadata={{
strategyCount: environment.strategies?.length ?? 0,
releasePlanCount: environment.releasePlans?.length ?? 0,
}}
environmentId={environment.name}
expandable={hasActivations}
>
<FeatureOverviewEnvironmentToggle
environment={environment}
/>
{!hasActivations ? (
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={environment.name}
variant='outlined'
/>
) : (
<FeatureOverviewEnvironmentMetrics
environmentMetric={metrics}
/>
)}
</LegacyEnvironmentHeader>
<NewStyledAccordionDetails>
<StyledEnvironmentAccordionContainer>
<EnvironmentAccordionBody
featureEnvironment={environment}
isDisabled={!environment.enabled}
otherEnvironments={otherEnvironments}
/>
</StyledEnvironmentAccordionContainer>
<StyledAccordionFooter>
<Box sx={{ display: 'flex', flexDirection: 'row' }}>
<ReleaseTemplatesFeedback />
<Box ml='auto'>
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={environment.name}
/>
</Box>
</Box>
{isOss() && environment?.type === 'production' ? (
<UpgradeChangeRequests />
) : null}
</StyledAccordionFooter>
</NewStyledAccordionDetails>
</StyledAccordion>
</StyledFeatureOverviewEnvironment>
);
};

View File

@ -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<typeof FeatureOverviewEnvironment>
ComponentProps<typeof LegacyFeatureOverviewEnvironment>
> = ({ environment, ...props }) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
@ -21,13 +23,24 @@ const FeatureOverviewWithReleasePlans: FC<
featureId,
environment?.name,
);
const envAddStrategySuggestionEnabled = useUiFlag(
'envAddStrategySuggestion',
);
if (envAddStrategySuggestionEnabled) {
return (
<FeatureOverviewEnvironment
{...props}
environment={{ ...environment, releasePlans }}
/>
);
}
return (
<LegacyFeatureOverviewEnvironment
{...props}
environment={{ ...environment, releasePlans }}
/>
);
};
export const FeatureOverviewEnvironments: FC<

View File

@ -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}`}
>
<EnvironmentHeader environmentId={name} expandable={false} />
<LegacyEnvironmentHeader
environmentId={name}
expandable={false}
/>
<StyledAccordionDetails>
<ProjectEnvironmentDefaultStrategy
environment={environment}

View File

@ -41,6 +41,7 @@ export type CustomEvents =
| 'context-usage'
| 'segment-usage'
| 'strategy-add'
| 'suggestion-strategy-add'
| 'playground'
| 'feature-type-edit'
| 'strategy-variants'

View File

@ -91,6 +91,7 @@ export type UiFlags = {
flagsUiFilterRefactor?: boolean;
trafficBillingDisplay?: boolean;
milestoneProgression?: boolean;
envAddStrategySuggestion?: boolean;
};
export interface IVersionInfo {

View File

@ -62,7 +62,8 @@ export type IFlagKey =
| 'newUiConfigService'
| 'flagsUiFilterRefactor'
| 'trafficBillingDisplay'
| 'milestoneProgression';
| 'milestoneProgression'
| 'envAddStrategySuggestion';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -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 = {

View File

@ -59,6 +59,7 @@ process.nextTick(async () => {
flagsUiFilterRefactor: true,
trafficBillingDisplay: true,
milestoneProgression: true,
envAddStrategySuggestion: true,
},
},
authentication: {