From 3dca3d53f94023aa871aead3835d063f089e865a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 22 Nov 2022 08:50:31 +0000 Subject: [PATCH] feat: improved feature overview sidepanel env toggles (#2487) https://linear.app/unleash/issue/2-423/update-feature-toggle-overview-sidepanel --- .../FeatureOverview/FeatureOverview.tsx | 10 +- .../FeatureOverviewSidePanel.tsx | 61 ++++++++ ...tureOverviewSidePanelEnvironmentSwitch.tsx | 146 ++++++++++++++++++ ...reOverviewSidePanelEnvironmentSwitches.tsx | 94 +++++++++++ 4 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx create mode 100644 frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index 347d03619d..a3257f750b 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -11,8 +11,12 @@ import { } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { usePageTitle } from 'hooks/usePageTitle'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; const FeatureOverview = () => { + const { uiConfig } = useUiConfig(); const { classes: styles } = useStyles(); const navigate = useNavigate(); const projectId = useRequiredPathParam('projectId'); @@ -25,7 +29,11 @@ const FeatureOverview = () => {
- + } + elseShow={} + />
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx new file mode 100644 index 0000000000..7c31209f16 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel.tsx @@ -0,0 +1,61 @@ +import { 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'; + +const StyledContainer = styled('div')(({ theme }) => ({ + borderRadius: theme.shape.borderRadiusLarge, + backgroundColor: theme.palette.background.paper, + display: 'flex', + flexDirection: 'column', + padding: '1.5rem', + maxWidth: '350px', + minWidth: '350px', + marginRight: '1rem', + marginTop: '1rem', + [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), + + // Make the help icon align with the text. + '& > :last-child': { + position: 'relative', + top: 1, + }, +})); + +export const FeatureOverviewSidePanel = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { feature } = useFeature(projectId, featureId); + + return ( + + + Enabled in environments ({feature.environments.length}) + + + } + feature={feature} + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx new file mode 100644 index 0000000000..0b6e3e3f97 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx @@ -0,0 +1,146 @@ +import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import useToast from 'hooks/useToast'; +import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle'; +import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog'; +import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { styled } from '@mui/material'; +import StringTruncator from 'component/common/StringTruncator/StringTruncator'; + +const StyledContainer = styled('div')(({ theme }) => ({ + '&:not(:last-of-type)': { + marginBottom: theme.spacing(2), + }, +})); + +const StyledLabel = styled('label')(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + cursor: 'pointer', +})); + +interface IFeatureOverviewSidePanelEnvironmentSwitchProps { + env: IFeatureEnvironment; + callback?: () => void; + showInfoBox: () => void; + children?: React.ReactNode; +} + +export const FeatureOverviewSidePanelEnvironmentSwitch = ({ + env, + callback, + showInfoBox, + children, +}: IFeatureOverviewSidePanelEnvironmentSwitchProps) => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } = + useFeatureApi(); + const { refetchFeature } = useFeature(projectId, featureId); + const { setToastData, setToastApiError } = useToast(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { + onChangeRequestToggle, + onChangeRequestToggleClose, + onChangeRequestToggleConfirm, + changeRequestDialogDetails, + } = useChangeRequestToggle(projectId); + + const handleToggleEnvironmentOn = async () => { + try { + await toggleFeatureEnvironmentOn(projectId, featureId, env.name); + setToastData({ + type: 'success', + title: `Available in ${env.name}`, + text: `${featureId} is now available in ${env.name} based on its defined strategies.`, + }); + refetchFeature(); + if (callback) { + callback(); + } + } catch (error: unknown) { + if ( + error instanceof Error && + error.message === ENVIRONMENT_STRATEGY_ERROR + ) { + showInfoBox(); + } else { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const handleToggleEnvironmentOff = async () => { + try { + await toggleFeatureEnvironmentOff(projectId, featureId, env.name); + setToastData({ + type: 'success', + title: `Unavailable in ${env.name}`, + text: `${featureId} is unavailable in ${env.name} and its strategies will no longer have any effect.`, + }); + refetchFeature(); + if (callback) { + callback(); + } + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const toggleEnvironment = async (e: React.ChangeEvent) => { + if (isChangeRequestConfigured(env.name)) { + e.preventDefault(); + onChangeRequestToggle(featureId, env.name, !env.enabled); + return; + } + if (env.enabled) { + await handleToggleEnvironmentOff(); + return; + } + await handleToggleEnvironmentOn(); + }; + + const defaultContent = ( + <> + {' '} + {env.enabled ? 'enabled' : 'disabled'} in +   + + + ); + + return ( + + + + {children ?? defaultContent} + + + } + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches.tsx new file mode 100644 index 0000000000..e64f5c82d1 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitches.tsx @@ -0,0 +1,94 @@ +import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; +import { IFeatureToggle } from 'interfaces/featureToggle'; +import { useState } from 'react'; +import { FeatureOverviewSidePanelEnvironmentSwitch } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch'; +import { Link, styled } from '@mui/material'; +import { Link as RouterLink } from 'react-router-dom'; + +const StyledContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', +})); + +const StyledLabel = styled('p')(({ theme }) => ({ + fontSize: theme.fontSizes.smallBody, +})); + +const StyledSubLabel = styled('p')(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.secondary, +})); + +const StyledLink = styled(Link)(() => ({ + '&:hover, &:focus': { + textDecoration: 'underline', + }, +})); + +interface IFeatureOverviewSidePanelEnvironmentSwitchesProps { + feature: IFeatureToggle; + header: React.ReactNode; +} + +export const FeatureOverviewSidePanelEnvironmentSwitches = ({ + feature, + header, +}: IFeatureOverviewSidePanelEnvironmentSwitchesProps) => { + const [showInfoBox, setShowInfoBox] = useState(false); + const [environmentName, setEnvironmentName] = useState(''); + + return ( + <> + {header} + {feature.environments.map(environment => { + const strategiesLabel = + environment.strategies.length === 1 + ? '1 strategy' + : `${environment.strategies.length} strategies`; + + const variants = environment.variants ?? []; + + const variantsLink = variants.length > 0 && ( + <> + {' - '} + + {variants.length === 1 + ? '1 variant' + : `${variants.length} variants`} + + + ); + + return ( + { + setEnvironmentName(environment.name); + setShowInfoBox(true); + }} + > + + {environment.name} + + {strategiesLabel} + {variantsLink} + + + + ); + })} + setShowInfoBox(false)} + projectId={feature.project} + featureId={feature.name} + environmentName={environmentName} + /> + + ); +};