1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-18 13:48:58 +02:00

change feature toggle switch rendering

This commit is contained in:
Tymoteusz Czech 2023-10-16 22:49:51 +02:00
parent 0fe28cb67b
commit 3ade62fa90
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
5 changed files with 297 additions and 77 deletions

View File

@ -3,7 +3,7 @@ import { Alert } from '@mui/material';
import { Checkbox, FormControlLabel } from '@mui/material'; import { Checkbox, FormControlLabel } from '@mui/material';
import { PRODUCTION } from 'constants/environmentTypes'; import { PRODUCTION } from 'constants/environmentTypes';
import { IFeatureToggle } from 'interfaces/featureToggle'; import { IFeatureToggle } from 'interfaces/featureToggle';
import { createPersistentGlobalStateHook } from 'hooks/usePersistentGlobalState'; import { createLocalStorage } from 'utils/createLocalStorage';
interface IFeatureStrategyProdGuardProps { interface IFeatureStrategyProdGuardProps {
open: boolean; open: boolean;
@ -24,7 +24,8 @@ export const FeatureStrategyProdGuard = ({
label, label,
loading, loading,
}: IFeatureStrategyProdGuardProps) => { }: IFeatureStrategyProdGuardProps) => {
const [settings, setSettings] = useFeatureStrategyProdGuardSettings(); const { value: settings, setValue: setSettings } =
getFeatureStrategyProdGuardSettings();
const toggleHideSetting = () => { const toggleHideSetting = () => {
setSettings((prev) => ({ hide: !prev.hide })); setSettings((prev) => ({ hide: !prev.hide }));
@ -65,7 +66,7 @@ export const useFeatureStrategyProdGuard = (
featureOrType: string | IFeatureToggle, featureOrType: string | IFeatureToggle,
environmentId?: string, environmentId?: string,
): boolean => { ): boolean => {
const [settings] = useFeatureStrategyProdGuardSettings(); const { value: settings } = getFeatureStrategyProdGuardSettings();
if (settings.hide) { if (settings.hide) {
return false; return false;
@ -85,8 +86,12 @@ export const useFeatureStrategyProdGuard = (
// Store the "always hide" prod guard dialog setting in localStorage. // Store the "always hide" prod guard dialog setting in localStorage.
const localStorageKey = 'useFeatureStrategyProdGuardSettings:v2'; const localStorageKey = 'useFeatureStrategyProdGuardSettings:v2';
const useFeatureStrategyProdGuardSettings = const getFeatureStrategyProdGuardSettings = () =>
createPersistentGlobalStateHook<IFeatureStrategyProdGuardSettings>( createLocalStorage<IFeatureStrategyProdGuardSettings>(localStorageKey, {
localStorageKey, hide: false,
{ hide: false }, });
);
export const isProdGuardEnabled = (type: string) => {
const { value: settings } = getFeatureStrategyProdGuardSettings();
return type === PRODUCTION && !settings.hide;
};

View File

@ -80,7 +80,7 @@ export const FeatureOverviewSidePanelEnvironmentSwitch = ({
featureId={feature.name} featureId={feature.name}
projectId={projectId} projectId={projectId}
environmentName={environment.name} environmentName={environment.name}
type={featureEnvironment?.type} type={featureEnvironment?.type || ''}
onToggle={handleToggle} onToggle={handleToggle}
onError={showInfoBox} onError={showInfoBox}
value={enabled} value={enabled}

View File

@ -0,0 +1,244 @@
import { ReactNode, useCallback, useMemo, useState, type VFC } from 'react';
import { Box, styled } from '@mui/material';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
import { useOptimisticUpdate } from './hooks/useOptimisticUpdate';
import { flexRow } from 'themes/themeStyles';
import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors';
import { formatUnknownError } from 'utils/formatUnknownError';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import {
FeatureStrategyProdGuard,
isProdGuardEnabled,
useFeatureStrategyProdGuard,
} from 'component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard';
import { EnableEnvironmentDialog } from './EnableEnvironmentDialog/EnableEnvironmentDialog';
import { ListItemType } from '../ProjectFeatureToggles.types';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
import useProject from 'hooks/api/getters/useProject/useProject';
const StyledBoxContainer = styled(Box)<{ 'data-testid': string }>(() => ({
mx: 'auto',
...flexRow,
}));
type OnFeatureToggleSwitchArgs = {
featureId: string;
projectId: string;
environmentName: string;
environmentType?: string;
hasStrategies?: boolean;
hasEnabledStrategies?: boolean;
isChangeRequestEnabled?: boolean;
onError?: () => void;
onSuccess?: () => void;
};
type FeatureToggleSwitchProps = {
featureId: string;
projectId: string;
environmentName: string;
value: boolean;
onToggle: (newState: boolean, onRollback: () => void) => void;
};
type Middleware = (next: () => void) => void;
const composeAndRunMiddlewares = (middlewares: Middleware[]) => {
const runMiddleware = (currentIndex: number) => {
if (currentIndex < middlewares.length) {
middlewares[currentIndex](() => runMiddleware(currentIndex + 1));
}
};
runMiddleware(0);
};
export const useFeatureToggleSwitch = () => {
const { loading, toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
// FIXME: change modals approach
const [modals, setModals] = useState<ReactNode>(null);
const onToggle = useCallback(
async (newState: boolean, config: OnFeatureToggleSwitchArgs) => {
const confirmProductionChanges: Middleware = (next) => {
if (config.isChangeRequestEnabled) {
// skip if change requests are enabled
return next();
}
if (isProdGuardEnabled(config.environmentType || '')) {
return setModals(
<FeatureStrategyProdGuard
open
onClose={() => {
setModals(null);
config.onError?.();
}}
onClick={() => {
setModals(null);
next();
}}
// FIXME: internalize loading
loading={loading}
label={`${
!newState ? 'Disable' : 'Enable'
} Environment`}
/>,
);
}
return next();
};
const addToChangeRequest: Middleware = (next) => {
next();
};
const ensureActiveStrategies: Middleware = (next) => {
next();
};
return composeAndRunMiddlewares([
confirmProductionChanges,
addToChangeRequest,
ensureActiveStrategies,
() => {
// FIXME: remove
console.log('done', { newState, config });
config.onSuccess?.();
},
// TODO: make actual changes
]);
},
[],
);
return { onToggle, modals };
};
export const FeatureToggleSwitch: VFC<FeatureToggleSwitchProps> = ({
projectId,
featureId,
environmentName,
value,
onToggle,
}) => {
const [isChecked, setIsChecked, rollbackIsChecked] =
useOptimisticUpdate<boolean>(value);
const onClick = () => {
setIsChecked(!isChecked);
requestAnimationFrame(() => {
onToggle(!isChecked, rollbackIsChecked);
});
};
const key = `${featureId}-${environmentName}`;
return (
<>
<StyledBoxContainer
key={key} // Prevent animation when archiving rows
data-testid={`TOGGLE-${key}`}
>
<PermissionSwitch
tooltip={
isChecked
? `Disable feature in ${environmentName}`
: `Enable feature in ${environmentName}`
}
checked={isChecked}
environmentId={environmentName}
projectId={projectId}
permission={UPDATE_FEATURE_ENVIRONMENT}
inputProps={{ 'aria-label': environmentName }}
onClick={onClick}
data-testid={'permission-switch'}
disableRipple
disabled={value !== isChecked}
/>
</StyledBoxContainer>
</>
);
};
const StyledSwitchContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'hasWarning',
})<{ hasWarning?: boolean }>(({ theme, hasWarning }) => ({
flexGrow: 0,
...flexRow,
justifyContent: 'center',
...(hasWarning && {
'::before': {
content: '""',
display: 'block',
width: theme.spacing(2),
},
}),
}));
export const createFeatureToggleCell =
(
projectId: string,
environmentName: string,
isChangeRequestEnabled: boolean,
refetch: () => void,
onFeatureToggleSwitch: ReturnType<
typeof useFeatureToggleSwitch
>['onToggle'],
) =>
({
value,
row: { original: feature },
}: {
value: boolean;
row: { original: ListItemType };
}) => {
const environment = feature.environments[environmentName];
const hasWarning = useMemo(
() =>
feature.someEnabledEnvironmentHasVariants &&
environment.variantCount === 0 &&
environment.enabled,
[feature, environment],
);
const onToggle = (newState: boolean, onRollback: () => void) => {
onFeatureToggleSwitch(newState, {
projectId,
featureId: feature.name,
environmentName,
environmentType: environment?.type,
hasStrategies: environment?.hasStrategies,
hasEnabledStrategies: environment?.hasEnabledStrategies,
isChangeRequestEnabled,
onError: onRollback,
onSuccess: refetch,
});
};
return (
<StyledSwitchContainer hasWarning={hasWarning}>
<FeatureToggleSwitch
projectId={projectId}
value={value}
featureId={feature.name}
environmentName={environmentName}
onToggle={onToggle}
/>
<ConditionallyRender
condition={hasWarning}
show={<VariantsWarningTooltip />}
/>
</StyledSwitchContainer>
);
};

View File

@ -48,7 +48,11 @@ import {
ProjectEnvironmentType, ProjectEnvironmentType,
useEnvironmentsRef, useEnvironmentsRef,
} from './hooks/useEnvironmentsRef'; } from './hooks/useEnvironmentsRef';
import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch'; import {
FeatureToggleSwitch,
createFeatureToggleCell,
useFeatureToggleSwitch,
} from './FeatureToggleSwitch/NewFeatureToggleSwitch';
import { ActionsCell } from './ActionsCell/ActionsCell'; import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles'; import { useStyles } from './ProjectFeatureToggles.styles';
@ -65,49 +69,19 @@ import { RowSelectCell } from './RowSelectCell/RowSelectCell';
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar'; import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions'; import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions';
import { FeatureEnvironmentSeenCell } from '../../../common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell'; import { FeatureEnvironmentSeenCell } from '../../../common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { ListItemType } from './ProjectFeatureToggles.types';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({ const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
})); }));
const StyledSwitchContainer = styled('div', {
shouldForwardProp: (prop) => prop !== 'hasWarning',
})<{ hasWarning?: boolean }>(({ theme, hasWarning }) => ({
flexGrow: 0,
...flexRow,
justifyContent: 'center',
...(hasWarning && {
'::before': {
content: '""',
display: 'block',
width: theme.spacing(2),
},
}),
}));
interface IProjectFeatureTogglesProps { interface IProjectFeatureTogglesProps {
features: IProject['features']; features: IProject['features'];
environments: IProject['environments']; environments: IProject['environments'];
loading: boolean; loading: boolean;
} }
type ListItemType = Pick<
IProject['features'][number],
'name' | 'lastSeenAt' | 'createdAt' | 'type' | 'stale' | 'favorite'
> & {
environments: {
[key in string]: {
name: string;
enabled: boolean;
variantCount: number;
type: string;
hasStrategies: boolean;
hasEnabledStrategies: boolean;
};
};
someEnabledEnvironmentHasVariants: boolean;
};
const staticColumns = ['Select', 'Actions', 'name', 'favorite']; const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
const defaultSort: SortingRule<string> & { const defaultSort: SortingRule<string> & {
@ -135,6 +109,8 @@ export const ProjectFeatureToggles = ({
string | undefined string | undefined
>(); >();
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
useFeatureToggleSwitch();
const { value: storedParams, setValue: setStoredParams } = const { value: storedParams, setValue: setStoredParams } =
createLocalStorage( createLocalStorage(
@ -163,6 +139,7 @@ export const ProjectFeatureToggles = ({
onChangeRequestToggleConfirm, onChangeRequestToggleConfirm,
changeRequestDialogDetails, changeRequestDialogDetails,
} = useChangeRequestToggle(projectId); } = useChangeRequestToggle(projectId);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const [showExportDialog, setShowExportDialog] = useState(false); const [showExportDialog, setShowExportDialog] = useState(false);
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const showEnvironmentLastSeen = Boolean( const showEnvironmentLastSeen = Boolean(
@ -294,6 +271,15 @@ export const ProjectFeatureToggles = ({
typeof value === 'string' typeof value === 'string'
? value ? value
: (value as ProjectEnvironmentType).environment; : (value as ProjectEnvironmentType).environment;
const isChangeRequestEnabled = isChangeRequestConfigured(name);
const FeatureToggleCell = createFeatureToggleCell(
projectId,
name,
isChangeRequestEnabled,
refetch,
onFeatureToggle,
);
return { return {
Header: loading ? () => '' : name, Header: loading ? () => '' : name,
maxWidth: 90, maxWidth: 90,
@ -301,41 +287,7 @@ export const ProjectFeatureToggles = ({
accessor: (row: ListItemType) => accessor: (row: ListItemType) =>
row.environments[name]?.enabled, row.environments[name]?.enabled,
align: 'center', align: 'center',
Cell: ({ Cell: FeatureToggleCell,
value,
row: { original: feature },
}: {
value: boolean;
row: { original: ListItemType };
}) => {
const hasWarning =
feature.someEnabledEnvironmentHasVariants &&
feature.environments[name].variantCount === 0 &&
feature.environments[name].enabled;
return (
<StyledSwitchContainer hasWarning={hasWarning}>
<FeatureToggleSwitch
value={value}
projectId={projectId}
featureId={feature.name}
environmentName={name}
type={feature.environments[name].type}
hasStrategies={
feature.environments[name].hasStrategies
}
hasEnabledStrategies={
feature.environments[name]
.hasEnabledStrategies
}
/>
<ConditionallyRender
condition={hasWarning}
show={<VariantsWarningTooltip />}
/>
</StyledSwitchContainer>
);
},
sortType: 'boolean', sortType: 'boolean',
filterName: name, filterName: name,
filterParsing: (value: boolean) => filterParsing: (value: boolean) =>
@ -725,6 +677,7 @@ export const ProjectFeatureToggles = ({
/> />
} }
/> />
{featureToggleModals}
</PageContent> </PageContent>
<BatchSelectionActionsBar <BatchSelectionActionsBar
count={Object.keys(selectedRowIds).length} count={Object.keys(selectedRowIds).length}

View File

@ -0,0 +1,18 @@
import { IProject } from 'interfaces/project';
export type ListItemType = Pick<
IProject['features'][number],
'name' | 'lastSeenAt' | 'createdAt' | 'type' | 'stale' | 'favorite'
> & {
environments: {
[key in string]: {
name: string;
enabled: boolean;
variantCount: number;
type: string;
hasStrategies: boolean;
hasEnabledStrategies: boolean;
};
};
someEnabledEnvironmentHasVariants: boolean;
};