From 3ade62fa90c7ed1ee82bbe5f1e6f69fa6f295cd9 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Mon, 16 Oct 2023 22:49:51 +0200 Subject: [PATCH] change feature toggle switch rendering --- .../FeatureStrategyProdGuard.tsx | 21 +- ...tureOverviewSidePanelEnvironmentSwitch.tsx | 2 +- .../NewFeatureToggleSwitch.tsx | 244 ++++++++++++++++++ .../ProjectFeatureToggles.tsx | 89 ++----- .../ProjectFeatureToggles.types.ts | 18 ++ 5 files changed, 297 insertions(+), 77 deletions(-) create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/NewFeatureToggleSwitch.tsx create mode 100644 frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.types.ts diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx index 450ed93ee2..a419020282 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard.tsx @@ -3,7 +3,7 @@ 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,7 +24,8 @@ export const FeatureStrategyProdGuard = ({ label, loading, }: IFeatureStrategyProdGuardProps) => { - const [settings, setSettings] = useFeatureStrategyProdGuardSettings(); + const { value: settings, setValue: setSettings } = + getFeatureStrategyProdGuardSettings(); const toggleHideSetting = () => { setSettings((prev) => ({ hide: !prev.hide })); @@ -65,7 +66,7 @@ export const useFeatureStrategyProdGuard = ( featureOrType: string | IFeatureToggle, environmentId?: string, ): boolean => { - const [settings] = useFeatureStrategyProdGuardSettings(); + const { value: settings } = getFeatureStrategyProdGuardSettings(); if (settings.hide) { return false; @@ -85,8 +86,12 @@ export const useFeatureStrategyProdGuard = ( // 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/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanelEnvironmentSwitches/FeatureOverviewSidePanelEnvironmentSwitch/FeatureOverviewSidePanelEnvironmentSwitch.tsx index 2cdac8cc12..7cff86af28 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 @@ -80,7 +80,7 @@ export const FeatureOverviewSidePanelEnvironmentSwitch = ({ featureId={feature.name} projectId={projectId} environmentName={environment.name} - type={featureEnvironment?.type} + type={featureEnvironment?.type || ''} onToggle={handleToggle} onError={showInfoBox} value={enabled} diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/NewFeatureToggleSwitch.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/NewFeatureToggleSwitch.tsx new file mode 100644 index 0000000000..53fbc5c49d --- /dev/null +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/NewFeatureToggleSwitch.tsx @@ -0,0 +1,244 @@ +import { ReactNode, useCallback, useMemo, useState, type VFC } from 'react'; +import { Box, styled } from '@mui/material'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions'; +import { useOptimisticUpdate } from './hooks/useOptimisticUpdate'; +import { flexRow } from 'themes/themeStyles'; +import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; +import useToast from 'hooks/useToast'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; +import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle'; +import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage'; +import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog'; +import { + FeatureStrategyProdGuard, + isProdGuardEnabled, + useFeatureStrategyProdGuard, +} from 'component/feature/FeatureStrategy/FeatureStrategyProdGuard/FeatureStrategyProdGuard'; +import { EnableEnvironmentDialog } from './EnableEnvironmentDialog/EnableEnvironmentDialog'; +import { ListItemType } from '../ProjectFeatureToggles.types'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import VariantsWarningTooltip from 'component/feature/FeatureView/FeatureVariants/VariantsTooltipWarning'; +import useProject from 'hooks/api/getters/useProject/useProject'; + +const StyledBoxContainer = styled(Box)<{ 'data-testid': string }>(() => ({ + mx: 'auto', + ...flexRow, +})); + +type OnFeatureToggleSwitchArgs = { + featureId: string; + projectId: string; + environmentName: string; + environmentType?: string; + hasStrategies?: boolean; + hasEnabledStrategies?: boolean; + isChangeRequestEnabled?: boolean; + onError?: () => void; + onSuccess?: () => void; +}; + +type FeatureToggleSwitchProps = { + featureId: string; + projectId: string; + environmentName: string; + value: boolean; + onToggle: (newState: boolean, onRollback: () => void) => void; +}; + +type Middleware = (next: () => void) => void; + +const composeAndRunMiddlewares = (middlewares: Middleware[]) => { + const runMiddleware = (currentIndex: number) => { + if (currentIndex < middlewares.length) { + middlewares[currentIndex](() => runMiddleware(currentIndex + 1)); + } + }; + + runMiddleware(0); +}; + +export const useFeatureToggleSwitch = () => { + const { loading, toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } = + useFeatureApi(); + // FIXME: change modals approach + const [modals, setModals] = useState(null); + + const onToggle = useCallback( + async (newState: boolean, config: OnFeatureToggleSwitchArgs) => { + const confirmProductionChanges: Middleware = (next) => { + if (config.isChangeRequestEnabled) { + // skip if change requests are enabled + return next(); + } + + if (isProdGuardEnabled(config.environmentType || '')) { + return setModals( + { + setModals(null); + config.onError?.(); + }} + onClick={() => { + setModals(null); + next(); + }} + // FIXME: internalize loading + loading={loading} + label={`${ + !newState ? 'Disable' : 'Enable' + } Environment`} + />, + ); + } + + return next(); + }; + + const addToChangeRequest: Middleware = (next) => { + next(); + }; + + const ensureActiveStrategies: Middleware = (next) => { + next(); + }; + + return composeAndRunMiddlewares([ + confirmProductionChanges, + addToChangeRequest, + ensureActiveStrategies, + () => { + // FIXME: remove + console.log('done', { newState, config }); + config.onSuccess?.(); + }, + // TODO: make actual changes + ]); + }, + [], + ); + + return { onToggle, modals }; +}; + +export const FeatureToggleSwitch: VFC = ({ + projectId, + featureId, + environmentName, + value, + onToggle, +}) => { + const [isChecked, setIsChecked, rollbackIsChecked] = + useOptimisticUpdate(value); + + const onClick = () => { + setIsChecked(!isChecked); + requestAnimationFrame(() => { + onToggle(!isChecked, rollbackIsChecked); + }); + }; + + const key = `${featureId}-${environmentName}`; + + return ( + <> + + + + + ); +}; + +const StyledSwitchContainer = styled('div', { + shouldForwardProp: (prop) => prop !== 'hasWarning', +})<{ hasWarning?: boolean }>(({ theme, hasWarning }) => ({ + flexGrow: 0, + ...flexRow, + justifyContent: 'center', + ...(hasWarning && { + '::before': { + content: '""', + display: 'block', + width: theme.spacing(2), + }, + }), +})); + +export const createFeatureToggleCell = + ( + projectId: string, + environmentName: string, + isChangeRequestEnabled: boolean, + refetch: () => void, + onFeatureToggleSwitch: ReturnType< + typeof useFeatureToggleSwitch + >['onToggle'], + ) => + ({ + value, + row: { original: feature }, + }: { + value: boolean; + row: { original: ListItemType }; + }) => { + const environment = feature.environments[environmentName]; + + const hasWarning = useMemo( + () => + feature.someEnabledEnvironmentHasVariants && + environment.variantCount === 0 && + environment.enabled, + [feature, environment], + ); + + const onToggle = (newState: boolean, onRollback: () => void) => { + onFeatureToggleSwitch(newState, { + projectId, + featureId: feature.name, + environmentName, + environmentType: environment?.type, + hasStrategies: environment?.hasStrategies, + hasEnabledStrategies: environment?.hasEnabledStrategies, + isChangeRequestEnabled, + onError: onRollback, + onSuccess: refetch, + }); + }; + + return ( + + + } + /> + + ); + }; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index e463b45366..20c5041b2b 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -48,7 +48,11 @@ import { ProjectEnvironmentType, useEnvironmentsRef, } from './hooks/useEnvironmentsRef'; -import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch'; +import { + FeatureToggleSwitch, + createFeatureToggleCell, + useFeatureToggleSwitch, +} from './FeatureToggleSwitch/NewFeatureToggleSwitch'; import { ActionsCell } from './ActionsCell/ActionsCell'; import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; import { useStyles } from './ProjectFeatureToggles.styles'; @@ -65,49 +69,19 @@ 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'; 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; - type: string; - hasStrategies: boolean; - hasEnabledStrategies: boolean; - }; - }; - someEnabledEnvironmentHasVariants: boolean; -}; - const staticColumns = ['Select', 'Actions', 'name', 'favorite']; const defaultSort: SortingRule & { @@ -135,6 +109,8 @@ export const ProjectFeatureToggles = ({ string | undefined >(); const projectId = useRequiredPathParam('projectId'); + const { onToggle: onFeatureToggle, modals: featureToggleModals } = + useFeatureToggleSwitch(); const { value: storedParams, setValue: setStoredParams } = createLocalStorage( @@ -163,6 +139,7 @@ export const ProjectFeatureToggles = ({ onChangeRequestToggleConfirm, changeRequestDialogDetails, } = useChangeRequestToggle(projectId); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const [showExportDialog, setShowExportDialog] = useState(false); const { uiConfig } = useUiConfig(); const showEnvironmentLastSeen = Boolean( @@ -294,6 +271,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, @@ -301,41 +287,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) => @@ -725,6 +677,7 @@ export const ProjectFeatureToggles = ({ /> } /> + {featureToggleModals} & { + environments: { + [key in string]: { + name: string; + enabled: boolean; + variantCount: number; + type: string; + hasStrategies: boolean; + hasEnabledStrategies: boolean; + }; + }; + someEnabledEnvironmentHasVariants: boolean; +};