diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index da7f172888a3..6eae7c8e2890 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -598,6 +598,7 @@ function BaseSelectionList({ shouldShowSelectAllButton={!!onSelectAll} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} selectAllAccessibilityLabel={selectAllAccessibilityLabel} + selectionButtonPosition={selectionButtonPosition} /> ); diff --git a/src/components/SelectionList/components/ListHeader.tsx b/src/components/SelectionList/components/ListHeader.tsx index 28bccb89b75a..2db93ca9d597 100644 --- a/src/components/SelectionList/components/ListHeader.tsx +++ b/src/components/SelectionList/components/ListHeader.tsx @@ -1,6 +1,7 @@ import React from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; import Checkbox from '@components/Checkbox'; import {PressableWithFeedback} from '@components/Pressable'; import type {DataDetailsType, ListItem} from '@components/SelectionList/types'; @@ -36,6 +37,9 @@ type ListHeaderProps = { /** Custom accessibility label for the select all checkbox, providing context about what is being selected */ selectAllAccessibilityLabel?: string; + + /** Side on which the select-all checkbox should be rendered, to align with the per-row checkboxes */ + selectionButtonPosition?: ValueOf; }; function ListHeader({ @@ -48,6 +52,7 @@ function ListHeader({ shouldShowSelectAllButton, shouldPreventDefaultFocusOnSelectRow, selectAllAccessibilityLabel, + selectionButtonPosition = CONST.SELECTION_BUTTON_POSITION.LEFT, }: ListHeaderProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -65,35 +70,51 @@ function ListHeader({ e.preventDefault(); }; + const checkbox = ( + + ); + + const label = !customListHeader && ( + + {translate('workspace.people.selectAll')} + + ); + + const isCheckboxOnRight = selectionButtonPosition === CONST.SELECTION_BUTTON_POSITION.RIGHT; + return ( - - - - {!customListHeader && ( - - {translate('workspace.people.selectAll')} - + + {isCheckboxOnRight ? ( + <> + {label} + {checkbox} + + ) : ( + <> + {checkbox} + {label} + )} {customListHeader} diff --git a/src/languages/de.ts b/src/languages/de.ts index c77a6ba1bd60..a8ee4f988a93 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6014,7 +6014,13 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU `Sie sind dabei, ${newWorkspaceName ?? ''} mit ${totalMembers ?? 0} Mitgliedern aus dem ursprünglichen Workspace zu erstellen und zu teilen.`, error: 'Beim Duplizieren deines neuen Workspace ist ein Fehler aufgetreten. Bitte versuche es erneut.', }, - copyPolicySettings: {error: 'Beim Kopieren der Arbeitsbereichseinstellungen ist ein Fehler aufgetreten. Bitte versuche es erneut.'}, + copyPolicySettings: { + error: 'Beim Kopieren der Arbeitsbereichseinstellungen ist ein Fehler aufgetreten. Bitte versuche es erneut.', + title: 'Einstellungen kopieren', + selectWorkspaces: 'Arbeitsbereiche auswählen', + description: 'Wählen Sie die Arbeitsbereiche aus, in die Sie Einstellungen kopieren möchten, und wählen Sie dann die Einstellungen aus, die Sie kopieren möchten.', + searchPlaceholder: 'Workspaces durchsuchen', + }, emptyWorkspace: { title: 'Du hast keine Arbeitsbereiche', subtitle: 'Belege erfassen, Auslagen erstatten, Reisen verwalten, Rechnungen versenden und mehr.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 23d4be8a5b88..b1bebf9abc8c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6036,6 +6036,10 @@ const translations = { }, copyPolicySettings: { error: 'An error occurred while copying workspace settings. Please try again.', + title: 'Copy settings', + selectWorkspaces: 'Select workspaces', + description: 'Choose the workspaces you want to copy settings to, then select the settings you’d like to copy.', + searchPlaceholder: 'Search workspaces', }, emptyWorkspace: { title: 'No workspaces yet', diff --git a/src/languages/es.ts b/src/languages/es.ts index cfcfe238925c..2f2405f82216 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5851,6 +5851,10 @@ ${amount} para ${merchant} - ${date}`, }, copyPolicySettings: { error: 'Se produjo un error al copiar la configuración del espacio de trabajo. Por favor, inténtalo de nuevo.', + title: 'Copiar configuración', + selectWorkspaces: 'Selecciona espacios de trabajo', + description: 'Elige los espacios de trabajo a los que quieres copiar la configuración y luego selecciona los ajustes que quieras copiar.', + searchPlaceholder: 'Buscar espacios de trabajo', }, emptyWorkspace: { title: 'Aún no hay espacios de trabajo', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f217296b269d..768722f4d86f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6034,7 +6034,13 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. `Vous êtes sur le point de créer et de partager ${newWorkspaceName ?? ''} avec ${totalMembers ?? 0} membres de l’espace de travail d’origine.`, error: 'Une erreur s’est produite lors de la duplication de votre nouvel espace de travail. Veuillez réessayer.', }, - copyPolicySettings: {error: 'Une erreur s’est produite lors de la copie des paramètres de l’espace de travail. Veuillez réessayer.'}, + copyPolicySettings: { + error: 'Une erreur s’est produite lors de la copie des paramètres de l’espace de travail. Veuillez réessayer.', + title: 'Copier les paramètres', + selectWorkspaces: 'Sélectionner des espaces de travail', + description: 'Choisissez les espaces de travail vers lesquels vous souhaitez copier les paramètres, puis sélectionnez les paramètres que vous souhaitez copier.', + searchPlaceholder: 'Rechercher des espaces de travail', + }, emptyWorkspace: { title: 'Vous n’avez aucun espace de travail', subtitle: 'Suivez les reçus, remboursez les dépenses, gérez les voyages, envoyez des factures, et plus encore.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 04714349e740..80dc979f6182 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6004,7 +6004,13 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. `Stai per creare e condividere ${newWorkspaceName ?? ''} con ${totalMembers ?? 0} membri dello spazio di lavoro originale.`, error: 'Si è verificato un errore durante la duplicazione del tuo nuovo workspace. Riprova.', }, - copyPolicySettings: {error: 'Si è verificato un errore durante la copia delle impostazioni dello spazio di lavoro. Riprova.'}, + copyPolicySettings: { + error: 'Si è verificato un errore durante la copia delle impostazioni dello spazio di lavoro. Riprova.', + title: 'Copia impostazioni', + selectWorkspaces: 'Seleziona gli spazi di lavoro', + description: 'Scegli gli spazi di lavoro a cui vuoi copiare le impostazioni, poi seleziona le impostazioni che desideri copiare.', + searchPlaceholder: 'Cerca spazio di lavoro', + }, emptyWorkspace: { title: 'Non hai nessuna area di lavoro', subtitle: 'Tieni traccia delle ricevute, rimborsa le spese, gestisci i viaggi, invia le fatture e altro ancora.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index c6ee83df7138..488548b9de6b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5936,7 +5936,13 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO `元のワークスペースから ${totalMembers ?? 0} 人のメンバーと一緒に、${newWorkspaceName ?? ''} を作成して共有しようとしています。`, error: '新しいワークスペースの複製中にエラーが発生しました。もう一度お試しください。', }, - copyPolicySettings: {error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。'}, + copyPolicySettings: { + error: 'ワークスペース設定のコピー中にエラーが発生しました。もう一度お試しください。', + title: '設定をコピー', + selectWorkspaces: 'ワークスペースを選択', + description: '設定をコピーしたいワークスペースを選択し、その後、コピーしたい設定を選びます。', + searchPlaceholder: 'ワークスペースを検索', + }, emptyWorkspace: { title: 'ワークスペースがありません', subtitle: '領収書を管理し、経費を精算し、出張を管理し、請求書を送信するなど、さまざまなことができます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 891386fd47b8..4c5dd1ebae79 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5983,7 +5983,13 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ `Je staat op het punt ${newWorkspaceName ?? ''} te maken en te delen met ${totalMembers ?? 0} leden van de oorspronkelijke werkruimte.`, error: 'Er is een fout opgetreden bij het dupliceren van je nieuwe werkruimte. Probeer het opnieuw.', }, - copyPolicySettings: {error: 'Er is een fout opgetreden bij het kopiëren van de werkruimtainstellingen. Probeer het opnieuw.'}, + copyPolicySettings: { + error: 'Er is een fout opgetreden bij het kopiëren van de werkruimtainstellingen. Probeer het opnieuw.', + title: 'Instellingen kopiëren', + selectWorkspaces: 'Selecteer werkruimtes', + description: 'Kies de werkruimtes waarnaar je instellingen wilt kopiëren en selecteer daarna de instellingen die je wilt kopiëren.', + searchPlaceholder: 'Werkruimtes zoeken', + }, emptyWorkspace: { title: 'Je hebt geen werkruimtes', subtitle: 'Volg bonnen, vergoed uitgaven, beheer reizen, verstuur facturen en meer.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 91459e089731..56b4e261871b 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5977,7 +5977,13 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy `Za chwilę utworzysz i udostępnisz ${newWorkspaceName ?? ''} ${totalMembers ?? 0} członkom oryginalnego obszaru roboczego.`, error: 'Wystąpił błąd podczas duplikowania Twojego nowego obszaru roboczego. Spróbuj ponownie.', }, - copyPolicySettings: {error: 'Wystąpił błąd podczas kopiowania ustawień przestrzeni roboczej. Spróbuj ponownie.'}, + copyPolicySettings: { + error: 'Wystąpił błąd podczas kopiowania ustawień przestrzeni roboczej. Spróbuj ponownie.', + title: 'Skopiuj ustawienia', + selectWorkspaces: 'Wybierz przestrzenie robocze', + description: 'Wybierz przestrzenie robocze, do których chcesz skopiować ustawienia, a potem zaznacz ustawienia, które chcesz skopiować.', + searchPlaceholder: 'Szukaj przestrzeni roboczych', + }, emptyWorkspace: { title: 'Nie masz żadnych przestrzeni roboczych', subtitle: 'Śledź paragony, rozliczaj wydatki, zarządzaj podróżami, wysyłaj faktury i wiele więcej.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 535659f23f58..6a203a5c545e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5983,7 +5983,13 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS `Você está prestes to criar e compartilhar ${newWorkspaceName ?? ''} com ${totalMembers ?? 0} membros do workspace original.`, error: 'Ocorreu um erro ao duplicar seu novo espaço de trabalho. Tente novamente.', }, - copyPolicySettings: {error: 'Ocorreu um erro ao copiar as configurações do workspace. Tente novamente.'}, + copyPolicySettings: { + error: 'Ocorreu um erro ao copiar as configurações do workspace. Tente novamente.', + title: 'Copiar configurações', + selectWorkspaces: 'Selecionar espaços de trabalho', + description: 'Escolha os espaços de trabalho para os quais você quer copiar as configurações e, em seguida, selecione quais configurações deseja copiar.', + searchPlaceholder: 'Buscar espaços de trabalho', + }, emptyWorkspace: { title: 'Você não tem nenhum workspace', subtitle: 'Controle recibos, reembolse despesas, gerencie viagens, envie faturas e muito mais.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3c9e8607e50e..8530994ec56f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5831,7 +5831,13 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM `您即将创建并共享 ${newWorkspaceName ?? ''},其中包含来自原始工作区的 ${totalMembers ?? 0} 位成员。`, error: '复制您的新工作区时发生错误。请重试。', }, - copyPolicySettings: {error: '复制工作区设置时发生错误。请重试。'}, + copyPolicySettings: { + error: '复制工作区设置时发生错误。请重试。', + title: '复制设置', + selectWorkspaces: '选择工作区', + description: '选择要复制设置到的工作区,然后选择你想复制的设置。', + searchPlaceholder: '搜索工作区', + }, emptyWorkspace: { title: '你还没有工作区', subtitle: '跟踪收据、报销费用、管理差旅、发送发票等。', diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index f59557695e73..ffec46a50579 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import {FlatList, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -133,7 +133,7 @@ function isUserReimburserForPolicy(policies: Record { + const adminNonPersonal: string[] = []; + const corporateOnly: string[] = []; + if (!policies) { + return {adminNonPersonal, corporateOnly}; + } + for (const policy of Object.values(policies)) { + if (!policy || policy.type === CONST.POLICY.TYPE.PERSONAL || !isPolicyAdmin(policy, session?.email)) { + continue; + } + adminNonPersonal.push(policy.id); + if (policy.type === CONST.POLICY.TYPE.CORPORATE) { + corporateOnly.push(policy.id); + } + } + return {adminNonPersonal, corporateOnly}; + }, [policies, session?.email]); + /** * Gets the menu item for each workspace */ @@ -393,10 +411,21 @@ function WorkspacesListPage() { if (isAdmin) { threeDotsMenuItems.push({ - icon: icons.Copy, + icon: icons.Plus, text: translate('workspace.common.duplicateWorkspace'), onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE.getRoute(item.policyID)) : undefined), }); + const isSourceCorporate = item.type === CONST.POLICY.TYPE.CORPORATE; + const candidates = isSourceCorporate ? copySettingsEligibleTargets.corporateOnly : copySettingsEligibleTargets.adminNonPersonal; + const hasEligibleCopyTarget = candidates.length > 1 || (candidates.length === 1 && candidates.at(0) !== item.policyID); + + if (hasEligibleCopyTarget) { + threeDotsMenuItems.push({ + icon: icons.Copy, + text: translate('workspace.copyPolicySettings.title'), + onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS.getRoute(item.policyID)) : undefined), + }); + } } if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy) { diff --git a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx index 419265f184b1..30f9581367d1 100644 --- a/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx +++ b/src/pages/workspace/copyPolicySettings/CopyPolicySettingsSelectWorkspacesPage.tsx @@ -1,5 +1,201 @@ +import {useRoute} from '@react-navigation/native'; +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import Avatar from '@components/Avatar'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import type {ConfirmButtonOptions, ListItem, TextInputOptions} from '@components/SelectionList/types'; +import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setCopyPolicySettingsData} from '@libs/actions/Policy/CopyPolicySettings'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {PolicyCopySettingsNavigatorParamList} from '@libs/Navigation/types'; +import {isPolicyAdmin} from '@libs/PolicyUtils'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; + +const SEARCH_THRESHOLD = 12; + +type EligiblePolicyItem = { + id: string; + title: string; + avatarURL?: string; +}; + function CopyPolicySettingsSelectWorkspacesPage() { - return null; + const route = useRoute>(); + const sourcePolicyID = route?.params?.policyID; + + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const currentUserEmail = currentUserPersonalDetails?.email; + + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [selectedTargetIDs, setSelectedTargetIDs] = useState([]); + + const sourcePolicy = sourcePolicyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${sourcePolicyID}`] : undefined; + const isSourceCorporate = sourcePolicy?.type === CONST.POLICY.TYPE.CORPORATE; + + const eligiblePolicies = useMemo(() => { + if (!policies) { + return []; + } + return Object.values(policies) + .filter((policy): policy is Policy => { + if (!policy || policy.id === sourcePolicyID) { + return false; + } + if (policy.type === CONST.POLICY.TYPE.PERSONAL) { + return false; + } + if (!isPolicyAdmin(policy, currentUserEmail)) { + return false; + } + // Release 1: when copying from a Corporate workspace, only allow Corporate targets. + // Issue 7 (R2) lifts this restriction by inserting an upgrade step. + if (isSourceCorporate && policy.type !== CONST.POLICY.TYPE.CORPORATE) { + return false; + } + return true; + }) + .map((policy) => ({ + id: policy.id, + title: policy.name, + avatarURL: policy.avatarURL, + })) + .sort((a, b) => localeCompare(a.title, b.title)); + }, [policies, sourcePolicyID, isSourceCorporate, currentUserEmail, localeCompare]); + + const filterPolicy = useCallback((policy: EligiblePolicyItem, query: string) => policy.title.toLowerCase().includes(query.toLowerCase()), []); + const sortPolicies = useCallback((items: EligiblePolicyItem[]) => items, []); + const [searchValue, setSearchValue, filteredPolicies] = useSearchResults(eligiblePolicies, filterPolicy, sortPolicies); + + const shouldShowSearch = eligiblePolicies.length > SEARCH_THRESHOLD; + + const listItems: ListItem[] = useMemo( + () => + filteredPolicies.map((policy) => ({ + text: policy.title, + keyForList: policy.id, + isSelected: selectedTargetIDs.includes(policy.id), + leftElement: ( + + + + ), + })), + [filteredPolicies, selectedTargetIDs, styles.mr3], + ); + + const toggleItem = useCallback((item: ListItem) => { + if (!item.keyForList) { + return; + } + const id = item.keyForList; + setSelectedTargetIDs((prev) => (prev.includes(id) ? prev.filter((selectedID) => selectedID !== id) : [...prev, id])); + }, []); + + // Scope select-all to the currently visible (filtered) rows so its behavior matches + // the header checkbox state that SelectionList derives from filteredPolicies. Selections + // on rows hidden by the active search are preserved across toggles. + const toggleAll = useCallback(() => { + const visibleIDs = filteredPolicies.map((policy) => policy.id); + if (visibleIDs.length === 0) { + return; + } + setSelectedTargetIDs((prev) => { + const areAllVisibleSelected = visibleIDs.every((id) => prev.includes(id)); + if (areAllVisibleSelected) { + const visibleSet = new Set(visibleIDs); + return prev.filter((id) => !visibleSet.has(id)); + } + return Array.from(new Set([...prev, ...visibleIDs])); + }); + }, [filteredPolicies]); + + const onConfirm = useCallback(() => { + if (!sourcePolicyID) { + return; + } + setCopyPolicySettingsData({sourcePolicyID, targetPolicyIDs: selectedTargetIDs}); + Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS_SELECT_FEATURES.getRoute(sourcePolicyID)); + }, [sourcePolicyID, selectedTargetIDs]); + + const confirmButtonOptions: ConfirmButtonOptions = useMemo( + () => ({ + showButton: true, + text: translate('common.next'), + onConfirm, + isDisabled: selectedTargetIDs.length === 0, + }), + [translate, onConfirm, selectedTargetIDs.length], + ); + + const textInputOptions: TextInputOptions = useMemo( + () => ({ + label: translate('workspace.copyPolicySettings.searchPlaceholder'), + value: searchValue, + onChangeText: setSearchValue, + headerMessage: filteredPolicies.length === 0 && searchValue.length > 0 ? translate('common.noResultsFound') : undefined, + }), + [translate, searchValue, setSearchValue, filteredPolicies.length], + ); + + return ( + + + + + {translate('workspace.copyPolicySettings.selectWorkspaces')} + {translate('workspace.copyPolicySettings.description')} + + + 0 ? toggleAll : undefined} + selectionButtonPosition={CONST.SELECTION_BUTTON_POSITION.RIGHT} + shouldSingleExecuteRowSelect + addBottomSafeAreaPadding + confirmButtonOptions={confirmButtonOptions} + shouldShowTextInput={shouldShowSearch} + textInputOptions={shouldShowSearch ? textInputOptions : undefined} + /> + + + + ); } CopyPolicySettingsSelectWorkspacesPage.displayName = 'CopyPolicySettingsSelectWorkspacesPage';