diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx
index 44da2cbd9012..57aaf8469900 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx
@@ -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 = ({
+
}
/>
@@ -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(
- localStorageKey,
- { hide: false },
- );
+const getFeatureStrategyProdGuardSettings = () =>
+ createLocalStorage(localStorageKey, {
+ hide: false,
+ });
+
+export const isProdGuardEnabled = (type: string) => {
+ const { value: settings } = getFeatureStrategyProdGuardSettings();
+ return type === PRODUCTION && !settings.hide;
+};
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/ChildrenTooltip.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/ChildrenTooltip.tsx
index a7795b5bdf08..c145ffe9ebe6 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/ChildrenTooltip.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelDetails/ChildrenTooltip.tsx
@@ -10,7 +10,10 @@ export const ChildrenTooltip: FC<{
tooltip={
<>
{childFeatures.map((child) => (
-
+
{child}
))}
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx
index dcb12910877f..972bafe917e9 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx
+++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx
@@ -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),
diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/EnableEnvironmentDialog.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/EnableEnvironmentDialog/EnableEnvironmentDialog.tsx
similarity index 75%
rename from frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/EnableEnvironmentDialog.tsx
rename to frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/EnableEnvironmentDialog/EnableEnvironmentDialog.tsx
index c1ad87aaef47..a11623974cc5 100644
--- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/EnableEnvironmentDialog.tsx
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/EnableEnvironmentDialog/EnableEnvironmentDialog.tsx
@@ -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 = ({
@@ -21,7 +22,7 @@ export const EnableEnvironmentDialog: FC = ({
onActivateDisabledStrategies,
onClose,
environment,
- disabledStrategiesCount = 0,
+ disabledStrategiesCount,
}) => {
const projectId = useRequiredPathParam('projectId');
@@ -61,8 +62,20 @@ export const EnableEnvironmentDialog: FC = ({
color='text.primary'
sx={{ mb: (theme) => theme.spacing(2) }}
>
- The feature toggle has {disabledStrategiesCount} disabled
- {disabledStrategiesCount === 1 ? ' strategy' : ' strategies'}.
+
+ The feature toggle has {disabledStrategiesCount}{' '}
+ disabled
+ {disabledStrategiesCount === 1
+ ? ' strategy'
+ : ' strategies'}
+ .
+ >
+ }
+ elseShow={'The feature toggle has disabled strategies.'}
+ />
You can choose to enable all the disabled strategies or you can
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx
index 973dfd3b298d..ebeb3c23c3ff 100644
--- a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx
@@ -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 = ({
+export const FeatureToggleSwitch: VFC = ({
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(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 onClick = () => {
+ setIsChecked(!isChecked);
+ requestAnimationFrame(() => {
+ onToggle(!isChecked, rollbackIsChecked);
+ });
};
- const key = `${feature.name}-${environmentName}`;
+ const key = `${featureId}-${environmentName}`;
return (
<>
@@ -212,43 +49,17 @@ export const FeatureToggleSwitch: VFC = ({
? `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}
/>
- setShowEnabledDialog(false)}
- environment={environmentName}
- disabledStrategiesCount={disabledStrategiesCount}
- onActivateDisabledStrategies={onActivateStrategies}
- onAddDefaultStrategy={onAddDefaultStrategy}
- />
-
- }
- />
- setShowProdGuard(false)}
- onClick={handleClick}
- loading={loading}
- label={`${isChecked ? 'Disable' : 'Enable'} Environment`}
- />
>
);
};
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.types.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.types.ts
new file mode 100644
index 000000000000..734746c2be92
--- /dev/null
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.types.ts
@@ -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;
+};
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/LegacyFeatureToggleSwitch.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/LegacyFeatureToggleSwitch.tsx
new file mode 100644
index 000000000000..fb92c9aec494
--- /dev/null
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/LegacyFeatureToggleSwitch.tsx
@@ -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 = ({
+ 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(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 (
+ <>
+
+
+
+ setShowEnabledDialog(false)}
+ environment={environmentName}
+ disabledStrategiesCount={disabledStrategiesCount}
+ onActivateDisabledStrategies={onActivateStrategies}
+ onAddDefaultStrategy={onAddDefaultStrategy}
+ />
+
+ }
+ />
+ setShowProdGuard(false)}
+ onClick={handleClick}
+ loading={loading}
+ label={`${isChecked ? 'Disable' : 'Enable'} Environment`}
+ />
+ >
+ );
+};
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/createFeatureToggleCell.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/createFeatureToggleCell.tsx
new file mode 100644
index 000000000000..832a87dc8ed1
--- /dev/null
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/createFeatureToggleCell.tsx
@@ -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['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 (
+
+
+ }
+ />
+
+ );
+ };
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch.tsx
new file mode 100644
index 000000000000..9c0e919bc6a6
--- /dev/null
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/useFeatureToggleSwitch.tsx
@@ -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
+ >({
+ open: false,
+ label: '',
+ loading: false,
+ onClose: () => {},
+ onClick: () => {},
+ });
+ const [enableEnvironmentDialogState, setEnableEnvironmentDialogState] =
+ useState>({
+ 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 = (
+ <>
+
+
+ {
+ changeRequestDialogCallback?.();
+ onChangeRequestToggleClose();
+ }}
+ environment={changeRequestDialogDetails?.environment}
+ onConfirm={() => {
+ changeRequestDialogCallback?.();
+ onChangeRequestToggleConfirm();
+ }}
+ messageComponent={
+
+ }
+ />
+ >
+ );
+
+ return { onToggle, modals };
+};
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/LegacyProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/LegacyProjectFeatureToggles.tsx
new file mode 100644
index 000000000000..2b00ce23c111
--- /dev/null
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/LegacyProjectFeatureToggles.tsx
@@ -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 & {
+ 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) => (
+
+ ),
+ Cell: ({ row }: any) => (
+
+ ),
+ maxWidth: 50,
+ disableSortBy: true,
+ hideInMenu: true,
+ },
+ {
+ id: 'favorite',
+ Header: (
+
+ ),
+ accessor: 'favorite',
+ Cell: ({ row: { original: feature } }: any) => (
+ onFavorite(feature)}
+ />
+ ),
+ maxWidth: 50,
+ disableSortBy: true,
+ hideInMenu: true,
+ },
+ {
+ Header: 'Seen',
+ accessor: 'lastSeenAt',
+ Cell: ({ value, row: { original: feature } }: any) => {
+ return showEnvironmentLastSeen ? (
+
+ ) : (
+
+ );
+ },
+ align: 'center',
+ maxWidth: 80,
+ },
+ {
+ Header: 'Type',
+ accessor: 'type',
+ Cell: FeatureTypeCell,
+ align: 'center',
+ filterName: 'type',
+ maxWidth: 80,
+ },
+ {
+ Header: 'Name',
+ accessor: 'name',
+ Cell: ({ value }: { value: string }) => (
+
+
+
+
+
+ ),
+ 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 (
+
+
+ }
+ />
+
+ );
+ },
+ sortType: 'boolean',
+ filterName: name,
+ filterParsing: (value: boolean) =>
+ value ? 'enabled' : 'disabled',
+ };
+ }),
+
+ {
+ id: 'Actions',
+ maxWidth: 56,
+ width: 56,
+ Cell: (props: { row: { original: ListItemType } }) => (
+
+ ),
+ 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 = {};
+ 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 (
+ <>
+
+ setShowTitle(false)}
+ onBlur={() => setShowTitle(true)}
+ hasFilters
+ getSearchContext={getSearchContext}
+ id='projectFeatureToggles'
+ />
+ }
+ />
+
+
+
+
+ setShowExportDialog(true)
+ }
+ sx={(theme) => ({
+ marginRight:
+ theme.spacing(2),
+ })}
+ >
+
+
+
+ }
+ />
+
+ navigate(getCreateTogglePath(projectId))
+ }
+ maxWidth='960px'
+ Icon={Add}
+ projectId={projectId}
+ permission={CREATE_FEATURE}
+ data-testid='NAVIGATE_TO_CREATE_FEATURE'
+ >
+ New feature toggle
+
+ >
+ }
+ >
+
+ }
+ />
+
+ }
+ >
+
+
+
+ 0}
+ show={
+
+ No feature toggles found matching “
+ {searchValue}
+ ”
+
+ }
+ elseShow={
+
+ No feature toggles available. Get started by
+ adding a new feature toggle.
+
+ }
+ />
+ }
+ />
+
+ setStrategiesDialogState((prev) => ({
+ ...prev,
+ open: false,
+ }))
+ }
+ projectId={projectId}
+ {...strategiesDialogState}
+ />
+ {
+ setFeatureStaleDialogState({});
+ refetch();
+ }}
+ featureId={featureStaleDialogState.featureId || ''}
+ projectId={projectId}
+ />
+ {
+ refetch();
+ }}
+ onClose={() => {
+ setFeatureArchiveState(undefined);
+ }}
+ featureIds={[featureArchiveState || '']}
+ projectId={projectId}
+ />{' '}
+
+ }
+ />
+ setShowExportDialog(false)}
+ environments={environments}
+ />
+ }
+ />
+
+
+ toggleAllRowsSelected(false)}
+ />
+
+ >
+ );
+};
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx
index 95e5c76076d7..708b0fa509c7 100644
--- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx
@@ -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 & {
@@ -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 (
-
-
- }
- />
-
- );
- },
+ 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}
- />{' '}
-
- }
/>
}
/>
+ {featureToggleModals}
& {
+ environments: {
+ [key in string]: {
+ name: string;
+ enabled: boolean;
+ variantCount: number;
+ type: string;
+ hasStrategies: boolean;
+ hasEnabledStrategies: boolean;
+ };
+ };
+ someEnabledEnvironmentHasVariants: boolean;
+};
diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx
index 1cd17d2b59f3..11f07e532e92 100644
--- a/frontend/src/component/project/Project/ProjectOverview.tsx
+++ b/frontend/src/component/project/Project/ProjectOverview.tsx
@@ -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 = () => {
- (
+
+ )}
+ elseShow={() => (
+
+ )}
/>
diff --git a/frontend/src/interfaces/featureToggle.ts b/frontend/src/interfaces/featureToggle.ts
index e9102e3dd1e4..8c64b94050e1 100644
--- a/frontend/src/interfaces/featureToggle.ts
+++ b/frontend/src/interfaces/featureToggle.ts
@@ -17,6 +17,9 @@ export interface IEnvironments {
enabled: boolean;
variantCount: number;
lastSeenAt?: string | null;
+ type?: string;
+ hasStrategies?: boolean;
+ hasEnabledStrategies?: boolean;
}
export interface IFeatureToggle {
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index 2d825365c1e3..c061d4d6178e 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -73,6 +73,7 @@ export type UiFlags = {
banners?: boolean;
disableEnvsOnRevive?: boolean;
playgroundImprovements?: boolean;
+ featureSwitchRefactor?: boolean;
};
export interface IVersionInfo {
diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap
index 8475acd9645f..b4e42e2ac7fd 100644
--- a/src/lib/__snapshots__/create-config.test.ts.snap
+++ b/src/lib/__snapshots__/create-config.test.ts.snap
@@ -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,
diff --git a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts
index 65808ba36ecd..e1628fea949e 100644
--- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts
+++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts
@@ -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)[];
+ ] as (string | Raw | 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 [];
diff --git a/src/lib/openapi/spec/feature-environment-schema.ts b/src/lib/openapi/spec/feature-environment-schema.ts
index 110140db5ee5..d776169e899a 100644
--- a/src/lib/openapi/spec/feature-environment-schema.ts
+++ b/src/lib/openapi/spec/feature-environment-schema.ts
@@ -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: {
diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts
index afbeb9b91fa7..05af0e8b58b7 100644
--- a/src/lib/types/experimental.ts
+++ b/src/lib/types/experimental.ts
@@ -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 = {
diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts
index 27d6d56800e7..060ce32bd81d 100644
--- a/src/lib/types/model.ts
+++ b/src/lib/types/model.ts
@@ -199,6 +199,8 @@ export interface IEnvironmentBase {
export interface IEnvironmentOverview extends IEnvironmentBase {
variantCount: number;
+ hasStrategies?: boolean;
+ hasEnabledStrategies?: boolean;
}
export interface IFeatureOverview {
diff --git a/src/server-dev.ts b/src/server-dev.ts
index c24a3059b5bd..fba0c67e7e72 100644
--- a/src/server-dev.ts
+++ b/src/server-dev.ts
@@ -50,6 +50,7 @@ process.nextTick(async () => {
disableEnvsOnRevive: true,
separateAdminClientApi: true,
playgroundImprovements: true,
+ featureSwitchRefactor: true,
},
},
authentication: {