1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: add hasStrategies and hasEnabledStrategies on feature environments (#5012)

This commit is contained in:
Tymoteusz Czech 2023-10-20 10:50:57 +02:00 committed by GitHub
parent 249b9e5605
commit 6fab6633c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1482 additions and 321 deletions

View File

@ -1,9 +1,10 @@
import { useState } from 'react';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { Alert } from '@mui/material';
import { Checkbox, FormControlLabel } from '@mui/material';
import { PRODUCTION } from 'constants/environmentTypes';
import { IFeatureToggle } from 'interfaces/featureToggle';
import { createPersistentGlobalStateHook } from 'hooks/usePersistentGlobalState';
import { createLocalStorage } from 'utils/createLocalStorage';
interface IFeatureStrategyProdGuardProps {
open: boolean;
@ -24,10 +25,13 @@ export const FeatureStrategyProdGuard = ({
label,
loading,
}: IFeatureStrategyProdGuardProps) => {
const [settings, setSettings] = useFeatureStrategyProdGuardSettings();
const { value: settings, setValue: setSettings } =
getFeatureStrategyProdGuardSettings();
const [hide, setHide] = useState(settings.hide);
const toggleHideSetting = () => {
setSettings((prev) => ({ hide: !prev.hide }));
setHide((prev) => !prev);
};
return (
@ -50,10 +54,7 @@ export const FeatureStrategyProdGuard = ({
<FormControlLabel
label="Don't show again"
control={
<Checkbox
checked={settings.hide}
onChange={toggleHideSetting}
/>
<Checkbox checked={hide} onChange={toggleHideSetting} />
}
/>
</Dialogue>
@ -62,27 +63,35 @@ export const FeatureStrategyProdGuard = ({
// Check if the prod guard dialog should be enabled.
export const useFeatureStrategyProdGuard = (
feature: IFeatureToggle,
environmentId: string,
featureOrType: string | IFeatureToggle,
environmentId?: string,
): boolean => {
const [settings] = useFeatureStrategyProdGuardSettings();
const environment = feature.environments.find((environment) => {
return environment.name === environmentId;
});
const { value: settings } = getFeatureStrategyProdGuardSettings();
if (settings.hide) {
return false;
}
return environment?.type === PRODUCTION;
if (typeof featureOrType === 'string') {
return featureOrType === PRODUCTION;
}
return featureOrType?.environments?.some(
(environment) =>
environment.name === environmentId &&
environment.type === PRODUCTION,
);
};
// Store the "always hide" prod guard dialog setting in localStorage.
const localStorageKey = 'useFeatureStrategyProdGuardSettings:v2';
const useFeatureStrategyProdGuardSettings =
createPersistentGlobalStateHook<IFeatureStrategyProdGuardSettings>(
localStorageKey,
{ hide: false },
);
const getFeatureStrategyProdGuardSettings = () =>
createLocalStorage<IFeatureStrategyProdGuardSettings>(localStorageKey, {
hide: false,
});
export const isProdGuardEnabled = (type: string) => {
const { value: settings } = getFeatureStrategyProdGuardSettings();
return type === PRODUCTION && !settings.hide;
};

View File

@ -10,7 +10,10 @@ export const ChildrenTooltip: FC<{
tooltip={
<>
{childFeatures.map((child) => (
<StyledLink to={`/projects/${project}/features/${child}`}>
<StyledLink
key={`${project}-${child}`}
to={`/projects/${project}/features/${child}`}
>
<div>{child}</div>
</StyledLink>
))}

View File

@ -4,7 +4,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { styled } from '@mui/material';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { FeatureOverviewSidePanelEnvironmentHider } from './FeatureOverviewSidePanelEnvironmentHider';
import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch';
import { FeatureToggleSwitch } from 'component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/LegacyFeatureToggleSwitch';
const StyledContainer = styled('div')(({ theme }) => ({
marginLeft: theme.spacing(-1.5),

View File

@ -4,6 +4,7 @@ import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IEnableEnvironmentDialogProps {
isOpen: boolean;
@ -12,7 +13,7 @@ interface IEnableEnvironmentDialogProps {
onClose: () => void;
environment?: string;
showBanner?: boolean;
disabledStrategiesCount: number;
disabledStrategiesCount?: number;
}
export const EnableEnvironmentDialog: FC<IEnableEnvironmentDialogProps> = ({
@ -21,7 +22,7 @@ export const EnableEnvironmentDialog: FC<IEnableEnvironmentDialogProps> = ({
onActivateDisabledStrategies,
onClose,
environment,
disabledStrategiesCount = 0,
disabledStrategiesCount,
}) => {
const projectId = useRequiredPathParam('projectId');
@ -61,8 +62,20 @@ export const EnableEnvironmentDialog: FC<IEnableEnvironmentDialogProps> = ({
color='text.primary'
sx={{ mb: (theme) => theme.spacing(2) }}
>
The feature toggle has {disabledStrategiesCount} disabled
{disabledStrategiesCount === 1 ? ' strategy' : ' strategies'}.
<ConditionallyRender
condition={disabledStrategiesCount !== undefined}
show={
<>
The feature toggle has {disabledStrategiesCount}{' '}
disabled
{disabledStrategiesCount === 1
? ' strategy'
: ' strategies'}
.
</>
}
elseShow={'The feature toggle has disabled strategies.'}
/>
</Typography>
<Typography variant='body1' color='text.primary'>
You can choose to enable all the disabled strategies or you can

View File

@ -1,204 +1,41 @@
import React, { useState, VFC } from 'react';
import { 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 { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useToast from 'hooks/useToast';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { EnableEnvironmentDialog } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/EnableEnvironmentDialog';
import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import {
FeatureStrategyProdGuard,
useFeatureStrategyProdGuard,
} from '../../../../feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard';
const StyledBoxContainer = styled(Box)<{ 'data-testid': string }>(() => ({
mx: 'auto',
...flexRow,
}));
interface IFeatureToggleSwitchProps {
type FeatureToggleSwitchProps = {
featureId: string;
environmentName: string;
projectId: string;
environmentName: string;
value: boolean;
onError?: () => void;
onToggle?: (
projectId: string,
feature: string,
env: string,
state: boolean,
) => void;
}
onToggle: (newState: boolean, onRollback: () => void) => void;
};
export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
export const FeatureToggleSwitch: VFC<FeatureToggleSwitchProps> = ({
projectId,
featureId,
environmentName,
value,
onToggle,
onError,
}) => {
const { loading, toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const { setToastData, setToastApiError } = useToast();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const {
onChangeRequestToggle,
onChangeRequestToggleClose,
onChangeRequestToggleConfirm,
changeRequestDialogDetails,
} = useChangeRequestToggle(projectId);
const [isChecked, setIsChecked, rollbackIsChecked] =
useOptimisticUpdate<boolean>(value);
const [showEnabledDialog, setShowEnabledDialog] = useState(false);
const { feature } = useFeature(projectId, featureId);
const enableProdGuard = useFeatureStrategyProdGuard(
feature,
environmentName,
);
const [showProdGuard, setShowProdGuard] = useState(false);
const disabledStrategiesCount =
feature?.environments
.find((env) => env.name === environmentName)
?.strategies.filter((strategy) => strategy.disabled).length ?? 0;
const handleToggleEnvironmentOn = async (
shouldActivateDisabled = false,
) => {
try {
setIsChecked(!isChecked);
await toggleFeatureEnvironmentOn(
projectId,
feature.name,
environmentName,
shouldActivateDisabled,
);
setToastData({
type: 'success',
title: `Available in ${environmentName}`,
text: `${feature.name} is now available in ${environmentName} based on its defined strategies.`,
});
onToggle?.(projectId, feature.name, environmentName, !isChecked);
} catch (error: unknown) {
if (
error instanceof Error &&
error.message === ENVIRONMENT_STRATEGY_ERROR
) {
onError?.();
} else {
setToastApiError(formatUnknownError(error));
}
rollbackIsChecked();
}
const onClick = () => {
setIsChecked(!isChecked);
requestAnimationFrame(() => {
onToggle(!isChecked, rollbackIsChecked);
});
};
const handleToggleEnvironmentOff = async () => {
try {
setIsChecked(!isChecked);
await toggleFeatureEnvironmentOff(
projectId,
feature.name,
environmentName,
);
setToastData({
type: 'success',
title: `Unavailable in ${environmentName}`,
text: `${feature.name} is unavailable in ${environmentName} and its strategies will no longer have any effect.`,
});
onToggle?.(projectId, feature.name, environmentName, !isChecked);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
rollbackIsChecked();
}
};
const handleClick = async () => {
setShowProdGuard(false);
if (isChangeRequestConfigured(environmentName)) {
if (featureHasOnlyDisabledStrategies()) {
setShowEnabledDialog(true);
} else {
onChangeRequestToggle(
feature.name,
environmentName,
!isChecked,
false,
);
}
return;
}
if (isChecked) {
await handleToggleEnvironmentOff();
return;
}
if (featureHasOnlyDisabledStrategies()) {
setShowEnabledDialog(true);
} else {
await handleToggleEnvironmentOn();
}
};
const onClick = async () => {
if (enableProdGuard && !isChangeRequestConfigured(environmentName)) {
setShowProdGuard(true);
} else {
await handleClick();
}
};
const onActivateStrategies = async () => {
if (isChangeRequestConfigured(environmentName)) {
onChangeRequestToggle(
feature.name,
environmentName,
!isChecked,
true,
);
} else {
await handleToggleEnvironmentOn(true);
}
setShowEnabledDialog(false);
};
const onAddDefaultStrategy = async () => {
if (isChangeRequestConfigured(environmentName)) {
onChangeRequestToggle(
feature.name,
environmentName,
!isChecked,
false,
);
} else {
await handleToggleEnvironmentOn();
}
setShowEnabledDialog(false);
};
const featureHasOnlyDisabledStrategies = () => {
const featureEnvironment = feature?.environments?.find(
(env) => env.name === environmentName,
);
return (
featureEnvironment?.strategies &&
featureEnvironment?.strategies?.length > 0 &&
featureEnvironment?.strategies?.every(
(strategy) => strategy.disabled,
)
);
};
const key = `${feature.name}-${environmentName}`;
const key = `${featureId}-${environmentName}`;
return (
<>
@ -212,43 +49,17 @@ export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
? `Disable feature in ${environmentName}`
: `Enable feature in ${environmentName}`
}
checked={isChecked}
checked={value}
environmentId={environmentName}
projectId={projectId}
permission={UPDATE_FEATURE_ENVIRONMENT}
inputProps={{ 'aria-label': environmentName }}
onClick={onClick}
data-testid={'permission-switch'}
disableRipple
disabled={value !== isChecked}
/>
</StyledBoxContainer>
<EnableEnvironmentDialog
isOpen={showEnabledDialog}
onClose={() => setShowEnabledDialog(false)}
environment={environmentName}
disabledStrategiesCount={disabledStrategiesCount}
onActivateDisabledStrategies={onActivateStrategies}
onAddDefaultStrategy={onAddDefaultStrategy}
/>
<ChangeRequestDialogue
isOpen={changeRequestDialogDetails.isOpen}
onClose={onChangeRequestToggleClose}
environment={changeRequestDialogDetails?.environment}
onConfirm={onChangeRequestToggleConfirm}
messageComponent={
<UpdateEnabledMessage
enabled={changeRequestDialogDetails?.enabled!}
featureName={changeRequestDialogDetails?.featureName!}
environment={changeRequestDialogDetails.environment!}
/>
}
/>
<FeatureStrategyProdGuard
open={showProdGuard}
onClose={() => setShowProdGuard(false)}
onClick={handleClick}
loading={loading}
label={`${isChecked ? 'Disable' : 'Enable'} Environment`}
/>
</>
);
};

View File

@ -0,0 +1,18 @@
import { ReactNode } from 'react';
export type OnFeatureToggleSwitchArgs = {
featureId: string;
projectId: string;
environmentName: string;
environmentType?: string;
hasStrategies?: boolean;
hasEnabledStrategies?: boolean;
isChangeRequestEnabled?: boolean;
onRollback?: () => void;
onSuccess?: () => void;
};
export type UseFeatureToggleSwitchType = (projectId: string) => {
modals: ReactNode;
onToggle: (newState: boolean, config: OnFeatureToggleSwitchArgs) => void;
};

View File

@ -0,0 +1,257 @@
import React, { useState, 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 { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useToast from 'hooks/useToast';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { EnableEnvironmentDialog } from './EnableEnvironmentDialog/EnableEnvironmentDialog';
import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import {
FeatureStrategyProdGuard,
useFeatureStrategyProdGuard,
} from '../../../../feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard';
const StyledBoxContainer = styled(Box)<{ 'data-testid': string }>(() => ({
mx: 'auto',
...flexRow,
}));
interface IFeatureToggleSwitchProps {
featureId: string;
environmentName: string;
projectId: string;
value: boolean;
onError?: () => void;
onToggle?: (
projectId: string,
feature: string,
env: string,
state: boolean,
) => void;
}
/**
* @deprecated remove when flag `featureSwitchRefactor` is removed
*/
export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
projectId,
featureId,
environmentName,
value,
onToggle,
onError,
}) => {
const { loading, toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const { setToastData, setToastApiError } = useToast();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const {
onChangeRequestToggle,
onChangeRequestToggleClose,
onChangeRequestToggleConfirm,
changeRequestDialogDetails,
} = useChangeRequestToggle(projectId);
const [isChecked, setIsChecked, rollbackIsChecked] =
useOptimisticUpdate<boolean>(value);
const [showEnabledDialog, setShowEnabledDialog] = useState(false);
const { feature } = useFeature(projectId, featureId);
const enableProdGuard = useFeatureStrategyProdGuard(
feature,
environmentName,
);
const [showProdGuard, setShowProdGuard] = useState(false);
const disabledStrategiesCount =
feature?.environments
.find((env) => env.name === environmentName)
?.strategies.filter((strategy) => strategy.disabled).length ?? 0;
const handleToggleEnvironmentOn = async (
shouldActivateDisabled = false,
) => {
try {
setIsChecked(!isChecked);
await toggleFeatureEnvironmentOn(
projectId,
feature.name,
environmentName,
shouldActivateDisabled,
);
setToastData({
type: 'success',
title: `Available in ${environmentName}`,
text: `${feature.name} is now available in ${environmentName} based on its defined strategies.`,
});
onToggle?.(projectId, feature.name, environmentName, !isChecked);
} catch (error: unknown) {
if (
error instanceof Error &&
error.message === ENVIRONMENT_STRATEGY_ERROR
) {
onError?.();
} else {
setToastApiError(formatUnknownError(error));
}
rollbackIsChecked();
}
};
const handleToggleEnvironmentOff = async () => {
try {
setIsChecked(!isChecked);
await toggleFeatureEnvironmentOff(
projectId,
feature.name,
environmentName,
);
setToastData({
type: 'success',
title: `Unavailable in ${environmentName}`,
text: `${feature.name} is unavailable in ${environmentName} and its strategies will no longer have any effect.`,
});
onToggle?.(projectId, feature.name, environmentName, !isChecked);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
rollbackIsChecked();
}
};
const handleClick = async () => {
setShowProdGuard(false);
if (isChangeRequestConfigured(environmentName)) {
if (featureHasOnlyDisabledStrategies()) {
setShowEnabledDialog(true);
} else {
onChangeRequestToggle(
feature.name,
environmentName,
!isChecked,
false,
);
}
return;
}
if (isChecked) {
await handleToggleEnvironmentOff();
return;
}
if (featureHasOnlyDisabledStrategies()) {
setShowEnabledDialog(true);
} else {
await handleToggleEnvironmentOn();
}
};
const onClick = async () => {
if (enableProdGuard && !isChangeRequestConfigured(environmentName)) {
setShowProdGuard(true);
} else {
await handleClick();
}
};
const onActivateStrategies = async () => {
if (isChangeRequestConfigured(environmentName)) {
onChangeRequestToggle(
feature.name,
environmentName,
!isChecked,
true,
);
} else {
await handleToggleEnvironmentOn(true);
}
setShowEnabledDialog(false);
};
const onAddDefaultStrategy = async () => {
if (isChangeRequestConfigured(environmentName)) {
onChangeRequestToggle(
feature.name,
environmentName,
!isChecked,
false,
);
} else {
await handleToggleEnvironmentOn();
}
setShowEnabledDialog(false);
};
const featureHasOnlyDisabledStrategies = () => {
const featureEnvironment = feature?.environments?.find(
(env) => env.name === environmentName,
);
return (
featureEnvironment?.strategies &&
featureEnvironment?.strategies?.length > 0 &&
featureEnvironment?.strategies?.every(
(strategy) => strategy.disabled,
)
);
};
const key = `${feature.name}-${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'}
/>
</StyledBoxContainer>
<EnableEnvironmentDialog
isOpen={showEnabledDialog}
onClose={() => setShowEnabledDialog(false)}
environment={environmentName}
disabledStrategiesCount={disabledStrategiesCount}
onActivateDisabledStrategies={onActivateStrategies}
onAddDefaultStrategy={onAddDefaultStrategy}
/>
<ChangeRequestDialogue
isOpen={changeRequestDialogDetails.isOpen}
onClose={onChangeRequestToggleClose}
environment={changeRequestDialogDetails?.environment}
onConfirm={onChangeRequestToggleConfirm}
messageComponent={
<UpdateEnabledMessage
enabled={changeRequestDialogDetails?.enabled!}
featureName={changeRequestDialogDetails?.featureName!}
environment={changeRequestDialogDetails.environment!}
/>
}
/>
<FeatureStrategyProdGuard
open={showProdGuard}
onClose={() => setShowProdGuard(false)}
onClick={handleClick}
loading={loading}
label={`${isChecked ? 'Disable' : 'Enable'} Environment`}
/>
</>
);
};

View File

@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { styled } from '@mui/material';
import { flexRow } from 'themes/themeStyles';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
import { FeatureToggleSwitch } from './FeatureToggleSwitch';
import type { ListItemType } from '../ProjectFeatureToggles.types';
import type { UseFeatureToggleSwitchType } from './FeatureToggleSwitch.types';
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<UseFeatureToggleSwitchType>['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,
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

@ -0,0 +1,235 @@
import { ComponentProps, useCallback, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import {
FeatureStrategyProdGuard,
isProdGuardEnabled,
} from 'component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard';
import { EnableEnvironmentDialog } from './EnableEnvironmentDialog/EnableEnvironmentDialog';
import {
OnFeatureToggleSwitchArgs,
UseFeatureToggleSwitchType,
} from './FeatureToggleSwitch.types';
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: UseFeatureToggleSwitchType = (
projectId: string,
) => {
const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } =
useFeatureApi();
const { setToastData, setToastApiError } = useToast();
const [prodGuardModalState, setProdGuardModalState] = useState<
ComponentProps<typeof FeatureStrategyProdGuard>
>({
open: false,
label: '',
loading: false,
onClose: () => {},
onClick: () => {},
});
const [enableEnvironmentDialogState, setEnableEnvironmentDialogState] =
useState<ComponentProps<typeof EnableEnvironmentDialog>>({
isOpen: false,
environment: '',
onClose: () => {},
onActivateDisabledStrategies: () => {},
onAddDefaultStrategy: () => {},
});
const {
onChangeRequestToggle,
onChangeRequestToggleClose,
onChangeRequestToggleConfirm,
changeRequestDialogDetails,
} = useChangeRequestToggle(projectId);
const [changeRequestDialogCallback, setChangeRequestDialogCallback] =
useState<() => void>();
const onToggle = useCallback(
async (newState: boolean, config: OnFeatureToggleSwitchArgs) => {
let shouldActivateDisabledStrategies = false;
const confirmProductionChanges: Middleware = (next) => {
if (config.isChangeRequestEnabled) {
// skip if change requests are enabled
return next();
}
if (!isProdGuardEnabled(config.environmentType || '')) {
return next();
}
setProdGuardModalState({
open: true,
label: `${!newState ? 'Disable' : 'Enable'} Environment`,
loading: false,
onClose: () => {
setProdGuardModalState((prev) => ({
...prev,
open: false,
}));
config.onRollback?.();
},
onClick: () => {
setProdGuardModalState((prev) => ({
...prev,
open: false,
loading: true,
}));
next();
},
});
};
const ensureActiveStrategies: Middleware = (next) => {
if (!config.hasStrategies || config.hasEnabledStrategies) {
return next();
}
setEnableEnvironmentDialogState({
isOpen: true,
environment: config.environmentName,
onClose: () => {
setEnableEnvironmentDialogState((prev) => ({
...prev,
isOpen: false,
}));
config.onRollback?.();
},
onActivateDisabledStrategies: () => {
setEnableEnvironmentDialogState((prev) => ({
...prev,
isOpen: false,
}));
shouldActivateDisabledStrategies = true;
next();
},
onAddDefaultStrategy: () => {
setEnableEnvironmentDialogState((prev) => ({
...prev,
isOpen: false,
}));
next();
},
});
};
const addToChangeRequest: Middleware = (next) => {
if (!config.isChangeRequestEnabled) {
return next();
}
setChangeRequestDialogCallback(() => {
setChangeRequestDialogCallback(undefined);
// always reset to previous state when using change requests
config.onRollback?.();
});
onChangeRequestToggle(
config.featureId,
config.environmentName,
newState,
shouldActivateDisabledStrategies,
);
};
const handleToggleEnvironmentOn: Middleware = async (next) => {
if (newState !== true) {
return next();
}
try {
await toggleFeatureEnvironmentOn(
config.projectId,
config.featureId,
config.environmentName,
shouldActivateDisabledStrategies,
);
setToastData({
type: 'success',
title: `Enabled in ${config.environmentName}`,
text: `${config.featureId} is now available in ${config.environmentName} based on its defined strategies.`,
});
config.onSuccess?.();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
config.onRollback?.();
}
};
const handleToggleEnvironmentOff: Middleware = async (next) => {
if (newState !== false) {
return next();
}
try {
await toggleFeatureEnvironmentOff(
config.projectId,
config.featureId,
config.environmentName,
);
setToastData({
type: 'success',
title: `Disabled in ${config.environmentName}`,
text: `${config.featureId} is unavailable in ${config.environmentName} and its strategies will no longer have any effect.`,
});
config.onSuccess?.();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
config.onRollback?.();
}
};
return composeAndRunMiddlewares([
confirmProductionChanges,
ensureActiveStrategies,
addToChangeRequest,
handleToggleEnvironmentOff,
handleToggleEnvironmentOn,
]);
},
[setProdGuardModalState],
);
const modals = (
<>
<FeatureStrategyProdGuard {...prodGuardModalState} />
<EnableEnvironmentDialog {...enableEnvironmentDialogState} />
<ChangeRequestDialogue
isOpen={changeRequestDialogDetails.isOpen}
onClose={() => {
changeRequestDialogCallback?.();
onChangeRequestToggleClose();
}}
environment={changeRequestDialogDetails?.environment}
onConfirm={() => {
changeRequestDialogCallback?.();
onChangeRequestToggleConfirm();
}}
messageComponent={
<UpdateEnabledMessage
enabled={changeRequestDialogDetails?.enabled!}
featureName={changeRequestDialogDetails?.featureName!}
environment={changeRequestDialogDetails.environment!}
/>
}
/>
</>
);
return { onToggle, modals };
};

View File

@ -0,0 +1,729 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Checkbox,
IconButton,
styled,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material';
import { Add } from '@mui/icons-material';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
SortingRule,
useFlexLayout,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
import type { FeatureSchema } from 'openapi';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { PageContent } from 'component/common/PageContent/PageContent';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { getCreateTogglePath } from 'utils/routePathHelpers';
import { CREATE_FEATURE } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { FeatureSeenCell } from 'component/common/Table/cells/FeatureSeenCell/FeatureSeenCell';
import { FeatureTypeCell } from 'component/common/Table/cells/FeatureTypeCell/FeatureTypeCell';
import { IProject } from 'interfaces/project';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import useProject from 'hooks/api/getters/useProject/useProject';
import { createLocalStorage } from 'utils/createLocalStorage';
import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog';
import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog';
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch';
import { Search } from 'component/common/Search/Search';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import {
ProjectEnvironmentType,
useEnvironmentsRef,
} from './hooks/useEnvironmentsRef';
import { FeatureToggleSwitch } from './FeatureToggleSwitch/LegacyFeatureToggleSwitch';
import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles';
import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { flexRow } from 'themes/themeStyles';
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
import FileDownload from '@mui/icons-material/FileDownload';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
import { RowSelectCell } from './RowSelectCell/RowSelectCell';
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions';
import { FeatureEnvironmentSeenCell } from '../../../common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
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 {
features: IProject['features'];
environments: IProject['environments'];
loading: boolean;
}
type ListItemType = Pick<
IProject['features'][number],
'name' | 'lastSeenAt' | 'createdAt' | 'type' | 'stale' | 'favorite'
> & {
environments: {
[key in string]: {
name: string;
enabled: boolean;
variantCount: number;
};
};
someEnabledEnvironmentHasVariants: boolean;
};
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
const defaultSort: SortingRule<string> & {
columns?: string[];
} = { id: 'createdAt' };
/**
* @deprecated remove when flag `featureSwitchRefactor` is removed
*/
export const ProjectFeatureToggles = ({
features,
loading,
environments: newEnvironments = [],
}: IProjectFeatureTogglesProps) => {
const { classes: styles } = useStyles();
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [strategiesDialogState, setStrategiesDialogState] = useState({
open: false,
featureId: '',
environmentName: '',
});
const [featureStaleDialogState, setFeatureStaleDialogState] = useState<{
featureId?: string;
stale?: boolean;
}>({});
const [featureArchiveState, setFeatureArchiveState] = useState<
string | undefined
>();
const projectId = useRequiredPathParam('projectId');
const { value: storedParams, setValue: setStoredParams } =
createLocalStorage(
`${projectId}:FeatureToggleListTable:v1`,
defaultSort,
);
const { value: globalStore, setValue: setGlobalStore } =
useGlobalLocalStorage();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const environments = useEnvironmentsRef(
loading
? [{ environment: 'a' }, { environment: 'b' }, { environment: 'c' }]
: newEnvironments,
);
const { refetch } = useProject(projectId);
const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } =
usePinnedFavorites(
searchParams.has('favorites')
? searchParams.get('favorites') === 'true'
: globalStore.favorites,
);
const { favorite, unfavorite } = useFavoriteFeaturesApi();
const {
onChangeRequestToggleClose,
onChangeRequestToggleConfirm,
changeRequestDialogDetails,
} = useChangeRequestToggle(projectId);
const [showExportDialog, setShowExportDialog] = useState(false);
const { uiConfig } = useUiConfig();
const showEnvironmentLastSeen = Boolean(
uiConfig.flags.lastSeenByEnvironment,
);
const onFavorite = useCallback(
async (feature: IFeatureToggleListItem) => {
if (feature?.favorite) {
await unfavorite(projectId, feature.name);
} else {
await favorite(projectId, feature.name);
}
refetch();
},
[projectId, refetch],
);
const showTagsColumn = useMemo(
() => features.some((feature) => feature?.tags?.length),
[features],
);
const columns = useMemo(
() => [
{
id: 'Select',
Header: ({ getToggleAllRowsSelectedProps }: any) => (
<Checkbox {...getToggleAllRowsSelectedProps()} />
),
Cell: ({ row }: any) => (
<RowSelectCell {...row?.getToggleRowSelectedProps?.()} />
),
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
},
{
id: 'favorite',
Header: (
<FavoriteIconHeader
isActive={isFavoritesPinned}
onClick={onChangeIsFavoritePinned}
/>
),
accessor: 'favorite',
Cell: ({ row: { original: feature } }: any) => (
<FavoriteIconCell
value={feature?.favorite}
onClick={() => onFavorite(feature)}
/>
),
maxWidth: 50,
disableSortBy: true,
hideInMenu: true,
},
{
Header: 'Seen',
accessor: 'lastSeenAt',
Cell: ({ value, row: { original: feature } }: any) => {
return showEnvironmentLastSeen ? (
<FeatureEnvironmentSeenCell feature={feature} />
) : (
<FeatureSeenCell value={value} />
);
},
align: 'center',
maxWidth: 80,
},
{
Header: 'Type',
accessor: 'type',
Cell: FeatureTypeCell,
align: 'center',
filterName: 'type',
maxWidth: 80,
},
{
Header: 'Name',
accessor: 'name',
Cell: ({ value }: { value: string }) => (
<Tooltip title={value} arrow describeChild>
<span>
<LinkCell
title={value}
to={`/projects/${projectId}/features/${value}`}
/>
</span>
</Tooltip>
),
minWidth: 100,
sortType: 'alphanumeric',
searchable: true,
},
...(showTagsColumn
? [
{
id: 'tags',
Header: 'Tags',
accessor: (row: IFeatureToggleListItem) =>
row.tags
?.map(({ type, value }) => `${type}:${value}`)
.join('\n') || '',
Cell: FeatureTagCell,
width: 80,
searchable: true,
filterName: 'tags',
filterBy(
row: IFeatureToggleListItem,
values: string[],
) {
return includesFilter(
getColumnValues(this, row),
values,
);
},
},
]
: []),
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
minWidth: 120,
},
...environments.map((value: ProjectEnvironmentType | string) => {
const name =
typeof value === 'string'
? value
: (value as ProjectEnvironmentType).environment;
return {
Header: loading ? () => '' : name,
maxWidth: 90,
id: `environments.${name}`,
accessor: (row: ListItemType) =>
row.environments[name]?.enabled,
align: 'center',
Cell: ({
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}
/>
<ConditionallyRender
condition={hasWarning}
show={<VariantsWarningTooltip />}
/>
</StyledSwitchContainer>
);
},
sortType: 'boolean',
filterName: name,
filterParsing: (value: boolean) =>
value ? 'enabled' : 'disabled',
};
}),
{
id: 'Actions',
maxWidth: 56,
width: 56,
Cell: (props: { row: { original: ListItemType } }) => (
<ActionsCell
projectId={projectId}
onOpenArchiveDialog={setFeatureArchiveState}
onOpenStaleDialog={setFeatureStaleDialogState}
{...props}
/>
),
disableSortBy: true,
hideInMenu: true,
},
],
[projectId, environments, loading],
);
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '',
);
const [showTitle, setShowTitle] = useState(true);
const featuresData = useMemo(
() =>
features.map((feature) => ({
...feature,
environments: Object.fromEntries(
environments.map((env) => {
const thisEnv = feature?.environments.find(
(featureEnvironment) =>
featureEnvironment?.name === env,
);
return [
env,
{
name: env,
enabled: thisEnv?.enabled || false,
variantCount: thisEnv?.variantCount || 0,
lastSeenAt: thisEnv?.lastSeenAt,
},
];
}),
),
someEnabledEnvironmentHasVariants:
feature.environments?.some(
(featureEnvironment) =>
featureEnvironment.variantCount > 0 &&
featureEnvironment.enabled,
) || false,
})),
[features, environments],
);
const {
data: searchedData,
getSearchText,
getSearchContext,
} = useSearch(columns, searchValue, featuresData);
const data = useMemo(() => {
if (loading) {
return Array(6).fill({
type: '-',
name: 'Feature name',
createdAt: new Date(),
environments: {
production: { name: 'production', enabled: false },
},
}) as FeatureSchema[];
}
return searchedData;
}, [loading, searchedData]);
const initialState = useMemo(
() => {
const allColumnIds = columns
.map(
(column: any) =>
(column?.id as string) ||
(typeof column?.accessor === 'string'
? (column?.accessor as string)
: ''),
)
.filter(Boolean);
let hiddenColumns = environments
.filter((_, index) => index >= 3)
.map((environment) => `environments.${environment}`);
if (searchParams.has('columns')) {
const columnsInParams =
searchParams.get('columns')?.split(',') || [];
const visibleColumns = [...staticColumns, ...columnsInParams];
hiddenColumns = allColumnIds.filter(
(columnId) => !visibleColumns.includes(columnId),
);
} else if (storedParams.columns) {
const visibleColumns = [
...staticColumns,
...storedParams.columns,
];
hiddenColumns = allColumnIds.filter(
(columnId) => !visibleColumns.includes(columnId),
);
}
return {
sortBy: [
{
id: searchParams.get('sort') || 'createdAt',
desc: searchParams.has('order')
? searchParams.get('order') === 'desc'
: storedParams.desc,
},
],
hiddenColumns,
selectedRowIds: {},
};
},
[environments], // eslint-disable-line react-hooks/exhaustive-deps
);
const getRowId = useCallback((row: any) => row.name, []);
const {
allColumns,
headerGroups,
rows,
state: { selectedRowIds, sortBy, hiddenColumns },
prepareRow,
setHiddenColumns,
toggleAllRowsSelected,
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSelectedRows: false,
disableSortRemove: true,
autoResetSortBy: false,
getRowId,
},
useFlexLayout,
useSortBy,
useRowSelect,
);
useEffect(() => {
if (loading) {
return;
}
const tableState: Record<string, string> = {};
tableState.sort = sortBy[0].id;
if (sortBy[0].desc) {
tableState.order = 'desc';
}
if (searchValue) {
tableState.search = searchValue;
}
if (isFavoritesPinned) {
tableState.favorites = 'true';
}
tableState.columns = allColumns
.map(({ id }) => id)
.filter(
(id) =>
!staticColumns.includes(id) && !hiddenColumns?.includes(id),
)
.join(',');
setSearchParams(tableState, {
replace: true,
});
setStoredParams((params) => ({
...params,
id: sortBy[0].id,
desc: sortBy[0].desc || false,
columns: tableState.columns.split(','),
}));
setGlobalStore((params) => ({
...params,
favorites: Boolean(isFavoritesPinned),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
loading,
sortBy,
hiddenColumns,
searchValue,
setSearchParams,
isFavoritesPinned,
]);
return (
<>
<PageContent
isLoading={loading}
className={styles.container}
header={
<PageHeader
titleElement={
showTitle
? `Feature toggles (${rows.length})`
: null
}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<Search
placeholder='Search and Filter'
expandable
initialValue={searchValue}
onChange={setSearchValue}
onFocus={() => setShowTitle(false)}
onBlur={() => setShowTitle(true)}
hasFilters
getSearchContext={getSearchContext}
id='projectFeatureToggles'
/>
}
/>
<ColumnsMenu
allColumns={allColumns}
staticColumns={staticColumns}
dividerAfter={['createdAt']}
dividerBefore={['Actions']}
isCustomized={Boolean(storedParams.columns)}
setHiddenColumns={setHiddenColumns}
/>
<PageHeader.Divider sx={{ marginLeft: 0 }} />
<ConditionallyRender
condition={Boolean(
uiConfig?.flags?.featuresExportImport,
)}
show={
<Tooltip
title='Export toggles visible in the table below'
arrow
>
<IconButton
onClick={() =>
setShowExportDialog(true)
}
sx={(theme) => ({
marginRight:
theme.spacing(2),
})}
>
<FileDownload />
</IconButton>
</Tooltip>
}
/>
<StyledResponsiveButton
onClick={() =>
navigate(getCreateTogglePath(projectId))
}
maxWidth='960px'
Icon={Add}
projectId={projectId}
permission={CREATE_FEATURE}
data-testid='NAVIGATE_TO_CREATE_FEATURE'
>
New feature toggle
</StyledResponsiveButton>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
id='projectFeatureToggles'
/>
}
/>
</PageHeader>
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No feature toggles found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No feature toggles available. Get started by
adding a new feature toggle.
</TablePlaceholder>
}
/>
}
/>
<EnvironmentStrategyDialog
onClose={() =>
setStrategiesDialogState((prev) => ({
...prev,
open: false,
}))
}
projectId={projectId}
{...strategiesDialogState}
/>
<FeatureStaleDialog
isStale={featureStaleDialogState.stale === true}
isOpen={Boolean(featureStaleDialogState.featureId)}
onClose={() => {
setFeatureStaleDialogState({});
refetch();
}}
featureId={featureStaleDialogState.featureId || ''}
projectId={projectId}
/>
<FeatureArchiveDialog
isOpen={Boolean(featureArchiveState)}
onConfirm={() => {
refetch();
}}
onClose={() => {
setFeatureArchiveState(undefined);
}}
featureIds={[featureArchiveState || '']}
projectId={projectId}
/>{' '}
<ChangeRequestDialogue
isOpen={changeRequestDialogDetails.isOpen}
onClose={onChangeRequestToggleClose}
environment={changeRequestDialogDetails?.environment}
onConfirm={onChangeRequestToggleConfirm}
messageComponent={
<UpdateEnabledMessage
featureName={
changeRequestDialogDetails.featureName!
}
enabled={changeRequestDialogDetails.enabled!}
environment={
changeRequestDialogDetails?.environment!
}
/>
}
/>
<ConditionallyRender
condition={
Boolean(uiConfig?.flags?.featuresExportImport) &&
!loading
}
show={
<ExportDialog
showExportDialog={showExportDialog}
data={data}
onClose={() => setShowExportDialog(false)}
environments={environments}
/>
}
/>
</PageContent>
<BatchSelectionActionsBar
count={Object.keys(selectedRowIds).length}
>
<ProjectFeaturesBatchActions
selectedIds={Object.keys(selectedRowIds)}
data={features}
projectId={projectId}
onResetSelection={() => toggleAllRowsSelected(false)}
/>
</BatchSelectionActionsBar>
</>
);
};

View File

@ -38,9 +38,6 @@ import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureS
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch';
import { Search } from 'component/common/Search/Search';
import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle';
import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog';
import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage';
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
@ -48,7 +45,6 @@ import {
ProjectEnvironmentType,
useEnvironmentsRef,
} from './hooks/useEnvironmentsRef';
import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch';
import { ActionsCell } from './ActionsCell/ActionsCell';
import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu';
import { useStyles } from './ProjectFeatureToggles.styles';
@ -56,8 +52,6 @@ import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
import { flexRow } from 'themes/themeStyles';
import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning';
import FileDownload from '@mui/icons-material/FileDownload';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
@ -65,46 +59,21 @@ import { RowSelectCell } from './RowSelectCell/RowSelectCell';
import { BatchSelectionActionsBar } from '../../../common/BatchSelectionActionsBar/BatchSelectionActionsBar';
import { ProjectFeaturesBatchActions } from './ProjectFeaturesBatchActions/ProjectFeaturesBatchActions';
import { FeatureEnvironmentSeenCell } from '../../../common/Table/cells/FeatureSeenCell/FeatureEnvironmentSeenCell';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { ListItemType } from './ProjectFeatureToggles.types';
import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell';
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
const StyledResponsiveButton = styled(ResponsiveButton)(() => ({
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 {
features: IProject['features'];
environments: IProject['environments'];
loading: boolean;
}
type ListItemType = Pick<
IProject['features'][number],
'name' | 'lastSeenAt' | 'createdAt' | 'type' | 'stale' | 'favorite'
> & {
environments: {
[key in string]: {
name: string;
enabled: boolean;
variantCount: number;
};
};
someEnabledEnvironmentHasVariants: boolean;
};
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
const defaultSort: SortingRule<string> & {
@ -132,6 +101,8 @@ export const ProjectFeatureToggles = ({
string | undefined
>();
const projectId = useRequiredPathParam('projectId');
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
useFeatureToggleSwitch(projectId);
const { value: storedParams, setValue: setStoredParams } =
createLocalStorage(
@ -155,11 +126,7 @@ export const ProjectFeatureToggles = ({
: globalStore.favorites,
);
const { favorite, unfavorite } = useFavoriteFeaturesApi();
const {
onChangeRequestToggleClose,
onChangeRequestToggleConfirm,
changeRequestDialogDetails,
} = useChangeRequestToggle(projectId);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const [showExportDialog, setShowExportDialog] = useState(false);
const { uiConfig } = useUiConfig();
const showEnvironmentLastSeen = Boolean(
@ -291,6 +258,15 @@ export const ProjectFeatureToggles = ({
typeof value === 'string'
? value
: (value as ProjectEnvironmentType).environment;
const isChangeRequestEnabled = isChangeRequestConfigured(name);
const FeatureToggleCell = createFeatureToggleCell(
projectId,
name,
isChangeRequestEnabled,
refetch,
onFeatureToggle,
);
return {
Header: loading ? () => '' : name,
maxWidth: 90,
@ -298,33 +274,7 @@ export const ProjectFeatureToggles = ({
accessor: (row: ListItemType) =>
row.environments[name]?.enabled,
align: 'center',
Cell: ({
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}
/>
<ConditionallyRender
condition={hasWarning}
show={<VariantsWarningTooltip />}
/>
</StyledSwitchContainer>
);
},
Cell: FeatureToggleCell,
sortType: 'boolean',
filterName: name,
filterParsing: (value: boolean) =>
@ -374,6 +324,10 @@ export const ProjectFeatureToggles = ({
enabled: thisEnv?.enabled || false,
variantCount: thisEnv?.variantCount || 0,
lastSeenAt: thisEnv?.lastSeenAt,
type: thisEnv?.type,
hasStrategies: thisEnv?.hasStrategies,
hasEnabledStrategies:
thisEnv?.hasEnabledStrategies,
},
];
}),
@ -678,23 +632,6 @@ export const ProjectFeatureToggles = ({
}}
featureIds={[featureArchiveState || '']}
projectId={projectId}
/>{' '}
<ChangeRequestDialogue
isOpen={changeRequestDialogDetails.isOpen}
onClose={onChangeRequestToggleClose}
environment={changeRequestDialogDetails?.environment}
onConfirm={onChangeRequestToggleConfirm}
messageComponent={
<UpdateEnabledMessage
featureName={
changeRequestDialogDetails.featureName!
}
enabled={changeRequestDialogDetails.enabled!}
environment={
changeRequestDialogDetails?.environment!
}
/>
}
/>
<ConditionallyRender
condition={
@ -710,6 +647,7 @@ export const ProjectFeatureToggles = ({
/>
}
/>
{featureToggleModals}
</PageContent>
<BatchSelectionActionsBar
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;
};

View File

@ -3,12 +3,14 @@ import useProject, {
useProjectNameOrId,
} from 'hooks/api/getters/useProject/useProject';
import { Box, styled } from '@mui/material';
import { ProjectFeatureToggles as LegacyProjectFeatureToggles } from './ProjectFeatureToggles/LegacyProjectFeatureToggles';
import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
import ProjectInfo from './ProjectInfo/ProjectInfo';
import { usePageTitle } from 'hooks/usePageTitle';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useLastViewedProject } from 'hooks/useLastViewedProject';
import { ProjectStats } from './ProjectStats/ProjectStats';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
const refreshInterval = 15 * 1000;
@ -59,11 +61,24 @@ const ProjectOverview = () => {
<StyledContentContainer>
<ProjectStats stats={project.stats} />
<StyledProjectToggles>
<ProjectFeatureToggles
key={loading ? 'loading' : 'ready'}
features={features}
environments={environments}
loading={loading}
<ConditionallyRender
condition={true}
show={() => (
<ProjectFeatureToggles
key={loading ? 'loading' : 'ready'}
features={features}
environments={environments}
loading={loading}
/>
)}
elseShow={() => (
<LegacyProjectFeatureToggles
key={loading ? 'loading' : 'ready'}
features={features}
environments={environments}
loading={loading}
/>
)}
/>
</StyledProjectToggles>
</StyledContentContainer>

View File

@ -17,6 +17,9 @@ export interface IEnvironments {
enabled: boolean;
variantCount: number;
lastSeenAt?: string | null;
type?: string;
hasStrategies?: boolean;
hasEnabledStrategies?: boolean;
}
export interface IFeatureToggle {

View File

@ -73,6 +73,7 @@ export type UiFlags = {
banners?: boolean;
disableEnvsOnRevive?: boolean;
playgroundImprovements?: boolean;
featureSwitchRefactor?: boolean;
};
export interface IVersionInfo {

View File

@ -91,6 +91,7 @@ exports[`should create default config 1`] = `
"embedProxy": true,
"embedProxyFrontend": true,
"featureNamingPattern": false,
"featureSwitchRefactor": false,
"featuresExportImport": true,
"filterInvalidClientMetrics": false,
"googleAuthEnabled": false,
@ -136,6 +137,7 @@ exports[`should create default config 1`] = `
"embedProxy": true,
"embedProxyFrontend": true,
"featureNamingPattern": false,
"featureSwitchRefactor": false,
"featuresExportImport": true,
"filterInvalidClientMetrics": false,
"googleAuthEnabled": false,

View File

@ -479,6 +479,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
sortOrder: r.environment_sort_order,
variantCount: r.variants?.length || 0,
lastSeenAt: r.env_last_seen_at,
hasStrategies: r.has_strategies,
hasEnabledStrategies: r.has_enabled_strategies,
};
}
@ -571,7 +573,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'environments.sort_order as environment_sort_order',
'ft.tag_value as tag_value',
'ft.tag_type as tag_type',
] as (string | Raw<any>)[];
] as (string | Raw<any> | Knex.QueryBuilder)[];
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
selectColumns.push(
@ -599,12 +601,23 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
];
}
if (this.flagResolver.isEnabled('featureSwitchRefactor')) {
selectColumns = [
...selectColumns,
this.db.raw(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
),
this.db.raw(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
),
];
}
query = query.select(selectColumns);
const rows = await query;
if (rows.length > 0) {
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
return sortEnvironments(overview);
}
return [];

View File

@ -71,6 +71,15 @@ export const featureEnvironmentSchema = {
description:
'The date when metrics where last collected for the feature environment',
},
hasStrategies: {
type: 'boolean',
description: 'Whether the feature has any strategies defined.',
},
hasEnabledStrategies: {
type: 'boolean',
description:
'Whether the feature has any enabled strategies defined.',
},
},
components: {
schemas: {

View File

@ -37,7 +37,8 @@ export type IFlagKey =
| 'banners'
| 'separateAdminClientApi'
| 'disableEnvsOnRevive'
| 'playgroundImprovements';
| 'playgroundImprovements'
| 'featureSwitchRefactor';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -177,6 +178,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_PLAYGROUND_IMPROVEMENTS,
false,
),
featureSwitchRefactor: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FEATURE_SWITCH_REFACTOR,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -199,6 +199,8 @@ export interface IEnvironmentBase {
export interface IEnvironmentOverview extends IEnvironmentBase {
variantCount: number;
hasStrategies?: boolean;
hasEnabledStrategies?: boolean;
}
export interface IFeatureOverview {

View File

@ -50,6 +50,7 @@ process.nextTick(async () => {
disableEnvsOnRevive: true,
separateAdminClientApi: true,
playgroundImprovements: true,
featureSwitchRefactor: true,
},
},
authentication: {