From 7cceffcd2d1aab27d60089dc9258705df50bfa99 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Thu, 14 May 2026 08:51:15 +0000 Subject: [PATCH 01/27] Add tests for DynamicWorkspaceInvitePage keyboard dismiss behavior Co-authored-by: Eskalifer1 --- tests/ui/DynamicWorkspaceInvitePageTest.tsx | 220 ++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 tests/ui/DynamicWorkspaceInvitePageTest.tsx diff --git a/tests/ui/DynamicWorkspaceInvitePageTest.tsx b/tests/ui/DynamicWorkspaceInvitePageTest.tsx new file mode 100644 index 000000000000..e4ea725bb96e --- /dev/null +++ b/tests/ui/DynamicWorkspaceInvitePageTest.tsx @@ -0,0 +1,220 @@ +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 {Keyboard} from 'react-native'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import * as useSearchSelectorModule from '@hooks/useSearchSelector'; +import Navigation from '@libs/Navigation/Navigation'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import {getEmptyOptions} from '@libs/OptionsListUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import DynamicWorkspaceInvitePage from '@pages/workspace/DynamicWorkspaceInvitePage'; +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'; + +jest.mock('@src/components/ConfirmedRoute.tsx'); +jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); +jest.mock('@components/withNavigationTransitionEnd', () => (Component: React.ComponentType) => Component); + +TestHelper.setupGlobalFetchMock(); + +const Stack = createPlatformStackNavigator(); + +const renderPage = (policyID: string) => { + return render( + + + + + + + + + , + ); +}; + +describe('DynamicWorkspaceInvitePage', () => { + const ownerAccountID = 1; + const ownerEmail = 'owner@example.com'; + const selfAccountID = 1206; + const selfEmail = 'self@example.com'; + const inviteeEmail = 'invitee@example.com'; + const inviteeAccountID = 9999; + + const policy = { + ...LHNTestUtils.getFakePolicy(), + role: CONST.POLICY.ROLE.ADMIN, + owner: ownerEmail, + ownerAccountID, + employeeList: { + [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN}, + [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.ADMIN}, + }, + }; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(async () => { + await TestHelper.signInWithTestUser(selfAccountID, selfEmail, undefined, 'Self'); + await act(async () => { + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [ownerAccountID]: TestHelper.buildPersonalDetails(ownerEmail, ownerAccountID, 'Owner'), + [selfAccountID]: TestHelper.buildPersonalDetails(selfEmail, selfAccountID, 'Self'), + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + }); + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue({ + isSmallScreenWidth: false, + shouldUseNarrowLayout: false, + } as ResponsiveLayoutResult); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('should dismiss keyboard before navigating when inviting users', async () => { + const keyboardDismissSpy = jest.spyOn(Keyboard, 'dismiss'); + const navigateSpy = jest.spyOn(Navigation, 'navigate'); + + // Mock useSearchSelector to return a selected option (simulating a user having selected an invitee) + jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ + searchTerm: '', + debouncedSearchTerm: '', + setSearchTerm: jest.fn(), + searchOptions: getEmptyOptions(), + availableOptions: getEmptyOptions(), + selectedOptions: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], + selectedOptionsForDisplay: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], + setSelectedOptions: jest.fn(), + toggleSelection: jest.fn(), + areOptionsInitialized: true, + onListEndReached: jest.fn(), + }); + + const {unmount} = renderPage(policy.id); + await waitForBatchedUpdatesWithAct(); + + // Press the "Next" button to trigger inviteUser + await waitFor(() => { + expect(screen.getByText('Next')).toBeOnTheScreen(); + }); + + fireEvent.press(screen.getByText('Next')); + await waitForBatchedUpdatesWithAct(); + + // Verify Keyboard.dismiss was called + expect(keyboardDismissSpy).toHaveBeenCalled(); + + // Verify navigation happened after keyboard dismiss + expect(navigateSpy).toHaveBeenCalled(); + + // Verify keyboard was dismissed before navigation + const keyboardDismissOrder = keyboardDismissSpy.mock.invocationCallOrder.at(0); + const navigateOrder = navigateSpy.mock.invocationCallOrder.at(0); + expect(keyboardDismissOrder).toBeDefined(); + expect(navigateOrder).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(keyboardDismissOrder!).toBeLessThan(navigateOrder!); + + unmount(); + keyboardDismissSpy.mockRestore(); + navigateSpy.mockRestore(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should disable the Next button when no users are selected', async () => { + // Mock useSearchSelector to return no selected options + jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ + searchTerm: '', + debouncedSearchTerm: '', + setSearchTerm: jest.fn(), + searchOptions: getEmptyOptions(), + availableOptions: getEmptyOptions(), + selectedOptions: [], + selectedOptionsForDisplay: [], + setSelectedOptions: jest.fn(), + toggleSelection: jest.fn(), + areOptionsInitialized: true, + onListEndReached: jest.fn(), + }); + + const {unmount} = renderPage(policy.id); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText('Next')).toBeOnTheScreen(); + }); + + // The Next button should be disabled when no users are selected + const nextButton = screen.getByText('Next'); + expect(nextButton).toBeOnTheScreen(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should save invited emails to account IDs draft when inviting users', async () => { + // Mock useSearchSelector with a selected option + jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ + searchTerm: '', + debouncedSearchTerm: '', + setSearchTerm: jest.fn(), + searchOptions: getEmptyOptions(), + availableOptions: getEmptyOptions(), + selectedOptions: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], + selectedOptionsForDisplay: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], + setSelectedOptions: jest.fn(), + toggleSelection: jest.fn(), + areOptionsInitialized: true, + onListEndReached: jest.fn(), + }); + + const {unmount} = renderPage(policy.id); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText('Next')).toBeOnTheScreen(); + }); + + fireEvent.press(screen.getByText('Next')); + await waitForBatchedUpdatesWithAct(); + + // Verify the draft was saved with the correct email-to-accountID mapping + const draftKey = `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policy.id}`; + const connection = Onyx.connect({ + key: draftKey, + callback: (value) => { + expect(value).toEqual({[inviteeEmail]: inviteeAccountID}); + Onyx.disconnect(connection); + }, + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); +}); From a2d9f1188ce6bec57437a65663e13b6801616c63 Mon Sep 17 00:00:00 2001 From: "Eskalifer1 (via MelvinBot)" Date: Thu, 14 May 2026 08:56:01 +0000 Subject: [PATCH 02/27] Remove DynamicWorkspaceInvitePage unit test file Co-authored-by: Eskalifer1 --- tests/ui/DynamicWorkspaceInvitePageTest.tsx | 220 -------------------- 1 file changed, 220 deletions(-) delete mode 100644 tests/ui/DynamicWorkspaceInvitePageTest.tsx diff --git a/tests/ui/DynamicWorkspaceInvitePageTest.tsx b/tests/ui/DynamicWorkspaceInvitePageTest.tsx deleted file mode 100644 index e4ea725bb96e..000000000000 --- a/tests/ui/DynamicWorkspaceInvitePageTest.tsx +++ /dev/null @@ -1,220 +0,0 @@ -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 {Keyboard} from 'react-native'; -import Onyx from 'react-native-onyx'; -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; -import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; -import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; -import * as useSearchSelectorModule from '@hooks/useSearchSelector'; -import Navigation from '@libs/Navigation/Navigation'; -import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; -import {getEmptyOptions} from '@libs/OptionsListUtils'; -import type {SettingsNavigatorParamList} from '@navigation/types'; -import DynamicWorkspaceInvitePage from '@pages/workspace/DynamicWorkspaceInvitePage'; -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'; - -jest.mock('@src/components/ConfirmedRoute.tsx'); -jest.mock('@pages/workspace/withPolicyAndFullscreenLoading', () => (Component: React.ComponentType) => Component); -jest.mock('@components/withNavigationTransitionEnd', () => (Component: React.ComponentType) => Component); - -TestHelper.setupGlobalFetchMock(); - -const Stack = createPlatformStackNavigator(); - -const renderPage = (policyID: string) => { - return render( - - - - - - - - - , - ); -}; - -describe('DynamicWorkspaceInvitePage', () => { - const ownerAccountID = 1; - const ownerEmail = 'owner@example.com'; - const selfAccountID = 1206; - const selfEmail = 'self@example.com'; - const inviteeEmail = 'invitee@example.com'; - const inviteeAccountID = 9999; - - const policy = { - ...LHNTestUtils.getFakePolicy(), - role: CONST.POLICY.ROLE.ADMIN, - owner: ownerEmail, - ownerAccountID, - employeeList: { - [ownerEmail]: {email: ownerEmail, role: CONST.POLICY.ROLE.ADMIN}, - [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.ADMIN}, - }, - }; - - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - }); - }); - - beforeEach(async () => { - await TestHelper.signInWithTestUser(selfAccountID, selfEmail, undefined, 'Self'); - await act(async () => { - await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.EN); - await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, { - [ownerAccountID]: TestHelper.buildPersonalDetails(ownerEmail, ownerAccountID, 'Owner'), - [selfAccountID]: TestHelper.buildPersonalDetails(selfEmail, selfAccountID, 'Self'), - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); - }); - jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue({ - isSmallScreenWidth: false, - shouldUseNarrowLayout: false, - } as ResponsiveLayoutResult); - }); - - afterEach(async () => { - await act(async () => { - await Onyx.clear(); - }); - jest.clearAllMocks(); - }); - - it('should dismiss keyboard before navigating when inviting users', async () => { - const keyboardDismissSpy = jest.spyOn(Keyboard, 'dismiss'); - const navigateSpy = jest.spyOn(Navigation, 'navigate'); - - // Mock useSearchSelector to return a selected option (simulating a user having selected an invitee) - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions(), - availableOptions: getEmptyOptions(), - selectedOptions: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], - selectedOptionsForDisplay: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - - const {unmount} = renderPage(policy.id); - await waitForBatchedUpdatesWithAct(); - - // Press the "Next" button to trigger inviteUser - await waitFor(() => { - expect(screen.getByText('Next')).toBeOnTheScreen(); - }); - - fireEvent.press(screen.getByText('Next')); - await waitForBatchedUpdatesWithAct(); - - // Verify Keyboard.dismiss was called - expect(keyboardDismissSpy).toHaveBeenCalled(); - - // Verify navigation happened after keyboard dismiss - expect(navigateSpy).toHaveBeenCalled(); - - // Verify keyboard was dismissed before navigation - const keyboardDismissOrder = keyboardDismissSpy.mock.invocationCallOrder.at(0); - const navigateOrder = navigateSpy.mock.invocationCallOrder.at(0); - expect(keyboardDismissOrder).toBeDefined(); - expect(navigateOrder).toBeDefined(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - expect(keyboardDismissOrder!).toBeLessThan(navigateOrder!); - - unmount(); - keyboardDismissSpy.mockRestore(); - navigateSpy.mockRestore(); - await waitForBatchedUpdatesWithAct(); - }); - - it('should disable the Next button when no users are selected', async () => { - // Mock useSearchSelector to return no selected options - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions(), - availableOptions: getEmptyOptions(), - selectedOptions: [], - selectedOptionsForDisplay: [], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - - const {unmount} = renderPage(policy.id); - await waitForBatchedUpdatesWithAct(); - - await waitFor(() => { - expect(screen.getByText('Next')).toBeOnTheScreen(); - }); - - // The Next button should be disabled when no users are selected - const nextButton = screen.getByText('Next'); - expect(nextButton).toBeOnTheScreen(); - - unmount(); - await waitForBatchedUpdatesWithAct(); - }); - - it('should save invited emails to account IDs draft when inviting users', async () => { - // Mock useSearchSelector with a selected option - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions(), - availableOptions: getEmptyOptions(), - selectedOptions: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], - selectedOptionsForDisplay: [{login: inviteeEmail, accountID: inviteeAccountID, selected: true, text: inviteeEmail}], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - - const {unmount} = renderPage(policy.id); - await waitForBatchedUpdatesWithAct(); - - await waitFor(() => { - expect(screen.getByText('Next')).toBeOnTheScreen(); - }); - - fireEvent.press(screen.getByText('Next')); - await waitForBatchedUpdatesWithAct(); - - // Verify the draft was saved with the correct email-to-accountID mapping - const draftKey = `${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policy.id}`; - const connection = Onyx.connect({ - key: draftKey, - callback: (value) => { - expect(value).toEqual({[inviteeEmail]: inviteeAccountID}); - Onyx.disconnect(connection); - }, - }); - - unmount(); - await waitForBatchedUpdatesWithAct(); - }); -}); From aaf7a7e94a22d95be11656bfa8b6e5113bdfd426 Mon Sep 17 00:00:00 2001 From: Nicolas Bonet Date: Tue, 12 May 2026 15:49:51 -0500 Subject: [PATCH 03/27] feat: Add avatar editing functionality for agents, including new routes, screens, and API parameters --- src/CONST/index.ts | 3 + src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + src/languages/en.ts | 4 + src/languages/es.ts | 4 + .../API/parameters/UpdateAgentAvatarParams.ts | 9 + src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 1 + src/libs/Navigation/linkingConfig/config.ts | 6 + src/libs/Navigation/types.ts | 3 + src/libs/actions/Agent.ts | 80 ++++++ src/pages/settings/Agents/EditAgentPage.tsx | 35 ++- .../Agents/Fields/EditAgentAvatarPage.tsx | 240 ++++++++++++++++++ src/types/onyx/AgentPrompt.ts | 3 + 15 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 src/libs/API/parameters/UpdateAgentAvatarParams.ts create mode 100644 src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e6da51e293d4..99292ed33231 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9693,6 +9693,9 @@ const CONST = { ADD_AGENT_PAGE: { AVATAR: 'AddAgentPage-Avatar', }, + EDIT_AGENT_PAGE: { + AVATAR: 'EditAgentPage-Avatar', + }, SETTINGS_PROFILE: { AVATAR: 'SettingsProfile-Avatar', DISPLAY_NAME: 'SettingsProfile-DisplayName', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index e532b5ff834c..aa125ab73e8a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1075,6 +1075,10 @@ const ROUTES = { route: 'settings/agents/:accountID/edit/prompt', getRoute: (accountID: number) => `settings/agents/${accountID}/edit/prompt` as const, }, + SETTINGS_AGENTS_EDIT_AVATAR: { + route: 'settings/agents/:accountID/edit/avatar', + getRoute: (accountID: number) => `settings/agents/${accountID}/edit/avatar` as const, + }, SETTINGS_RULES: 'settings/rules', SETTINGS_RULES_ADD: { route: 'settings/rules/new/:field?/:index?', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index bcb641f0304f..e47e4545bb8a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -183,6 +183,7 @@ const SCREENS = { EDIT: 'Settings_Agents_Edit', EDIT_NAME: 'Settings_Agents_Edit_Name', EDIT_PROMPT: 'Settings_Agents_Edit_Prompt', + EDIT_AVATAR: 'Settings_Agents_Edit_Avatar', }, RULES: { diff --git a/src/languages/en.ts b/src/languages/en.ts index a405193a355f..4f0f9e7c978d 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2804,6 +2804,7 @@ const translations = { genericUpdate: 'There was a problem updating this agent', updateName: "There was a problem updating this agent's name", updatePrompt: "There was a problem updating this agent's instructions", + updateAvatar: "There was a problem updating this agent's avatar", }, }, addAgentPage: { @@ -2824,6 +2825,9 @@ const translations = { deleteAgentTitle: 'Delete agent?', deleteAgentMessage: 'Are you sure you want to delete this agent? This action cannot be undone.', }, + editAgentAvatarPage: { + title: 'Edit avatar', + }, editAgentNamePage: { title: 'Agent name', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index 7c06d26e6e86..bc655604bc4e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2633,6 +2633,7 @@ ${amount} para ${merchant} - ${date}`, genericUpdate: 'Hubo un problema al actualizar este agente', updateName: 'Hubo un problema al actualizar el nombre de este agente', updatePrompt: 'Hubo un problema al actualizar las instrucciones de este agente', + updateAvatar: 'Hubo un problema al actualizar el avatar de este agente', }, }, addAgentPage: { @@ -2653,6 +2654,9 @@ ${amount} para ${merchant} - ${date}`, deleteAgentTitle: '¿Eliminar agente?', deleteAgentMessage: '¿Estás seguro de que quieres eliminar este agente? Esta acción no se puede deshacer.', }, + editAgentAvatarPage: { + title: 'Editar avatar', + }, editAgentNamePage: { title: 'Nombre del agente', }, diff --git a/src/libs/API/parameters/UpdateAgentAvatarParams.ts b/src/libs/API/parameters/UpdateAgentAvatarParams.ts new file mode 100644 index 000000000000..d4674ee19677 --- /dev/null +++ b/src/libs/API/parameters/UpdateAgentAvatarParams.ts @@ -0,0 +1,9 @@ +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; + +type UpdateAgentAvatarParams = { + agentAccountID: number; + file?: File | CustomRNImageManipulatorResult; + customExpensifyAvatarID?: string; +}; + +export default UpdateAgentAvatarParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index ed17e828c3b4..be3820f68cc8 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -537,4 +537,5 @@ export type {default as CreateAgentParams} from './CreateAgentParams'; export type {default as CreateDomainSecurityGroupParams} from './CreateDomainSecurityGroupParams'; export type {default as UpdateAgentNameParams} from './UpdateAgentNameParams'; export type {default as UpdateAgentPromptParams} from './UpdateAgentPromptParams'; +export type {default as UpdateAgentAvatarParams} from './UpdateAgentAvatarParams'; export type {default as DeleteAgentParams} from './DeleteAgentParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index b458161c8677..1f8343f71381 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -609,6 +609,7 @@ const WRITE_COMMANDS = { CREATE_DOMAIN_SECURITY_GROUP: 'CreateDomainSecurityGroup', UPDATE_AGENT_NAME: 'UpdateAgentName', UPDATE_AGENT_PROMPT: 'UpdateAgentPrompt', + UPDATE_AGENT_AVATAR: 'UpdateAgentAvatar', DELETE_AGENT: 'DeleteAgent', } as const; @@ -1238,6 +1239,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_AGENT]: Parameters.CreateAgentParams; [WRITE_COMMANDS.UPDATE_AGENT_NAME]: Parameters.UpdateAgentNameParams; [WRITE_COMMANDS.UPDATE_AGENT_PROMPT]: Parameters.UpdateAgentPromptParams; + [WRITE_COMMANDS.UPDATE_AGENT_AVATAR]: Parameters.UpdateAgentAvatarParams; [WRITE_COMMANDS.DELETE_AGENT]: Parameters.DeleteAgentParams; }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index d5654e452f44..6e1df3917d32 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -484,6 +484,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Agents/EditAgentPage').default, [SCREENS.SETTINGS.AGENTS.EDIT_NAME]: () => require('../../../../pages/settings/Agents/Fields/EditNamePage').default, [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: () => require('../../../../pages/settings/Agents/Fields/EditPromptPage').default, + [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: () => require('../../../../pages/settings/Agents/Fields/EditAgentAvatarPage').default, [SCREENS.SETTINGS.RULES.ADD]: () => require('../../../../pages/settings/Rules/AddRulePage').default, [SCREENS.SETTINGS.RULES.ADD_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddMerchantPage').default, [SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: () => require('../../../../pages/settings/Rules/Fields/AddRenameMerchantPage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b7361ae4cbc4..bcf785250e94 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -400,6 +400,12 @@ const config: LinkingOptions['config'] = { accountID: Number, }, }, + [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { + path: ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.route, + parse: { + accountID: Number, + }, + }, [SCREENS.SETTINGS.RULES.ADD]: { path: ROUTES.SETTINGS_RULES_ADD.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index c260d9a24446..25eb18b1aa54 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -257,6 +257,9 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.AGENTS.EDIT_PROMPT]: { accountID: number; }; + [SCREENS.SETTINGS.AGENTS.EDIT_AVATAR]: { + accountID: number; + }; [SCREENS.SETTINGS.RULES.ADD]: undefined; [SCREENS.SETTINGS.RULES.ADD_MERCHANT]: undefined; [SCREENS.SETTINGS.RULES.ADD_RENAME_MERCHANT]: undefined; diff --git a/src/libs/actions/Agent.ts b/src/libs/actions/Agent.ts index c21686e19fc2..16d66f21aede 100644 --- a/src/libs/actions/Agent.ts +++ b/src/libs/actions/Agent.ts @@ -1,8 +1,10 @@ import Onyx from 'react-native-onyx'; import {read, write} from '@libs/API'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -162,6 +164,82 @@ function updateAgentPrompt(accountID: number, prompt: string, originalPrompt: st write(WRITE_COMMANDS.UPDATE_AGENT_PROMPT, {agentAccountID: accountID, prompt}, {optimisticData, successData, failureData}); } +function clearAgentAvatarUpdateError(accountID: number) { + Onyx.merge(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, {avatarErrors: null}); +} + +function updateAgentAvatar( + accountID: number, + update: {customExpensifyAvatarID: string} | {file: File | CustomRNImageManipulatorResult; uri: string}, + currentAvatar: AvatarSource | undefined, +) { + const isCustomExpensifyAvatar = 'customExpensifyAvatarID' in update; + + const optimisticData: AnyOnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + ...(!isCustomExpensifyAvatar && { + avatar: update.uri, + avatarThumbnail: update.uri, + }), + pendingFields: {avatar: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {avatar: null}, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + value: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, errors: null, avatarErrors: null}, + }, + ]; + + const successData: AnyOnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + pendingFields: {avatar: null}, + errorFields: {avatar: null}, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + value: {pendingAction: null, avatarErrors: null}, + }, + ]; + + const failureData: AnyOnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: { + [accountID]: { + avatar: currentAvatar, + avatarThumbnail: typeof currentAvatar === 'string' ? currentAvatar : undefined, + pendingFields: {avatar: null}, + errorFields: {avatar: null}, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`, + value: {pendingAction: null, avatarErrors: getMicroSecondOnyxErrorWithTranslationKey('agentsPage.error.updateAvatar')}, + }, + ]; + + const params = isCustomExpensifyAvatar ? {agentAccountID: accountID, customExpensifyAvatarID: update.customExpensifyAvatarID} : {agentAccountID: accountID, file: update.file}; + + write(WRITE_COMMANDS.UPDATE_AGENT_AVATAR, params, {optimisticData, successData, failureData}); +} + function deleteAgent(accountID: number) { const optimisticData: AnyOnyxUpdate[] = [ { @@ -206,8 +284,10 @@ export { clearAgentUpdateError, clearAgentNameUpdateError, clearAgentPromptUpdateError, + clearAgentAvatarUpdateError, clearAgentDeleteError, updateAgentName, updateAgentPrompt, + updateAgentAvatar, deleteAgent, }; diff --git a/src/pages/settings/Agents/EditAgentPage.tsx b/src/pages/settings/Agents/EditAgentPage.tsx index 20c3813c8044..c0c734478141 100644 --- a/src/pages/settings/Agents/EditAgentPage.tsx +++ b/src/pages/settings/Agents/EditAgentPage.tsx @@ -1,24 +1,22 @@ import React from 'react'; import {View} from 'react-native'; +import AvatarButtonWithIcon from '@components/AvatarButtonWithIcon'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ReportActionAvatars from '@components/ReportActionAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import {clearAgentNameUpdateError, clearAgentPromptUpdateError, deleteAgent} from '@libs/actions/Agent'; +import {clearAgentAvatarUpdateError, clearAgentNameUpdateError, clearAgentPromptUpdateError, deleteAgent} from '@libs/actions/Agent'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -33,10 +31,10 @@ function EditAgentPage({route}: EditAgentPageProps) { const accountID = route.params.accountID; const [agent] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_AGENT_PROMPT}${accountID}`); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (list) => list?.[accountID]}); - const StyleUtils = useStyleUtils(); const {showConfirmModal} = useConfirmModal(); const handleBackPress = () => Navigation.goBack(); + const handleEditAvatarPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_AVATAR.getRoute(accountID)); const handleEditNamePress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_NAME.getRoute(accountID)); const handleEditPromptPress = () => Navigation.navigate(ROUTES.SETTINGS_AGENTS_EDIT_PROMPT.getRoute(accountID)); const handleDeletePress = async () => { @@ -64,14 +62,25 @@ function EditAgentPage({route}: EditAgentPageProps) { onBackButtonPress={handleBackPress} /> - - - + clearAgentAvatarUpdateError(accountID)} + > + + + + ; + +type ImageData = { + uri: string; + name: string; + type: string; + file: File | CustomRNImageManipulatorResult | null; +}; + +const EMPTY_IMAGE_DATA: ImageData = {uri: '', name: '', type: '', file: null}; + +function EditAgentAvatarPage({route}: EditAgentAvatarPageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const icons = useMemoizedLazyExpensifyIcons(['Upload']); + const accountID = route.params.accountID; + + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (list) => list?.[accountID]}); + + const [selectedBotAvatar, setSelectedBotAvatar] = useState(null); + const [imageData, setImageData] = useState(EMPTY_IMAGE_DATA); + const [cropImageData, setCropImageData] = useState(EMPTY_IMAGE_DATA); + const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false); + const [errorData, setErrorData] = useState<{validationError: TranslationPaths | null; phraseParam: Record}>({validationError: null, phraseParam: {}}); + + const isSavingRef = useRef(false); + const isDirty = selectedBotAvatar !== null || imageData.uri !== ''; + + useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => !isSavingRef.current && isDirty, + }); + + let previewSource: AvatarSource = personalDetails?.avatar ?? ''; + if (selectedBotAvatar) { + previewSource = selectedBotAvatar; + } else if (imageData.uri) { + previewSource = imageData.uri; + } + + const showAvatarCropModal = (image: FileObject) => { + validateAvatarImage(image) + .then((result) => { + if (!result.isValid) { + setErrorData({validationError: result.errorKey ?? null, phraseParam: result.errorParams ?? {}}); + return; + } + setIsAvatarCropModalOpen(true); + setErrorData({validationError: null, phraseParam: {}}); + setCropImageData({ + uri: image.uri ?? '', + name: image.name ?? '', + type: image.type ?? '', + file: null, + }); + }) + .catch(() => { + setErrorData({validationError: 'attachmentPicker.errorWhileSelectingCorruptedAttachment', phraseParam: {}}); + }); + }; + + const onImageSelected = (file: File | CustomRNImageManipulatorResult) => { + setSelectedBotAvatar(null); + setImageData({ + uri: file?.uri ?? '', + name: file?.name ?? '', + file, + type: '', + }); + setIsAvatarCropModalOpen(false); + }; + + const handleSave = () => { + if (!isDirty) { + return; + } + isSavingRef.current = true; + + if (imageData.file) { + updateAgentAvatar(accountID, {file: imageData.file, uri: imageData.uri}, personalDetails?.avatar); + Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)); + return; + } + + if (selectedBotAvatar) { + const customExpensifyAvatarID = botAvatarIDs.get(selectedBotAvatar); + if (customExpensifyAvatarID) { + updateAgentAvatar(accountID, {customExpensifyAvatarID}, personalDetails?.avatar); + Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID)); + } + } + }; + + return ( + + Navigation.goBack(ROUTES.SETTINGS_AGENTS_EDIT.getRoute(accountID))} + /> + + + + + {({openPicker}) => ( +