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: {