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:
parent
7597bb91ac
commit
b4fde58fa0
@ -40,6 +40,7 @@ describe('demo', () => {
|
||||
res.body.flags = {
|
||||
...res.body.flags,
|
||||
demo: true,
|
||||
flagOverviewRedesign: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -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', () => {
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user