diff --git a/cspell.json b/cspell.json index 232fb5466088..8fcd179fb07d 100644 --- a/cspell.json +++ b/cspell.json @@ -1081,6 +1081,8 @@ "ukkonen", "unapprove", "unapproves", + "unarchived", + "unarchiving", "unassigning", "unassignment", "unbaselined", diff --git a/src/CONST/index.ts b/src/CONST/index.ts index bde832c72f9f..b905af3f6887 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4530,6 +4530,7 @@ const CONST = { THREE_DOT_MENU_ACTION: { LEAVE: 'leave', TRANSFER_OWNERSHIP: 'transferOwnership', + UNARCHIVE: 'unarchive', }, POLICY_FEATURE: { OVERVIEW: 'overview', diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx index 2f339c25a182..bff0bfab5a42 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -12,8 +12,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearCopyPolicySettings} from '@libs/actions/Policy/CopyPolicySettings'; import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; import Navigation from '@libs/Navigation/Navigation'; +import {canUnarchivePolicy} from '@libs/PolicyUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; +import UnarchiveWorkspaceFlow from '@pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow'; + import {setNameValuePair} from '@userActions/User'; import CONST from '@src/CONST'; @@ -59,7 +62,7 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, onArchiveWorkspace, const isFocused = useIsFocused(); const {translate} = useLocalize(); const {isBetaEnabled} = usePermissions(); - const icons = useMemoizedLazyExpensifyIcons(['Box', 'Building', 'Exit', 'Plus', 'Copy', 'Star', 'Trashcan', 'Transfer']); + const icons = useMemoizedLazyExpensifyIcons(['ArrowCircleClockwise', 'Box', 'Building', 'Exit', 'Plus', 'Copy', 'Star', 'Trashcan', 'Transfer']); const canArchivePolicies = isBetaEnabled(CONST.BETAS.ARCHIVE_POLICIES); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); @@ -170,6 +173,15 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, onArchiveWorkspace, } } + if (canUnarchivePolicy(item.isArchived, item.ownerAccountID, currentUserPersonalDetails.accountID, canArchivePolicies)) { + menuItems.push({ + icon: icons.ArrowCircleClockwise, + text: translate('workspace.common.unarchive'), + onSelected: callFunctionIfActionIsAllowed(() => setActiveAction(CONST.POLICY.THREE_DOT_MENU_ACTION.UNARCHIVE)), + shouldCallAfterModalHide: true, + }); + } + // Transferring ownership applies to both active and archived workspaces, so it lives outside the split above. if (isAdmin && !isOwner && canRenderTransferOwnerButton) { menuItems.push({ @@ -200,6 +212,12 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, onArchiveWorkspace, onDismiss={() => setActiveAction(undefined)} /> )} + {activeAction === CONST.POLICY.THREE_DOT_MENU_ACTION.UNARCHIVE && ( + setActiveAction(undefined)} + /> + )} {activeAction === CONST.POLICY.THREE_DOT_MENU_ACTION.TRANSFER_OWNERSHIP && ( `${workspaceName} löschen?`, deleteConfirmation: 'Möchten Sie diesen Workspace wirklich löschen?', deleteWithCardsConfirmation: 'Möchtest du diesen Workspace wirklich löschen? Dadurch werden alle Kartenfeeds und zugewiesenen Karten entfernt.', diff --git a/src/languages/el.ts b/src/languages/el.ts index 2e6d20fbefd2..6f7339be0e09 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4856,6 +4856,9 @@ ${amount} για ${merchant} - ${date}`, 'Είστε βέβαιοι ότι θέλετε να αρχειοθετήσετε αυτόν τον χώρο εργασίας; Θα καταργηθεί η ανάθεση όλων των πιστωτικών καρτών από τους χρήστες και θα διαγραφούν οριστικά όσες δαπάνες καρτών δεν έχουν υποβληθεί.', archiveWithExpensifyCardsConfirmation: 'Είστε βέβαιοι ότι θέλετε να αρχειοθετήσετε αυτόν τον χώρο εργασίας; Όλα τα όρια των Expensify Card θα οριστούν σε $0 και κάθε νέα απόπειρα αγοράς θα απορρίπτεται αυτόματα.', + unarchive: 'Αναίρεση αρχειοθέτησης', + unarchiveWorkspace: 'Αναίρεση αρχειοθέτησης χώρου εργασίας', + unarchiveConfirmation: 'Είστε βέβαιοι ότι θέλετε να αναιρέσετε την αρχειοθέτηση αυτού του χώρου εργασίας;', deleteWorkspaceTitle: (workspaceName: string) => `Διαγραφή του ${workspaceName};`, deleteConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτόν τον χώρο εργασίας;', deleteWithCardsConfirmation: 'Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτόν τον χώρο εργασίας; Θα αφαιρεθούν όλες οι ροές καρτών και οι ανατεθειμένες κάρτες.', diff --git a/src/languages/en.ts b/src/languages/en.ts index 72c201f83558..b65ba8ea491c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4962,6 +4962,9 @@ const translations = { 'Are you sure you want to archive this workspace? This will unassign all credit cards from users and permanently delete any unsubmitted card expenses.', archiveWithExpensifyCardsConfirmation: 'Are you sure you want to archive this workspace? This will set all Expensify Card limits to $0 and automatically decline any new purchase attempts.', + unarchive: 'Unarchive', + unarchiveWorkspace: 'Unarchive workspace', + unarchiveConfirmation: 'Are you sure you want to unarchive this workspace?', deleteWorkspaceTitle: (workspaceName: string) => `Delete ${workspaceName}?`, deleteConfirmation: 'Are you sure you want to delete this workspace?', deleteWithCardsConfirmation: 'Are you sure you want to delete this workspace? This will remove all card feeds and assigned cards.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5cbd50b2a2bc..05a52d541c7c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4712,6 +4712,9 @@ ${amount} para ${merchant} - ${date}`, '¿Estás seguro de que quieres archivar este espacio de trabajo? Se desasignarán todas las tarjetas de crédito de los usuarios y se eliminarán permanentemente los gastos de tarjeta no enviados.', archiveWithExpensifyCardsConfirmation: '¿Estás seguro de que quieres archivar este espacio de trabajo? Se establecerán todos los límites de las Tarjetas Expensify a $0 y se rechazarán automáticamente los nuevos intentos de compra.', + unarchive: 'Desarchivar', + unarchiveWorkspace: 'Desarchivar espacio de trabajo', + unarchiveConfirmation: '¿Estás seguro de que quieres desarchivar este espacio de trabajo?', deleteWorkspaceTitle: (workspaceName: string) => `¿Eliminar ${workspaceName}?`, deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', deleteWithCardsConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo? Se eliminarán todos los datos de las tarjetas y las tarjetas asignadas.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6280a922038d..5522b612b2a6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4795,6 +4795,9 @@ ${amount} pour ${merchant} - ${date}`, 'Voulez-vous vraiment archiver cet espace de travail ? Cela désassignera toutes les cartes de crédit des utilisateurs et supprimera définitivement toutes les dépenses de carte non soumises.', archiveWithExpensifyCardsConfirmation: 'Voulez-vous vraiment archiver cet espace de travail ? Cela fixera toutes les limites des Expensify Card à 0 $ et refusera automatiquement toute nouvelle tentative d’achat.', + unarchive: 'Désarchiver', + unarchiveWorkspace: 'Désarchiver l’espace de travail', + unarchiveConfirmation: 'Voulez-vous vraiment désarchiver cet espace de travail ?', deleteWorkspaceTitle: (workspaceName: string) => `Supprimer ${workspaceName} ?`, deleteConfirmation: 'Voulez-vous vraiment supprimer cet espace de travail ?', deleteWithCardsConfirmation: 'Voulez-vous vraiment supprimer cet espace de travail ? Cela supprimera tous les flux de cartes et les cartes assignées.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 47c0a59227f5..5bccd4b6767e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4758,6 +4758,9 @@ ${amount} per ${merchant} - ${date}`, 'Sei sicuro di voler archiviare questo spazio di lavoro? Questa azione annullerà l’assegnazione di tutte le carte di credito agli utenti ed eliminerà definitivamente le spese con carta non inviate.', archiveWithExpensifyCardsConfirmation: 'Sei sicuro di voler archiviare questo spazio di lavoro? Questa azione imposterà tutti i limiti delle Expensify Card a 0 $ e rifiuterà automaticamente qualsiasi nuovo tentativo di acquisto.', + unarchive: 'Ripristina dall’archivio', + unarchiveWorkspace: 'Ripristina spazio di lavoro dall’archivio', + unarchiveConfirmation: 'Sei sicuro di voler ripristinare questo spazio di lavoro dall’archivio?', deleteWorkspaceTitle: (workspaceName: string) => `Eliminare ${workspaceName}?`, deleteConfirmation: 'Sei sicuro di voler eliminare questo spazio di lavoro?', deleteWithCardsConfirmation: 'Sei sicuro di voler eliminare questo spazio di lavoro? Questa azione rimuoverà tutti i feed delle carte e le carte assegnate.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 510ebbf5d92e..ff36fe085b56 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4723,6 +4723,9 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' 'このワークスペースをアーカイブしてもよろしいですか? すべてのクレジットカードのユーザーへの割り当てが解除され、未申請のカード経費は完全に削除されます。', archiveWithExpensifyCardsConfirmation: 'このワークスペースをアーカイブしてもよろしいですか? すべての Expensify カードの利用限度額が $0 に設定され、新しい購入はすべて自動的に拒否されます。', + unarchive: 'アーカイブ解除', + unarchiveWorkspace: 'ワークスペースのアーカイブを解除', + unarchiveConfirmation: 'このワークスペースのアーカイブを解除してもよろしいですか?', deleteWorkspaceTitle: (workspaceName: string) => `${workspaceName} を削除しますか?`, deleteConfirmation: 'このワークスペースを削除してもよろしいですか?', deleteWithCardsConfirmation: 'このワークスペースを削除してもよろしいですか? すべてのカードフィードと割り当て済みカードが削除されます。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index edbcd252e049..e0c9e8b430e0 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4759,6 +4759,9 @@ ${amount} voor ${merchant} - ${date}`, 'Weet je zeker dat je deze werkruimte wilt archiveren? Hiermee worden alle creditcards van gebruikers losgekoppeld en worden alle niet-ingediende kaartuitgaven permanent verwijderd.', archiveWithExpensifyCardsConfirmation: 'Weet je zeker dat je deze werkruimte wilt archiveren? Hiermee worden alle Expensify Card-limieten op $0 gezet en worden nieuwe aankooppogingen automatisch geweigerd.', + unarchive: 'Dearchiveren', + unarchiveWorkspace: 'Werkruimte dearchiveren', + unarchiveConfirmation: 'Weet je zeker dat je deze werkruimte wilt dearchiveren?', deleteWorkspaceTitle: (workspaceName: string) => `${workspaceName} verwijderen?`, deleteConfirmation: 'Weet je zeker dat je deze werkruimte wilt verwijderen?', deleteWithCardsConfirmation: 'Weet je zeker dat je deze werkruimte wilt verwijderen? Hiermee worden alle kaartfeeds en toegewezen kaarten verwijderd.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1c112c078b87..97f356431456 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4788,6 +4788,9 @@ ${amount} dla ${merchant} - ${date}`, 'Czy na pewno chcesz zarchiwizować tę przestrzeń roboczą? Spowoduje to anulowanie przypisania wszystkich kart kredytowych użytkownikom i trwałe usunięcie nieprzesłanych wydatków z kart.', archiveWithExpensifyCardsConfirmation: 'Czy na pewno chcesz zarchiwizować tę przestrzeń roboczą? Spowoduje to ustawienie wszystkich limitów Expensify Card na $0 i automatyczne odrzucanie nowych prób zakupu.', + unarchive: 'Przywróć z archiwum', + unarchiveWorkspace: 'Przywróć przestrzeń roboczą z archiwum', + unarchiveConfirmation: 'Czy na pewno chcesz przywrócić tę przestrzeń roboczą z archiwum?', deleteWorkspaceTitle: (workspaceName: string) => `Usunąć ${workspaceName}?`, deleteConfirmation: 'Czy na pewno chcesz usunąć tę przestrzeń roboczą?', deleteWithCardsConfirmation: 'Na pewno chcesz usunąć tę przestrzeń roboczą? Spowoduje to usunięcie wszystkich źródeł kart i przypisanych kart.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 452d9e5351a8..602b1838856e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4751,6 +4751,9 @@ ${amount} para ${merchant} - ${date}`, 'Tem certeza de que deseja arquivar este workspace? Isso removerá a atribuição de todos os cartões de crédito dos usuários e excluirá permanentemente as despesas de cartão não enviadas.', archiveWithExpensifyCardsConfirmation: 'Tem certeza de que deseja arquivar este workspace? Isso definirá todos os limites do Expensify Card como $0 e recusará automaticamente novas tentativas de compra.', + unarchive: 'Desarquivar', + unarchiveWorkspace: 'Desarquivar espaço de trabalho', + unarchiveConfirmation: 'Tem certeza de que deseja desarquivar este espaço de trabalho?', deleteWorkspaceTitle: (workspaceName: string) => `Excluir ${workspaceName}?`, deleteConfirmation: 'Tem certeza de que deseja excluir este workspace?', deleteWithCardsConfirmation: 'Tem certeza de que deseja excluir este workspace? Isso removerá todos os feeds de cartão e cartões atribuídos.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b0256fe1d06b..006951d26fdf 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4613,6 +4613,9 @@ ${amount},商户:${merchant} - 日期:${date}`, archiveConfirmation: '确定要归档此工作区吗?', archiveWithThirdPartyCardsConfirmation: '确定要归档此工作区吗?这将取消所有用户的信用卡分配,并永久删除所有未提交的卡片报销。', archiveWithExpensifyCardsConfirmation: '确定要归档此工作区吗?这将把所有 Expensify 卡的限额设为 $0,并自动拒绝任何新的消费尝试。', + unarchive: '取消归档', + unarchiveWorkspace: '取消归档工作区', + unarchiveConfirmation: '确定要取消归档此工作区吗?', deleteWorkspaceTitle: (workspaceName: string) => `删除 ${workspaceName}?`, deleteConfirmation: '确定要删除此工作区吗?', deleteWithCardsConfirmation: '确定要删除此工作区吗?这将移除所有卡片数据源和已分配的卡片。', diff --git a/src/libs/API/parameters/UnarchivePolicyParams.ts b/src/libs/API/parameters/UnarchivePolicyParams.ts new file mode 100644 index 000000000000..e04cd5accdf9 --- /dev/null +++ b/src/libs/API/parameters/UnarchivePolicyParams.ts @@ -0,0 +1,5 @@ +type UnarchivePolicyParams = { + policyID: string; +}; + +export default UnarchivePolicyParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 21f15b086cbd..eb9e36d5083a 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -248,6 +248,7 @@ export type {default as ChronosRemoveOOOEventParams} from './ChronosRemoveOOOEve export type {default as TransferWalletBalanceParams} from './TransferWalletBalanceParams'; export type {default as DeleteWorkspaceParams} from './DeleteWorkspaceParams'; export type {default as ArchivePolicyParams} from './ArchivePolicyParams'; +export type {default as UnarchivePolicyParams} from './UnarchivePolicyParams'; export type {default as ShareBankAccountAndSetPayerParams} from './ShareBankAccountAndSetPayerParams'; export type {default as CreateWorkspaceParams} from './CreateWorkspaceParams'; export type {default as UpdateWorkspaceGeneralSettingsParams} from './UpdateWorkspaceGeneralSettingsParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d5b04efd0274..0007f985af16 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -156,6 +156,7 @@ const WRITE_COMMANDS = { RESOLVE_ACTIONABLE_REPORT_MENTION_WHISPER: 'ResolveActionableReportMentionWhisper', DELETE_WORKSPACE: 'DeleteWorkspace', ARCHIVE_POLICY: 'ArchivePolicy', + UNARCHIVE_POLICY: 'UnarchivePolicy', DELETE_MEMBERS_FROM_WORKSPACE: 'DeleteMembersFromWorkspace', ADD_MEMBERS_TO_WORKSPACE: 'AddMembersToWorkspace', UPDATE_WORKSPACE_AVATAR: 'UpdateWorkspaceAvatar', @@ -901,6 +902,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.TRANSFER_WALLET_BALANCE]: Parameters.TransferWalletBalanceParams; [WRITE_COMMANDS.DELETE_WORKSPACE]: Parameters.DeleteWorkspaceParams; [WRITE_COMMANDS.ARCHIVE_POLICY]: Parameters.ArchivePolicyParams; + [WRITE_COMMANDS.UNARCHIVE_POLICY]: Parameters.UnarchivePolicyParams; [WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE]: Parameters.DeleteMembersFromWorkspaceParams; [WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE]: Parameters.AddMembersToWorkspaceParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_AVATAR]: Parameters.UpdateWorkspaceAvatarParams; diff --git a/src/libs/PolicyUtils/index.ts b/src/libs/PolicyUtils/index.ts index 124d59d9e476..d120ed210db5 100644 --- a/src/libs/PolicyUtils/index.ts +++ b/src/libs/PolicyUtils/index.ts @@ -108,6 +108,14 @@ function isArchivedPolicy(policy: OnyxInputOrEntry): boolean { return !!policy?.archivedDate; } +/** + * Whether the current user can unarchive the workspace: only the owner of an archived workspace can, and only + * while the archive policies beta is enabled. Takes primitives so it works with both a Policy and a workspace row. + */ +function canUnarchivePolicy(isArchived: boolean, ownerAccountID: number | undefined, currentUserAccountID: number, isArchivePoliciesBetaEnabled: boolean): boolean { + return isArchivePoliciesBetaEnabled && isArchived && ownerAccountID === currentUserAccountID; +} + /** * Whether the policy is archived or is optimistically pending deletion. Deleting a workspace * archives it on the backend, but the optimistic data only sets pendingAction, so report state @@ -2831,6 +2839,7 @@ export { isPolicyFieldListEmpty, isArchivedOrPendingDeletePolicy, isArchivedPolicy, + canUnarchivePolicy, getUberConnectionErrorDirectlyFromPolicy, isPolicyOwner, isPolicyMember, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 0c5c8abf010f..06810bd2276a 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -12,6 +12,7 @@ import type { ChangePolicyUberBillingAccountPageParams, CreateWorkspaceFromIOUPaymentParams, ArchivePolicyParams, + UnarchivePolicyParams, CreateWorkspaceParams, DeletePolicyRulesDocumentParams, DeleteWorkspaceAvatarParams, @@ -783,6 +784,58 @@ function archivePolicy(params: ArchivePolicyActionParams) { Log.info(`[ArchivePolicy] Archived policy ${policyName} (${policyID})`); } +type UnarchivePolicyActionParams = { + policyID: string; + policyName?: string; + + /** The policy's current archivedDate, restored if the request fails */ + archivedDate: string | undefined; +}; + +function unarchivePolicy(params: UnarchivePolicyActionParams) { + const {policyID, policyName, archivedDate} = params; + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + archivedDate: null, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + errors: null, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + archivedDate: archivedDate ?? null, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + ]; + + const successData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingAction: null, + }, + }, + ]; + + const apiParams: UnarchivePolicyParams = {policyID}; + + API.write(WRITE_COMMANDS.UNARCHIVE_POLICY, apiParams, {optimisticData, failureData, successData}); + + Log.info(`[UnarchivePolicy] Unarchived policy ${policyName} (${policyID})`); +} + /* Set the auto harvesting on a workspace. This goes in tandem with auto reporting. so when you enable/disable * harvesting, you are enabling/disabling auto reporting too. */ @@ -7962,6 +8015,7 @@ export { addBillingCardAndRequestPolicyOwnerChange, deleteWorkspace, archivePolicy, + unarchivePolicy, updateAddress, updateLastAccessedWorkspace, dismissWorkspaceError, diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 241875ed5041..14840d4ecf98 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -56,7 +56,16 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {canEditWorkspaceSettings, getRulesDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyOwner} from '@libs/PolicyUtils'; +import { + canEditWorkspaceSettings, + canUnarchivePolicy, + getRulesDocumentSourceURL, + getUserFriendlyWorkspaceType, + goBackFromInvalidPolicy, + isArchivedPolicy, + isPendingDeletePolicy, + isPolicyOwner, +} from '@libs/PolicyUtils'; import {formatAddressToString} from '@libs/ReportActionsUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import StringUtils from '@libs/StringUtils'; @@ -82,6 +91,7 @@ import {View} from 'react-native'; import type {WithPolicyProps} from './withPolicy'; import ArchiveWorkspaceFlow from './archiveWorkspace/ArchiveWorkspaceFlow'; +import UnarchiveWorkspaceFlow from './archiveWorkspace/UnarchiveWorkspaceFlow'; import DeleteWorkspaceFlow from './deleteWorkspace/DeleteWorkspaceFlow'; import withPolicy from './withPolicy'; import WorkspacePageWithSections from './WorkspacePageWithSections'; @@ -98,7 +108,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const {getCurrencySymbol} = useCurrencyListActions(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Box', 'Exit', 'ImageCropSquareMask', 'QrCode', 'Transfer', 'Trashcan', 'Upload', 'UserPlus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowCircleClockwise', 'Box', 'Exit', 'ImageCropSquareMask', 'QrCode', 'Transfer', 'Trashcan', 'Upload', 'UserPlus']); const buildDynamicRoute = useScreenBoundDynamicRoute(); const {isBetaEnabled} = usePermissions(); const canArchivePolicies = isBetaEnabled(CONST.BETAS.ARCHIVE_POLICIES); @@ -111,6 +121,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const {showConfirmModal} = useConfirmModal(); const [isDeleteWorkspaceFlowVisible, setIsDeleteWorkspaceFlowVisible] = useState(false); const [isArchiveWorkspaceFlowVisible, setIsArchiveWorkspaceFlowVisible] = useState(false); + const [isUnarchiveWorkspaceFlowVisible, setIsUnarchiveWorkspaceFlowVisible] = useState(false); // Primitive-valued subscriptions configuring the Delete menu item (popover behavior and the loading spinner) // before a deletion starts. The deletion itself is handled by DeleteWorkspaceFlow, mounted on demand below. @@ -190,6 +201,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const currencyReadOnly = readOnly || isBankAccountVerified; const isCurrencyInteractive = !shouldBlockCurrencyChange && !currencyReadOnly; const isOwner = isPolicyOwner(policy, currentUserPersonalDetails.accountID); + const canUnarchive = canUnarchivePolicy(isArchivedPolicy(policy), policy?.ownerAccountID, currentUserPersonalDetails.accountID, canArchivePolicies); const shouldShowAddress = !readOnly || !!formattedAddress; const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); @@ -368,6 +380,15 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const secondaryActions: Array> = []; if (readOnly) { + if (canUnarchive) { + secondaryActions.push({ + value: 'unarchive', + text: translate('workspace.common.unarchive'), + icon: expensifyIcons.ArrowCircleClockwise, + // The confirmation modal is handled by UnarchiveWorkspaceFlow, which mounts when this is set. + onSelected: () => setIsUnarchiveWorkspaceFlowVisible(true), + }); + } if (canLeave) { secondaryActions.push({ value: 'leave', @@ -480,6 +501,13 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa onArchiveComplete={goBackFromInvalidPolicy} /> )} + {isUnarchiveWorkspaceFlowVisible && !!policyID && ( + setIsUnarchiveWorkspaceFlowVisible(false)} + /> + )} {!!pendingRulesDocumentFile && ( void; +}; + +/** + * Self-contained workspace unarchive flow, mounted only while an unarchive is in progress so the policy entry + * is subscribed to only for the lifetime of the flow. + * + * Unarchiving is online only: it unlocks writes on the policy, so any edit made afterward depends on it, and the + * archived policy data available locally may be incomplete. While offline we show the offline modal instead. + */ +function UnarchiveWorkspaceFlow({policyID, onDismiss}: UnarchiveWorkspaceFlowProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {showConfirmModal} = useConfirmModal(); + const [policy, policyResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const isLoadingData = isLoadingOnyxValue(policyResult); + + // The modal callbacks below run after the effect that created them, so they read the latest network state from a ref. + const isOfflineRef = useRef(isOffline); + useEffect(() => { + isOfflineRef.current = isOffline; + }, [isOffline]); + + // Closes the popover (if still open) and shows the confirmation modal once the policy entry has loaded. + const hasStartedRef = useRef(false); + useEffect(() => { + if (hasStartedRef.current || isLoadingData) { + return; + } + hasStartedRef.current = true; + + const showOfflineModal = () => { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => onDismiss()); + }; + + close(() => { + if (isOfflineRef.current) { + showOfflineModal(); + return; + } + + showConfirmModal({ + title: translate('workspace.common.unarchiveWorkspace'), + prompt: translate('workspace.common.unarchiveConfirmation'), + confirmText: translate('workspace.common.unarchive'), + cancelText: translate('common.cancel'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + onDismiss(); + return; + } + + if (isOfflineRef.current) { + showOfflineModal(); + return; + } + + unarchivePolicy({ + policyID, + policyName: policy?.name, + archivedDate: policy?.archivedDate, + }); + onDismiss(); + }); + }); + }); + + // Every modal this flow shows is owned by the global modal stack, so the flow itself renders nothing. + return null; +} + +export default UnarchiveWorkspaceFlow; diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index 242ed1cc80be..4568e254bcaa 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -2256,6 +2256,65 @@ describe('actions/Policy', () => { }); }); + describe('unarchivePolicy', () => { + const archivedDate = '2026-08-01 00:00:00'; + + it('should call UnarchivePolicy with the policyID', async () => { + // Given an archived workspace + const policy = {...createRandomPolicy(0), archivedDate}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + const apiWriteSpy = jest.spyOn(APIModule, 'write').mockImplementation(() => Promise.resolve()); + + // When unarchiving the workspace + Policy.unarchivePolicy({policyID: policy.id, policyName: policy.name, archivedDate}); + + // Then the UnarchivePolicy command should be called with only the policyID, since the backend does the rest + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.UNARCHIVE_POLICY, {policyID: policy.id}, expect.anything()); + apiWriteSpy.mockRestore(); + }); + + it('should clear archivedDate optimistically and succeed', async () => { + // Given an archived workspace + const policy = {...createRandomPolicy(0), archivedDate}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + mockFetch.pause(); + + // When unarchiving the workspace + Policy.unarchivePolicy({policyID: policy.id, policyName: policy.name, archivedDate}); + await waitForBatchedUpdates(); + + // Then archivedDate should be cleared right away so the workspace shows as active and editable, with a pending update + let updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.archivedDate).toBeUndefined(); + expect(updatedPolicy?.pendingAction).toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE); + + // When the request succeeds + await mockFetch.resume(); + + // Then the pending action should be cleared and the workspace stays unarchived + updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.pendingAction).toBeUndefined(); + expect(updatedPolicy?.archivedDate).toBeUndefined(); + }); + + it('should restore archivedDate when the request fails', async () => { + // Given an archived workspace + const policy = {...createRandomPolicy(0), archivedDate}; + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + + // When unarchiving the workspace fails + mockFetch.fail(); + Policy.unarchivePolicy({policyID: policy.id, policyName: policy.name, archivedDate}); + await waitForBatchedUpdates(); + + // Then the workspace should be archived again with its original archivedDate, and an error shown to the user + const updatedPolicy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`); + expect(updatedPolicy?.archivedDate).toBe(archivedDate); + expect(updatedPolicy?.pendingAction).toBeUndefined(); + expect(updatedPolicy?.errors).not.toBeUndefined(); + }); + }); + describe('updateGeneralSettings', () => { const NEW_NAME = 'New Workspace Name'; const NEW_CURRENCY = CONST.CURRENCY.EUR; diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts index e335e8142072..e2027cf3610a 100644 --- a/tests/unit/PolicyUtilsTest.ts +++ b/tests/unit/PolicyUtilsTest.ts @@ -13,6 +13,7 @@ import { canMemberRead, canMemberWrite, canSendInvoiceFromWorkspace, + canUnarchivePolicy, evaluateApprovalWorkflowRule, findVendorByID, getVendorDisplayName, @@ -448,6 +449,28 @@ describe('PolicyUtils', () => { }); }); + describe('canUnarchivePolicy', () => { + it('returns true for the owner of an archived workspace when the beta is enabled', () => { + expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID, true)).toBe(true); + }); + + it('returns false when the beta is disabled', () => { + expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID, false)).toBe(false); + }); + + it('returns false when the workspace is not archived', () => { + expect(canUnarchivePolicy(false, ownerAccountID, ownerAccountID, true)).toBe(false); + }); + + it('returns false when the current user is not the owner', () => { + expect(canUnarchivePolicy(true, ownerAccountID, ownerAccountID + 1, true)).toBe(false); + }); + + it('returns false when the owner is unknown', () => { + expect(canUnarchivePolicy(true, undefined, ownerAccountID, true)).toBe(false); + }); + }); + describe('isPolicyAdmin', () => { const adminLogin = 'admin@test.com'; const memberLogin = 'member@test.com';