diff --git a/src/CONST.ts b/src/CONST.ts index 9254044ee99e..bd3ea4177e3e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5764,6 +5764,7 @@ const CONST = { ICON_HEIGHT: 160, CATEGORIES_ARTICLE_LINK: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-categories#import-custom-categories', + MEMBERS_ARTICLE_LINK: 'https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles#import-a-group-of-members', TAGS_ARTICLE_LINK: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#import-a-spreadsheet-1', }, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 95dc53f979a0..146d35611a72 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -728,6 +728,14 @@ const ROUTES = { route: 'settings/workspaces/:policyID/members', getRoute: (policyID: string) => `settings/workspaces/${policyID}/members` as const, }, + WORKSPACE_MEMBERS_IMPORT: { + route: 'settings/workspaces/:policyID/members/import', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/members/import` as const, + }, + WORKSPACE_MEMBERS_IMPORTED: { + route: 'settings/workspaces/:policyID/members/imported', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/members/imported` as const, + }, POLICY_ACCOUNTING: { route: 'settings/workspaces/:policyID/accounting', getRoute: (policyID: string, newConnectionName?: ConnectionName, integrationToDisconnect?: ConnectionName, shouldDisconnectIntegrationBeforeConnecting?: boolean) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 23c9794fa914..496978677870 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -408,6 +408,8 @@ const SCREENS = { INVOICES_COMPANY_WEBSITE: 'Workspace_Invoices_Company_Website', TRAVEL: 'Workspace_Travel', MEMBERS: 'Workspace_Members', + MEMBERS_IMPORT: 'Members_Import', + MEMBERS_IMPORTED: 'Members_Imported', INVITE: 'Workspace_Invite', INVITE_MESSAGE: 'Workspace_Invite_Message', CATEGORIES: 'Workspace_Categories', diff --git a/src/components/ImportSpreadsheetColumns.tsx b/src/components/ImportSpreadsheetColumns.tsx index 95a6ea4516ac..9ba0597bd3d6 100644 --- a/src/components/ImportSpreadsheetColumns.tsx +++ b/src/components/ImportSpreadsheetColumns.tsx @@ -40,7 +40,7 @@ type ImportSpreadsheetColumnsProps = { learnMoreLink?: string; }; -function ImportSpreeadsheetColumns({spreadsheetColumns, columnNames, columnRoles, errors, importFunction, isButtonLoading, learnMoreLink}: ImportSpreadsheetColumnsProps) { +function ImportSpreadsheetColumns({spreadsheetColumns, columnNames, columnRoles, errors, importFunction, isButtonLoading, learnMoreLink}: ImportSpreadsheetColumnsProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -101,6 +101,6 @@ function ImportSpreeadsheetColumns({spreadsheetColumns, columnNames, columnRoles ); } -ImportSpreeadsheetColumns.displayName = 'ImportSpreeadsheetColumns'; +ImportSpreadsheetColumns.displayName = 'ImportSpreadsheetColumns'; -export default ImportSpreeadsheetColumns; +export default ImportSpreadsheetColumns; diff --git a/src/languages/en.ts b/src/languages/en.ts index bd5ff405529e..eae87b70ecca 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -687,6 +687,7 @@ export default { singleFieldMultipleColumns: (fieldName: string) => `Oops! You've mapped a single field ("${fieldName}") to multiple columns. Please review and try again.`, importSuccessfullTitle: 'Import successful', importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `${categories} categories have been added.` : '1 category has been added.'), + importMembersSuccessfullDescription: (members: number) => (members > 1 ? `${members} members have been added.` : '1 member has been added.'), importTagsSuccessfullDescription: (tags: number) => (tags > 1 ? `${tags} tags have been added.` : '1 tag has been added.'), importFailedTitle: 'Import failed', importFailedDescription: 'Please ensure all fields are filled out correctly and try again. If the problem persists, please reach out to Concierge.', @@ -3269,6 +3270,7 @@ export default { addedWithPrimary: 'Some members were added with their primary logins.', invitedBySecondaryLogin: ({secondaryLogin}) => `Added by secondary login ${secondaryLogin}.`, membersListTitle: 'Directory of all workspace members.', + importMembers: 'Import members', }, card: { header: 'Unlock free Expensify Cards', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2f11de46faed..d94db88f2720 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -681,6 +681,7 @@ export default { importFailedTitle: 'Fallo en la importación', importFailedDescription: 'Por favor, asegúrate de que todos los campos estén llenos correctamente e inténtalo de nuevo. Si el problema persiste, por favor contacta a Concierge.', importCategoriesSuccessfullDescription: (categories: number) => (categories > 1 ? `Se han agregado ${categories} categorías.` : 'Se ha agregado 1 categoría.'), + importMembersSuccessfullDescription: (members: number) => (members > 1 ? `Se han agregado ${members} miembros.` : 'Se ha agregado 1 miembro.'), importTagsSuccessfullDescription: (tags: number) => (tags > 1 ? `Se han agregado ${tags} etiquetas.` : 'Se ha agregado 1 etiqueta.'), importSuccessfullTitle: 'Importar categorías', importDescription: 'Elige qué campos mapear desde tu hoja de cálculo haciendo clic en el menú desplegable junto a cada columna importada a continuación.', @@ -3318,6 +3319,7 @@ export default { addedWithPrimary: 'Se agregaron algunos miembros con sus nombres de usuario principales.', invitedBySecondaryLogin: ({secondaryLogin}) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, membersListTitle: 'Directorio de todos los miembros del espacio de trabajo.', + importMembers: 'Importar miembros', }, accounting: { settings: 'configuración', diff --git a/src/libs/API/parameters/ExportMembersSpreadsheetParams.ts b/src/libs/API/parameters/ExportMembersSpreadsheetParams.ts new file mode 100644 index 000000000000..04a114cddaf5 --- /dev/null +++ b/src/libs/API/parameters/ExportMembersSpreadsheetParams.ts @@ -0,0 +1,6 @@ +type ExportMembersSpreadsheetParams = { + /** ID of the policy */ + policyID: string; +}; + +export default ExportMembersSpreadsheetParams; diff --git a/src/libs/API/parameters/ImportMembersSpreadsheet.ts b/src/libs/API/parameters/ImportMembersSpreadsheet.ts new file mode 100644 index 000000000000..166e43c0510f --- /dev/null +++ b/src/libs/API/parameters/ImportMembersSpreadsheet.ts @@ -0,0 +1,10 @@ +type ImportMembersSpreadsheetParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{email: string, role: string}> + */ + employees: string; +}; + +export default ImportMembersSpreadsheetParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 3e4251628394..871a2a1da2ba 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -312,6 +312,8 @@ export type {default as SetPolicyCategoryReceiptsRequiredParams} from './SetPoli export type {default as RemovePolicyCategoryReceiptsRequiredParams} from './RemovePolicyCategoryReceiptsRequiredParams'; export type {default as UpdateQuickbooksOnlineAutoCreateVendorParams} from './UpdateQuickbooksOnlineAutoCreateVendorParams'; export type {default as ImportCategoriesSpreadsheetParams} from './ImportCategoriesSpreadsheet'; +export type {default as ImportMembersSpreadsheetParams} from './ImportMembersSpreadsheet'; +export type {default as ExportMembersSpreadsheetParams} from './ExportCategoriesSpreadsheet'; export type {default as ImportTagsSpreadsheetParams} from './ImportTagsSpreadsheet'; export type {default as ExportCategoriesSpreadsheetParams} from './ExportCategoriesSpreadsheet'; export type {default as ExportTagsSpreadsheetParams} from './ExportTagsSpreadsheet'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index fb847e4059b8..37bdf6b81d6e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -142,9 +142,11 @@ const WRITE_COMMANDS = { SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', SET_POLICY_TAGS_ENABLED: 'SetPolicyTagsEnabled', CREATE_WORKSPACE_CATEGORIES: 'CreateWorkspaceCategories', - IMPORT_TAGS_SREADSHEET: 'ImportTagsSpreadsheet', IMPORT_CATEGORIES_SPREADSHEET: 'ImportCategoriesSpreadsheet', + IMPORT_MEMBERS_SPREADSHEET: 'ImportMembersSpreadsheet', + IMPORT_TAGS_SPREADSHEET: 'ImportTagsSpreadsheet', EXPORT_CATEGORIES_CSV: 'ExportCategoriesCSV', + EXPORT_MEMBERS_CSV: 'ExportMembersCSV', EXPORT_TAGS_CSV: 'ExportTagsCSV', RENAME_WORKSPACE_CATEGORY: 'RenameWorkspaceCategory', CREATE_POLICY_TAG: 'CreatePolicyTag', @@ -530,9 +532,11 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT]: Parameters.CreateWorkspaceFromIOUPaymentParams; [WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED]: Parameters.SetWorkspaceCategoriesEnabledParams; [WRITE_COMMANDS.CREATE_WORKSPACE_CATEGORIES]: Parameters.CreateWorkspaceCategoriesParams; - [WRITE_COMMANDS.IMPORT_TAGS_SREADSHEET]: Parameters.ImportTagsSpreadsheetParams; [WRITE_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET]: Parameters.ImportCategoriesSpreadsheetParams; + [WRITE_COMMANDS.IMPORT_MEMBERS_SPREADSHEET]: Parameters.ImportMembersSpreadsheetParams; + [WRITE_COMMANDS.IMPORT_TAGS_SPREADSHEET]: Parameters.ImportTagsSpreadsheetParams; [WRITE_COMMANDS.EXPORT_CATEGORIES_CSV]: Parameters.ExportCategoriesSpreadsheetParams; + [WRITE_COMMANDS.EXPORT_MEMBERS_CSV]: Parameters.ExportMembersSpreadsheetParams; [WRITE_COMMANDS.EXPORT_TAGS_CSV]: Parameters.ExportTagsSpreadsheetParams; [WRITE_COMMANDS.RENAME_WORKSPACE_CATEGORY]: Parameters.RenameWorkspaceCategoriesParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index e0c9a36fe963..8632234d0f7a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -225,6 +225,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default, [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default, [SCREENS.WORKSPACE.INVITE]: () => require('../../../../pages/workspace/WorkspaceInvitePage').default, + [SCREENS.WORKSPACE.MEMBERS_IMPORT]: () => require('../../../../pages/workspace/members/ImportMembersPage').default, + [SCREENS.WORKSPACE.MEMBERS_IMPORTED]: () => require('../../../../pages/workspace/members/ImportedMembersPage').default, [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_NEW]: () => require('../../../../pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage').default, [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EDIT]: () => require('../../../../pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage').default, [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EXPENSES_FROM]: () => diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index ef8685878aaf..3d97b7be2db7 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -12,6 +12,8 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.OWNER_CHANGE_SUCCESS, SCREENS.WORKSPACE.OWNER_CHANGE_ERROR, SCREENS.WORKSPACE.OWNER_CHANGE_ERROR, + SCREENS.WORKSPACE.MEMBERS_IMPORT, + SCREENS.WORKSPACE.MEMBERS_IMPORTED, ], [SCREENS.WORKSPACE.WORKFLOWS]: [ SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_NEW, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 473d5928f3c0..09ed50a57395 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -569,6 +569,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.INVITE]: { path: ROUTES.WORKSPACE_INVITE.route, }, + [SCREENS.WORKSPACE.MEMBERS_IMPORT]: { + path: ROUTES.WORKSPACE_MEMBERS_IMPORT.route, + }, + [SCREENS.WORKSPACE.MEMBERS_IMPORTED]: { + path: ROUTES.WORKSPACE_MEMBERS_IMPORTED.route, + }, [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_NEW]: { path: ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 7882d90c44a8..a1fa710428df 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -194,6 +194,12 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.INVITE]: { policyID: string; }; + [SCREENS.WORKSPACE.MEMBERS_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.MEMBERS_IMPORTED]: { + policyID: string; + }; [SCREENS.WORKSPACE.INVITE_MESSAGE]: { policyID: string; }; diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 146562990ca0..eae625388f33 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -10,8 +10,12 @@ import type { UpdateWorkspaceMembersRoleParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ApiUtils from '@libs/ApiUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; +import fileDownload from '@libs/fileDownload'; +import {translateLocal} from '@libs/Localize'; import Log from '@libs/Log'; +import enhanceParameters from '@libs/Network/enhanceParameters'; import Parser from '@libs/Parser'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; @@ -23,6 +27,7 @@ import type {InvitedEmailsToAccountIDs, PersonalDetailsList, Policy, PolicyEmplo import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {JoinWorkspaceResolution} from '@src/types/onyx/OriginalMessage'; import type {Attributes, Rate} from '@src/types/onyx/Policy'; +import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {createPolicyExpenseChats} from './Policy'; @@ -167,6 +172,36 @@ function buildAnnounceRoomMembersOnyxData(policyID: string, accountIDs: number[] }); return announceRoomMembers; } +/** + * Updates the import spreadsheet data according to the result of the import + */ +function updateImportSpreadsheetData(membersLength: number): OnyxData { + const onyxData: OnyxData = { + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: {title: translateLocal('spreadsheet.importSuccessfullTitle'), prompt: translateLocal('spreadsheet.importMembersSuccessfullDescription', membersLength)}, + }, + }, + ], + + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IMPORTED_SPREADSHEET, + value: { + shouldFinalModalBeOpened: true, + importFinalModal: {title: translateLocal('spreadsheet.importFailedTitle'), prompt: translateLocal('spreadsheet.importFailedDescription')}, + }, + }, + ], + }; + + return onyxData; +} /** * Build optimistic data for removing users from the announcement room @@ -640,6 +675,22 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount API.write(WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE, params, {optimisticData, successData, failureData}); } +type PolicyMember = { + email: string; + role: string; +}; + +function importPolicyMembers(policyID: string, members: PolicyMember[]) { + const onyxData = updateImportSpreadsheetData(members.length); + + const parameters = { + policyID, + employees: JSON.stringify(members.map((member) => ({email: member.email, role: member.role}))), + }; + + API.write(WRITE_COMMANDS.IMPORT_MEMBERS_SPREADSHEET, parameters, onyxData); +} + /** * Invite member to the specified policyID * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details @@ -836,6 +887,21 @@ function declineJoinRequest(reportID: string, reportAction: OnyxEntry { + formData.append(key, String(value)); + }); + + fileDownload(ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_MEMBERS_CSV}), fileName, '', false, formData, CONST.NETWORK.METHOD.POST); +} + export { removeMembers, updateWorkspaceMembersRole, @@ -850,6 +916,8 @@ export { acceptJoinRequest, declineJoinRequest, isApprover, + importPolicyMembers, + downloadMembersCSV, }; export type {NewCustomUnit}; diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts index 88430b941838..d6f67e496b92 100644 --- a/src/libs/actions/Policy/Tag.ts +++ b/src/libs/actions/Policy/Tag.ts @@ -230,7 +230,7 @@ function importPolicyTags(policyID: string, tags: PolicyTag[]) { tags: JSON.stringify(tags.map((tag) => ({name: tag.name, enabled: tag.enabled, 'GL Code': tag['GL Code']}))), }; - API.write(WRITE_COMMANDS.IMPORT_TAGS_SREADSHEET, parameters, onyxData); + API.write(WRITE_COMMANDS.IMPORT_TAGS_SPREADSHEET, parameters, onyxData); } function setWorkspaceTagEnabled(policyID: string, tagsToUpdate: Record, tagListIndex: number) { diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index 9de547509762..5cee6f58d1de 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -28,6 +28,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; @@ -37,6 +38,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; +import * as Modal from '@userActions/Modal'; import * as Member from '@userActions/Policy/Member'; import * as Policy from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; @@ -70,10 +72,12 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false); const [errors, setErrors] = useState({}); const {isOffline} = useNetwork(); + const {windowWidth} = useWindowDimensions(); const prevIsOffline = usePrevious(isOffline); const accountIDs = useMemo(() => Object.values(policyMemberEmailsToAccountIDs ?? {}).map((accountID) => Number(accountID)), [policyMemberEmailsToAccountIDs]); const prevAccountIDs = usePrevious(accountIDs); const textInputRef = useRef(null); + const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline; const prevPersonalDetails = usePrevious(personalDetails); const {translate, formatPhoneNumber, preferredLocale} = useLocalize(); @@ -531,7 +535,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson return null; } return ( - + {(shouldUseNarrowLayout ? canSelectMultiple : selectedEmployees.length > 0) ? ( shouldAlwaysShowDropdownMenu @@ -558,6 +562,35 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson ); }; + const threeDotsMenuItems = useMemo(() => { + const menuItems = [ + { + icon: Expensicons.Table, + text: translate('spreadsheet.importSpreadsheet'), + onSelected: () => { + if (isOffline) { + Modal.close(() => setIsOfflineModalVisible(true)); + return; + } + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS_IMPORT.getRoute(policyID)); + }, + }, + { + icon: Expensicons.Download, + text: translate('spreadsheet.downloadCSV'), + onSelected: () => { + if (isOffline) { + Modal.close(() => setIsOfflineModalVisible(true)); + return; + } + Member.downloadMembersCSV(policyID); + }, + }, + ]; + + return menuItems; + }, [policyID, translate, isOffline]); + const selectionModeHeader = selectionMode?.isEnabled && shouldUseNarrowLayout; return ( @@ -570,6 +603,9 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson testID={WorkspaceMembersPage.displayName} shouldShowLoading={false} shouldShowOfflineIndicatorInWideScreen + shouldShowThreeDotsButton + threeDotsMenuItems={threeDotsMenuItems} + threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} shouldShowNonAdmin onBackButtonPress={() => { if (selectionMode?.isEnabled) { @@ -583,6 +619,15 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson {() => ( <> {shouldUseNarrowLayout && {getHeaderButtons()}} + setIsOfflineModalVisible(false)} + title={translate('common.youAppearToBeOffline')} + prompt={translate('common.thisFeatureRequiresInternet')} + confirmText={translate('common.buttonConfirm')} + shouldShowCancelButton={false} + /> + ; - - /** User Data from Onyx */ - user: OnyxEntry; -}; - type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & - WorkspacePageWithSectionsOnyxProps & { + Pick & { shouldSkipVBBACall?: boolean; /** The text to display in the header */ @@ -60,9 +52,6 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & /** Option to show the loading page while the API is calling */ shouldShowLoading?: boolean; - /** Should show the back button. It is used when in RHP. */ - shouldShowBackButton?: boolean; - /** Whether the offline indicator should be shown in wide screen devices */ shouldShowOfflineIndicatorInWideScreen?: boolean; @@ -96,9 +85,6 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & /** Whether the page is loading, example any other API call in progres */ isLoading?: boolean; - - /** Callback to be called when the back button is pressed */ - onBackButtonPress?: () => void; }; function fetchData(policyID: string, skipVBBACal?: boolean) { @@ -118,13 +104,11 @@ function WorkspacePageWithSections({ headerText, policy, policyDraft, - reimbursementAccount = CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA, route, shouldUseScrollView = false, showLoadingAsFirstRender = true, shouldSkipVBBACall = true, shouldShowBackButton = false, - user, shouldShowLoading = true, shouldShowOfflineIndicatorInWideScreen = false, includeSafeAreaPaddingBottom = false, @@ -134,11 +118,17 @@ function WorkspacePageWithSections({ shouldShowNotFoundPage = false, isLoading: isPageLoading = false, onBackButtonPress, + shouldShowThreeDotsButton, + threeDotsMenuItems, + threeDotsAnchorPosition, }: WorkspacePageWithSectionsProps) { const styles = useThemeStyles(); const policyID = route.params?.policyID ?? '-1'; useNetwork({onReconnect: () => fetchData(policyID, shouldSkipVBBACall)}); + const [user] = useOnyx(ONYXKEYS.USER); + const [reimbursementAccount = CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isLoading = (reimbursementAccount?.isLoading || isPageLoading) ?? true; const achState = reimbursementAccount?.achData?.state ?? '-1'; @@ -149,7 +139,6 @@ function WorkspacePageWithSections({ const firstRender = useRef(showLoadingAsFirstRender); const isFocused = useIsFocused(); const prevPolicy = usePrevious(policy); - useEffect(() => { // Because isLoading is false before merging in Onyx, we need firstRender ref to display loading page as well before isLoading is change to true firstRender.current = false; @@ -197,6 +186,9 @@ function WorkspacePageWithSections({ shouldShowBackButton={shouldUseNarrowLayout || shouldShowBackButton} icon={icon ?? undefined} style={styles.headerBarDesktopHeight} + shouldShowThreeDotsButton={shouldShowThreeDotsButton} + threeDotsMenuItems={threeDotsMenuItems} + threeDotsAnchorPosition={threeDotsAnchorPosition} > {headerContent} @@ -224,14 +216,4 @@ function WorkspacePageWithSections({ WorkspacePageWithSections.displayName = 'WorkspacePageWithSections'; -export default withPolicyAndFullscreenLoading( - withOnyx({ - user: { - key: ONYXKEYS.USER, - }, - // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, - })(WorkspacePageWithSections), -); +export default withPolicyAndFullscreenLoading(WorkspacePageWithSections); diff --git a/src/pages/workspace/members/ImportMembersPage.tsx b/src/pages/workspace/members/ImportMembersPage.tsx new file mode 100644 index 000000000000..6fff3085b472 --- /dev/null +++ b/src/pages/workspace/members/ImportMembersPage.tsx @@ -0,0 +1,21 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import ImportSpreedsheet from '@components/ImportSpreadsheet'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type ImportMembersPageProps = StackScreenProps; + +function ImportMembersPage({route}: ImportMembersPageProps) { + const policyID = route.params.policyID; + + return ( + + ); +} + +export default ImportMembersPage; diff --git a/src/pages/workspace/members/ImportedMembersPage.tsx b/src/pages/workspace/members/ImportedMembersPage.tsx new file mode 100644 index 000000000000..5f8f38de0b83 --- /dev/null +++ b/src/pages/workspace/members/ImportedMembersPage.tsx @@ -0,0 +1,140 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {ColumnRole} from '@components/ImportColumn'; +import ImportSpreadsheetColumns from '@components/ImportSpreadsheetColumns'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import {closeImportPage} from '@libs/actions/ImportSpreadsheet'; +import {importPolicyMembers} from '@libs/actions/Policy/Member'; +import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type ImportedMembersPageProps = StackScreenProps; + +function ImportedMembersPage({route}: ImportedMembersPageProps) { + const {translate} = useLocalize(); + const [spreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); + const [isImporting, setIsImporting] = useState(false); + const [isValidationEnabled, setIsValidationEnabled] = useState(false); + const policyID = route.params.policyID; + const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); + const {containsHeader = true} = spreadsheet ?? {}; + + const columnRoles: ColumnRole[] = [ + {text: translate('common.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, + {text: translate('common.email'), value: CONST.CSV_IMPORT_COLUMNS.EMAIL, isRequired: true}, + {text: translate('common.role'), value: CONST.CSV_IMPORT_COLUMNS.ROLE}, + ]; + + const requiredColumns = columnRoles.filter((role) => role.isRequired).map((role) => role); + + // checks if all required columns are mapped and no column is mapped more than once + // returns found errors or empty object if both conditions are met + const validate = useCallback(() => { + const columns = Object.values(spreadsheet?.columns ?? {}); + let errors: Record = {}; + + if (!requiredColumns.every((requiredColumn) => columns.includes(requiredColumn.value))) { + // eslint-disable-next-line rulesdir/prefer-early-return + requiredColumns.forEach((requiredColumn) => { + if (!columns.includes(requiredColumn.value)) { + errors.required = translate('spreadsheet.fieldNotMapped', requiredColumn.text); + } + }); + } else { + const duplicate = findDuplicate(columns); + if (duplicate) { + errors.duplicates = translate('spreadsheet.singleFieldMultipleColumns', duplicate); + } else { + errors = {}; + } + } + + return errors; + }, [requiredColumns, spreadsheet?.columns, translate]); + + const importMembers = useCallback(() => { + setIsValidationEnabled(true); + + const errors = validate(); + if (Object.keys(errors).length > 0) { + return; + } + + const columns = Object.values(spreadsheet?.columns ?? {}); + const membersEmailsColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.EMAIL); + const membersRolesColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.ROLE); + const membersEmails = spreadsheet?.data[membersEmailsColumn].map((email) => email); + const membersRoles = membersRolesColumn !== -1 ? spreadsheet?.data[membersRolesColumn].map((role) => role) : []; + const members = membersEmails?.slice(containsHeader ? 1 : 0).map((email, index) => { + let role: string = CONST.POLICY.ROLE.USER; + if (membersRolesColumn !== -1 && membersRoles?.[containsHeader ? index + 1 : index]) { + role = membersRoles?.[containsHeader ? index + 1 : index]; + } + + return { + email, + role, + }; + }); + + if (members) { + setIsImporting(true); + importPolicyMembers(policyID, members); + } + }, [validate, spreadsheet, containsHeader, policyID]); + + const spreadsheetColumns = spreadsheet?.data; + if (!spreadsheetColumns) { + return; + } + + const closeImportPageAndModal = () => { + setIsImporting(false); + closeImportPage(); + Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)); + }; + + return ( + + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS_IMPORT.getRoute(policyID))} + /> + + + + + ); +} + +ImportedMembersPage.displayName = 'ImportedMembersPage'; + +export default ImportedMembersPage;