From 6fd70ef12f9b2cbb643c56d3143a7d1252b374df Mon Sep 17 00:00:00 2001 From: Povilas Zirgulis Date: Mon, 23 Dec 2024 17:32:53 +0200 Subject: [PATCH 1/6] filter deleted categories --- .../categories/WorkspaceCategoriesPage.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 737fbc2972c1..6301f6027aec 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -107,19 +107,21 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { 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], + (lodashSortBy(Object.values(policyCategories ?? {}), 'name', localeCompare) as PolicyCategory[]) + .filter((value) => (isOffline ? value : value.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) + .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, isOffline, selectedCategories, canSelectMultiple, translate], ); useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryList); From 7cfadc0614fa9ab1c7274da8193648825991882e Mon Sep 17 00:00:00 2001 From: Povilas Zirgulis Date: Fri, 27 Dec 2024 12:55:49 +0200 Subject: [PATCH 2/6] fix eslint: remove ID default value --- src/pages/workspace/categories/WorkspaceCategoriesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 6301f6027aec..8099e739fdac 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(); From 98ee0aa81ae0b5b7a84b5dc967e361975c6f5987 Mon Sep 17 00:00:00 2001 From: Povilas Zirgulis Date: Fri, 27 Dec 2024 17:04:57 +0200 Subject: [PATCH 3/6] change array.map + filter to reduce --- .../categories/WorkspaceCategoriesPage.tsx | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 8099e739fdac..09d250addaff 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -105,24 +105,28 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { setSelectedCategories({}); }, [isFocused]); - const categoryList = useMemo( - () => - (lodashSortBy(Object.values(policyCategories ?? {}), 'name', localeCompare) as PolicyCategory[]) - .filter((value) => (isOffline ? value : value.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)) - .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, isOffline, 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); From 390500dfee4f36a5cdaec4b1bc151b372339a7ab Mon Sep 17 00:00:00 2001 From: Povilas Zirgulis Date: Mon, 30 Dec 2024 15:44:27 +0200 Subject: [PATCH 4/6] add WorkspaceCategoriesTest --- .../ButtonWithDropdownMenu/index.tsx | 6 +- .../ButtonWithDropdownMenu/types.ts | 3 + src/components/ConfirmContent.tsx | 1 + src/components/MenuItem.tsx | 5 + src/components/PopoverMenu.tsx | 6 + .../SelectionList/TableListItem.tsx | 1 + .../categories/WorkspaceCategoriesPage.tsx | 1 + tests/ui/WorkspaceCategoriesTest.tsx | 172 ++++++++++++++++++ 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 tests/ui/WorkspaceCategoriesTest.tsx diff --git a/src/components/ButtonWithDropdownMenu/index.tsx b/src/components/ButtonWithDropdownMenu/index.tsx index 7cf752a61214..46c3ad18a635 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 766c0df950b4..dbafbc497105 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 cb0fc6e8e8cb..3bfb5a146d05 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 19703f7a3c92..59e7b78feeda 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 7432c683e0a7..b8dc71aef515 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 8b27ee8a20f8..7c11a55a7b7f 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 09d250addaff..7ec5bfeafe5a 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -254,6 +254,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { isSplitButton={false} style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} isDisabled={!selectedCategoriesArray.length} + testID="WorkspaceCategoriesPage-header-dropdown-menu-button" /> ); } diff --git a/tests/ui/WorkspaceCategoriesTest.tsx b/tests/ui/WorkspaceCategoriesTest.tsx new file mode 100644 index 000000000000..c7e3fa056120 --- /dev/null +++ b/tests/ui/WorkspaceCategoriesTest.tsx @@ -0,0 +1,172 @@ +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}`)); + + // Wait for selection mode to be active and click the dropdown menu button + await waitFor(() => { + expect(screen.getByTestId('WorkspaceCategoriesPage-header-dropdown-menu-button')).toBeOnTheScreen(); + }); + + // Click the "2 selected" button to open the menu + const dropdownButton = screen.getByTestId('WorkspaceCategoriesPage-header-dropdown-menu-button'); + 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(); + }); +}); From 9e476de0310a41722b50713ed09282b917c7f5af Mon Sep 17 00:00:00 2001 From: Povilas Zirgulis Date: Mon, 30 Dec 2024 15:54:37 +0200 Subject: [PATCH 5/6] use displayName in testID --- src/pages/workspace/categories/WorkspaceCategoriesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 7ec5bfeafe5a..c1bf65affc79 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -254,7 +254,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { isSplitButton={false} style={[shouldUseNarrowLayout && styles.flexGrow1, shouldUseNarrowLayout && styles.mb3]} isDisabled={!selectedCategoriesArray.length} - testID="WorkspaceCategoriesPage-header-dropdown-menu-button" + testID={`${WorkspaceCategoriesPage.displayName}-header-dropdown-menu-button`} /> ); } From 14d36e20dc43ca9bcdab4692537f595cb2256550 Mon Sep 17 00:00:00 2001 From: Povilas Zirgulis Date: Mon, 30 Dec 2024 15:57:16 +0200 Subject: [PATCH 6/6] use displayName is test --- tests/ui/WorkspaceCategoriesTest.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/ui/WorkspaceCategoriesTest.tsx b/tests/ui/WorkspaceCategoriesTest.tsx index c7e3fa056120..eca2f803f70e 100644 --- a/tests/ui/WorkspaceCategoriesTest.tsx +++ b/tests/ui/WorkspaceCategoriesTest.tsx @@ -108,13 +108,15 @@ describe('WorkspaceCategories', () => { 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('WorkspaceCategoriesPage-header-dropdown-menu-button')).toBeOnTheScreen(); + expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen(); }); // Click the "2 selected" button to open the menu - const dropdownButton = screen.getByTestId('WorkspaceCategoriesPage-header-dropdown-menu-button'); + const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID); fireEvent.press(dropdownButton); await waitForBatchedUpdatesWithAct();