mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-15 17:50:48 +02:00
feat: add hasStrategies and hasEnabledStrategies on feature environments (#5012)
This commit is contained in:
parent
249b9e5605
commit
6fab6633c9
@ -1,9 +1,10 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
import { Alert } from '@mui/material';
|
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,10 +25,13 @@ export const FeatureStrategyProdGuard = ({
|
|||||||
label,
|
label,
|
||||||
loading,
|
loading,
|
||||||
}: IFeatureStrategyProdGuardProps) => {
|
}: IFeatureStrategyProdGuardProps) => {
|
||||||
const [settings, setSettings] = useFeatureStrategyProdGuardSettings();
|
const { value: settings, setValue: setSettings } =
|
||||||
|
getFeatureStrategyProdGuardSettings();
|
||||||
|
const [hide, setHide] = useState(settings.hide);
|
||||||
|
|
||||||
const toggleHideSetting = () => {
|
const toggleHideSetting = () => {
|
||||||
setSettings((prev) => ({ hide: !prev.hide }));
|
setSettings((prev) => ({ hide: !prev.hide }));
|
||||||
|
setHide((prev) => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -50,10 +54,7 @@ export const FeatureStrategyProdGuard = ({
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
label="Don't show again"
|
label="Don't show again"
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox checked={hide} onChange={toggleHideSetting} />
|
||||||
checked={settings.hide}
|
|
||||||
onChange={toggleHideSetting}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Dialogue>
|
</Dialogue>
|
||||||
@ -62,27 +63,35 @@ export const FeatureStrategyProdGuard = ({
|
|||||||
|
|
||||||
// Check if the prod guard dialog should be enabled.
|
// Check if the prod guard dialog should be enabled.
|
||||||
export const useFeatureStrategyProdGuard = (
|
export const useFeatureStrategyProdGuard = (
|
||||||
feature: IFeatureToggle,
|
featureOrType: string | IFeatureToggle,
|
||||||
environmentId: string,
|
environmentId?: string,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const [settings] = useFeatureStrategyProdGuardSettings();
|
const { value: settings } = getFeatureStrategyProdGuardSettings();
|
||||||
|
|
||||||
const environment = feature.environments.find((environment) => {
|
|
||||||
return environment.name === environmentId;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (settings.hide) {
|
if (settings.hide) {
|
||||||
return false;
|
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.
|
// 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;
|
||||||
|
};
|
||||||
|
@ -10,7 +10,10 @@ export const ChildrenTooltip: FC<{
|
|||||||
tooltip={
|
tooltip={
|
||||||
<>
|
<>
|
||||||
{childFeatures.map((child) => (
|
{childFeatures.map((child) => (
|
||||||
<StyledLink to={`/projects/${project}/features/${child}`}>
|
<StyledLink
|
||||||
|
key={`${project}-${child}`}
|
||||||
|
to={`/projects/${project}/features/${child}`}
|
||||||
|
>
|
||||||
<div>{child}</div>
|
<div>{child}</div>
|
||||||
</StyledLink>
|
</StyledLink>
|
||||||
))}
|
))}
|
||||||
|
@ -4,7 +4,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
|
||||||
import { FeatureOverviewSidePanelEnvironmentHider } from './FeatureOverviewSidePanelEnvironmentHider';
|
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 }) => ({
|
const StyledContainer = styled('div')(({ theme }) => ({
|
||||||
marginLeft: theme.spacing(-1.5),
|
marginLeft: theme.spacing(-1.5),
|
||||||
|
@ -4,6 +4,7 @@ import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
|||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
|
||||||
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_FEATURE } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
interface IEnableEnvironmentDialogProps {
|
interface IEnableEnvironmentDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -12,7 +13,7 @@ interface IEnableEnvironmentDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
environment?: string;
|
environment?: string;
|
||||||
showBanner?: boolean;
|
showBanner?: boolean;
|
||||||
disabledStrategiesCount: number;
|
disabledStrategiesCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnableEnvironmentDialog: FC<IEnableEnvironmentDialogProps> = ({
|
export const EnableEnvironmentDialog: FC<IEnableEnvironmentDialogProps> = ({
|
||||||
@ -21,7 +22,7 @@ export const EnableEnvironmentDialog: FC<IEnableEnvironmentDialogProps> = ({
|
|||||||
onActivateDisabledStrategies,
|
onActivateDisabledStrategies,
|
||||||
onClose,
|
onClose,
|
||||||
environment,
|
environment,
|
||||||
disabledStrategiesCount = 0,
|
disabledStrategiesCount,
|
||||||
}) => {
|
}) => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
|
||||||
@ -61,8 +62,20 @@ export const EnableEnvironmentDialog: FC<IEnableEnvironmentDialogProps> = ({
|
|||||||
color='text.primary'
|
color='text.primary'
|
||||||
sx={{ mb: (theme) => theme.spacing(2) }}
|
sx={{ mb: (theme) => theme.spacing(2) }}
|
||||||
>
|
>
|
||||||
The feature toggle has {disabledStrategiesCount} disabled
|
<ConditionallyRender
|
||||||
{disabledStrategiesCount === 1 ? ' strategy' : ' strategies'}.
|
condition={disabledStrategiesCount !== undefined}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
The feature toggle has {disabledStrategiesCount}{' '}
|
||||||
|
disabled
|
||||||
|
{disabledStrategiesCount === 1
|
||||||
|
? ' strategy'
|
||||||
|
: ' strategies'}
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
elseShow={'The feature toggle has disabled strategies.'}
|
||||||
|
/>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='body1' color='text.primary'>
|
<Typography variant='body1' color='text.primary'>
|
||||||
You can choose to enable all the disabled strategies or you can
|
You can choose to enable all the disabled strategies or you can
|
@ -1,204 +1,41 @@
|
|||||||
import React, { useState, VFC } from 'react';
|
import { type VFC } from 'react';
|
||||||
import { Box, styled } from '@mui/material';
|
import { Box, styled } from '@mui/material';
|
||||||
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
|
||||||
import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions';
|
||||||
import { useOptimisticUpdate } from './hooks/useOptimisticUpdate';
|
import { useOptimisticUpdate } from './hooks/useOptimisticUpdate';
|
||||||
import { flexRow } from 'themes/themeStyles';
|
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 }>(() => ({
|
const StyledBoxContainer = styled(Box)<{ 'data-testid': string }>(() => ({
|
||||||
mx: 'auto',
|
mx: 'auto',
|
||||||
...flexRow,
|
...flexRow,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface IFeatureToggleSwitchProps {
|
type FeatureToggleSwitchProps = {
|
||||||
featureId: string;
|
featureId: string;
|
||||||
environmentName: string;
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
environmentName: string;
|
||||||
value: boolean;
|
value: boolean;
|
||||||
onError?: () => void;
|
onToggle: (newState: boolean, onRollback: () => void) => void;
|
||||||
onToggle?: (
|
};
|
||||||
projectId: string,
|
|
||||||
feature: string,
|
|
||||||
env: string,
|
|
||||||
state: boolean,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
|
export const FeatureToggleSwitch: VFC<FeatureToggleSwitchProps> = ({
|
||||||
projectId,
|
projectId,
|
||||||
featureId,
|
featureId,
|
||||||
environmentName,
|
environmentName,
|
||||||
value,
|
value,
|
||||||
onToggle,
|
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] =
|
const [isChecked, setIsChecked, rollbackIsChecked] =
|
||||||
useOptimisticUpdate<boolean>(value);
|
useOptimisticUpdate<boolean>(value);
|
||||||
|
|
||||||
const [showEnabledDialog, setShowEnabledDialog] = useState(false);
|
const onClick = () => {
|
||||||
const { feature } = useFeature(projectId, featureId);
|
setIsChecked(!isChecked);
|
||||||
const enableProdGuard = useFeatureStrategyProdGuard(
|
requestAnimationFrame(() => {
|
||||||
feature,
|
onToggle(!isChecked, rollbackIsChecked);
|
||||||
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 () => {
|
const key = `${featureId}-${environmentName}`;
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -212,43 +49,17 @@ export const FeatureToggleSwitch: VFC<IFeatureToggleSwitchProps> = ({
|
|||||||
? `Disable feature in ${environmentName}`
|
? `Disable feature in ${environmentName}`
|
||||||
: `Enable feature in ${environmentName}`
|
: `Enable feature in ${environmentName}`
|
||||||
}
|
}
|
||||||
checked={isChecked}
|
checked={value}
|
||||||
environmentId={environmentName}
|
environmentId={environmentName}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
permission={UPDATE_FEATURE_ENVIRONMENT}
|
permission={UPDATE_FEATURE_ENVIRONMENT}
|
||||||
inputProps={{ 'aria-label': environmentName }}
|
inputProps={{ 'aria-label': environmentName }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-testid={'permission-switch'}
|
data-testid={'permission-switch'}
|
||||||
|
disableRipple
|
||||||
|
disabled={value !== isChecked}
|
||||||
/>
|
/>
|
||||||
</StyledBoxContainer>
|
</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`}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
};
|
@ -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`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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 };
|
||||||
|
};
|
@ -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 “
|
||||||
|
{searchValue}
|
||||||
|
”
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -38,9 +38,6 @@ import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureS
|
|||||||
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
|
import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog';
|
||||||
import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch';
|
import { getColumnValues, includesFilter, useSearch } from 'hooks/useSearch';
|
||||||
import { Search } from 'component/common/Search/Search';
|
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 { IFeatureToggleListItem } from 'interfaces/featureToggle';
|
||||||
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
|
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
|
||||||
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
|
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
|
||||||
@ -48,7 +45,6 @@ import {
|
|||||||
ProjectEnvironmentType,
|
ProjectEnvironmentType,
|
||||||
useEnvironmentsRef,
|
useEnvironmentsRef,
|
||||||
} from './hooks/useEnvironmentsRef';
|
} from './hooks/useEnvironmentsRef';
|
||||||
import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch';
|
|
||||||
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';
|
||||||
@ -56,8 +52,6 @@ import { usePinnedFavorites } from 'hooks/usePinnedFavorites';
|
|||||||
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
import { useFavoriteFeaturesApi } from 'hooks/api/actions/useFavoriteFeaturesApi/useFavoriteFeaturesApi';
|
||||||
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
|
import { FeatureTagCell } from 'component/common/Table/cells/FeatureTagCell/FeatureTagCell';
|
||||||
import { useGlobalLocalStorage } from 'hooks/useGlobalLocalStorage';
|
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 FileDownload from '@mui/icons-material/FileDownload';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
import { ExportDialog } from 'component/feature/FeatureToggleList/ExportDialog';
|
||||||
@ -65,46 +59,21 @@ 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';
|
||||||
|
import { createFeatureToggleCell } from './FeatureToggleSwitch/createFeatureToggleCell';
|
||||||
|
import { useFeatureToggleSwitch } from './FeatureToggleSwitch/useFeatureToggleSwitch';
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
someEnabledEnvironmentHasVariants: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
|
const staticColumns = ['Select', 'Actions', 'name', 'favorite'];
|
||||||
|
|
||||||
const defaultSort: SortingRule<string> & {
|
const defaultSort: SortingRule<string> & {
|
||||||
@ -132,6 +101,8 @@ export const ProjectFeatureToggles = ({
|
|||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const { onToggle: onFeatureToggle, modals: featureToggleModals } =
|
||||||
|
useFeatureToggleSwitch(projectId);
|
||||||
|
|
||||||
const { value: storedParams, setValue: setStoredParams } =
|
const { value: storedParams, setValue: setStoredParams } =
|
||||||
createLocalStorage(
|
createLocalStorage(
|
||||||
@ -155,11 +126,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
: globalStore.favorites,
|
: globalStore.favorites,
|
||||||
);
|
);
|
||||||
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
const { favorite, unfavorite } = useFavoriteFeaturesApi();
|
||||||
const {
|
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
|
||||||
onChangeRequestToggleClose,
|
|
||||||
onChangeRequestToggleConfirm,
|
|
||||||
changeRequestDialogDetails,
|
|
||||||
} = useChangeRequestToggle(projectId);
|
|
||||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const showEnvironmentLastSeen = Boolean(
|
const showEnvironmentLastSeen = Boolean(
|
||||||
@ -291,6 +258,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,
|
||||||
@ -298,33 +274,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}
|
|
||||||
/>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={hasWarning}
|
|
||||||
show={<VariantsWarningTooltip />}
|
|
||||||
/>
|
|
||||||
</StyledSwitchContainer>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
sortType: 'boolean',
|
sortType: 'boolean',
|
||||||
filterName: name,
|
filterName: name,
|
||||||
filterParsing: (value: boolean) =>
|
filterParsing: (value: boolean) =>
|
||||||
@ -374,6 +324,10 @@ export const ProjectFeatureToggles = ({
|
|||||||
enabled: thisEnv?.enabled || false,
|
enabled: thisEnv?.enabled || false,
|
||||||
variantCount: thisEnv?.variantCount || 0,
|
variantCount: thisEnv?.variantCount || 0,
|
||||||
lastSeenAt: thisEnv?.lastSeenAt,
|
lastSeenAt: thisEnv?.lastSeenAt,
|
||||||
|
type: thisEnv?.type,
|
||||||
|
hasStrategies: thisEnv?.hasStrategies,
|
||||||
|
hasEnabledStrategies:
|
||||||
|
thisEnv?.hasEnabledStrategies,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
@ -678,23 +632,6 @@ export const ProjectFeatureToggles = ({
|
|||||||
}}
|
}}
|
||||||
featureIds={[featureArchiveState || '']}
|
featureIds={[featureArchiveState || '']}
|
||||||
projectId={projectId}
|
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
|
<ConditionallyRender
|
||||||
condition={
|
condition={
|
||||||
@ -710,6 +647,7 @@ export const ProjectFeatureToggles = ({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{featureToggleModals}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
<BatchSelectionActionsBar
|
<BatchSelectionActionsBar
|
||||||
count={Object.keys(selectedRowIds).length}
|
count={Object.keys(selectedRowIds).length}
|
||||||
|
@ -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;
|
||||||
|
};
|
@ -3,12 +3,14 @@ import useProject, {
|
|||||||
useProjectNameOrId,
|
useProjectNameOrId,
|
||||||
} from 'hooks/api/getters/useProject/useProject';
|
} from 'hooks/api/getters/useProject/useProject';
|
||||||
import { Box, styled } from '@mui/material';
|
import { Box, styled } from '@mui/material';
|
||||||
|
import { ProjectFeatureToggles as LegacyProjectFeatureToggles } from './ProjectFeatureToggles/LegacyProjectFeatureToggles';
|
||||||
import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
|
import { ProjectFeatureToggles } from './ProjectFeatureToggles/ProjectFeatureToggles';
|
||||||
import ProjectInfo from './ProjectInfo/ProjectInfo';
|
import ProjectInfo from './ProjectInfo/ProjectInfo';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
import { useLastViewedProject } from 'hooks/useLastViewedProject';
|
||||||
import { ProjectStats } from './ProjectStats/ProjectStats';
|
import { ProjectStats } from './ProjectStats/ProjectStats';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
const refreshInterval = 15 * 1000;
|
const refreshInterval = 15 * 1000;
|
||||||
|
|
||||||
@ -59,11 +61,24 @@ const ProjectOverview = () => {
|
|||||||
<StyledContentContainer>
|
<StyledContentContainer>
|
||||||
<ProjectStats stats={project.stats} />
|
<ProjectStats stats={project.stats} />
|
||||||
<StyledProjectToggles>
|
<StyledProjectToggles>
|
||||||
<ProjectFeatureToggles
|
<ConditionallyRender
|
||||||
key={loading ? 'loading' : 'ready'}
|
condition={true}
|
||||||
features={features}
|
show={() => (
|
||||||
environments={environments}
|
<ProjectFeatureToggles
|
||||||
loading={loading}
|
key={loading ? 'loading' : 'ready'}
|
||||||
|
features={features}
|
||||||
|
environments={environments}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
elseShow={() => (
|
||||||
|
<LegacyProjectFeatureToggles
|
||||||
|
key={loading ? 'loading' : 'ready'}
|
||||||
|
features={features}
|
||||||
|
environments={environments}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</StyledProjectToggles>
|
</StyledProjectToggles>
|
||||||
</StyledContentContainer>
|
</StyledContentContainer>
|
||||||
|
@ -17,6 +17,9 @@ export interface IEnvironments {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
variantCount: number;
|
variantCount: number;
|
||||||
lastSeenAt?: string | null;
|
lastSeenAt?: string | null;
|
||||||
|
type?: string;
|
||||||
|
hasStrategies?: boolean;
|
||||||
|
hasEnabledStrategies?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureToggle {
|
export interface IFeatureToggle {
|
||||||
|
@ -73,6 +73,7 @@ export type UiFlags = {
|
|||||||
banners?: boolean;
|
banners?: boolean;
|
||||||
disableEnvsOnRevive?: boolean;
|
disableEnvsOnRevive?: boolean;
|
||||||
playgroundImprovements?: boolean;
|
playgroundImprovements?: boolean;
|
||||||
|
featureSwitchRefactor?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -91,6 +91,7 @@ exports[`should create default config 1`] = `
|
|||||||
"embedProxy": true,
|
"embedProxy": true,
|
||||||
"embedProxyFrontend": true,
|
"embedProxyFrontend": true,
|
||||||
"featureNamingPattern": false,
|
"featureNamingPattern": false,
|
||||||
|
"featureSwitchRefactor": false,
|
||||||
"featuresExportImport": true,
|
"featuresExportImport": true,
|
||||||
"filterInvalidClientMetrics": false,
|
"filterInvalidClientMetrics": false,
|
||||||
"googleAuthEnabled": false,
|
"googleAuthEnabled": false,
|
||||||
@ -136,6 +137,7 @@ exports[`should create default config 1`] = `
|
|||||||
"embedProxy": true,
|
"embedProxy": true,
|
||||||
"embedProxyFrontend": true,
|
"embedProxyFrontend": true,
|
||||||
"featureNamingPattern": false,
|
"featureNamingPattern": false,
|
||||||
|
"featureSwitchRefactor": false,
|
||||||
"featuresExportImport": true,
|
"featuresExportImport": true,
|
||||||
"filterInvalidClientMetrics": false,
|
"filterInvalidClientMetrics": false,
|
||||||
"googleAuthEnabled": false,
|
"googleAuthEnabled": false,
|
||||||
|
@ -479,6 +479,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
sortOrder: r.environment_sort_order,
|
sortOrder: r.environment_sort_order,
|
||||||
variantCount: r.variants?.length || 0,
|
variantCount: r.variants?.length || 0,
|
||||||
lastSeenAt: r.env_last_seen_at,
|
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',
|
'environments.sort_order as environment_sort_order',
|
||||||
'ft.tag_value as tag_value',
|
'ft.tag_value as tag_value',
|
||||||
'ft.tag_type as tag_type',
|
'ft.tag_type as tag_type',
|
||||||
] as (string | Raw<any>)[];
|
] as (string | Raw<any> | Knex.QueryBuilder)[];
|
||||||
|
|
||||||
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
|
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||||
selectColumns.push(
|
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);
|
query = query.select(selectColumns);
|
||||||
const rows = await query;
|
const rows = await query;
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
|
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
|
||||||
|
|
||||||
return sortEnvironments(overview);
|
return sortEnvironments(overview);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
@ -71,6 +71,15 @@ export const featureEnvironmentSchema = {
|
|||||||
description:
|
description:
|
||||||
'The date when metrics where last collected for the feature environment',
|
'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: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
@ -37,7 +37,8 @@ export type IFlagKey =
|
|||||||
| 'banners'
|
| 'banners'
|
||||||
| 'separateAdminClientApi'
|
| 'separateAdminClientApi'
|
||||||
| 'disableEnvsOnRevive'
|
| 'disableEnvsOnRevive'
|
||||||
| 'playgroundImprovements';
|
| 'playgroundImprovements'
|
||||||
|
| 'featureSwitchRefactor';
|
||||||
|
|
||||||
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
@ -177,6 +178,10 @@ const flags: IFlags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_PLAYGROUND_IMPROVEMENTS,
|
process.env.UNLEASH_EXPERIMENTAL_PLAYGROUND_IMPROVEMENTS,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
featureSwitchRefactor: parseEnvVarBoolean(
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_FEATURE_SWITCH_REFACTOR,
|
||||||
|
false,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
|
@ -199,6 +199,8 @@ export interface IEnvironmentBase {
|
|||||||
|
|
||||||
export interface IEnvironmentOverview extends IEnvironmentBase {
|
export interface IEnvironmentOverview extends IEnvironmentBase {
|
||||||
variantCount: number;
|
variantCount: number;
|
||||||
|
hasStrategies?: boolean;
|
||||||
|
hasEnabledStrategies?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureOverview {
|
export interface IFeatureOverview {
|
||||||
|
@ -50,6 +50,7 @@ process.nextTick(async () => {
|
|||||||
disableEnvsOnRevive: true,
|
disableEnvsOnRevive: true,
|
||||||
separateAdminClientApi: true,
|
separateAdminClientApi: true,
|
||||||
playgroundImprovements: true,
|
playgroundImprovements: true,
|
||||||
|
featureSwitchRefactor: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
Loading…
Reference in New Issue
Block a user