From b28b782941df578c05b061c465675875f92d3d36 Mon Sep 17 00:00:00 2001 From: truph01 Date: Wed, 7 Oct 2026 10:59:44 +0700 Subject: [PATCH 1/3] feat: implement unarchive button --- cspell.json | 2 + src/CONST/index.ts | 1 + .../WorkspaceRowThreeDotsMenu.tsx | 19 ++++- src/languages/de.ts | 3 + src/languages/el.ts | 3 + src/languages/en.ts | 3 + src/languages/es.ts | 3 + src/languages/fr.ts | 3 + src/languages/it.ts | 3 + src/languages/ja.ts | 3 + src/languages/nl.ts | 3 + src/languages/pl.ts | 3 + src/languages/pt-BR.ts | 3 + src/languages/zh-hans.ts | 3 + .../API/parameters/UnarchivePolicyParams.ts | 5 ++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + src/libs/actions/Policy/Policy.ts | 54 +++++++++++++ src/pages/workspace/WorkspaceOverviewPage.tsx | 30 ++++++- .../UnarchiveWorkspaceFlow.tsx | 80 +++++++++++++++++++ tests/actions/PolicyTest.ts | 59 ++++++++++++++ 21 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 src/libs/API/parameters/UnarchivePolicyParams.ts create mode 100644 src/pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow.tsx 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..b7ba2fb520df 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -14,6 +14,8 @@ import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; import Navigation from '@libs/Navigation/Navigation'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; +import UnarchiveWorkspaceFlow from '@pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow'; + import {setNameValuePair} from '@userActions/User'; import CONST from '@src/CONST'; @@ -59,7 +61,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 +172,15 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, onArchiveWorkspace, } } + if (item.isArchived && isOwner && 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 +211,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 efec389bde0d..0cc082718b6d 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 c323aab09500..63de47534da8 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 5d22f6494554..052c2209b106 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 bb75b55e623f..056ec7629ebd 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 6d6fd5d0dc0a..03cb0ef9d016 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 dedd9c28c5c9..46b1da69cc1b 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 2edb1f2c1f58..433a972dd600 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 2d63bc3cff66..c559665d195a 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 fe7d8ea789cf..f16e04cd4536 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 1175f91919be..00543bd6d818 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/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..0037c32b7bd5 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -56,7 +56,15 @@ 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, + 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 +90,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 +107,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 +120,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. @@ -368,6 +378,15 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const secondaryActions: Array> = []; if (readOnly) { + if (canArchivePolicies && isOwner && isArchivedPolicy(policy)) { + 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 +499,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); + + // 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; + + close(() => { + if (isOffline) { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => onDismiss()); + 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) { + 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; From ac3961173d0a90f2f1c751cc20e5009a25faa3bf Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 8 Oct 2026 00:23:50 +0700 Subject: [PATCH 2/3] fix: prevent unarchive in offline --- .../WorkspaceRowThreeDotsMenu.tsx | 3 +- src/libs/PolicyUtils/index.ts | 9 ++++ src/pages/workspace/WorkspaceOverviewPage.tsx | 3 +- .../UnarchiveWorkspaceFlow.tsx | 44 +++++++++++++------ tests/unit/PolicyUtilsTest.ts | 23 ++++++++++ 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx index b7ba2fb520df..bff0bfab5a42 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -12,6 +12,7 @@ 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'; @@ -172,7 +173,7 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, onArchiveWorkspace, } } - if (item.isArchived && isOwner && canArchivePolicies) { + if (canUnarchivePolicy(item.isArchived, item.ownerAccountID, currentUserPersonalDetails.accountID, canArchivePolicies)) { menuItems.push({ icon: icons.ArrowCircleClockwise, text: translate('workspace.common.unarchive'), 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/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 0037c32b7bd5..4e84bb279ea9 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -58,6 +58,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import { canEditWorkspaceSettings, + canUnarchivePolicy, getRulesDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, @@ -378,7 +379,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const secondaryActions: Array> = []; if (readOnly) { - if (canArchivePolicies && isOwner && isArchivedPolicy(policy)) { + if (canUnarchivePolicy(isArchivedPolicy(policy), policy?.ownerAccountID, currentUserPersonalDetails.accountID, canArchivePolicies)) { secondaryActions.push({ value: 'unarchive', text: translate('workspace.common.unarchive'), diff --git a/src/pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow.tsx b/src/pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow.tsx index 7184c0be942f..15946c4ff260 100644 --- a/src/pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow.tsx +++ b/src/pages/workspace/archiveWorkspace/UnarchiveWorkspaceFlow.tsx @@ -36,6 +36,12 @@ function UnarchiveWorkspaceFlow({policyID, onDismiss}: UnarchiveWorkspaceFlowPro 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(() => { @@ -44,14 +50,18 @@ function UnarchiveWorkspaceFlow({policyID, onDismiss}: UnarchiveWorkspaceFlowPro } hasStartedRef.current = true; + const showOfflineModal = () => { + showConfirmModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }).then(() => onDismiss()); + }; + close(() => { - if (isOffline) { - showConfirmModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.offlinePrompt'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }).then(() => onDismiss()); + if (isOfflineRef.current) { + showOfflineModal(); return; } @@ -61,13 +71,21 @@ function UnarchiveWorkspaceFlow({policyID, onDismiss}: UnarchiveWorkspaceFlowPro confirmText: translate('workspace.common.unarchive'), cancelText: translate('common.cancel'), }).then((result) => { - if (result.action === ModalActions.CONFIRM) { - unarchivePolicy({ - policyID, - policyName: policy?.name, - archivedDate: policy?.archivedDate, - }); + if (result.action !== ModalActions.CONFIRM) { + onDismiss(); + return; } + + if (isOfflineRef.current) { + showOfflineModal(); + return; + } + + unarchivePolicy({ + policyID, + policyName: policy?.name, + archivedDate: policy?.archivedDate, + }); onDismiss(); }); }); 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'; From e407c2737c7d8c15643cba523bb2ba8abfa9468a Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 8 Oct 2026 00:55:38 +0700 Subject: [PATCH 3/3] Hoist canUnarchive so WorkspaceOverviewPage compiles with React Compiler --- src/pages/workspace/WorkspaceOverviewPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 4e84bb279ea9..14840d4ecf98 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -201,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(); @@ -379,7 +380,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const secondaryActions: Array> = []; if (readOnly) { - if (canUnarchivePolicy(isArchivedPolicy(policy), policy?.ownerAccountID, currentUserPersonalDetails.accountID, canArchivePolicies)) { + if (canUnarchive) { secondaryActions.push({ value: 'unarchive', text: translate('workspace.common.unarchive'),