diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 34054147a56b..2fb0bd43c65b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5091,6 +5091,8 @@ const CONST = { STANDARD_LENGTH_LIMIT: 100, STANDARD_LIST_ITEM_LIMIT: 12, SEARCH_BAR_THRESHOLD: 3, + // Number of approval workflow cards rendered before the "Load more" affordance on Workspace → Workflows. + WORKFLOW_APPROVALS_INITIAL_BATCH: 5, LEGAL_NAMES_CHARACTER_LIMIT: 150, LOGIN_CHARACTER_LIMIT: 254, CATEGORY_NAME_LIMIT: 256, @@ -8487,6 +8489,7 @@ const CONST = { WORKFLOWS: { AUTO_REPORTING_FREQUENCY: 'WorkspaceWorkflows-AutoReportingFrequency', ADD_APPROVAL: 'WorkspaceWorkflows-AddApproval', + LOAD_MORE_APPROVALS: 'WorkspaceWorkflows-LoadMoreApprovals', BANK_ACCOUNT: 'WorkspaceWorkflows-BankAccount', ADD_BANK_ACCOUNT: 'WorkspaceWorkflows-AddBankAccount', AUTHORIZED_PAYER: 'WorkspaceWorkflows-AuthorizedPayer', diff --git a/src/languages/de.ts b/src/languages/de.ts index 4d3ea2d5a05f..b5063f4d5e41 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2631,6 +2631,7 @@ ${amount} für ${merchant} – ${date}`, addApprovalsTitle: 'Genehmigungen', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `Ausgaben von ${members} und die genehmigende Person ist ${approvers}`, addApprovalButton: 'Genehmigungsablauf hinzufügen', + loadMoreWorkflows: ({count}: {count: number}) => `${count} weitere laden`, editWorkflowAction: 'Bearbeiten', findWorkflow: 'Workflow suchen', addApprovalTip: 'Dieser Standard-Workflow gilt für alle Mitglieder, sofern kein spezifischerer Workflow vorhanden ist.', diff --git a/src/languages/en.ts b/src/languages/en.ts index ce190738b047..cb7800e7144d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2698,6 +2698,7 @@ const translations = { addApprovalsTitle: 'Approvals', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `expenses from ${members}, and the approver is ${approvers}`, addApprovalButton: 'Add approval workflow', + loadMoreWorkflows: ({count}: {count: number}) => `Load ${count} more`, editWorkflowAction: 'Edit', findWorkflow: 'Find workflow', addApprovalTip: 'This default workflow applies to all members, unless a more specific workflow exists.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 07608053ab22..1ee60bb6f0e8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2501,6 +2501,7 @@ ${amount} para ${merchant} - ${date}`, addApprovalsTitle: 'Aprobaciones', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `gastos de ${members}, y el aprobador es ${approvers}`, addApprovalButton: 'Añadir flujo de aprobación', + loadMoreWorkflows: ({count}: {count: number}) => `Cargar ${count} más`, editWorkflowAction: 'Editar', findWorkflow: 'Buscar flujo de trabajo', addApprovalTip: 'Este flujo de trabajo por defecto se aplica a todos los miembros, a menos que exista un flujo de trabajo más específico.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c3e494354b3d..35a3b0bceaf9 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2637,6 +2637,7 @@ ${amount} pour ${merchant} - ${date}`, addApprovalsTitle: 'Approbations', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `dépenses de ${members}, et l'approbateur est ${approvers}`, addApprovalButton: 'Ajouter un workflow d’approbation', + loadMoreWorkflows: ({count}: {count: number}) => `Charger ${count} de plus`, editWorkflowAction: 'Modifier', findWorkflow: 'Rechercher un flux de travail', addApprovalTip: 'Ce workflow par défaut s’applique à tous les membres, sauf si un workflow plus spécifique existe.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 61253d53f813..79a37f652b49 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2626,6 +2626,7 @@ ${amount} per ${merchant} - ${date}`, addApprovalsTitle: 'Approvazioni', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `spese di ${members} e l'approvatore è ${approvers}`, addApprovalButton: 'Aggiungi flusso di approvazione', + loadMoreWorkflows: ({count}: {count: number}) => `Carica altri ${count}`, editWorkflowAction: 'Modifica', findWorkflow: 'Cerca flusso di lavoro', addApprovalTip: 'Questo flusso di lavoro predefinito si applica a tutti i membri, a meno che non esista un flusso di lavoro più specifico.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 03cc392a81e6..5cd03f1bd30c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2604,6 +2604,7 @@ ${date} の ${merchant} への ${amount}`, addApprovalsTitle: '承認', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `${members} の経費で、承認者は ${approvers} です`, addApprovalButton: '承認ワークフローを追加', + loadMoreWorkflows: ({count}: {count: number}) => `さらに${count}件を読み込む`, editWorkflowAction: '編集', findWorkflow: 'ワークフローを検索', addApprovalTip: 'より詳細なワークフローが存在する場合を除き、このデフォルトのワークフローがすべてのメンバーに適用されます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 115a119c1362..f54146477ac3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2624,6 +2624,7 @@ ${amount} voor ${merchant} - ${date}`, addApprovalsTitle: 'Goedkeuringen', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `de uitgaven van ${members}, en de goedkeurder is ${approvers}`, addApprovalButton: 'Goedkeuringsworkflow toevoegen', + loadMoreWorkflows: ({count}: {count: number}) => `${count} meer laden`, editWorkflowAction: 'Bewerken', findWorkflow: 'Workflow zoeken', addApprovalTip: 'Deze standaardworkflow is van toepassing op alle leden, tenzij er een specifiekere workflow bestaat.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e13535b066ba..80b95cd3b0ea 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2620,6 +2620,7 @@ ${amount} dla ${merchant} - ${date}`, addApprovalsTitle: 'Zatwierdzenia', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `wydatki od ${members}, a zatwierdzającym jest ${approvers}`, addApprovalButton: 'Dodaj proces akceptacji', + loadMoreWorkflows: ({count}: {count: number}) => `Załaduj ${count} więcej`, editWorkflowAction: 'Edytuj', findWorkflow: 'Znajdź przepływ pracy', addApprovalTip: 'Domyślny proces pracy ma zastosowanie do wszystkich członków, chyba że istnieje bardziej szczegółowy proces pracy.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 61d0a368e0b0..3c65046f08d3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2618,6 +2618,7 @@ ${amount} para ${merchant} - ${date}`, addApprovalsTitle: 'Aprovações', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `despesas de ${members}, e o aprovador é ${approvers}`, addApprovalButton: 'Adicionar fluxo de aprovação', + loadMoreWorkflows: ({count}: {count: number}) => `Carregar mais ${count}`, editWorkflowAction: 'Editar', findWorkflow: 'Buscar fluxo de trabalho', addApprovalTip: 'Este fluxo de trabalho padrão se aplica a todos os membros, a menos que exista um fluxo de trabalho mais específico.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f94921af0b7a..a79d3f3ab3d5 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2554,6 +2554,7 @@ ${amount},商户:${merchant} - 日期:${date}`, addApprovalsTitle: '审批', accessibilityLabel: ({members, approvers}: {members: string; approvers: string}) => `来自${members}的报销,审批人是${approvers}`, addApprovalButton: '添加审批工作流', + loadMoreWorkflows: ({count}: {count: number}) => `加载更多 ${count} 个`, editWorkflowAction: '编辑', findWorkflow: '查找工作流', addApprovalTip: '除非存在更具体的工作流程,否则此默认工作流程适用于所有成员。', diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 7993669516d2..ee669f8061e1 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -1,6 +1,6 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {InteractionManager, View} from 'react-native'; import type {TupleToUnion} from 'type-fest'; @@ -13,6 +13,7 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import RenderHTML from '@components/RenderHTML'; import SearchBar from '@components/SearchBar'; import Section from '@components/Section'; @@ -70,6 +71,7 @@ import ExpenseReportRulesSection from '@pages/workspace/rules/ExpenseReportRules import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicy from '@pages/workspace/withPolicy'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import variables from '@styles/variables'; import {pressLockedBankAccount} from '@userActions/BankAccounts'; import {getPaymentMethods} from '@userActions/PaymentMethods'; import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; @@ -79,6 +81,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; +import type IconAsset from '@src/types/utils/IconAsset'; import type {ToggleSettingOptionRowProps} from './ToggleSettingsOptionRow'; import ToggleSettingOptionRow from './ToggleSettingsOptionRow'; import {getAutoReportingFrequencyDisplayNames} from './WorkspaceAutoReportingFrequencyPage'; @@ -107,13 +110,43 @@ function WorkflowNoResultsView({message, shouldShow, searchValue}: {message: str ); } +// Bordered "Load more" card matching the workflow rows: the whole card is the tap target and gets the row-hover state (per #91727 design). +function WorkflowsLoadMoreCard({count, icon, onPress}: {count: number; icon: IconAsset; onPress: () => void}) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const label = translate('workflowsPage.loadMoreWorkflows', {count}); + + return ( + + + + {label} + + + ); +} + function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.workflows'); const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const illustrations = useMemoizedLazyIllustrations(['Workflows']); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Info', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['CircularArrowBackwards', 'DotIndicator', 'Info', 'Plus']); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply a correct padding style // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); @@ -294,6 +327,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { }; const [workflowSearchInput, setWorkflowSearchInput, searchFilteredWorkflows] = useSearchResults(filteredApprovalWorkflows, filterWorkflow); + const [isWorkflowListExpanded, setIsWorkflowListExpanded] = useState(false); useEffect(() => { if (filteredApprovalWorkflows.length > CONST.SEARCH_BAR_THRESHOLD) { @@ -302,6 +336,11 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { setWorkflowSearchInput(''); }, [filteredApprovalWorkflows.length, setWorkflowSearchInput]); + // Searching reveals every match, so pagination is bypassed while a query is active. Pressing "Load more" reveals all remaining workflows at once. + const isSearchingWorkflows = workflowSearchInput.length > 0; + const displayedWorkflows = isWorkflowListExpanded || isSearchingWorkflows ? searchFilteredWorkflows : searchFilteredWorkflows.slice(0, CONST.WORKFLOW_APPROVALS_INITIAL_BATCH); + const hiddenWorkflowsCount = searchFilteredWorkflows.length - displayedWorkflows.length; + const isDEWEnabled = hasDynamicExternalWorkflow(policy); const isHRConnected = isAnyHRConnected(policy); const shouldBlockApprovalWorkflowEditing = isAnyHRReadOnlyWorkflowMode(policy); @@ -473,7 +512,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { shouldShow={searchFilteredWorkflows.length === 0 && workflowSearchInput.length > 0} searchValue={workflowSearchInput} /> - {searchFilteredWorkflows.map((workflow) => { + {displayedWorkflows.map((workflow) => { const firstApproverEmail = workflow.approvers.at(0)?.email ?? ''; return ( @@ -514,6 +553,13 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { ); })} + {hiddenWorkflowsCount > 0 && ( + setIsWorkflowListExpanded(true)} + /> + )} {!shouldBlockApprovalWorkflowEditing && canWriteApprovals && ( { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View: MockView} = require('react-native'); + return { + RenderHTMLConfigProvider: ({children}: {children: React.ReactNode}) => children, + RenderHTMLSource: () => , + }; +}); + +TestHelper.setupGlobalFetchMock(); + +const POLICY_ID = 'workflows-load-more-test'; +const OWNER_EMAIL = 'test@user.com'; +const OWNER_ACCOUNT_ID = 1; +const BATCH = CONST.WORKFLOW_APPROVALS_INITIAL_BATCH; + +const Stack = createPlatformStackNavigator(); + +/** + * Builds an employeeList that yields `customWorkflowCount` custom approval workflows. Each custom workflow is one + * submitter routed to its own approver; the conversion always prepends the default ("Everyone") workflow, so the + * total rendered card count is `customWorkflowCount + 1`. + */ +function buildWorkflowData(customWorkflowCount: number): {employeeList: PolicyEmployeeList; personalDetails: PersonalDetailsList} { + const employeeList: PolicyEmployeeList = { + [OWNER_EMAIL]: {email: OWNER_EMAIL, submitsTo: OWNER_EMAIL, forwardsTo: undefined}, + }; + const personalDetails: PersonalDetailsList = { + [OWNER_ACCOUNT_ID]: TestHelper.buildPersonalDetails(OWNER_EMAIL, OWNER_ACCOUNT_ID, 'Owner'), + }; + + for (let i = 1; i <= customWorkflowCount; i++) { + const approverEmail = `approver${i}@example.com`; + const memberEmail = `member${i}@example.com`; + const approverAccountID = 100 + i; + const memberAccountID = 200 + i; + + // The approver itself doesn't submit anywhere, so it never creates its own workflow — only the submitter does. + employeeList[approverEmail] = {email: approverEmail, submitsTo: undefined, forwardsTo: undefined}; + employeeList[memberEmail] = {email: memberEmail, submitsTo: approverEmail, forwardsTo: undefined}; + personalDetails[approverAccountID] = TestHelper.buildPersonalDetails(approverEmail, approverAccountID, `Approver ${i}`); + personalDetails[memberAccountID] = TestHelper.buildPersonalDetails(memberEmail, memberAccountID, `Member ${i}`); + } + + return {employeeList, personalDetails}; +} + +const buildPolicy = (employeeList: PolicyEmployeeList): Policy => + ({ + ...LHNTestUtils.getFakePolicy(POLICY_ID), + type: CONST.POLICY.TYPE.CORPORATE, + role: CONST.POLICY.ROLE.ADMIN, + owner: OWNER_EMAIL, + approver: OWNER_EMAIL, + outputCurrency: 'USD', + areWorkflowsEnabled: true, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, + employeeList, + }) as Policy; + +const setupPolicy = async (customWorkflowCount: number) => { + const {employeeList, personalDetails} = buildWorkflowData(customWorkflowCount); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy(employeeList)); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails); + }); +}; + +const renderPage = () => + render( + + + + + + + + + + + , + ); + +const loadMoreLabel = (count: number) => TestHelper.translateLocal('workflowsPage.loadMoreWorkflows', {count}); +const countWorkflowCards = () => screen.queryAllByText(TestHelper.translateLocal('workflowsExpensesFromPage.title')).length; + +describe('WorkspaceWorkflowsPage - Approvals Load more', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + }); + const wideLayout: ResponsiveLayoutResult = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: false, + }; + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue(wideLayout); + await TestHelper.signInWithTestUser(OWNER_ACCOUNT_ID, OWNER_EMAIL); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('renders all workflows without a Load more button when there are no more than the initial batch', async () => { + // BATCH - 1 custom workflows + 1 default = BATCH total (exactly the initial batch). + await setupPolicy(BATCH - 1); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(countWorkflowCards()).toBe(BATCH); + expect(screen.queryByRole(CONST.ROLE.BUTTON, {name: /load .* more/i})).not.toBeOnTheScreen(); + }); + + it('shows only the initial batch with a "Load more" button labelled with the full remaining count', async () => { + // 10 custom + 1 default = 11 total, so BATCH are shown and the remaining (11 - BATCH) are hidden. + await setupPolicy(10); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + expect(countWorkflowCards()).toBe(BATCH); + expect(screen.getByText(loadMoreLabel(11 - BATCH))).toBeOnTheScreen(); + }); + + it('reveals all remaining workflows in a single press and hides the button', async () => { + await setupPolicy(10); // 11 total + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Single press reveals every remaining workflow at once. + fireEvent.press(screen.getByRole(CONST.ROLE.BUTTON, {name: loadMoreLabel(11 - BATCH)})); + await waitForBatchedUpdatesWithAct(); + expect(countWorkflowCards()).toBe(11); + expect(screen.queryByRole(CONST.ROLE.BUTTON, {name: /load .* more/i})).not.toBeOnTheScreen(); + }); + + it('shows every match and hides the Load more button while searching (search bypasses pagination)', async () => { + await setupPolicy(10); // 11 total; search bar appears above the SEARCH_BAR_THRESHOLD + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // "Member" matches all 10 custom workflows but not the default "Everyone" workflow. + fireEvent.changeText(screen.getByLabelText(TestHelper.translateLocal('workflowsPage.findWorkflow')), 'Member'); + + await waitFor(() => { + expect(countWorkflowCards()).toBe(10); + }); + expect(screen.queryByRole(CONST.ROLE.BUTTON, {name: /load .* more/i})).not.toBeOnTheScreen(); + }); +});