1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-03 01:18:43 +02:00

chore: flag overview page redesign - environments (#8683)

https://linear.app/unleash/issue/2-2826/enabling-environment-via-feature-flag-environment-section-header

https://linear.app/unleash/issue/2-2825/feature-flag-list-bottom-left-to-be-a-nav-section

Follow-up to: https://github.com/Unleash/unleash/pull/8663

Implements most of the remaining work for our flag overview page
redesign.

Most of the code you see is a straight copy/paste from our older
existing components, with the slight improvement here and there.

Includes some improvements to our vertical tabs component to suit our
use case.
Also updates the Demo flow accordingly. I did some manual tests and it
seems to work decently in both scenarios, whether `flagOverviewRedesign`
is enabled or not. The demo needs some love but that's a story for a
different PR and a different time.

Once again, due to the duplicate file pattern, we should remember to
clean this up if we decide to remove the flag.

<img width="1086" alt="image"
src="https://github.com/user-attachments/assets/0c375e34-cbb5-4ac4-a764-39a36b6c6781">
This commit is contained in:
Nuno Góis 2024-11-08 09:56:46 +00:00 committed by GitHub
parent 7597bb91ac
commit b4fde58fa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 659 additions and 83 deletions

View File

@ -40,6 +40,7 @@ describe('demo', () => {
res.body.flags = {
...res.body.flags,
demo: true,
flagOverviewRedesign: true,
};
}
});

View File

@ -1,6 +1,7 @@
///<reference path="../../global.d.ts" />
describe('feature', () => {
const baseUrl = Cypress.config().baseUrl;
const randomId = String(Math.random()).split('.')[1];
const featureToggleName = `unleash-e2e-${randomId}`;
const projectName = `unleash-e2e-project-${randomId}`;
@ -35,6 +36,19 @@ describe('feature', () => {
beforeEach(() => {
cy.login_UI();
cy.visit('/features');
cy.intercept('GET', `${baseUrl}/api/admin/ui-config`, (req) => {
req.headers['cache-control'] =
'no-cache, no-store, must-revalidate';
req.on('response', (res) => {
if (res.body) {
res.body.flags = {
...res.body.flags,
flagOverviewRedesign: true,
};
}
});
});
});
it('can create a feature flag', () => {

View File

@ -227,7 +227,6 @@ export const deleteFeatureStrategy_UI = (
},
).as('deleteUserStrategy');
cy.visit(`/projects/${project}/features/${featureToggleName}`);
cy.get('[data-testid=FEATURE_ENVIRONMENT_ACCORDION_development]').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,4 +1,5 @@
import { Button, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const StyledTab = styled(Button)<{ selected: boolean }>(
({ theme, selected }) => ({
@ -17,7 +18,8 @@ const StyledTab = styled(Button)<{ selected: boolean }>(
transition: 'background-color 0.2s ease',
color: theme.palette.text.primary,
textAlign: 'left',
padding: theme.spacing(2, 4),
padding: theme.spacing(0, 2),
gap: theme.spacing(1),
fontSize: theme.fontSizes.bodySize,
fontWeight: selected
? theme.fontWeight.bold
@ -41,27 +43,53 @@ const StyledTab = styled(Button)<{ selected: boolean }>(
}),
);
const StyledTabLabel = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
}));
const StyledTabDescription = styled('div')(({ theme }) => ({
fontWeight: theme.fontWeight.medium,
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
interface IVerticalTabProps {
label: string;
description?: string;
selected?: boolean;
onClick: () => void;
icon?: React.ReactNode;
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
}
export const VerticalTab = ({
label,
description,
selected,
onClick,
icon,
startIcon,
endIcon,
}: IVerticalTabProps) => (
<StyledTab
selected={Boolean(selected)}
className={selected ? 'selected' : ''}
onClick={onClick}
disableElevation
disableFocusRipple
fullWidth
>
{label}
{icon}
{startIcon}
<StyledTabLabel>
{label}
<ConditionallyRender
condition={Boolean(description)}
show={
<StyledTabDescription>{description}</StyledTabDescription>
}
/>
</StyledTabLabel>
{endIcon}
</StyledTab>
);

View File

@ -1,5 +1,6 @@
import { styled } from '@mui/material';
import { VerticalTab } from './VerticalTab/VerticalTab';
import type { HTMLAttributes } from 'react';
const StyledTabPage = styled('div')(({ theme }) => ({
display: 'flex',
@ -15,11 +16,13 @@ const StyledTabPageContent = styled('div')(() => ({
flexDirection: 'column',
}));
const StyledTabs = styled('div')(({ theme }) => ({
const StyledTabs = styled('div', {
shouldForwardProp: (prop) => prop !== 'fullWidth',
})<{ fullWidth?: boolean }>(({ theme, fullWidth }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
width: theme.spacing(30),
width: fullWidth ? '100%' : theme.spacing(30),
flexShrink: 0,
[theme.breakpoints.down('xl')]: {
width: '100%',
@ -29,16 +32,19 @@ const StyledTabs = styled('div')(({ theme }) => ({
export interface ITab {
id: string;
label: string;
description?: string;
path?: string;
hidden?: boolean;
icon?: React.ReactNode;
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
}
interface IVerticalTabsProps {
interface IVerticalTabsProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {
tabs: ITab[];
value: string;
onChange: (tab: ITab) => void;
children: React.ReactNode;
children?: React.ReactNode;
}
export const VerticalTabs = ({
@ -46,21 +52,33 @@ export const VerticalTabs = ({
value,
onChange,
children,
}: IVerticalTabsProps) => (
<StyledTabPage>
<StyledTabs>
{tabs
.filter((tab) => !tab.hidden)
.map((tab) => (
<VerticalTab
key={tab.id}
label={tab.label}
selected={tab.id === value}
onClick={() => onChange(tab)}
icon={tab.icon}
/>
))}
</StyledTabs>
<StyledTabPageContent>{children}</StyledTabPageContent>
</StyledTabPage>
);
...props
}: IVerticalTabsProps) => {
const verticalTabs = tabs
.filter((tab) => !tab.hidden)
.map((tab) => (
<VerticalTab
key={tab.id}
label={tab.label}
description={tab.description}
selected={tab.id === value}
onClick={() => onChange(tab)}
startIcon={tab.startIcon}
endIcon={tab.endIcon}
/>
));
if (!children) {
return (
<StyledTabs fullWidth {...props}>
{verticalTabs}
</StyledTabs>
);
}
return (
<StyledTabPage>
<StyledTabs {...props}>{verticalTabs}</StyledTabs>
<StyledTabPageContent>{children}</StyledTabPageContent>
</StyledTabPage>
);
};

View File

@ -131,7 +131,7 @@ export const TOPICS: ITutorialTopic[] = [
},
{
href: `/projects/${PROJECT}/features/demoApp.step2`,
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button`,
target: 'button[data-testid="ADD_STRATEGY_BUTTON"]',
content: (
<Description>
Add a new strategy to this environment by using this
@ -363,9 +363,10 @@ export const TOPICS: ITutorialTopic[] = [
strategies by using the arrow button.
</Description>
),
optional: true,
},
{
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"].Mui-expanded a[data-testid="STRATEGY_EDIT-flexibleRollout"]`,
target: `a[data-testid="STRATEGY_EDIT-flexibleRollout"]`,
content: (
<Description>
Edit the existing gradual rollout strategy by using the
@ -471,7 +472,7 @@ export const TOPICS: ITutorialTopic[] = [
},
{
href: `/projects/${PROJECT}/features/demoApp.step4`,
target: `div[data-testid="FEATURE_ENVIRONMENT_ACCORDION_${ENVIRONMENT}"] button`,
target: 'button[data-testid="ADD_STRATEGY_BUTTON"]',
content: (
<Description>
Add a new strategy to this environment by using this

View File

@ -80,6 +80,7 @@ export const FeatureStrategyMenu = ({
return (
<StyledStrategyMenu onClick={(event) => event.stopPropagation()}>
<PermissionButton
data-testid='ADD_STRATEGY_BUTTON'
permission={CREATE_FEATURE_STRATEGY}
projectId={projectId}
environmentId={environmentId}

View File

@ -12,11 +12,13 @@ import { FeatureOverviewSidePanel as NewFeatureOverviewSidePanel } from 'compone
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
import { styled } from '@mui/material';
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useLastViewedFlags } from 'hooks/useLastViewedFlags';
import { useUiFlag } from 'hooks/useUiFlag';
import OldFeatureOverviewMetaData from './FeatureOverviewMetaData/OldFeatureOverviewMetaData';
import { OldFeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/OldFeatureOverviewSidePanel';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { NewFeatureOverviewEnvironment } from './NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment';
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -48,26 +50,40 @@ const FeatureOverview = () => {
useEffect(() => {
setLastViewed({ featureId, projectId });
}, [featureId]);
const [environmentId, setEnvironmentId] = useState('');
const flagOverviewRedesign = useUiFlag('flagOverviewRedesign');
const FeatureOverviewMetaData = flagOverviewRedesign
? NewFeatureOverviewMetaData
: OldFeatureOverviewMetaData;
const FeatureOverviewSidePanel = flagOverviewRedesign
? NewFeatureOverviewSidePanel
: OldFeatureOverviewSidePanel;
const FeatureOverviewSidePanel = flagOverviewRedesign ? (
<NewFeatureOverviewSidePanel
environmentId={environmentId}
setEnvironmentId={setEnvironmentId}
/>
) : (
<OldFeatureOverviewSidePanel
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
/>
);
return (
<StyledContainer>
<div>
<FeatureOverviewMetaData />
<FeatureOverviewSidePanel
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
/>
{FeatureOverviewSidePanel}
</div>
<StyledMainContent>
<FeatureOverviewEnvironments />
<ConditionallyRender
condition={flagOverviewRedesign}
show={
<NewFeatureOverviewEnvironment
environmentId={environmentId}
/>
}
elseShow={<FeatureOverviewEnvironments />}
/>
</StyledMainContent>
<Routes>
<Route

View File

@ -1,78 +1,83 @@
import { Box, styled } from '@mui/material';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureOverviewSidePanelEnvironmentSwitches } from './FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches';
import { Sticky } from 'component/common/Sticky/Sticky';
import {
type ITab,
VerticalTabs,
} from 'component/common/VerticalTabs/VerticalTabs';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import { useEffect } from 'react';
const StyledContainer = styled(Box)(({ theme }) => ({
top: theme.spacing(2),
margin: theme.spacing(2),
marginLeft: 0,
padding: theme.spacing(3),
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.background.paper,
display: 'flex',
flexDirection: 'column',
maxWidth: '350px',
minWidth: '350px',
marginRight: '1rem',
marginTop: '1rem',
gap: theme.spacing(2),
width: '350px',
[theme.breakpoints.down(1000)]: {
marginBottom: '1rem',
width: '100%',
maxWidth: 'none',
minWidth: 'auto',
},
}));
const StyledHeader = styled('h3')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
fontSize: theme.fontSizes.bodySize,
margin: 0,
marginBottom: theme.spacing(3),
marginBottom: theme.spacing(1),
}));
// Make the help icon align with the text.
'& > :last-child': {
position: 'relative',
top: 1,
const StyledVerticalTabs = styled(VerticalTabs)(({ theme }) => ({
'&&& .selected': {
backgroundColor: theme.palette.neutral.light,
},
}));
interface IFeatureOverviewSidePanelProps {
hiddenEnvironments: Set<String>;
setHiddenEnvironments: (environment: string) => void;
environmentId: string;
setEnvironmentId: React.Dispatch<React.SetStateAction<string>>;
}
export const FeatureOverviewSidePanel = ({
hiddenEnvironments,
setHiddenEnvironments,
environmentId,
setEnvironmentId,
}: IFeatureOverviewSidePanelProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature } = useFeature(projectId, featureId);
const isSticky = feature.environments?.length <= 3;
const tabs: ITab[] = feature.environments.map(
({ name, enabled, strategies }) => ({
id: name,
label: name,
description:
strategies.length === 1
? '1 strategy'
: `${strategies.length || 'No'} strategies`,
startIcon: <EnvironmentIcon enabled={enabled} />,
}),
);
useEffect(() => {
if (!environmentId) {
setEnvironmentId(tabs[0]?.id);
}
}, [tabs]);
return (
<StyledContainer as={isSticky ? Sticky : Box}>
<FeatureOverviewSidePanelEnvironmentSwitches
header={
<StyledHeader data-loading>
Enabled in environments (
{
feature.environments.filter(
({ enabled }) => enabled,
).length
}
)
<HelpIcon
tooltip='When a feature is switched off in an environment, it will always return false. When switched on, it will return true or false depending on its strategies.'
placement='top'
/>
</StyledHeader>
}
feature={feature}
hiddenEnvironments={hiddenEnvironments}
setHiddenEnvironments={setHiddenEnvironments}
<StyledHeader data-loading>
Environments ({feature.environments.length})
</StyledHeader>
<StyledVerticalTabs
tabs={tabs}
value={environmentId}
onChange={({ id }) => setEnvironmentId(id)}
/>
</StyledContainer>
);

View File

@ -0,0 +1,311 @@
import {
type DragEventHandler,
type RefObject,
useEffect,
useState,
} from 'react';
import { Alert, Pagination, styled } from '@mui/material';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { StrategyDraggableItem } from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyDraggableItem';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
import { FeatureStrategyEmpty } from 'component/feature/FeatureStrategy/FeatureStrategyEmpty/FeatureStrategyEmpty';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import usePagination from 'hooks/usePagination';
import type { IFeatureStrategy } from 'interfaces/strategy';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { useUiFlag } from 'hooks/useUiFlag';
import isEqual from 'lodash/isEqual';
interface IEnvironmentAccordionBodyProps {
isDisabled: boolean;
featureEnvironment?: IFeatureEnvironment;
otherEnvironments?: IFeatureEnvironment['name'][];
}
const StyledAccordionBody = styled('div')(({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}));
const StyledAccordionBodyInnerContainer = styled('div')(({ theme }) => ({
[theme.breakpoints.down(400)]: {
padding: theme.spacing(1),
},
}));
export const FeatureOverviewEnvironmentBody = ({
featureEnvironment,
isDisabled,
otherEnvironments,
}: IEnvironmentAccordionBodyProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { setStrategiesSortOrder } = useFeatureStrategyApi();
const { addChange } = useChangeRequestApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { setToastData, setToastApiError } = useToast();
const { refetchFeature } = useFeature(projectId, featureId);
const manyStrategiesPagination = useUiFlag('manyStrategiesPagination');
const [strategies, setStrategies] = useState(
featureEnvironment?.strategies || [],
);
const { trackEvent } = usePlausibleTracker();
const [dragItem, setDragItem] = useState<{
id: string;
index: number;
height: number;
} | null>(null);
const [isReordering, setIsReordering] = useState(false);
useEffect(() => {
if (isReordering) {
if (isEqual(featureEnvironment?.strategies, strategies)) {
setIsReordering(false);
}
} else {
setStrategies(featureEnvironment?.strategies || []);
}
}, [featureEnvironment?.strategies]);
useEffect(() => {
if (strategies.length > 50) {
trackEvent('many-strategies');
}
}, []);
if (!featureEnvironment) {
return null;
}
const pageSize = 20;
const { page, pages, setPageIndex, pageIndex } =
usePagination<IFeatureStrategy>(strategies, pageSize);
const onReorder = async (payload: { id: string; sortOrder: number }[]) => {
try {
await setStrategiesSortOrder(
projectId,
featureId,
featureEnvironment.name,
payload,
);
refetchFeature();
setToastData({
title: 'Order of strategies updated',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onChangeRequestReorder = async (
payload: { id: string; sortOrder: number }[],
) => {
await addChange(projectId, featureEnvironment.name, {
action: 'reorderStrategy',
feature: featureId,
payload,
});
setToastData({
title: 'Strategy execution order added to draft',
type: 'success',
confetti: true,
});
refetchChangeRequests();
};
const onStrategyReorder = async (
payload: { id: string; sortOrder: number }[],
) => {
try {
if (isChangeRequestConfigured(featureEnvironment.name)) {
await onChangeRequestReorder(payload);
} else {
await onReorder(payload);
}
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const onDragStartRef =
(
ref: RefObject<HTMLDivElement>,
index: number,
): DragEventHandler<HTMLButtonElement> =>
(event) => {
setIsReordering(true);
setDragItem({
id: strategies[index].id,
index,
height: ref.current?.offsetHeight || 0,
});
if (ref?.current) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/html', ref.current.outerHTML);
event.dataTransfer.setDragImage(ref.current, 20, 20);
}
};
const onDragOver =
(targetId: string) =>
(
ref: RefObject<HTMLDivElement>,
targetIndex: number,
): DragEventHandler<HTMLDivElement> =>
(event) => {
if (dragItem === null || ref.current === null) return;
if (dragItem.index === targetIndex || targetId === dragItem.id)
return;
const { top, bottom } = ref.current.getBoundingClientRect();
const overTargetTop = event.clientY - top < dragItem.height;
const overTargetBottom = bottom - event.clientY < dragItem.height;
const draggingUp = dragItem.index > targetIndex;
// prevent oscillating by only reordering if there is sufficient space
if (
(overTargetTop && draggingUp) ||
(overTargetBottom && !draggingUp)
) {
const newStrategies = [...strategies];
const movedStrategy = newStrategies.splice(
dragItem.index,
1,
)[0];
newStrategies.splice(targetIndex, 0, movedStrategy);
setStrategies(newStrategies);
setDragItem({
...dragItem,
index: targetIndex,
});
}
};
const onDragEnd = () => {
setDragItem(null);
onStrategyReorder(
strategies.map((strategy, sortOrder) => ({
id: strategy.id,
sortOrder,
})),
);
};
const strategiesToDisplay = isReordering
? strategies
: featureEnvironment.strategies;
return (
<StyledAccordionBody>
<StyledAccordionBodyInnerContainer>
<ConditionallyRender
condition={strategiesToDisplay.length > 0 && isDisabled}
show={() => (
<Alert severity='warning' sx={{ mb: 2 }}>
This environment is disabled, which means that none
of your strategies are executing.
</Alert>
)}
/>
<ConditionallyRender
condition={strategiesToDisplay.length > 0}
show={
<ConditionallyRender
condition={
strategiesToDisplay.length < 50 ||
!manyStrategiesPagination
}
show={
<>
{strategiesToDisplay.map(
(strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={
dragItem?.id === strategy.id
}
onDragStartRef={onDragStartRef}
onDragOver={onDragOver(
strategy.id,
)}
onDragEnd={onDragEnd}
/>
),
)}
</>
}
elseShow={
<>
<Alert severity='error'>
We noticed you're using a high number of
activation strategies. To ensure a more
targeted approach, consider leveraging
constraints or segments.
</Alert>
<br />
{page.map((strategy, index) => (
<StrategyDraggableItem
key={strategy.id}
strategy={strategy}
index={index + pageIndex * pageSize}
environmentName={
featureEnvironment.name
}
otherEnvironments={
otherEnvironments
}
isDragging={false}
onDragStartRef={(() => {}) as any}
onDragOver={(() => {}) as any}
onDragEnd={(() => {}) as any}
/>
))}
<br />
<Pagination
count={pages.length}
shape='rounded'
page={pageIndex + 1}
onChange={(_, page) =>
setPageIndex(page - 1)
}
/>
</>
}
/>
}
elseShow={
<FeatureStrategyEmpty
projectId={projectId}
featureId={featureId}
environmentId={featureEnvironment.name}
/>
}
/>
</StyledAccordionBodyInnerContainer>
</StyledAccordionBody>
);
};

View File

@ -0,0 +1,51 @@
import { useFeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch';
import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import type { IFeatureEnvironment } from 'interfaces/featureToggle';
interface IFeatureOverviewEnvironmentToggleProps {
environment: IFeatureEnvironment;
}
export const FeatureOverviewEnvironmentToggle = ({
environment: { name, type, strategies, enabled },
}: IFeatureOverviewEnvironmentToggleProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { refetchFeature } = useFeature(projectId, featureId);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
useFeatureToggleSwitch(projectId);
const onToggle = (newState: boolean, onRollback: () => void) =>
onFeatureToggle(newState, {
projectId,
featureId,
environmentName: name,
environmentType: type,
hasStrategies: strategies.length > 0,
hasEnabledStrategies: strategies.some(
(strategy) => !strategy.disabled,
),
isChangeRequestEnabled: isChangeRequestConfigured(name),
onRollback,
onSuccess: refetchFeature,
});
return (
<>
<FeatureToggleSwitch
projectId={projectId}
value={enabled}
featureId={featureId}
environmentName={name}
onToggle={onToggle}
/>
{featureToggleModals}
</>
);
};

View File

@ -0,0 +1,131 @@
import { Box, styled } from '@mui/material';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import { getFeatureMetrics } from 'utils/getFeatureMetrics';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { FeatureOverviewEnvironmentBody } from './FeatureOverviewEnvironmentBody';
import FeatureOverviewEnvironmentMetrics from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics';
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { FeatureOverviewEnvironmentToggle } from './FeatureOverviewEnvironmentToggle';
const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({
padding: theme.spacing(1, 3),
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.background.paper,
}));
const StyledFeatureOverviewEnvironmentBody = styled(
FeatureOverviewEnvironmentBody,
)(({ theme }) => ({
width: '100%',
position: 'relative',
paddingBottom: theme.spacing(2),
}));
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
marginBottom: theme.spacing(2),
}));
const StyledHeaderToggleContainer = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}));
const StyledHeaderTitleContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}));
const StyledHeaderTitleLabel = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
lineHeight: 0.5,
color: theme.palette.text.secondary,
}));
const StyledHeaderTitle = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader,
fontWeight: theme.typography.fontWeightBold,
}));
interface INewFeatureOverviewEnvironmentProps {
environmentId: string;
}
export const NewFeatureOverviewEnvironment = ({
environmentId,
}: INewFeatureOverviewEnvironmentProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { metrics } = useFeatureMetrics(projectId, featureId);
const { feature } = useFeature(projectId, featureId);
const featureMetrics = getFeatureMetrics(feature?.environments, metrics);
const environmentMetric = featureMetrics.find(
({ environment }) => environment === environmentId,
);
const featureEnvironment = feature?.environments.find(
({ name }) => name === environmentId,
);
if (!featureEnvironment)
return (
<StyledFeatureOverviewEnvironment className='skeleton'>
<Box sx={{ height: '400px' }} />
</StyledFeatureOverviewEnvironment>
);
return (
<StyledFeatureOverviewEnvironment>
<StyledHeader data-loading>
<StyledHeaderToggleContainer>
<FeatureOverviewEnvironmentToggle
environment={featureEnvironment}
/>
<StyledHeaderTitleContainer>
<StyledHeaderTitleLabel>
Environment
</StyledHeaderTitleLabel>
<StyledHeaderTitle>{environmentId}</StyledHeaderTitle>
</StyledHeaderTitleContainer>
</StyledHeaderToggleContainer>
<FeatureOverviewEnvironmentMetrics
environmentMetric={environmentMetric}
disabled={!featureEnvironment.enabled}
/>
</StyledHeader>
<StyledFeatureOverviewEnvironmentBody
featureEnvironment={featureEnvironment}
isDisabled={!featureEnvironment.enabled}
otherEnvironments={feature?.environments
.map(({ name }) => name)
.filter((name) => name !== environmentId)}
/>
<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={environmentId}
/>
</Box>
</>
}
/>
</StyledFeatureOverviewEnvironment>
);
};

View File

@ -68,7 +68,7 @@ export const ProjectSettings = () => {
...paidTabs({
id: 'change-requests',
label: 'Change request configuration',
icon: isPro() ? (
endIcon: isPro() ? (
<StyledBadgeContainer>
<EnterpriseBadge />
</StyledBadgeContainer>
@ -80,7 +80,7 @@ export const ProjectSettings = () => {
tabs.push({
id: 'actions',
label: 'Actions',
icon: isPro() ? (
endIcon: isPro() ? (
<StyledBadgeContainer>
<EnterpriseBadge />
</StyledBadgeContainer>