mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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