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

feat: new environment box (#9342)

Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
Tymoteusz Czech 2025-02-25 11:34:36 +01:00 committed by GitHub
parent 71ca0e413e
commit 42a05ef418
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 799 additions and 268 deletions

View File

@ -230,6 +230,9 @@ export const deleteFeatureStrategy_UI = (
},
).as('deleteUserStrategy');
cy.visit(`/projects/${project}/features/${featureToggleName}`);
cy.get('[data-testid=FEATURE_ENVIRONMENT_ACCORDION_development]')
.first()
.click();
cy.get('[data-testid=STRATEGY_REMOVE_MENU_BTN]').first().click();
cy.get('[data-testid=STRATEGY_FORM_REMOVE_ID]').first().click();
if (!shouldWait) return cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();

View File

@ -1,17 +1,17 @@
import { useTheme } from '@mui/material';
import type { CSSProperties } from 'react';
interface IPercentageCircleProps {
type PercentageCircleProps = {
percentage: number;
size?: `${number}rem`;
disabled?: boolean | null;
}
};
const PercentageCircle = ({
percentage,
size = '4rem',
disabled = false,
}: IPercentageCircleProps) => {
}: PercentageCircleProps) => {
const theme = useTheme();
const style: CSSProperties = {

View File

@ -0,0 +1,84 @@
import { styled, useTheme } from '@mui/material';
import type { CSSProperties, ReactNode } from 'react';
const StyledContainer = styled('div')(() => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
margin: 0,
}));
const StyledContent = styled('div', {
shouldForwardProp: (prop) => prop !== 'color',
})<{ color?: string }>(({ theme, color }) => ({
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color,
fontSize: theme.fontSizes.smallerBody,
margin: 0,
}));
type PercentageDonutProps = {
percentage: number;
size?: `${number}rem`;
disabled?: boolean | null;
donut?: boolean;
children?: ReactNode;
};
export const PercentageDonut = ({
percentage,
size = '4rem',
disabled = false,
children,
}: PercentageDonutProps) => {
const theme = useTheme();
const style: CSSProperties = {
display: 'block',
borderRadius: '100%',
transform: 'rotate(-90deg)',
height: size,
width: size,
};
// The percentage circle used to be drawn by CSS with a conic-gradient,
// but the result was either jagged or blurry. SVG seems to look better.
// See https://stackoverflow.com/a/70659532.
const r = 100 / (2 * Math.PI);
const d = 2 * r;
const strokeWidth = d * 0.2;
const color = disabled
? theme.palette.neutral.border
: theme.palette.primary.light;
return (
<StyledContainer>
{/* biome-ignore lint/a11y/noSvgWithoutTitle: should be in a figure with figcaption */}
<svg viewBox={`0 0 ${d} ${d}`} style={style} aria-hidden>
<circle
r={r}
cx={r}
cy={r}
fill='none'
stroke={theme.palette.background.elevation2}
strokeWidth={strokeWidth}
/>
<circle
r={r}
cx={r}
cy={r}
fill='none'
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={`${percentage} 100`}
/>
</svg>
<StyledContent color={color}>{children}</StyledContent>
</StyledContainer>
);
};

View File

@ -46,7 +46,7 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { BuiltInStrategies, formatStrategyName } from 'utils/strategyNames';
import { Badge } from 'component/common/Badge/Badge';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import { UpgradeChangeRequests } from '../../FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/UpgradeChangeRequests';
import { UpgradeChangeRequests } from '../../FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/UpgradeChangeRequests/UpgradeChangeRequests';
interface IFeatureStrategyFormProps {
feature: IFeatureToggle;

View File

@ -12,7 +12,7 @@ import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/Feature
import { useEffect } from 'react';
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
import { useUiFlag } from 'hooks/useUiFlag';
import { FeatureOverviewEnvironment } from './NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment';
import { FeatureOverviewEnvironments } from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { default as LegacyFleatureOverview } from './LegacyFeatureOverview';
import { useEnvironmentVisibility } from './FeatureOverviewMetaData/EnvironmentVisibilityMenu/hooks/useEnvironmentVisibility';
@ -62,7 +62,7 @@ export const FeatureOverview = () => {
/>
</div>
<StyledMainContent>
<FeatureOverviewEnvironment
<FeatureOverviewEnvironments
hiddenEnvironments={hiddenEnvironments}
/>
</StyledMainContent>

View File

@ -12,6 +12,9 @@ interface IEnvironmentFooterProps {
environmentMetric?: IFeatureEnvironmentMetrics;
}
/**
* @deprecated remove with `featureOverviewRedesign` flag
*/
export const EnvironmentFooter = ({
environmentMetric,
}: IEnvironmentFooterProps) => {

View File

@ -0,0 +1,87 @@
import type { FC, ReactNode } from 'react';
import {
AccordionSummary,
type AccordionSummaryProps,
styled,
} from '@mui/material';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { Truncator } from 'component/common/Truncator/Truncator';
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',
[theme.breakpoints.down('sm')]: {
fontWeight: 'bold',
},
'&&&': {
cursor: expandable ? 'pointer' : 'default',
},
}));
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',
flexDirection: 'column',
flex: 1,
}));
const StyledHeaderTitleLabel = styled('p')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
margin: 0,
}));
const StyledTruncator = styled(Truncator)(({ theme }) => ({
fontSize: theme.typography.body1.fontSize,
fontWeight: theme.typography.fontWeightMedium,
}));
type EnvironmentHeaderProps = {
environmentId: string;
children: ReactNode;
expandable?: boolean;
} & AccordionSummaryProps;
export const EnvironmentHeader: FC<EnvironmentHeaderProps> = ({
environmentId,
children,
expandable = true,
...props
}) => {
return (
<StyledAccordionSummary
{...props}
expandIcon={
<ExpandMore
sx={{ visibility: expandable ? 'visible' : 'hidden' }}
/>
}
expandable={expandable}
>
<StyledHeader data-loading>
<StyledHeaderTitle>
<StyledHeaderTitleLabel>Environment</StyledHeaderTitleLabel>
<StyledTruncator component='h2'>
{environmentId}
</StyledTruncator>
</StyledHeaderTitle>
{children}
</StyledHeader>
</StyledAccordionSummary>
);
};

View File

@ -0,0 +1,108 @@
import type { IFeatureEnvironmentMetrics } from 'interfaces/featureToggle';
import { calculatePercentage } from 'utils/calculatePercentage';
import { PrettifyLargeNumber } from 'component/common/PrettifyLargeNumber/PrettifyLargeNumber';
import {
styled,
Tooltip,
tooltipClasses,
type TooltipProps,
} from '@mui/material';
import { PercentageDonut } from 'component/common/PercentageCircle/PercentageDonut';
const StyledContainer = styled('figure')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
margin: 0,
padding: 0,
}));
const StyledInfo = styled('figcaption')(({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
textAlign: 'right',
[theme.breakpoints.down('xl')]: {
display: 'none',
},
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
margin: 0,
padding: 0,
span: {
textWrap: 'nowrap',
},
}));
const StyledPercentageCircle = styled('div')(({ theme }) => ({
marginRight: theme.spacing(1),
marginLeft: theme.spacing(1.5),
[theme.breakpoints.down(500)]: {
display: 'none',
},
}));
const StyledTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} classes={{ popper: className }} />
))({
[`& .${tooltipClasses.tooltip}`]: {
maxWidth: 200,
},
});
type FeatureOverviewEnvironmentMetrics = {
environmentMetric?: Pick<IFeatureEnvironmentMetrics, 'yes' | 'no'>;
collapsed?: boolean;
};
const FeatureOverviewEnvironmentMetrics = ({
environmentMetric,
collapsed,
}: FeatureOverviewEnvironmentMetrics) => {
if (!environmentMetric) return null;
const total = environmentMetric.yes + environmentMetric.no;
const percentage = calculatePercentage(total, environmentMetric?.yes);
const isEmpty =
!environmentMetric ||
(environmentMetric.yes === 0 && environmentMetric.no === 0);
const content = isEmpty ? (
<>
No evaluation metrics
<br />
received in the last hour
</>
) : (
<>
<span>
The flag has been evaluated{' '}
<b>
<PrettifyLargeNumber value={total} /> times
</b>
</span>{' '}
<span>
and enabled{' '}
<b>
<PrettifyLargeNumber value={environmentMetric.yes} /> times
</b>{' '}
in the last hour
</span>
</>
);
return (
<StyledContainer>
{!collapsed ? <StyledInfo>{content}</StyledInfo> : null}
<StyledTooltip title={collapsed ? content : ''} arrow>
<StyledPercentageCircle data-loading>
<PercentageDonut percentage={percentage} size='3rem'>
{!isEmpty ? `${percentage}%` : null}
</PercentageDonut>
</StyledPercentageCircle>
</StyledTooltip>
</StyledContainer>
);
};
export default FeatureOverviewEnvironmentMetrics;

View File

@ -1,3 +1,4 @@
import { styled } from '@mui/material';
import { useFeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch';
import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
@ -5,13 +6,21 @@ import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
interface IFeatureOverviewEnvironmentToggleProps {
environment: IFeatureEnvironment;
}
const StyledContainer = styled('div')(({ theme }) => ({
order: -1,
flex: 0,
}));
type FeatureOverviewEnvironmentToggleProps = {
environment: Pick<
IFeatureEnvironment,
'name' | 'type' | 'strategies' | 'enabled'
>;
};
export const FeatureOverviewEnvironmentToggle = ({
environment: { name, type, strategies, enabled },
}: IFeatureOverviewEnvironmentToggleProps) => {
}: FeatureOverviewEnvironmentToggleProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { refetchFeature } = useFeature(projectId, featureId);
@ -37,7 +46,7 @@ export const FeatureOverviewEnvironmentToggle = ({
});
return (
<>
<StyledContainer onClick={(event) => event.stopPropagation()}>
<FeatureToggleSwitch
projectId={projectId}
value={enabled}
@ -46,6 +55,6 @@ export const FeatureOverviewEnvironmentToggle = ({
onToggle={onToggle}
/>
{featureToggleModals}
</>
</StyledContainer>
);
};

View File

@ -1,34 +1,66 @@
import type { ReactNode } from 'react';
import { screen } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import FeatureOverviewEnvironment from './FeatureOverviewEnvironment';
import { FeatureOverviewEnvironment } from './FeatureOverviewEnvironment';
import { Route, Routes } from 'react-router-dom';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
const environmentWithoutStrategies = {
name: 'production',
enabled: true,
type: 'production',
strategies: [],
};
test('should allow to add strategy', async () => {
const renderRoute = (element: ReactNode, permissions: any[] = []) =>
render(
<Routes>
<Route
path='/projects/:projectId/features/:featureId/strategies/create'
element={
<FeatureOverviewEnvironment
env={environmentWithoutStrategies}
/>
}
element={element}
/>
</Routes>,
{
route: '/projects/default/features/featureWithoutStrategies/strategies/create',
permissions: [{ permission: CREATE_FEATURE_STRATEGY }],
route: '/projects/default/features/featureId/strategies/create',
permissions,
},
);
const button = await screen.findByText('Add strategy');
expect(button).toBeEnabled();
describe('FeatureOverviewEnvironment', () => {
test('should allow to add strategy', async () => {
renderRoute(
<FeatureOverviewEnvironment
environment={{
name: 'production',
enabled: false,
type: 'production',
strategies: [],
}}
/>,
[{ permission: CREATE_FEATURE_STRATEGY }],
);
const button = await screen.findByText('Add strategy');
expect(button).toBeEnabled();
});
test("should disable add button if permissions don't allow for it", async () => {
render(
<Routes>
<Route
path='/projects/:projectId/features/:featureId/strategies/create'
element={
<FeatureOverviewEnvironment
environment={{
name: 'production',
enabled: false,
type: 'production',
strategies: [],
}}
/>
}
/>
</Routes>,
{
route: '/projects/default/features/featureWithoutStrategies/strategies/create',
permissions: [],
},
);
const button = await screen.findByText('Add strategy');
expect(button).toBeDisabled();
});
});

View File

@ -1,249 +1,138 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
styled,
} from '@mui/material';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import { getFeatureMetrics } from 'utils/getFeatureMetrics';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { Accordion, AccordionDetails, styled } from '@mui/material';
import type {
IFeatureEnvironment,
IFeatureEnvironmentMetrics,
} from 'interfaces/featureToggle';
import EnvironmentAccordionBody from './EnvironmentAccordionBody/EnvironmentAccordionBody';
import { EnvironmentFooter } from './EnvironmentFooter/EnvironmentFooter';
import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics';
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { Badge } from 'component/common/Badge/Badge';
import { UpgradeChangeRequests } from './UpgradeChangeRequests';
import { UpgradeChangeRequests } from './UpgradeChangeRequests/UpgradeChangeRequests';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { EnvironmentHeader } from './EnvironmentHeader/EnvironmentHeader';
import FeatureOverviewEnvironmentMetrics from './EnvironmentHeader/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics';
import { FeatureOverviewEnvironmentToggle } from './EnvironmentHeader/FeatureOverviewEnvironmentToggle/FeatureOverviewEnvironmentToggle';
import { useState } from 'react';
import type { IReleasePlan } from 'interfaces/releasePlans';
interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
}
const StyledFeatureOverviewEnvironment = styled('div', {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({
borderRadius: theme.shape.borderRadiusLarge,
marginBottom: theme.spacing(2),
backgroundColor: enabled
? theme.palette.background.paper
: theme.palette.envAccordion.disabled,
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
}));
const StyledAccordion = styled(Accordion)({
const StyledAccordion = styled(Accordion)(({ theme }) => ({
boxShadow: 'none',
background: 'none',
});
const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
boxShadow: 'none',
padding: theme.spacing(2, 4),
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1, 2),
'&&& .MuiAccordionSummary-root': {
borderRadius: theme.shape.borderRadiusLarge,
pointerEvents: 'auto',
opacity: 1,
backgroundColor: theme.palette.background.paper,
},
}));
const StyledAccordionDetails = styled(AccordionDetails, {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme }) => ({
padding: theme.spacing(3),
const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
padding: 0,
background: theme.palette.envAccordion.expanded,
borderBottomLeftRadius: theme.shape.borderRadiusLarge,
borderBottomRightRadius: theme.shape.borderRadiusLarge,
boxShadow: theme.boxShadows.accordionFooter,
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 1),
},
}));
const StyledEnvironmentAccordionBody = styled(EnvironmentAccordionBody)(
({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}),
);
const StyledHeader = styled('div', {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ theme, enabled }) => ({
const StyledAccordionFooter = styled('footer')(({ theme }) => ({
padding: theme.spacing(2, 3, 3),
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',
[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 StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginTop: theme.spacing(2),
alignItems: 'flex-end',
gap: theme.spacing(2),
flexWrap: 'wrap',
[theme.breakpoints.down(560)]: {
flexDirection: 'column',
},
borderTop: `1px solid ${theme.palette.divider}`,
}));
const FeatureOverviewEnvironment = ({
env,
}: IFeatureOverviewEnvironmentProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { metrics } = useFeatureMetrics(projectId, featureId);
const { feature } = useFeature(projectId, featureId);
const { value: globalStore } = useGlobalLocalStorage();
const StyledEnvironmentAccordionContainer = styled('div')(({ theme }) => ({
width: '100%',
position: 'relative',
padding: theme.spacing(3, 3, 1),
}));
const featureMetrics = getFeatureMetrics(feature?.environments, metrics);
const environmentMetric = featureMetrics.find(
(featureMetric) => featureMetric.environment === env.name,
);
const featureEnvironment = feature?.environments.find(
(featureEnvironment) => featureEnvironment.name === env.name,
);
const { isOss } = useUiConfig();
const showChangeRequestUpgrade = env.type === 'production' && isOss();
return (
<ConditionallyRender
condition={!new Set(globalStore.hiddenEnvironments).has(env.name)}
show={
<StyledFeatureOverviewEnvironment enabled={env.enabled}>
<StyledAccordion
TransitionProps={{ mountOnEnter: true }}
data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${env.name}`}
className={`environment-accordion ${
env.enabled ? '' : 'accordion-disabled'
}`}
>
<StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />}
>
<StyledHeader data-loading enabled={env.enabled}>
<StyledHeaderTitle>
<StyledEnvironmentIcon
enabled={env.enabled}
/>
<div>
<StyledStringTruncator
text={env.name}
maxWidth='100'
maxLength={15}
/>
</div>
<ConditionallyRender
condition={!env.enabled}
show={
<Badge
color='neutral'
sx={{ ml: 1 }}
>
Disabled
</Badge>
}
/>
</StyledHeaderTitle>
<StyledButtonContainer>
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={env.name}
variant='outlined'
size='small'
/>
<FeatureStrategyIcons
strategies={
featureEnvironment?.strategies
}
/>
</StyledButtonContainer>
</StyledHeader>
<FeatureOverviewEnvironmentMetrics
environmentMetric={environmentMetric}
disabled={!env.enabled}
/>
</StyledAccordionSummary>
<StyledAccordionDetails enabled={env.enabled}>
<StyledEnvironmentAccordionBody
featureEnvironment={featureEnvironment}
isDisabled={!env.enabled}
otherEnvironments={feature?.environments
.map(({ name }) => name)
.filter((name) => name !== env.name)}
/>
<ConditionallyRender
condition={
(featureEnvironment?.strategies?.length ||
0) > 0
}
show={
<>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 1,
}}
>
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={env.name}
/>
</Box>
<EnvironmentFooter
environmentMetric={
environmentMetric
}
/>
{showChangeRequestUpgrade ? (
<UpgradeChangeRequests />
) : null}
</>
}
/>
</StyledAccordionDetails>
</StyledAccordion>
</StyledFeatureOverviewEnvironment>
}
/>
);
type FeatureOverviewEnvironmentProps = {
environment: IFeatureEnvironment & {
releasePlans?: IReleasePlan[];
};
metrics?: Pick<IFeatureEnvironmentMetrics, 'yes' | 'no'>;
otherEnvironments?: string[];
};
export default FeatureOverviewEnvironment;
export const FeatureOverviewEnvironment = ({
environment,
metrics = { yes: 0, no: 0 },
otherEnvironments = [],
}: FeatureOverviewEnvironmentProps) => {
const [isOpen, setIsOopen] = 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}
disabled={!hasActivations}
onChange={() => setIsOopen(isOpen ? !isOpen : hasActivations)}
>
<EnvironmentHeader
environmentId={environment.name}
expandable={hasActivations}
>
<FeatureOverviewEnvironmentToggle
environment={environment}
/>
{!hasActivations ? (
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={environment.name}
variant='outlined'
size='small'
/>
) : null}
<FeatureOverviewEnvironmentMetrics
environmentMetric={metrics}
collapsed={!hasActivations}
/>
</EnvironmentHeader>
<StyledAccordionDetails>
<StyledEnvironmentAccordionContainer>
<EnvironmentAccordionBody
featureEnvironment={environment}
isDisabled={!environment.enabled}
otherEnvironments={otherEnvironments}
/>
</StyledEnvironmentAccordionContainer>
<StyledAccordionFooter>
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={environment.name}
/>
{isOss() && environment?.type === 'production' ? (
<UpgradeChangeRequests />
) : null}
</StyledAccordionFooter>
</StyledAccordionDetails>
</StyledAccordion>
</StyledFeatureOverviewEnvironment>
);
};

View File

@ -0,0 +1,252 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
styled,
} from '@mui/material';
import ExpandMore from '@mui/icons-material/ExpandMore';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import { getFeatureMetrics } from 'utils/getFeatureMetrics';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import EnvironmentAccordionBody from './EnvironmentAccordionBody/EnvironmentAccordionBody';
import { EnvironmentFooter } from './EnvironmentFooter/EnvironmentFooter';
import FeatureOverviewEnvironmentMetrics from './EnvironmentHeader/FeatureOverviewEnvironmentMetrics/LegacyFeatureOverviewEnvironmentMetrics';
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { Badge } from 'component/common/Badge/Badge';
import { UpgradeChangeRequests } from './UpgradeChangeRequests/UpgradeChangeRequests';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
}
const StyledFeatureOverviewEnvironment = 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),
[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: theme.boxShadows.accordionFooter,
[theme.breakpoints.down('md')]: {
padding: theme.spacing(2, 1),
},
}));
const StyledEnvironmentAccordionBody = styled(EnvironmentAccordionBody)(
({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}),
);
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',
[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 StyledButtonContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
marginTop: theme.spacing(2),
gap: theme.spacing(2),
flexWrap: 'wrap',
[theme.breakpoints.down(560)]: {
flexDirection: 'column',
},
}));
/**
* @deprecated remove this file with `flagOverviewRedesign`
*/
const FeatureOverviewEnvironment = ({
env,
}: IFeatureOverviewEnvironmentProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { metrics } = useFeatureMetrics(projectId, featureId);
const { feature } = useFeature(projectId, featureId);
const { value: globalStore } = useGlobalLocalStorage();
const featureMetrics = getFeatureMetrics(feature?.environments, metrics);
const environmentMetric = featureMetrics.find(
(featureMetric) => featureMetric.environment === env.name,
);
const featureEnvironment = feature?.environments.find(
(featureEnvironment) => featureEnvironment.name === env.name,
);
const { isOss } = useUiConfig();
const showChangeRequestUpgrade = env.type === 'production' && isOss();
return (
<ConditionallyRender
condition={!new Set(globalStore.hiddenEnvironments).has(env.name)}
show={
<StyledFeatureOverviewEnvironment enabled={env.enabled}>
<StyledAccordion
TransitionProps={{ mountOnEnter: true }}
data-testid={`${FEATURE_ENVIRONMENT_ACCORDION}_${env.name}`}
className={`environment-accordion ${
env.enabled ? '' : 'accordion-disabled'
}`}
>
<StyledAccordionSummary
expandIcon={<ExpandMore titleAccess='Toggle' />}
>
<StyledHeader data-loading enabled={env.enabled}>
<StyledHeaderTitle>
<StyledEnvironmentIcon
enabled={env.enabled}
/>
<div>
<StyledStringTruncator
text={env.name}
maxWidth='100'
maxLength={15}
/>
</div>
<ConditionallyRender
condition={!env.enabled}
show={
<Badge
color='neutral'
sx={{ ml: 1 }}
>
Disabled
</Badge>
}
/>
</StyledHeaderTitle>
<StyledButtonContainer>
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={env.name}
variant='outlined'
size='small'
/>
<FeatureStrategyIcons
strategies={
featureEnvironment?.strategies
}
/>
</StyledButtonContainer>
</StyledHeader>
<FeatureOverviewEnvironmentMetrics
environmentMetric={environmentMetric}
disabled={!env.enabled}
/>
</StyledAccordionSummary>
<StyledAccordionDetails enabled={env.enabled}>
<StyledEnvironmentAccordionBody
featureEnvironment={featureEnvironment}
isDisabled={!env.enabled}
otherEnvironments={feature?.environments
.map(({ name }) => name)
.filter((name) => name !== env.name)}
/>
<ConditionallyRender
condition={
(featureEnvironment?.strategies?.length ||
0) > 0
}
show={
<>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
py: 1,
}}
>
<FeatureStrategyMenu
label='Add strategy'
projectId={projectId}
featureId={featureId}
environmentId={env.name}
/>
</Box>
<EnvironmentFooter
environmentMetric={
environmentMetric
}
/>
{showChangeRequestUpgrade ? (
<UpgradeChangeRequests />
) : null}
</>
}
/>
</StyledAccordionDetails>
</StyledAccordion>
</StyledFeatureOverviewEnvironment>
}
/>
);
};
export default FeatureOverviewEnvironment;

View File

@ -1,23 +1,79 @@
import type { ComponentProps, FC } from 'react';
import { useUiFlag } from 'hooks/useUiFlag';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import FeatureOverviewEnvironment from './FeatureOverviewEnvironment/FeatureOverviewEnvironment';
import { FeatureOverviewEnvironment } from './FeatureOverviewEnvironment/FeatureOverviewEnvironment';
import LegacyFeatureOverviewEnvironment from './FeatureOverviewEnvironment/LegacyFeatureOverviewEnvironment';
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';
const FeatureOverviewEnvironments = () => {
type FeatureOverviewEnvironmentsProps = {
hiddenEnvironments?: string[];
};
const FeatureOverviewWithReleasePlans: FC<
ComponentProps<typeof FeatureOverviewEnvironment>
> = ({ environment, ...props }) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature } = useFeature(projectId, featureId);
if (!feature) return null;
const { environments } = feature;
const { releasePlans } = useReleasePlans(
projectId,
featureId,
environment?.name,
);
return (
<>
{environments?.map((env) => (
<FeatureOverviewEnvironment env={env} key={env.name} />
))}
</>
<FeatureOverviewEnvironment
{...props}
environment={{ ...environment, releasePlans }}
/>
);
};
export default FeatureOverviewEnvironments;
export const FeatureOverviewEnvironments: FC<
FeatureOverviewEnvironmentsProps
> = ({ hiddenEnvironments = [] }) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature } = useFeature(projectId, featureId);
const { metrics } = useFeatureMetrics(projectId, featureId);
const featureMetrics = getFeatureMetrics(feature?.environments, metrics);
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
if (!feature) return null;
if (!flagOverviewRedesign) {
return (
<>
{feature.environments?.map((env) => (
<LegacyFeatureOverviewEnvironment
env={env}
key={env.name}
/>
))}
</>
);
}
return feature.environments
?.filter((env) => !hiddenEnvironments.includes(env.name))
.map((env) => (
<FeatureOverviewWithReleasePlans
environment={env}
key={env.name}
metrics={featureMetrics.find(
(featureMetric) => featureMetric.environment === env?.name,
)}
otherEnvironments={
feature.environments
?.map((e) => e.name)
.filter(
(name) =>
name !== env.name &&
!hiddenEnvironments.includes(name),
) || []
}
/>
));
};

View File

@ -1,4 +1,4 @@
import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { FeatureOverviewEnvironments } from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import {

View File

@ -51,6 +51,9 @@ const StyledBadge = styled(Badge)(({ theme }) => ({
color: theme.palette.common.white,
}));
/**
* @deprecated initial version, clean up after done with `flagOverviewRedesign`
*/
export const FeatureOverviewEnvironmentBody = ({
featureEnvironment,
isDisabled,

View File

@ -3,10 +3,10 @@ import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import { getFeatureMetrics } from 'utils/getFeatureMetrics';
import { FeatureOverviewEnvironmentBody } from './FeatureOverviewEnvironmentBody';
import FeatureOverviewEnvironmentMetrics from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics';
import FeatureOverviewEnvironmentMetrics from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/FeatureOverviewEnvironmentMetrics/LegacyFeatureOverviewEnvironmentMetrics';
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureOverviewEnvironmentToggle } from './FeatureOverviewEnvironmentToggle';
import { FeatureOverviewEnvironmentToggle } from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentHeader/FeatureOverviewEnvironmentToggle/FeatureOverviewEnvironmentToggle';
const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({
padding: theme.spacing(1, 3),
@ -55,6 +55,9 @@ interface INewFeatureOverviewEnvironmentProps {
hiddenEnvironments: string[];
}
/**
* @deprecated initial version, clean up after done with `flagOverviewRedesign`
*/
export const FeatureOverviewEnvironment = ({
hiddenEnvironments,
}: INewFeatureOverviewEnvironmentProps) => {

View File

@ -509,13 +509,13 @@ export const lightTheme = createTheme({
},
},
// Environment accordion
MuiAccordion: {
styleOverrides: {
root: ({ theme }) => ({
'&:first-of-type, &:last-of-type': {
borderRadius: theme.shape.borderRadiusLarge,
},
// Environment accordion -- remove with `flagOverviewRedesign` flag
'&.environment-accordion.Mui-expanded': {
outline: `2px solid ${alpha(
theme.palette.background.alternative,

View File

@ -13,11 +13,10 @@ const emptyMetric = (environment: string) => ({
export const getFeatureMetrics = (
environments: IFeatureEnvironment[],
metrics: IFeatureMetrics,
) => {
return environments.map((env) => {
) =>
environments.map((env) => {
const envMetric = metrics.lastHourUsage.find(
(metric) => metric.environment === env.name,
);
return envMetric || emptyMetric(env.name);
});
};