diff --git a/frontend/cypress/integration/import/import.spec.ts b/frontend/cypress/integration/import/import.spec.ts index 3b90440eaf..25bcae50b1 100644 --- a/frontend/cypress/integration/import/import.spec.ts +++ b/frontend/cypress/integration/import/import.spec.ts @@ -120,8 +120,8 @@ describe('imports', () => { cy.get( "[data-testid='feature-toggle-status'] input[type='checkbox']:checked" ) - .invoke('attr', 'aria-label') - .should('eq', 'development'); + .closest('div') + .contains('development'); cy.contains('50%'); }); }); diff --git a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx index affef02b31..dfa54a6c27 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink.tsx @@ -86,6 +86,8 @@ export const StrategyTooltipLink: FC = ({ {previousTitle} + PREVIOUS consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. {' '} } @@ -101,6 +103,9 @@ export const StrategyTooltipLink: FC = ({ {change.payload.title || formatStrategyName(change.payload.name)} + lorem ipsum dolor sit amet, consectetur adipiscing elit, + sed do eiusmod tempor incididunt ut labore et dolore + magna aliqua. diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/MenuStrategyRemove/MenuStrategyRemove.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/MenuStrategyRemove/MenuStrategyRemove.tsx index 8790e9716b..5583f90571 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/MenuStrategyRemove/MenuStrategyRemove.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/MenuStrategyRemove/MenuStrategyRemove.tsx @@ -20,10 +20,7 @@ import { UPDATE_FEATURE_STRATEGY, } from '@server/types/permissions'; import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess'; -import { - STRATEGY_FORM_REMOVE_ID, - STRATEGY_REMOVE_MENU_BTN, -} from 'utils/testIds'; +import { STRATEGY_FORM_REMOVE_ID } from 'utils/testIds'; export interface IRemoveStrategyMenuProps { projectId: string; @@ -77,7 +74,6 @@ const MenuStrategyRemove = ({ aria-controls={open ? 'actions-menu' : undefined} aria-haspopup="true" aria-expanded={open ? 'true' : undefined} - data-testid={STRATEGY_REMOVE_MENU_BTN} > 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 ba181bceb4..a7d740c8b5 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 @@ -1,10 +1,21 @@ +import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; +import useToast from 'hooks/useToast'; import { IFeatureEnvironment } from 'interfaces/featureToggle'; +import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch'; +import { UPDATE_FEATURE_ENVIRONMENT } from 'component/providers/AccessProvider/permissions'; +import { formatUnknownError } from 'utils/formatUnknownError'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; +import { useChangeRequestToggle } from 'hooks/useChangeRequestToggle'; +import { ChangeRequestDialogue } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestConfirmDialog'; +import { UpdateEnabledMessage } from 'component/changeRequest/ChangeRequestConfirmDialog/ChangeRequestMessages/UpdateEnabledMessage'; +import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; 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 { useState } from 'react'; +import { EnableEnvironmentDialog } from './EnableEnvironmentDialog'; const StyledContainer = styled('div')(({ theme }) => ({ marginLeft: theme.spacing(-1.5), @@ -42,7 +53,98 @@ export const FeatureOverviewSidePanelEnvironmentSwitch = ({ const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); + const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } = + useFeatureApi(); const { feature, refetchFeature } = useFeature(projectId, featureId); + const { setToastData, setToastApiError } = useToast(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); + const { + onChangeRequestToggle, + onChangeRequestToggleClose, + onChangeRequestToggleConfirm, + changeRequestDialogDetails, + } = useChangeRequestToggle(projectId); + + const [showEnabledDialog, setShowEnabledDialog] = useState(false); + const disabledStrategiesCount = environment.strategies.filter( + strategy => strategy.disabled + ).length; + const handleToggleEnvironmentOn = async ( + shouldActivateDisabled = false + ) => { + try { + await toggleFeatureEnvironmentOn( + projectId, + featureId, + name, + shouldActivateDisabled + ); + setToastData({ + type: 'success', + title: `Available in ${name}`, + text: `${featureId} is now available in ${name} based on its defined strategies.`, + }); + refetchFeature(); + if (callback) { + callback(); + } + } catch (error: unknown) { + if ( + error instanceof Error && + error.message === ENVIRONMENT_STRATEGY_ERROR + ) { + showInfoBox(); + } else { + setToastApiError(formatUnknownError(error)); + } + } + }; + + const handleToggleEnvironmentOff = async () => { + try { + await toggleFeatureEnvironmentOff(projectId, featureId, name); + setToastData({ + type: 'success', + title: `Unavailable in ${name}`, + text: `${featureId} is unavailable in ${name} and its strategies will no longer have any effect.`, + }); + refetchFeature(); + if (callback) { + callback(); + } + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const toggleEnvironment = async (e: React.ChangeEvent) => { + if (isChangeRequestConfigured(name)) { + e.preventDefault(); + onChangeRequestToggle(featureId, name, !enabled); + return; + } + if (enabled) { + await handleToggleEnvironmentOff(); + return; + } + + if (featureHasOnlyDisabledStrategies()) { + setShowEnabledDialog(true); + } else { + await handleToggleEnvironmentOn(); + } + }; + + const featureHasOnlyDisabledStrategies = () => { + const featureEnvironment = feature?.environments?.find( + env => env.name === name + ); + return ( + featureEnvironment?.strategies && + featureEnvironment?.strategies?.length > 0 && + featureEnvironment?.strategies?.every(strategy => strategy.disabled) + ); + }; const defaultContent = ( <> @@ -53,21 +155,30 @@ export const FeatureOverviewSidePanelEnvironmentSwitch = ({ ); - const handleToggle = () => { - refetchFeature(); - if (callback) callback(); + const onActivateStrategies = async () => { + await handleToggleEnvironmentOn(true); + setShowEnabledDialog(false); + }; + + const onAddDefaultStrategy = async () => { + await handleToggleEnvironmentOn(); + setShowEnabledDialog(false); }; return ( - handleToggle()} - showInfoBox={showInfoBox} - value={enabled} + checked={enabled} + onChange={toggleEnvironment} + environmentId={name} /> {children ?? defaultContent} @@ -76,6 +187,27 @@ export const FeatureOverviewSidePanelEnvironmentSwitch = ({ hiddenEnvironments={hiddenEnvironments} setHiddenEnvironments={setHiddenEnvironments} /> + + } + /> + setShowEnabledDialog(false)} + environment={name} + disabledStrategiesCount={disabledStrategiesCount} + onActivateDisabledStrategies={onActivateStrategies} + onAddDefaultStrategy={onAddDefaultStrategy} + /> ); }; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx index 5ffdfbeafb..157d7f5039 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/FeatureToggleSwitch/FeatureToggleSwitch.tsx @@ -1,19 +1,9 @@ -import { useState, VFC } from 'react'; +import { 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'; const StyledBoxContainer = styled(Box)<{ 'data-testid': string }>(() => ({ mx: 'auto', @@ -21,203 +11,51 @@ const StyledBoxContainer = styled(Box)<{ 'data-testid': string }>(() => ({ })); interface IFeatureToggleSwitchProps { - featureId: string; + featureName: string; environmentName: string; projectId: string; value: boolean; - showInfoBox?: () => void; - onToggle?: ( + onToggle: ( projectId: string, feature: string, env: string, state: boolean - ) => void; + ) => Promise; } export const FeatureToggleSwitch: VFC = ({ projectId, - featureId, + featureName, environmentName, value, onToggle, - showInfoBox, }) => { - const { 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 disabledStrategiesCount = - feature?.environments - .find(env => env.name === environmentName) - ?.strategies.filter(strategy => strategy.disabled).length ?? 0; - - const callback = () => { - onToggle && - onToggle(projectId, feature.name, environmentName, !isChecked); - }; - - 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.`, - }); - callback(); - } catch (error: unknown) { - if ( - error instanceof Error && - error.message === ENVIRONMENT_STRATEGY_ERROR - ) { - showInfoBox && showInfoBox(); - } 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.`, - }); - callback(); - } catch (error: unknown) { - setToastApiError(formatUnknownError(error)); - rollbackIsChecked(); - } - }; - - const onClick = async (e: React.MouseEvent) => { - if (isChangeRequestConfigured(environmentName)) { - e.preventDefault(); - if (featureHasOnlyDisabledStrategies()) { - setShowEnabledDialog(true); - } else { - onChangeRequestToggle( - feature.name, - environmentName, - !value, - false - ); - } - return; - } - if (value) { - await handleToggleEnvironmentOff(); - return; - } - - if (featureHasOnlyDisabledStrategies()) { - setShowEnabledDialog(true); - } else { - await handleToggleEnvironmentOn(); - } - }; - - const onActivateStrategies = async () => { - if (isChangeRequestConfigured(environmentName)) { - onChangeRequestToggle(feature.name, environmentName, !value, true); - } else { - await handleToggleEnvironmentOn(true); - } - setShowEnabledDialog(false); - }; - - const onAddDefaultStrategy = async () => { - if (isChangeRequestConfigured(environmentName)) { - onChangeRequestToggle(feature.name, environmentName, !value, 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); + onToggle(projectId, featureName, environmentName, !isChecked).catch( + rollbackIsChecked ); }; - const key = `${feature.name}-${environmentName}`; + const key = `${featureName}-${environmentName}`; return ( - <> - - - - setShowEnabledDialog(false)} - environment={environmentName} - disabledStrategiesCount={disabledStrategiesCount} - onActivateDisabledStrategies={onActivateStrategies} - onAddDefaultStrategy={onAddDefaultStrategy} + + - - } - /> - + ); }; diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index ac62ef21f6..39fb1741e1 100644 --- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx +++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx @@ -12,8 +12,8 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { SortingRule, useFlexLayout, - useRowSelect, useSortBy, + useRowSelect, useTable, } from 'react-table'; import type { FeatureSchema } from 'openapi'; @@ -28,11 +28,14 @@ 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 { formatUnknownError } from 'utils/formatUnknownError'; 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 useToast from 'hooks/useToast'; +import { ENVIRONMENT_STRATEGY_ERROR } from 'constants/apiErrors'; import EnvironmentStrategyDialog from 'component/common/EnvironmentStrategiesDialog/EnvironmentStrategyDialog'; import { FeatureStaleDialog } from 'component/common/FeatureStaleDialog/FeatureStaleDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; @@ -41,6 +44,7 @@ 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 { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { IFeatureToggleListItem } from 'interfaces/featureToggle'; import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader'; import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell'; @@ -48,6 +52,7 @@ import { ProjectEnvironmentType, useEnvironmentsRef, } from './hooks/useEnvironmentsRef'; +import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi'; import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch'; import { ActionsCell } from './ActionsCell/ActionsCell'; import { ColumnsMenu } from './ColumnsMenu/ColumnsMenu'; @@ -141,20 +146,25 @@ export const ProjectFeatureToggles = ({ useGlobalLocalStorage(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); const environments = useEnvironmentsRef( loading ? [{ environment: 'a' }, { environment: 'b' }, { environment: 'c' }] : newEnvironments ); const { refetch } = useProject(projectId); + const { setToastData, setToastApiError } = useToast(); const { isFavoritesPinned, sortTypes, onChangeIsFavoritePinned } = usePinnedFavorites( searchParams.has('favorites') ? searchParams.get('favorites') === 'true' : globalStore.favorites ); + const { toggleFeatureEnvironmentOn, toggleFeatureEnvironmentOff } = + useFeatureApi(); const { favorite, unfavorite } = useFavoriteFeaturesApi(); const { + onChangeRequestToggle, onChangeRequestToggleClose, onChangeRequestToggleConfirm, changeRequestDialogDetails, @@ -162,6 +172,60 @@ export const ProjectFeatureToggles = ({ const [showExportDialog, setShowExportDialog] = useState(false); const { uiConfig } = useUiConfig(); + const onToggle = useCallback( + async ( + projectId: string, + featureName: string, + environment: string, + enabled: boolean + ) => { + if (isChangeRequestConfigured(environment)) { + onChangeRequestToggle(featureName, environment, enabled); + throw new Error('Additional approval required'); + } + try { + if (enabled) { + await toggleFeatureEnvironmentOn( + projectId, + featureName, + environment + ); + } else { + await toggleFeatureEnvironmentOff( + projectId, + featureName, + environment + ); + } + refetch(); + } catch (error) { + const message = formatUnknownError(error); + if (message === ENVIRONMENT_STRATEGY_ERROR) { + setStrategiesDialogState({ + open: true, + featureId: featureName, + environmentName: environment, + }); + } else { + setToastApiError(message); + } + throw error; // caught when reverting optimistic update + } + + setToastData({ + type: 'success', + title: 'Updated toggle status', + text: 'Successfully updated toggle status.', + }); + refetch(); + }, + [ + toggleFeatureEnvironmentOff, + toggleFeatureEnvironmentOn, + isChangeRequestConfigured, + ] + ); + const onFavorite = useCallback( async (feature: IFeatureToggleListItem) => { if (feature?.favorite) { @@ -291,8 +355,9 @@ export const ProjectFeatureToggles = ({ { const [changeRequestDialogDetails, setChangeRequestDialogDetails] = useState<{ enabled?: boolean; - shouldActivateDisabledStrategies?: boolean; featureName?: string; environment?: string; isOpen: boolean; }>({ isOpen: false }); const onChangeRequestToggle = useCallback( - ( - featureName: string, - environment: string, - enabled: boolean, - shouldActivateDisabledStrategies: boolean - ) => { + (featureName: string, environment: string, enabled: boolean) => { setChangeRequestDialogDetails({ featureName, environment, enabled, - shouldActivateDisabledStrategies, isOpen: true, }); }, @@ -48,9 +41,6 @@ export const useChangeRequestToggle = (project: string) => { action: 'updateEnabled', payload: { enabled: Boolean(changeRequestDialogDetails.enabled), - shouldActivateDisabledStrategies: Boolean( - changeRequestDialogDetails.shouldActivateDisabledStrategies - ), }, }); refetchChangeRequests(); diff --git a/frontend/src/utils/testIds.ts b/frontend/src/utils/testIds.ts index 555de47a1d..9fef658b39 100644 --- a/frontend/src/utils/testIds.ts +++ b/frontend/src/utils/testIds.ts @@ -63,7 +63,6 @@ export const ADD_TO_STRATEGY_INPUT_LIST = 'ADD_TO_STRATEGY_INPUT_LIST'; export const STRATEGY_FORM_SUBMIT_ID = 'STRATEGY_FORM_SUBMIT_ID'; export const STRATEGY_FORM_REMOVE_ID = 'STRATEGY_FORM_REMOVE_ID'; export const STRATEGY_FORM_COPY_ID = 'STRATEGY_FORM_COPY_ID'; -export const STRATEGY_REMOVE_MENU_BTN = 'STRATEGY_REMOVE_MENU_BTN'; /* SPLASH */ export const CLOSE_SPLASH = 'CLOSE_SPLASH';