mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-06 01:15:28 +02:00
feat: improved feature overview sidepanel env toggles (#2487)
https://linear.app/unleash/issue/2-423/update-feature-toggle-overview-sidepanel
This commit is contained in:
parent
97372cf48c
commit
3dca3d53f9
@ -11,8 +11,12 @@ import {
|
|||||||
} from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
} from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
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 FeatureOverview = () => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
@ -25,7 +29,11 @@ const FeatureOverview = () => {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div>
|
<div>
|
||||||
<FeatureOverviewMetaData />
|
<FeatureOverviewMetaData />
|
||||||
<FeatureOverviewEnvSwitches />
|
<ConditionallyRender
|
||||||
|
condition={Boolean(uiConfig.flags.variantsPerEnvironment)}
|
||||||
|
show={<FeatureOverviewSidePanel />}
|
||||||
|
elseShow={<FeatureOverviewEnvSwitches />}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.mainContent}>
|
<div className={styles.mainContent}>
|
||||||
<FeatureOverviewEnvironments />
|
<FeatureOverviewEnvironments />
|
||||||
|
@ -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 (
|
||||||
|
<StyledContainer>
|
||||||
|
<FeatureOverviewSidePanelEnvironmentSwitches
|
||||||
|
header={
|
||||||
|
<StyledHeader data-loading>
|
||||||
|
Enabled in environments ({feature.environments.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}
|
||||||
|
/>
|
||||||
|
</StyledContainer>
|
||||||
|
);
|
||||||
|
};
|
@ -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 = (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<span data-loading>{env.enabled ? 'enabled' : 'disabled'} in</span>
|
||||||
|
|
||||||
|
<StringTruncator text={env.name} maxWidth="120" maxLength={15} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<StyledLabel>
|
||||||
|
<PermissionSwitch
|
||||||
|
permission={UPDATE_FEATURE_ENVIRONMENT}
|
||||||
|
projectId={projectId}
|
||||||
|
checked={env.enabled}
|
||||||
|
onChange={toggleEnvironment}
|
||||||
|
environmentId={env.name}
|
||||||
|
/>
|
||||||
|
{children ?? defaultContent}
|
||||||
|
</StyledLabel>
|
||||||
|
<ChangeRequestDialogue
|
||||||
|
isOpen={changeRequestDialogDetails.isOpen}
|
||||||
|
onClose={onChangeRequestToggleClose}
|
||||||
|
environment={changeRequestDialogDetails?.environment}
|
||||||
|
onConfirm={onChangeRequestToggleConfirm}
|
||||||
|
messageComponent={
|
||||||
|
<UpdateEnabledMessage
|
||||||
|
enabled={changeRequestDialogDetails?.enabled!}
|
||||||
|
featureName={changeRequestDialogDetails?.featureName!}
|
||||||
|
environment={changeRequestDialogDetails.environment!}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledContainer>
|
||||||
|
);
|
||||||
|
};
|
@ -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<typeof RouterLink | 'a'>)(() => ({
|
||||||
|
'&: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 && (
|
||||||
|
<>
|
||||||
|
{' - '}
|
||||||
|
<StyledLink
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/projects/${feature.project}/features/${feature.name}/variants`}
|
||||||
|
underline="hover"
|
||||||
|
>
|
||||||
|
{variants.length === 1
|
||||||
|
? '1 variant'
|
||||||
|
: `${variants.length} variants`}
|
||||||
|
</StyledLink>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureOverviewSidePanelEnvironmentSwitch
|
||||||
|
key={environment.name}
|
||||||
|
env={environment}
|
||||||
|
showInfoBox={() => {
|
||||||
|
setEnvironmentName(environment.name);
|
||||||
|
setShowInfoBox(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledContainer>
|
||||||
|
<StyledLabel>{environment.name}</StyledLabel>
|
||||||
|
<StyledSubLabel>
|
||||||
|
{strategiesLabel}
|
||||||
|
{variantsLink}
|
||||||
|
</StyledSubLabel>
|
||||||
|
</StyledContainer>
|
||||||
|
</FeatureOverviewSidePanelEnvironmentSwitch>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<EnvironmentStrategyDialog
|
||||||
|
open={showInfoBox}
|
||||||
|
onClose={() => setShowInfoBox(false)}
|
||||||
|
projectId={feature.project}
|
||||||
|
featureId={feature.name}
|
||||||
|
environmentName={environmentName}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user