diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 7cf752a61214e..46c3ad18a635c 100644 --- a/src/components/ButtonWithDropdownMenu/index.tsx +++ b/src/components/ButtonWithDropdownMenu/index.tsx @@ -44,13 +44,16 @@ function ButtonWithDropdownMenu({ shouldUseStyleUtilityForAnchorPosition = false, defaultSelectedIndex = 0, shouldShowSelectedItemCheck = false, + testID, }: ButtonWithDropdownMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [selectedItemIndex, setSelectedItemIndex] = useState(defaultSelectedIndex); const [isMenuVisible, setIsMenuVisible] = useState(false); - const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); + // In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests. + const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null; + const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(defaultPopoverAnchorPosition); const {windowWidth, windowHeight} = useWindowDimensions(); const dropdownAnchor = useRef(null); // eslint-disable-next-line react-compiler/react-compiler @@ -139,6 +142,7 @@ function ButtonWithDropdownMenu({ iconRight={Expensicons.DownArrow} shouldShowRightIcon={!isSplitButton} isSplitButton={isSplitButton} + testID={testID} /> {isSplitButton && ( diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 766c0df950b40..dbafbc497105b 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -108,6 +108,9 @@ type ButtonWithDropdownMenuProps = { /** Whether selected items should be marked as selected */ shouldShowSelectedItemCheck?: boolean; + + /** Used to locate the component in the tests */ + testID?: string; }; export type { diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index cb0fc6e8e8cb7..3bfb5a146d05d 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -207,6 +207,7 @@ function ConfirmContent({ isPressOnEnterActive={isVisible} large text={confirmText || translate('common.yes')} + accessibilityLabel={confirmText || translate('common.yes')} isDisabled={isOffline && shouldDisableConfirmButtonWhenOffline} /> {shouldShowCancelButton && !shouldReverseStackedButtons && ( diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 19703f7a3c922..59e7b78feedac 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -348,6 +348,9 @@ type MenuItemBaseProps = { /** Should break word for room title */ shouldBreakWord?: boolean; + + /** Pressable component Test ID. Used to locate the component in tests. */ + pressableTestID?: string; }; type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; @@ -461,6 +464,7 @@ function MenuItem( onHideTooltip, shouldIconUseAutoWidthStyle = false, shouldBreakWord = false, + pressableTestID, }: MenuItemProps, ref: PressableRef, ) { @@ -610,6 +614,7 @@ function MenuItem( wrapperStyle={outerWrapperStyle} activeOpacity={variables.pressDimValue} opacityAnimationDuration={0} + testID={pressableTestID} style={({pressed}) => [ containerStyle, diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 7432c683e0a7e..b8dc71aef515a 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -130,6 +130,9 @@ type PopoverMenuProps = Partial & { /** Should we apply padding style in modal itself. If this value is false, we will handle it in ScreenWrapper */ shouldUseModalPaddingStyle?: boolean; + + /** Used to locate the component in the tests */ + testID?: string; }; const renderWithConditionalWrapper = (shouldUseScrollView: boolean, contentContainerStyle: StyleProp, children: ReactNode): React.JSX.Element => { @@ -174,6 +177,7 @@ function PopoverMenu({ shouldUseScrollView = false, shouldUpdateFocusedIndex = true, shouldUseModalPaddingStyle, + testID, }: PopoverMenuProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -261,6 +265,7 @@ function PopoverMenu({ selectItem(menuIndex)} focused={focusedIndex === menuIndex} @@ -357,6 +362,7 @@ function PopoverMenu({ restoreFocusType={restoreFocusType} innerContainerStyle={innerContainerStyle} shouldUseModalPaddingStyle={shouldUseModalPaddingStyle} + testID={testID} > diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx index 8b27ee8a20f8f..7c11a55a7b7f3 100644 --- a/src/components/SelectionList/TableListItem.tsx +++ b/src/components/SelectionList/TableListItem.tsx @@ -89,6 +89,7 @@ function TableListItem({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={isDisabled || item.isDisabledCheckbox} onPress={handleCheckboxPress} + testID={`TableListItemCheckbox-${item.text}`} style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3, item.cursorStyle]} > diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 737fbc2972c11..c1bf65affc79e 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -72,7 +72,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const [deleteCategoriesConfirmModalVisible, setDeleteCategoriesConfirmModalVisible] = useState(false); const isFocused = useIsFocused(); const {environmentURL} = useEnvironment(); - const policyId = route.params.policyID ?? '-1'; + const policyId = route.params.policyID; const backTo = route.params?.backTo; const policy = usePolicy(policyId); const {selectionMode} = useMobileSelectionMode(); @@ -105,22 +105,28 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { setSelectedCategories({}); }, [isFocused]); - const categoryList = useMemo( - () => - (lodashSortBy(Object.values(policyCategories ?? {}), 'name', localeCompare) as PolicyCategory[]).map((value) => { - const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - return { - text: value.name, - keyForList: value.name, - isSelected: !!selectedCategories[value.name] && canSelectMultiple, - isDisabled, - pendingAction: value.pendingAction, - errors: value.errors ?? undefined, - rightElement: , - }; - }), - [policyCategories, selectedCategories, canSelectMultiple, translate], - ); + const categoryList = useMemo(() => { + const categories = lodashSortBy(Object.values(policyCategories ?? {}), 'name', localeCompare) as PolicyCategory[]; + return categories.reduce((acc, value) => { + const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + if (!isOffline && isDisabled) { + return acc; + } + + acc.push({ + text: value.name, + keyForList: value.name, + isSelected: !!selectedCategories[value.name] && canSelectMultiple, + isDisabled, + pendingAction: value.pendingAction, + errors: value.errors ?? undefined, + rightElement: , + }); + + return acc; + }, []); + }, [policyCategories, isOffline, selectedCategories, canSelectMultiple, translate]); useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryList); @@ -248,6 +254,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { isSplitButton={false} style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} isDisabled={!selectedCategoriesArray.length} + testID={`${WorkspaceCategoriesPage.displayName}-header-dropdown-menu-button`} /> ); } diff --git a/tests/ui/WorkspaceCategoriesTest.tsx b/tests/ui/WorkspaceCategoriesTest.tsx new file mode 100644 index 0000000000000..eca2f803f70e1 --- /dev/null +++ b/tests/ui/WorkspaceCategoriesTest.tsx @@ -0,0 +1,174 @@ +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxProvider from '@components/OnyxProvider'; +import {CurrentReportIDContextProvider} from '@components/withCurrentReportID'; +import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import * as Localize from '@libs/Localize'; +import createResponsiveStackNavigator from '@navigation/AppNavigator/createResponsiveStackNavigator'; +import type {FullScreenNavigatorParamList} from '@navigation/types'; +import WorkspaceCategoriesPage from '@pages/workspace/categories/WorkspaceCategoriesPage'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +TestHelper.setupGlobalFetchMock(); + +const RootStack = createResponsiveStackNavigator(); + +const renderPage = (initialRouteName: typeof SCREENS.WORKSPACE.CATEGORIES, initialParams: FullScreenNavigatorParamList[typeof SCREENS.WORKSPACE.CATEGORIES]) => { + return render( + + + + + + + + + , + ); +}; + +describe('WorkspaceCategories', () => { + const FIRST_CATEGORY = 'categoryOne'; + const SECOND_CATEGORY = 'categoryTwo'; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue({ + isSmallScreenWidth: false, + shouldUseNarrowLayout: false, + } as ResponsiveLayoutResult); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('should delete categories through UI interactions', async () => { + await TestHelper.signInWithTestUser(); + + const policy = { + ...LHNTestUtils.getFakePolicy(), + role: CONST.POLICY.ROLE.ADMIN, + areCategoriesEnabled: true, + }; + + const categories = { + [FIRST_CATEGORY]: { + name: FIRST_CATEGORY, + enabled: true, + }, + [SECOND_CATEGORY]: { + name: SECOND_CATEGORY, + enabled: true, + }, + }; + + // Initialize categories + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`, categories); + }); + + const {unmount} = renderPage(SCREENS.WORKSPACE.CATEGORIES, {policyID: policy.id}); + + await waitForBatchedUpdatesWithAct(); + + // Wait for initial render and verify categories are visible + await waitFor(() => { + expect(screen.getByText(FIRST_CATEGORY)).toBeOnTheScreen(); + }); + await waitFor(() => { + expect(screen.getByText(SECOND_CATEGORY)).toBeOnTheScreen(); + }); + + // Select categories to delete by clicking their checkboxes + fireEvent.press(screen.getByTestId(`TableListItemCheckbox-${FIRST_CATEGORY}`)); + fireEvent.press(screen.getByTestId(`TableListItemCheckbox-${SECOND_CATEGORY}`)); + + const dropdownMenuButtonTestID = `${WorkspaceCategoriesPage.displayName}-header-dropdown-menu-button`; + + // Wait for selection mode to be active and click the dropdown menu button + await waitFor(() => { + expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen(); + }); + + // Click the "2 selected" button to open the menu + const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID); + fireEvent.press(dropdownButton); + + await waitForBatchedUpdatesWithAct(); + + // Wait for menu items to be visible + await waitFor(() => { + const deleteText = Localize.translateLocal('workspace.categories.deleteCategories'); + expect(screen.getByText(deleteText)).toBeOnTheScreen(); + }); + + // Find and verify "Delete categories" dropdown menu item + const deleteMenuItem = screen.getByTestId('PopoverMenuItem-Delete categories'); + expect(deleteMenuItem).toBeOnTheScreen(); + + // Create a mock event object that matches GestureResponderEvent. Needed for onPress in MenuItem to be called + const mockEvent = { + nativeEvent: {}, + type: 'press', + target: deleteMenuItem, + currentTarget: deleteMenuItem, + }; + fireEvent.press(deleteMenuItem, mockEvent); + + await waitForBatchedUpdatesWithAct(); + + // After clicking delete categories dropdown menu item, verify the confirmation modal appears + await waitFor(() => { + const confirmModalPrompt = Localize.translateLocal('workspace.categories.deleteCategoriesPrompt'); + expect(screen.getByText(confirmModalPrompt)).toBeOnTheScreen(); + }); + + // Verify the delete button in the modal is visible + await waitFor(() => { + const deleteConfirmButton = screen.getByLabelText(Localize.translateLocal('common.delete')); + expect(deleteConfirmButton).toBeOnTheScreen(); + }); + + // Click the delete button in the confirmation modal + const deleteConfirmButton = screen.getByLabelText(Localize.translateLocal('common.delete')); + fireEvent.press(deleteConfirmButton); + + await waitForBatchedUpdatesWithAct(); + + // Verify the categories are deleted from the UI + await waitFor(() => { + expect(screen.queryByText(FIRST_CATEGORY)).not.toBeOnTheScreen(); + }); + await waitFor(() => { + expect(screen.queryByText(SECOND_CATEGORY)).not.toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); +});