From 117582895b01a22f6570959821c0661fab99c879 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 6 Nov 2024 17:19:34 +0000 Subject: [PATCH 1/7] chore: flag overview page redesign - environments --- .../VerticalTabs/VerticalTab/VerticalTab.tsx | 38 ++++- .../common/VerticalTabs/VerticalTabs.tsx | 64 +++++--- .../FeatureOverview/FeatureOverview.tsx | 34 ++-- .../FeatureOverviewSidePanel.tsx | 85 +++++----- .../NewFeatureOverviewEnvironment.tsx | 153 ++++++++++++++++++ .../ProjectSettings/ProjectSettings.tsx | 4 +- 6 files changed, 299 insertions(+), 79 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx diff --git a/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx b/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx index 14f3758a2d63..6ac27e89d39a 100644 --- a/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx +++ b/frontend/src/component/common/VerticalTabs/VerticalTab/VerticalTab.tsx @@ -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) => ( - {label} - {icon} + {startIcon} + + {label} + {description} + } + /> + + {endIcon} ); diff --git a/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx b/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx index 85d05d8d8ee3..b40707445de4 100644 --- a/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx +++ b/frontend/src/component/common/VerticalTabs/VerticalTabs.tsx @@ -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, '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) => ( - - - {tabs - .filter((tab) => !tab.hidden) - .map((tab) => ( - onChange(tab)} - icon={tab.icon} - /> - ))} - - {children} - -); + ...props +}: IVerticalTabsProps) => { + const verticalTabs = tabs + .filter((tab) => !tab.hidden) + .map((tab) => ( + onChange(tab)} + startIcon={tab.startIcon} + endIcon={tab.endIcon} + /> + )); + + if (!children) { + return ( + + {verticalTabs} + + ); + } + return ( + + {verticalTabs} + {children} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index d1320c8ef19d..29cacdbbe74e 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -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 ? ( + + ) : ( + + ); return (
- + {FeatureOverviewSidePanel}
- + + } + elseShow={} + /> ({ - 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.secondary.light, }, })); interface IFeatureOverviewSidePanelProps { - hiddenEnvironments: Set; - setHiddenEnvironments: (environment: string) => void; + environmentId: string; + setEnvironmentId: React.Dispatch>; } 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: , + }), + ); + + useEffect(() => { + if (!environmentId) { + setEnvironmentId(tabs[0]?.id); + } + }, [tabs]); + return ( - - Enabled in environments ( - { - feature.environments.filter( - ({ enabled }) => enabled, - ).length - } - ) - - - } - feature={feature} - hiddenEnvironments={hiddenEnvironments} - setHiddenEnvironments={setHiddenEnvironments} + + Environments ({feature.environments.length}) + + setEnvironmentId(id)} /> ); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx new file mode 100644 index 000000000000..4f274427eeb0 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx @@ -0,0 +1,153 @@ +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 EnvironmentAccordionBody from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody'; +import FeatureOverviewEnvironmentMetrics from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics'; +import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useFeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch'; +import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; + +const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({ + padding: theme.spacing(1, 3), + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, +})); + +const StyledEnvironmentAccordionBody = styled(EnvironmentAccordionBody)( + ({ 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, refetchFeature } = useFeature(projectId, featureId); + + const featureMetrics = getFeatureMetrics(feature?.environments, metrics); + const environmentMetric = featureMetrics.find( + ({ environment }) => environment === environmentId, + ); + const featureEnvironment = feature?.environments.find( + ({ name }) => name === environmentId, + ); + + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + + const { onToggle: onFeatureToggle, modals: featureToggleModals } = + useFeatureToggleSwitch(projectId); + + const onToggle = (newState: boolean, onRollback: () => void) => + onFeatureToggle(newState, { + projectId, + featureId, + environmentName: environmentId, + environmentType: featureEnvironment!.type, + hasStrategies: featureEnvironment!.strategies.length > 0, + hasEnabledStrategies: featureEnvironment!.strategies.some( + (strategy) => !strategy.disabled, + ), + isChangeRequestEnabled: isChangeRequestConfigured(environmentId), + onRollback, + onSuccess: refetchFeature, + }); + + if (!featureEnvironment) return null; + + return ( + + + + + + + Environment + + {environmentId} + + + + + + name) + .filter((name) => name !== environmentId)} + /> + 0} + show={ + <> + + + + + } + /> + {featureToggleModals} + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx index a5ec9eb7c445..ee5b15a92760 100644 --- a/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx +++ b/frontend/src/component/project/Project/ProjectSettings/ProjectSettings.tsx @@ -68,7 +68,7 @@ export const ProjectSettings = () => { ...paidTabs({ id: 'change-requests', label: 'Change request configuration', - icon: isPro() ? ( + endIcon: isPro() ? ( @@ -80,7 +80,7 @@ export const ProjectSettings = () => { tabs.push({ id: 'actions', label: 'Actions', - icon: isPro() ? ( + endIcon: isPro() ? ( From 572ddd2dba8d0cde793fd55aba3ab77d19f383af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Wed, 6 Nov 2024 17:24:25 +0000 Subject: [PATCH 2/7] fix: use neutral for selected environment tabs --- .../FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx index 29b298e79c3b..aa323e82a5e8 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx @@ -33,7 +33,7 @@ const StyledHeader = styled('h3')(({ theme }) => ({ const StyledVerticalTabs = styled(VerticalTabs)(({ theme }) => ({ '&&& .selected': { - backgroundColor: theme.palette.secondary.light, + backgroundColor: theme.palette.neutral.light, }, })); From ced2afc8639d3583290d4659b17d0c08121cb994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 7 Nov 2024 11:01:02 +0000 Subject: [PATCH 3/7] chore: FeatureOverviewEnvironmentBody --- .../FeatureOverviewEnvironmentBody.tsx | 293 ++++++++++++++++++ .../NewFeatureOverviewEnvironment.tsx | 11 +- 2 files changed, 301 insertions(+), 3 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx new file mode 100644 index 000000000000..1988e83b5a3e --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx @@ -0,0 +1,293 @@ +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'; + +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); + useEffect(() => { + // Use state to enable drag and drop, but switch to API output when it arrives + 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(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, + index: number, + ): DragEventHandler => + (event) => { + 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, + targetIndex: number, + ): DragEventHandler => + (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, + })), + ); + }; + + return ( + + + 0 && isDisabled} + show={() => ( + + This environment is disabled, which means that none + of your strategies are executing. + + )} + /> + 0} + show={ + + {strategies.map((strategy, index) => ( + + ))} + + } + elseShow={ + <> + + We noticed you're using a high number of + activation strategies. To ensure a more + targeted approach, consider leveraging + constraints or segments. + +
+ {page.map((strategy, index) => ( + {}) as any} + onDragOver={(() => {}) as any} + onDragEnd={(() => {}) as any} + /> + ))} +
+ + setPageIndex(page - 1) + } + /> + + } + /> + } + elseShow={ + + } + /> +
+
+ ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx index 4f274427eeb0..6bc7cdccc877 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx @@ -3,7 +3,7 @@ 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 EnvironmentAccordionBody from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody'; +import { FeatureOverviewEnvironmentBody } from './FeatureOverviewEnvironmentBody'; import FeatureOverviewEnvironmentMetrics from '../FeatureOverviewEnvironments/FeatureOverviewEnvironment/FeatureOverviewEnvironmentMetrics/FeatureOverviewEnvironmentMetrics'; import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; @@ -17,7 +17,7 @@ const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({ backgroundColor: theme.palette.background.paper, })); -const StyledEnvironmentAccordionBody = styled(EnvironmentAccordionBody)( +const StyledEnvironmentAccordionBody = styled(FeatureOverviewEnvironmentBody)( ({ theme }) => ({ width: '100%', position: 'relative', @@ -93,7 +93,12 @@ export const NewFeatureOverviewEnvironment = ({ onSuccess: refetchFeature, }); - if (!featureEnvironment) return null; + if (!featureEnvironment) + return ( + + + + ); return ( From 2a6c5ee954d7526fb6d1296ce7cfcf9b7bff0646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 7 Nov 2024 12:33:38 +0000 Subject: [PATCH 4/7] chore: fix flickering when changing between environments --- .../FeatureOverviewEnvironmentBody.tsx | 66 ++++++++++++------- .../NewFeatureOverviewEnvironment.tsx | 16 ++--- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx index 1988e83b5a3e..5cea19c55552 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentBody.tsx @@ -21,6 +21,7 @@ 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; @@ -65,9 +66,17 @@ export const FeatureOverviewEnvironmentBody = ({ index: number; height: number; } | null>(null); + + const [isReordering, setIsReordering] = useState(false); + useEffect(() => { - // Use state to enable drag and drop, but switch to API output when it arrives - setStrategies(featureEnvironment?.strategies || []); + if (isReordering) { + if (isEqual(featureEnvironment?.strategies, strategies)) { + setIsReordering(false); + } + } else { + setStrategies(featureEnvironment?.strategies || []); + } }, [featureEnvironment?.strategies]); useEffect(() => { @@ -139,6 +148,7 @@ export const FeatureOverviewEnvironmentBody = ({ index: number, ): DragEventHandler => (event) => { + setIsReordering(true); setDragItem({ id: strategies[index].id, index, @@ -197,11 +207,15 @@ export const FeatureOverviewEnvironmentBody = ({ ); }; + const strategiesToDisplay = isReordering + ? strategies + : featureEnvironment.strategies; + return ( 0 && isDisabled} + condition={strategiesToDisplay.length > 0 && isDisabled} show={() => ( This environment is disabled, which means that none @@ -210,34 +224,38 @@ export const FeatureOverviewEnvironmentBody = ({ )} /> 0} + condition={strategiesToDisplay.length > 0} show={ - {strategies.map((strategy, index) => ( - - ))} + {strategiesToDisplay.map( + (strategy, index) => ( + + ), + )} } elseShow={ diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx index 6bc7cdccc877..ff2e6ba32fca 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx @@ -17,13 +17,13 @@ const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({ backgroundColor: theme.palette.background.paper, })); -const StyledEnvironmentAccordionBody = styled(FeatureOverviewEnvironmentBody)( - ({ theme }) => ({ - width: '100%', - position: 'relative', - paddingBottom: theme.spacing(2), - }), -); +const StyledFeatureOverviewEnvironmentBody = styled( + FeatureOverviewEnvironmentBody, +)(({ theme }) => ({ + width: '100%', + position: 'relative', + paddingBottom: theme.spacing(2), +})); const StyledHeader = styled('div')(({ theme }) => ({ display: 'flex', @@ -124,7 +124,7 @@ export const NewFeatureOverviewEnvironment = ({ /> - Date: Thu, 7 Nov 2024 14:43:48 +0000 Subject: [PATCH 5/7] test: fix feature e2e test --- .../cypress/integration/feature/feature.spec.ts | 14 ++++++++++++++ frontend/cypress/support/UI.ts | 1 - 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts index d65c017b93d8..d52c7ecd5516 100644 --- a/frontend/cypress/integration/feature/feature.spec.ts +++ b/frontend/cypress/integration/feature/feature.spec.ts @@ -1,6 +1,7 @@ /// 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', () => { diff --git a/frontend/cypress/support/UI.ts b/frontend/cypress/support/UI.ts index d9801a494364..64ba45d62b2a 100644 --- a/frontend/cypress/support/UI.ts +++ b/frontend/cypress/support/UI.ts @@ -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(); From 63b2bfef5a3901e22fc0e9c6adee530405db7b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 7 Nov 2024 16:56:37 +0000 Subject: [PATCH 6/7] test: fix demo flow --- frontend/cypress/integration/demo/demo.spec.ts | 1 + frontend/src/component/demo/demo-topics.tsx | 7 ++++--- .../FeatureStrategyMenu/FeatureStrategyMenu.tsx | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/integration/demo/demo.spec.ts b/frontend/cypress/integration/demo/demo.spec.ts index f40fb4e2e35b..2a3b5e0fe663 100644 --- a/frontend/cypress/integration/demo/demo.spec.ts +++ b/frontend/cypress/integration/demo/demo.spec.ts @@ -40,6 +40,7 @@ describe('demo', () => { res.body.flags = { ...res.body.flags, demo: true, + flagOverviewRedesign: true, }; } }); diff --git a/frontend/src/component/demo/demo-topics.tsx b/frontend/src/component/demo/demo-topics.tsx index 9ef80f17dc5f..3fed931dfa66 100644 --- a/frontend/src/component/demo/demo-topics.tsx +++ b/frontend/src/component/demo/demo-topics.tsx @@ -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: ( Add a new strategy to this environment by using this @@ -363,9 +363,10 @@ export const TOPICS: ITutorialTopic[] = [ strategies by using the arrow button. ), + 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: ( 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: ( Add a new strategy to this environment by using this diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx index 6bc5083bbb7d..4d17eb5682df 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu.tsx @@ -80,6 +80,7 @@ export const FeatureStrategyMenu = ({ return ( event.stopPropagation()}> Date: Thu, 7 Nov 2024 17:37:10 +0000 Subject: [PATCH 7/7] refactor: extract env toggle to separate component --- .../FeatureOverviewEnvironmentToggle.tsx | 51 +++++++++++++++++++ .../NewFeatureOverviewEnvironment.tsx | 35 ++----------- 2 files changed, 55 insertions(+), 31 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentToggle.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentToggle.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentToggle.tsx new file mode 100644 index 000000000000..aad7cf576976 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/FeatureOverviewEnvironmentToggle.tsx @@ -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 ( + <> + + {featureToggleModals} + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx index ff2e6ba32fca..4c088c9e6a17 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/NewFeatureOverviewEnvironment/NewFeatureOverviewEnvironment.tsx @@ -7,9 +7,7 @@ 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 { useFeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch'; -import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch'; -import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { FeatureOverviewEnvironmentToggle } from './FeatureOverviewEnvironmentToggle'; const StyledFeatureOverviewEnvironment = styled('div')(({ theme }) => ({ padding: theme.spacing(1, 3), @@ -63,7 +61,7 @@ export const NewFeatureOverviewEnvironment = ({ const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); const { metrics } = useFeatureMetrics(projectId, featureId); - const { feature, refetchFeature } = useFeature(projectId, featureId); + const { feature } = useFeature(projectId, featureId); const featureMetrics = getFeatureMetrics(feature?.environments, metrics); const environmentMetric = featureMetrics.find( @@ -73,26 +71,6 @@ export const NewFeatureOverviewEnvironment = ({ ({ name }) => name === environmentId, ); - const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); - - const { onToggle: onFeatureToggle, modals: featureToggleModals } = - useFeatureToggleSwitch(projectId); - - const onToggle = (newState: boolean, onRollback: () => void) => - onFeatureToggle(newState, { - projectId, - featureId, - environmentName: environmentId, - environmentType: featureEnvironment!.type, - hasStrategies: featureEnvironment!.strategies.length > 0, - hasEnabledStrategies: featureEnvironment!.strategies.some( - (strategy) => !strategy.disabled, - ), - isChangeRequestEnabled: isChangeRequestConfigured(environmentId), - onRollback, - onSuccess: refetchFeature, - }); - if (!featureEnvironment) return ( @@ -104,12 +82,8 @@ export const NewFeatureOverviewEnvironment = ({ - @@ -152,7 +126,6 @@ export const NewFeatureOverviewEnvironment = ({ } /> - {featureToggleModals} ); };