From 0fd6adf898eba89bcfd08a71bceaeb57ff140e85 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Mon, 9 Jun 2025 19:24:09 +0100 Subject: [PATCH 1/3] Remove provider hooks --- src/hooks/sync-sites/index.ts | 2 - src/hooks/sync-sites/sync-sites-context.tsx | 151 -------- src/hooks/sync-sites/use-pull-push-states.ts | 57 --- .../sync-sites/use-site-sync-management.ts | 131 ------- src/hooks/sync-sites/use-sync-pull.ts | 358 ------------------ src/hooks/sync-sites/use-sync-push.ts | 303 --------------- src/hooks/tests/use-sync-sites.test.tsx | 161 -------- 7 files changed, 1163 deletions(-) delete mode 100644 src/hooks/sync-sites/index.ts delete mode 100644 src/hooks/sync-sites/sync-sites-context.tsx delete mode 100644 src/hooks/sync-sites/use-pull-push-states.ts delete mode 100644 src/hooks/sync-sites/use-site-sync-management.ts delete mode 100644 src/hooks/sync-sites/use-sync-pull.ts delete mode 100644 src/hooks/sync-sites/use-sync-push.ts delete mode 100644 src/hooks/tests/use-sync-sites.test.tsx diff --git a/src/hooks/sync-sites/index.ts b/src/hooks/sync-sites/index.ts deleted file mode 100644 index 00fe40a83..000000000 --- a/src/hooks/sync-sites/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './sync-sites-context'; -export { SyncBackupState } from './use-sync-pull'; diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx deleted file mode 100644 index 83b29e7c6..000000000 --- a/src/hooks/sync-sites/sync-sites-context.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { __, sprintf } from '@wordpress/i18n'; -import React, { createContext, useCallback, useContext, useState } from 'react'; -import { useListenDeepLinkConnection } from 'src/hooks/sync-sites/use-listen-deep-link-connection'; -import { - UseSiteSyncManagement, - useSiteSyncManagement, -} from 'src/hooks/sync-sites/use-site-sync-management'; -import { PullStates, UseSyncPull, useSyncPull } from 'src/hooks/sync-sites/use-sync-pull'; -import { PushStates, UseSyncPush, useSyncPush } from 'src/hooks/sync-sites/use-sync-push'; -import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; - -type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; -type UpdateSiteTimestamp = ( - siteId: number | undefined, - localSiteId: string, - type: 'pull' | 'push' -) => Promise< void >; - -type IsSyncSitesSelectorOpen = boolean | { disconnectSiteId?: number }; - -export type SyncSitesContextType = Omit< UseSyncPull, 'pullStates' > & - Omit< UseSyncPush, 'pushStates' > & - Omit< UseSiteSyncManagement, 'loadConnectedSites' > & { - getLastSyncTimeText: GetLastSyncTimeText; - isSyncSitesSelectorOpen: IsSyncSitesSelectorOpen; - setIsSyncSitesSelectorOpen: ( open: IsSyncSitesSelectorOpen ) => void; - closeSyncSitesSelector: () => void; - }; - -const SyncSitesContext = createContext< SyncSitesContextType | undefined >( undefined ); - -export function SyncSitesProvider( { children }: { children: React.ReactNode } ) { - const { formatRelativeTime } = useFormatLocalizedTimestamps(); - const [ pullStates, setPullStates ] = useState< PullStates >( {} ); - const [ connectedSites, setConnectedSites ] = useState< SyncSite[] >( [] ); - const [ isSyncSitesSelectorOpen, setIsSyncSitesSelectorOpen ] = - useState< IsSyncSitesSelectorOpen >( false ); - const closeSyncSitesSelector = useCallback( () => setIsSyncSitesSelectorOpen( false ), [] ); - - const getLastSyncTimeText = useCallback< GetLastSyncTimeText >( - ( timestamp, type ) => { - if ( ! timestamp ) { - return type === 'pull' - ? __( 'You have not pulled this site yet.' ) - : __( 'You have not pushed this site yet.' ); - } - - return sprintf( - type === 'pull' - ? __( 'You pulled this site %s ago.' ) - : __( 'You pushed this site %s ago.' ), - formatRelativeTime( timestamp ) - ); - }, - [ formatRelativeTime ] - ); - - const updateSiteTimestamp = useCallback< UpdateSiteTimestamp >( - async ( siteId, localSiteId, type ) => { - const site = connectedSites.find( - ( { id, localSiteId: siteLocalId } ) => siteId === id && localSiteId === siteLocalId - ); - - if ( ! site ) { - return; - } - - try { - const updatedSite = { - ...site, - [ type === 'pull' ? 'lastPullTimestamp' : 'lastPushTimestamp' ]: new Date().toISOString(), - }; - - await getIpcApi().updateSingleConnectedWpcomSite( updatedSite ); - setConnectedSites( ( sites ) => - sites.map( ( s ) => ( s.id === site.id ? updatedSite : s ) ) - ); - } catch ( error ) { - console.error( 'Failed to update timestamp:', error ); - } - }, - [ connectedSites ] - ); - - const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState } = useSyncPull( - { - pullStates, - setPullStates, - onPullSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( remoteSiteId, localSiteId, 'pull' ), - } - ); - - const [ pushStates, setPushStates ] = useState< PushStates >( {} ); - const { pushSite, isAnySitePushing, isSiteIdPushing, clearPushState, getPushState } = useSyncPush( - { - pushStates, - setPushStates, - onPushSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( remoteSiteId, localSiteId, 'push' ), - } - ); - - const { connectSite, disconnectSite, syncSites, isFetching, refetchSites } = - useSiteSyncManagement( { - connectedSites, - setConnectedSites, - closeSyncSitesSelector, - } ); - - useListenDeepLinkConnection( { connectSite, refetchSites } ); - - return ( - - { children } - - ); -} - -export function useSyncSites() { - const context = useContext( SyncSitesContext ); - if ( context === undefined ) { - throw new Error( 'useSyncSites must be used within a SyncSitesProvider' ); - } - return context; -} diff --git a/src/hooks/sync-sites/use-pull-push-states.ts b/src/hooks/sync-sites/use-pull-push-states.ts deleted file mode 100644 index 3cdee814b..000000000 --- a/src/hooks/sync-sites/use-pull-push-states.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useCallback } from 'react'; - -export const generateStateId = ( selectedSiteId: string, remoteSiteId: number ) => - `${ selectedSiteId }-${ remoteSiteId }`; - -export type States< T > = Record< string, T >; -export type UpdateState< T > = ( - selectedSiteId: string, - remoteSiteId: number, - state: Partial< T > -) => void; -export type GetState< T > = ( selectedSiteId: string, remoteSiteId: number ) => T | undefined; -export type ClearState = ( selectedSiteId: string, remoteSiteId: number ) => void; - -export type UsePullPushStates< T > = { - updateState: UpdateState< T >; - getState: GetState< T >; - clearState: ClearState; -}; - -export function usePullPushStates< T >( - states: States< T >, - setStates: React.Dispatch< React.SetStateAction< States< T > > > -): UsePullPushStates< T > { - const updateState = useCallback< UpdateState< T > >( - ( selectedSiteId, remoteSiteId, state ) => { - setStates( ( prevStates ) => ( { - ...prevStates, - [ generateStateId( selectedSiteId, remoteSiteId ) ]: { - ...prevStates[ generateStateId( selectedSiteId, remoteSiteId ) ], - ...state, - }, - } ) ); - }, - [ setStates ] - ); - - const getState = useCallback< GetState< T > >( - ( selectedSiteId, remoteSiteId ): T | undefined => { - return states[ generateStateId( selectedSiteId, remoteSiteId ) ]; - }, - [ states ] - ); - - const clearState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - setStates( ( prevStates ) => { - const newStates = { ...prevStates }; - delete newStates[ generateStateId( selectedSiteId, remoteSiteId ) ]; - return newStates; - } ); - }, - [ setStates ] - ); - - return { updateState, getState, clearState }; -} diff --git a/src/hooks/sync-sites/use-site-sync-management.ts b/src/hooks/sync-sites/use-site-sync-management.ts deleted file mode 100644 index 75cdc0894..000000000 --- a/src/hooks/sync-sites/use-site-sync-management.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useCallback } from 'react'; -import { useAuth } from 'src/hooks/use-auth'; -import { FetchSites, useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites'; -import { useSiteDetails } from 'src/hooks/use-site-details'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; - -type ConnectedSites = SyncSite[]; -type LoadConnectedSites = () => Promise< void >; -type ConnectSite = ( site: SyncSite, overrideLocalSiteId?: string ) => Promise< void >; -type DisconnectSite = ( siteId: number ) => Promise< void >; - -type UseSiteSyncManagementProps = { - connectedSites: ConnectedSites; - setConnectedSites: React.Dispatch< React.SetStateAction< ConnectedSites > >; - closeSyncSitesSelector: () => void; -}; - -export type UseSiteSyncManagement = { - connectedSites: ConnectedSites; - loadConnectedSites: LoadConnectedSites; - connectSite: ConnectSite; - disconnectSite: DisconnectSite; - syncSites: SyncSite[]; - isFetching: boolean; - refetchSites: FetchSites; -}; - -export const useSiteSyncManagement = ( { - connectedSites, - setConnectedSites, - closeSyncSitesSelector, -}: UseSiteSyncManagementProps ): UseSiteSyncManagement => { - const { isAuthenticated } = useAuth(); - const { syncSites, isFetching, refetchSites } = useFetchWpComSites( - connectedSites.map( ( { id } ) => id ) - ); - const { selectedSite } = useSiteDetails(); - const localSiteId = selectedSite?.id; - - const loadConnectedSites = useCallback< LoadConnectedSites >( async () => { - if ( ! localSiteId ) { - setConnectedSites( [] ); - return; - } - - try { - const sites = await getIpcApi().getConnectedWpcomSites( localSiteId ); - setConnectedSites( sites ); - } catch ( error ) { - console.error( 'Failed to load connected sites:', error ); - setConnectedSites( [] ); - } - }, [ localSiteId, setConnectedSites ] ); - - useEffect( () => { - if ( isAuthenticated ) { - void loadConnectedSites(); - } - }, [ isAuthenticated, syncSites, loadConnectedSites ] ); - - const connectSite = useCallback< ConnectSite >( - async ( site, overrideLocalSiteId ) => { - const localSiteIdToConnect = overrideLocalSiteId ?? localSiteId; - if ( ! localSiteIdToConnect ) { - return; - } - try { - const stagingSites = site.stagingSiteIds.flatMap( - ( id ) => syncSites.find( ( s ) => s.id === id ) ?? [] - ); - const sitesToConnect = [ site, ...stagingSites ]; - await getIpcApi().connectWpcomSites( [ - { - sites: sitesToConnect, - localSiteId: localSiteIdToConnect, - }, - ] ); - if ( localSiteIdToConnect === localSiteId ) { - const newConnectedSites = - await getIpcApi().getConnectedWpcomSites( localSiteIdToConnect ); - setConnectedSites( newConnectedSites ); - } - closeSyncSitesSelector(); - } catch ( error ) { - console.error( 'Failed to connect site:', error ); - throw error; - } - }, - [ localSiteId, syncSites, setConnectedSites, closeSyncSitesSelector ] - ); - - const disconnectSite = useCallback< DisconnectSite >( - async ( siteId ) => { - if ( ! localSiteId ) { - return; - } - try { - const siteToDisconnect = connectedSites.find( ( site ) => site.id === siteId ); - if ( ! siteToDisconnect ) { - throw new Error( 'Site not found' ); - } - - const sitesToDisconnect = [ siteId, ...siteToDisconnect.stagingSiteIds ]; - await getIpcApi().disconnectWpcomSites( [ - { - siteIds: sitesToDisconnect, - localSiteId, - }, - ] ); - - const newConnectedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); - setConnectedSites( newConnectedSites ); - } catch ( error ) { - console.error( 'Failed to disconnect site:', error ); - throw error; - } - }, - [ localSiteId, connectedSites, setConnectedSites ] - ); - - return { - connectedSites, - loadConnectedSites, - connectSite, - disconnectSite, - syncSites, - isFetching, - refetchSites, - }; -}; diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts deleted file mode 100644 index ef0864e75..000000000 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ /dev/null @@ -1,358 +0,0 @@ -import * as Sentry from '@sentry/electron/renderer'; -import { sprintf } from '@wordpress/i18n'; -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo } from 'react'; -import { SYNC_PUSH_SIZE_LIMIT_GB, SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; -import { - ClearState, - generateStateId, - GetState, - UpdateState, - usePullPushStates, -} from 'src/hooks/sync-sites/use-pull-push-states'; -import { useAuth } from 'src/hooks/use-auth'; -import { useImportExport } from 'src/hooks/use-import-export'; -import { useSiteDetails } from 'src/hooks/use-site-details'; -import { - PullStateProgressInfo, - SyncBackupResponse, - useSyncStatesProgressInfo, -} from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { getHostnameFromUrl } from 'src/lib/url-utils'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; - -export type SyncBackupState = { - remoteSiteId: number; - backupId: string | null; - status: PullStateProgressInfo; - downloadUrl: string | null; - selectedSite: SiteDetails; - remoteSiteUrl: string; -}; - -export type PullStates = Record< string, SyncBackupState >; -type OnPullSuccess = ( siteId: number, localSiteId: string ) => void; -type PullSite = ( connectedSite: SyncSite, selectedSite: SiteDetails ) => void; -type IsSiteIdPulling = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; - -type UseSyncPullProps = { - pullStates: PullStates; - setPullStates: React.Dispatch< React.SetStateAction< PullStates > >; - onPullSuccess?: OnPullSuccess; -}; - -export type UseSyncPull = { - pullStates: PullStates; - getPullState: GetState< SyncBackupState >; - pullSite: PullSite; - isAnySitePulling: boolean; - isSiteIdPulling: IsSiteIdPulling; - clearPullState: ClearState; -}; - -export function useSyncPull( { - pullStates, - setPullStates, - onPullSuccess, -}: UseSyncPullProps ): UseSyncPull { - const { __ } = useI18n(); - const { client } = useAuth(); - const { importFile, clearImportState } = useImportExport(); - const { - pullStatesProgressInfo, - isKeyPulling, - isKeyFinished, - isKeyFailed, - getBackupStatusWithProgress, - } = useSyncStatesProgressInfo(); - const { - updateState, - getState: getPullState, - clearState, - } = usePullPushStates< SyncBackupState >( pullStates, setPullStates ); - - const updatePullState = useCallback< UpdateState< SyncBackupState > >( - ( selectedSiteId, remoteSiteId, state ) => { - updateState( selectedSiteId, remoteSiteId, state ); - const statusKey = state.status?.key; - - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) ) { - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } else { - getIpcApi().addSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } - }, - [ isKeyFailed, isKeyFinished, updateState ] - ); - - const clearPullState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - clearState( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - }, - [ clearState ] - ); - - const { startServer } = useSiteDetails(); - - const pullSite = useCallback< PullSite >( - async ( connectedSite, selectedSite ) => { - if ( ! client ) { - return; - } - - const remoteSiteId = connectedSite.id; - const remoteSiteUrl = connectedSite.url; - updatePullState( selectedSite.id, remoteSiteId, { - backupId: null, - status: pullStatesProgressInfo[ 'in-progress' ], - downloadUrl: null, - remoteSiteId, - remoteSiteUrl, - selectedSite, - } ); - - try { - // Initializing backup on remote - const response = await client.req.post< { success: boolean; backup_id: string } >( { - path: `/sites/${ remoteSiteId }/studio-app/sync/backup`, - apiNamespace: 'wpcom/v2', - } ); - - if ( response.success ) { - updatePullState( selectedSite.id, remoteSiteId, { - backupId: response.backup_id, - } ); - } else { - console.error( response ); - throw new Error( 'Pull request failed' ); - } - } catch ( error ) { - console.error( 'Pull request failed:', error ); - - Sentry.captureException( error ); - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.failed, - } ); - - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), connectedSite.name ), - message: __( 'Studio was unable to connect to WordPress.com. Please try again.' ), - } ); - } - }, - [ __, client, pullStatesProgressInfo, updatePullState ] - ); - - const checkBackupFileSize = async ( downloadUrl: string ): Promise< number > => { - try { - return await getIpcApi().checkSyncBackupSize( downloadUrl ); - } catch ( error ) { - console.error( 'Failed to check backup file size', error ); - Sentry.captureException( error ); - throw new Error( 'Failed to check backup file size' ); - } - }; - - const onBackupCompleted = useCallback( - async ( remoteSiteId: number, backupState: SyncBackupState & { downloadUrl: string } ) => { - const { downloadUrl, selectedSite, remoteSiteUrl } = backupState; - - try { - const fileSize = await checkBackupFileSize( downloadUrl ); - - if ( fileSize > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - const CANCEL_ID = 1; - - const { response: userChoice } = await getIpcApi().showMessageBox( { - type: 'warning', - message: __( "Large site's backup" ), - detail: sprintf( - __( - "Your site's backup exceeds %s GB. Pulling it will prevent you from pushing the site back.\n\nDo you want to continue?" - ), - SYNC_PUSH_SIZE_LIMIT_GB - ), - buttons: [ __( 'Continue' ), __( 'Cancel' ) ], - defaultId: 0, - cancelId: CANCEL_ID, - } ); - - if ( userChoice === CANCEL_ID ) { - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.cancelled, - } ); - clearPullState( selectedSite.id, remoteSiteId ); - return; - } - } - - // Initiating backup file download - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.downloading, - downloadUrl, - } ); - - const filePath = await getIpcApi().downloadSyncBackup( remoteSiteId, downloadUrl ); - - // Starting import process - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.importing, - } ); - - await importFile( - { - path: filePath, - type: 'application/tar+gzip', - }, - selectedSite, - { showImportNotification: false } - ); - - await getIpcApi().removeSyncBackup( remoteSiteId ); - - await startServer( selectedSite.id ); - - clearImportState( selectedSite.id ); - - // Sync pull operation completed successfully - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.finished, - } ); - - getIpcApi().showNotification( { - title: selectedSite.name, - body: sprintf( - // translators: %s is the site url without the protocol. - __( 'Studio site has been updated from %s' ), - getHostnameFromUrl( remoteSiteUrl ) - ), - } ); - - onPullSuccess?.( remoteSiteId, selectedSite.id ); - } catch ( error ) { - console.error( 'Backup completion failed:', error ); - Sentry.captureException( error ); - updatePullState( selectedSite.id, remoteSiteId, { - status: pullStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pulling from %s' ), selectedSite.name ), - message: __( 'Failed to check backup file size. Please try again.' ), - } ); - } - }, - [ - __, - clearImportState, - clearPullState, - importFile, - onPullSuccess, - pullStatesProgressInfo.cancelled, - pullStatesProgressInfo.downloading, - pullStatesProgressInfo.failed, - pullStatesProgressInfo.finished, - pullStatesProgressInfo.importing, - startServer, - updatePullState, - ] - ); - - const fetchAndUpdateBackup = useCallback( - async ( remoteSiteId: number, selectedSiteId: string ) => { - if ( ! client ) { - return; - } - - const backupId = getPullState( selectedSiteId, remoteSiteId )?.backupId; - if ( ! backupId ) { - console.error( 'No backup ID found' ); - return; - } - - try { - const response = await client.req.get< SyncBackupResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/backup`, - { - apiNamespace: 'wpcom/v2', - backup_id: backupId, - } - ); - - const hasBackupCompleted = response.status === 'finished'; - const downloadUrl = hasBackupCompleted ? response.download_url : null; - - if ( downloadUrl ) { - // Replacing the 'in-progress' status will stop the active listening for the backup completion - const backupState = getPullState( selectedSiteId, remoteSiteId ); - if ( backupState ) { - await onBackupCompleted( remoteSiteId, { - ...backupState, - downloadUrl, - } ); - } - } else { - const statusWithProgress = getBackupStatusWithProgress( - hasBackupCompleted, - pullStatesProgressInfo, - response - ); - - updatePullState( selectedSiteId, remoteSiteId, { - status: statusWithProgress, - downloadUrl, - } ); - } - } catch ( error ) { - console.error( 'Failed to fetch backup status:', error ); - throw error; - } - }, - [ - client, - getBackupStatusWithProgress, - getPullState, - onBackupCompleted, - pullStatesProgressInfo, - updatePullState, - ] - ); - - useEffect( () => { - const intervals: Record< string, NodeJS.Timeout > = {}; - - Object.entries( pullStates ).forEach( ( [ key, state ] ) => { - if ( state.backupId && state.status.key === 'in-progress' ) { - intervals[ key ] = setTimeout( () => { - void fetchAndUpdateBackup( state.remoteSiteId, state.selectedSite.id ); - }, 2000 ); - } - } ); - - return () => { - Object.values( intervals ).forEach( clearTimeout ); - }; - }, [ pullStates, fetchAndUpdateBackup ] ); - - const isAnySitePulling = useMemo< boolean >( () => { - return Object.values( pullStates ).some( ( state ) => isKeyPulling( state.status.key ) ); - }, [ pullStates, isKeyPulling ] ); - - const isSiteIdPulling = useCallback< IsSiteIdPulling >( - ( selectedSiteId, remoteSiteId ) => { - return Object.values( pullStates ).some( ( state ) => { - if ( state.selectedSite.id !== selectedSiteId ) { - return false; - } - if ( remoteSiteId !== undefined ) { - return isKeyPulling( state.status.key ) && state.remoteSiteId === remoteSiteId; - } - return isKeyPulling( state.status.key ); - } ); - }, - [ pullStates, isKeyPulling ] - ); - - return { pullStates, getPullState, pullSite, isAnySitePulling, isSiteIdPulling, clearPullState }; -} diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts deleted file mode 100644 index 8ae6d294e..000000000 --- a/src/hooks/sync-sites/use-sync-push.ts +++ /dev/null @@ -1,303 +0,0 @@ -import * as Sentry from '@sentry/electron/renderer'; -import { sprintf } from '@wordpress/i18n'; -import { useI18n } from '@wordpress/react-i18n'; -import { useCallback, useEffect, useMemo } from 'react'; -import { SYNC_PUSH_SIZE_LIMIT_BYTES } from 'src/constants'; -import { - ClearState, - generateStateId, - GetState, - UpdateState, - usePullPushStates, -} from 'src/hooks/sync-sites/use-pull-push-states'; -import { useAuth } from 'src/hooks/use-auth'; -import { - useSyncStatesProgressInfo, - PushStateProgressInfo, -} from 'src/hooks/use-sync-states-progress-info'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { getHostnameFromUrl } from 'src/lib/url-utils'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; -import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; - -export type SyncPushState = { - remoteSiteId: number; - status: PushStateProgressInfo; - selectedSite: SiteDetails; - remoteSiteUrl: string; -}; - -export type PushStates = Record< string, SyncPushState >; -type OnPushSuccess = ( siteId: number, localSiteId: string ) => void; -type PushSite = ( connectedSite: SyncSite, selectedSite: SiteDetails ) => Promise< void >; -type IsSiteIdPushing = ( selectedSiteId: string, remoteSiteId?: number ) => boolean; - -type UseSyncPushProps = { - pushStates: PushStates; - setPushStates: React.Dispatch< React.SetStateAction< PushStates > >; - onPushSuccess?: OnPushSuccess; -}; - -export type UseSyncPush = { - pushStates: PushStates; - getPushState: GetState< SyncPushState >; - pushSite: PushSite; - isAnySitePushing: boolean; - isSiteIdPushing: IsSiteIdPushing; - clearPushState: ClearState; -}; - -export function useSyncPush( { - pushStates, - setPushStates, - onPushSuccess, -}: UseSyncPushProps ): UseSyncPush { - const { __ } = useI18n(); - const { client } = useAuth(); - const { - updateState, - getState: getPushState, - clearState, - } = usePullPushStates< SyncPushState >( pushStates, setPushStates ); - const { - pushStatesProgressInfo, - isKeyPushing, - isKeyImporting, - isKeyFinished, - isKeyFailed, - getPushStatusWithProgress, - } = useSyncStatesProgressInfo(); - - const updatePushState = useCallback< UpdateState< SyncPushState > >( - ( selectedSiteId, remoteSiteId, state ) => { - updateState( selectedSiteId, remoteSiteId, state ); - const statusKey = state.status?.key; - - if ( isKeyFailed( statusKey ) || isKeyFinished( statusKey ) ) { - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } else { - getIpcApi().addSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - } - }, - [ isKeyFailed, isKeyFinished, updateState ] - ); - - const clearPushState = useCallback< ClearState >( - ( selectedSiteId, remoteSiteId ) => { - clearState( selectedSiteId, remoteSiteId ); - getIpcApi().clearSyncOperation( generateStateId( selectedSiteId, remoteSiteId ) ); - }, - [ clearState ] - ); - - const getPushProgressInfo = useCallback( - async ( remoteSiteId: number, syncPushState: SyncPushState ) => { - if ( ! client ) { - return; - } - - const response = await client.req.get< ImportResponse >( - `/sites/${ remoteSiteId }/studio-app/sync/import`, - { - apiNamespace: 'wpcom/v2', - } - ); - - let status: PushStateProgressInfo = pushStatesProgressInfo.creatingRemoteBackup; - if ( response.success && response.status === 'finished' ) { - status = pushStatesProgressInfo.finished; - onPushSuccess?.( remoteSiteId, syncPushState.selectedSite.id ); - getIpcApi().showNotification( { - title: syncPushState.selectedSite.name, - body: sprintf( - // translators: %s is the site url without the protocol. - __( '%s has been updated' ), - getHostnameFromUrl( syncPushState.remoteSiteUrl ) - ), - } ); - } else if ( response.success && response.status === 'failed' ) { - status = pushStatesProgressInfo.failed; - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), syncPushState.selectedSite.name ), - message: - response.error === 'Import timed out' - ? __( - "A timeout error occurred while pushing the site, likely due to its large size. Please try reducing the site's content or files and try again. If this problem persists, please contact support." - ) - : __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' - ), - showOpenLogs: true, - } ); - } else if ( response.success && response.status === 'archive_import_started' ) { - status = pushStatesProgressInfo.applyingChanges; - } else if ( response.success && response.status === 'archive_import_finished' ) { - status = pushStatesProgressInfo.finishing; - } - status = getPushStatusWithProgress( status, response ); - // Update state in any case to keep polling push state - updatePushState( syncPushState.selectedSite.id, syncPushState.remoteSiteId, { - status, - } ); - }, - [ - __, - client, - getPushStatusWithProgress, - onPushSuccess, - pushStatesProgressInfo.applyingChanges, - pushStatesProgressInfo.creatingRemoteBackup, - pushStatesProgressInfo.finishing, - pushStatesProgressInfo.failed, - pushStatesProgressInfo.finished, - updatePushState, - ] - ); - - const getErrorFromResponse = useCallback( - ( error: unknown ): string => { - if ( - typeof error === 'object' && - error !== null && - 'error' in error && - typeof ( error as { error: unknown } ).error === 'string' - ) { - return ( error as { error: string } ).error; - } - - return __( 'Studio was unable to connect to WordPress.com. Please try again.' ); - }, - [ __ ] - ); - - const pushSite = useCallback< PushSite >( - async ( connectedSite, selectedSite ) => { - if ( ! client ) { - return; - } - const remoteSiteId = connectedSite.id; - const remoteSiteUrl = connectedSite.url; - updatePushState( selectedSite.id, remoteSiteId, { - remoteSiteId, - status: pushStatesProgressInfo.creatingBackup, - selectedSite, - remoteSiteUrl, - } ); - - let archiveContent, archivePath, archiveSizeInBytes; - - try { - const result = await getIpcApi().exportSiteToPush( selectedSite.id ); - ( { archiveContent, archivePath, archiveSizeInBytes } = result ); - } catch ( error ) { - Sentry.captureException( error ); - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: __( - 'An error occurred while pushing the site. If this problem persists, please contact support.' - ), - error, - showOpenLogs: true, - } ); - return; - } - - if ( archiveSizeInBytes > SYNC_PUSH_SIZE_LIMIT_BYTES ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: __( - 'The site is too large to push. Please reduce the size of the site and try again.' - ), - } ); - return; - } - - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.uploading, - } ); - - const file = new File( [ archiveContent ], 'loca-env-site-1.tar.gz', { - type: 'application/gzip', - } ); - const formData = [ [ 'import', file ] ]; - try { - const response = await client.req.post< { - success: boolean; - } >( { - path: `/sites/${ remoteSiteId }/studio-app/sync/import`, - apiNamespace: 'wpcom/v2', - formData, - } ); - if ( response.success ) { - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.creatingRemoteBackup, - } ); - } else { - console.error( response ); - throw new Error( 'Push request failed' ); - } - } catch ( error ) { - Sentry.captureException( error ); - updatePushState( selectedSite.id, remoteSiteId, { - status: pushStatesProgressInfo.failed, - } ); - getIpcApi().showErrorMessageBox( { - title: sprintf( __( 'Error pushing to %s' ), connectedSite.name ), - message: getErrorFromResponse( error ), - } ); - } finally { - await getIpcApi().removeTemporalFile( archivePath ); - } - }, - [ __, client, pushStatesProgressInfo, updatePushState, getErrorFromResponse ] - ); - - useEffect( () => { - const intervals: Record< string, NodeJS.Timeout > = {}; - - Object.entries( pushStates ).forEach( ( [ key, state ] ) => { - if ( isKeyImporting( state.status.key ) ) { - intervals[ key ] = setTimeout( () => { - void getPushProgressInfo( state.remoteSiteId, state ); - }, 2000 ); - } - } ); - - return () => { - Object.values( intervals ).forEach( clearTimeout ); - }; - }, [ - pushStates, - getPushProgressInfo, - pushStatesProgressInfo.creatingBackup.key, - pushStatesProgressInfo.applyingChanges.key, - isKeyImporting, - ] ); - - const isAnySitePushing = useMemo< boolean >( () => { - return Object.values( pushStates ).some( ( state ) => isKeyPushing( state.status.key ) ); - }, [ pushStates, isKeyPushing ] ); - - const isSiteIdPushing = useCallback< IsSiteIdPushing >( - ( selectedSiteId, remoteSiteId ) => { - return Object.values( pushStates ).some( ( state ) => { - if ( state.selectedSite.id !== selectedSiteId ) { - return false; - } - if ( remoteSiteId !== undefined ) { - return isKeyPushing( state.status.key ) && state.remoteSiteId === remoteSiteId; - } - return isKeyPushing( state.status.key ); - } ); - }, - [ pushStates, isKeyPushing ] - ); - - return { pushStates, getPushState, pushSite, isAnySitePushing, isSiteIdPushing, clearPushState }; -} diff --git a/src/hooks/tests/use-sync-sites.test.tsx b/src/hooks/tests/use-sync-sites.test.tsx deleted file mode 100644 index 1b53d8ee1..000000000 --- a/src/hooks/tests/use-sync-sites.test.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { SyncSitesProvider, useSyncSites } from 'src/hooks/sync-sites'; -import { useAuth } from 'src/hooks/use-auth'; -import { ContentTabsProvider } from 'src/hooks/use-content-tabs'; -import { useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites'; -import { useSiteDetails } from 'src/hooks/use-site-details'; - -jest.mock( 'src/hooks/use-auth' ); -jest.mock( 'src/hooks/use-site-details' ); -jest.mock( 'src/hooks/use-fetch-wpcom-sites' ); - -const mockConnectedWpcomSites = [ - { - id: 6, - localSiteId: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c', - name: 'My simple business site', - url: 'https://developer.wordpress.com/studio/', - isStaging: false, - isPressable: false, - stagingSiteIds: [ 7 ], - syncSupport: 'syncable', - lastPullTimestamp: null, - lastPushTimestamp: null, - }, - { - id: 7, - localSiteId: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c', - name: 'Staging: My simple business site', - url: 'https://developer-staging.wordpress.com/studio/', - isStaging: true, - isPressable: false, - stagingSiteIds: [], - syncSupport: 'syncable', - lastPullTimestamp: null, - lastPushTimestamp: null, - }, -]; - -const mockSyncSites = [ - { - id: 8, - localSiteId: '', - name: 'My simple store', - url: 'https://developer.wordpress.com/studio/store', - isStaging: false, - isPressable: false, - stagingSiteIds: [ 9 ], - syncSupport: 'syncable', - lastPullTimestamp: null, - lastPushTimestamp: null, - }, - { - id: 9, - localSiteId: '', - name: 'Staging: My simple test store', - url: 'https://developer-staging.wordpress.com/studio/test-store', - isStaging: true, - isPressable: false, - stagingSiteIds: [], - syncSupport: 'syncable', - lastPullTimestamp: null, - lastPushTimestamp: null, - }, -]; - -const disconnectWpcomSiteMock = jest.fn().mockResolvedValue( [] ); -const connectWpcomSiteMock = jest - .fn() - .mockResolvedValue( [ ...mockConnectedWpcomSites, { id: 6, stagingSiteIds: [] } ] ); - -jest.mock( 'src/lib/get-ipc-api', () => ( { - getIpcApi: () => ( { - getConnectedWpcomSites: jest.fn().mockResolvedValue( mockConnectedWpcomSites ), - connectWpcomSites: connectWpcomSiteMock, - disconnectWpcomSites: disconnectWpcomSiteMock, - updateConnectedWpcomSites: jest.fn(), - } ), -} ) ); - -describe( 'useSyncSites management', () => { - const wrapper = ( { children }: { children: React.ReactNode } ) => ( - - { children } - - ); - - beforeEach( () => { - ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: true } ); - ( useSiteDetails as jest.Mock ).mockReturnValue( { - selectedSite: { id: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c' }, - } ); - ( useFetchWpComSites as jest.Mock ).mockReturnValue( { - syncSites: mockSyncSites, - isFetching: false, - } ); - } ); - - afterEach( () => { - jest.clearAllMocks(); - } ); - - it( 'loads connected sites on mount when authenticated', async () => { - const { result } = renderHook( () => useSyncSites(), { wrapper } ); - - await waitFor( () => { - expect( result.current.connectedSites ).toEqual( mockConnectedWpcomSites ); - } ); - } ); - - it( 'does not load connected sites when not authenticated', async () => { - ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: false } ); - const { result } = renderHook( () => useSyncSites(), { wrapper } ); - - await waitFor( () => { - expect( result.current.connectedSites ).toEqual( [] ); - } ); - } ); - - it( 'connects a site and its staging sites successfully', async () => { - const { result } = renderHook( () => useSyncSites(), { wrapper } ); - const siteToConnect = mockSyncSites[ 0 ]; - - await waitFor( async () => { - await result.current.connectSite( { - ...siteToConnect, - isPressable: false, - syncSupport: 'syncable', - } ); - } ); - - await waitFor( () => { - expect( connectWpcomSiteMock ).toHaveBeenCalledWith( [ - { - localSiteId: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c', - sites: [ siteToConnect, mockSyncSites[ 1 ] ], - }, - ] ); - } ); - } ); - - it( 'disconnects a site and its staging sites successfully', async () => { - const { result } = renderHook( () => useSyncSites(), { wrapper } ); - const siteToDisconnect = mockConnectedWpcomSites[ 0 ]; - - await waitFor( () => { - expect( result.current.connectedSites ).toBeDefined(); - expect( result.current.connectedSites ).toEqual( mockConnectedWpcomSites ); - } ); - - await waitFor( async () => { - await result.current.disconnectSite( siteToDisconnect.id ); - } ); - - expect( disconnectWpcomSiteMock ).toHaveBeenCalledWith( [ - { - localSiteId: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c', - siteIds: [ siteToDisconnect.id, ...siteToDisconnect.stagingSiteIds ], - }, - ] ); - } ); -} ); From 27cee8b082efb6c9a9b6d48bb302ad35b0275de8 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Fri, 13 Jun 2025 21:24:41 +0100 Subject: [PATCH 2/3] Add sync redux slice --- src/stores/index.ts | 3 + src/stores/sync-sites-slice.ts | 372 +++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/stores/sync-sites-slice.ts diff --git a/src/stores/index.ts b/src/stores/index.ts index 81564ba83..6fa01d4f2 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -15,6 +15,7 @@ import i18nReducer from 'src/stores/i18n-slice'; import { installedAppsApi } from 'src/stores/installed-apps-api'; import { reducer as newSitesReducer } from 'src/stores/new-sites-slice'; import { reducer as snapshotReducer, updateSnapshotLocally } from 'src/stores/snapshot-slice'; +import { syncSitesReducer } from 'src/stores/sync-sites-slice'; import { wpcomApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from 'common/lib/locale'; @@ -29,6 +30,7 @@ export type RootState = { wpcomApi: ReturnType< typeof wpcomApi.reducer >; certificateTrustApi: ReturnType< typeof certificateTrustApi.reducer >; i18n: ReturnType< typeof i18nReducer >; + syncSites: ReturnType< typeof syncSitesReducer >; }; const listenerMiddleware = createListenerMiddleware< RootState >(); @@ -80,6 +82,7 @@ export const rootReducer = combineReducers( { wpcomApi: wpcomApi.reducer, certificateTrustApi: certificateTrustApi.reducer, i18n: i18nReducer, + syncSites: syncSitesReducer, } ); export const store = configureStore( { diff --git a/src/stores/sync-sites-slice.ts b/src/stores/sync-sites-slice.ts new file mode 100644 index 000000000..def7a3689 --- /dev/null +++ b/src/stores/sync-sites-slice.ts @@ -0,0 +1,372 @@ +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + sitesEndpointResponseSchema, + transformSitesResponse, +} from 'src/hooks/use-fetch-wpcom-sites/index'; +import { reconcileConnectedSites } from 'src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { RootState } from './index'; +import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import type { BackupArchiveInfo } from 'src/lib/import-export/import/types'; + +// Types for pull/push state (to be expanded as needed) +type PullState = object; +type PushState = object; + +type IsSyncSitesSelectorOpen = boolean | { disconnectSiteId?: number }; + +interface SyncSitesError { + message: string; + code?: string | number; +} + +interface SyncSitesState { + pullStates: Record< string, PullState >; + pushStates: Record< string, PushState >; + connectedSites: SyncSite[]; + isSyncSitesSelectorOpen: IsSyncSitesSelectorOpen; + isFetching: boolean; + syncSites: SyncSite[]; + error?: SyncSitesError | null; +} + +const initialState: SyncSitesState = { + pullStates: {}, + pushStates: {}, + connectedSites: [], + isSyncSitesSelectorOpen: false, + isFetching: false, + syncSites: [], + error: null, +}; + +// Add a minimal type for the REST client +interface WpcomRestClient { + req: { + get: ( + path: { apiNamespace: string; path: string }, + params?: Record< string, unknown > + ) => Promise< unknown >; + }; +} + +// Async thunks (placeholder logic) +export const loadConnectedSites = createAsyncThunk( + 'syncSites/loadConnectedSites', + async ( localSiteId: string, { rejectWithValue } ) => { + try { + const sites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + return sites as SyncSite[]; + } catch ( error ) { + return rejectWithValue( error ); + } + } +); + +export const refetchSites = createAsyncThunk( + 'syncSites/refetchSites', + async ( { client }: { client: WpcomRestClient }, { rejectWithValue } ) => { + try { + const allConnectedSites = await getIpcApi().getConnectedWpcomSites(); + const fields = [ + 'name', + 'ID', + 'URL', + 'plan', + 'capabilities', + 'is_wpcom_atomic', + 'options', + 'jetpack', + 'is_deleted', + 'is_a8c', + 'hosting_provider_guess', + 'environment_type', + ].join( ',' ); + + const response = await client.req.get( + { + apiNamespace: 'rest/v1.2', + path: `/me/sites`, + }, + { + fields, + filter: 'atomic,wpcom', + options: 'created_at,wpcom_staging_blog_ids', + site_activity: 'active', + } + ); + + const parsedResponse = sitesEndpointResponseSchema.parse( response ); + const syncSites = transformSitesResponse( + parsedResponse.sites, + ( allConnectedSites as SyncSite[] ).map( ( site: SyncSite ) => site.id ) + ); + + const { updatedConnectedSites, stagingSitesToAdd, stagingSitesToDelete } = + reconcileConnectedSites( allConnectedSites as SyncSite[], syncSites ); + + await getIpcApi().updateConnectedWpcomSites( updatedConnectedSites ); + + if ( stagingSitesToDelete.length ) { + const data = stagingSitesToDelete.map( ( { id, localSiteId } ) => ( { + siteIds: [ id ], + localSiteId, + } ) ); + await getIpcApi().disconnectWpcomSites( data ); + } + + if ( stagingSitesToAdd.length ) { + const data = stagingSitesToAdd.map( ( site ) => ( { + sites: [ site ], + localSiteId: site.localSiteId, + } ) ); + await getIpcApi().connectWpcomSites( data ); + } + + return syncSites; + } catch ( error ) { + // Optionally: Sentry.captureException(error); + console.error( error ); + return rejectWithValue( error ); + } + } +); + +export const connectSite = createAsyncThunk( + 'syncSites/connectSite', + async ( + { sites, localSiteId }: { sites: SyncSite[]; localSiteId: string }, + { rejectWithValue } + ) => { + try { + await getIpcApi().connectWpcomSites( [ { sites, localSiteId } ] ); + // Return the updated list of connected sites + const updatedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + return updatedSites as SyncSite[]; + } catch ( error ) { + return rejectWithValue( error ); + } + } +); + +export const disconnectSite = createAsyncThunk( + 'syncSites/disconnectSite', + async ( + { siteIds, localSiteId }: { siteIds: number[]; localSiteId: string }, + { rejectWithValue } + ) => { + try { + await getIpcApi().disconnectWpcomSites( [ { siteIds, localSiteId } ] ); + // Return the updated list of connected sites + const updatedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + return updatedSites as SyncSite[]; + } catch ( error ) { + return rejectWithValue( error ); + } + } +); + +/** + * Pulls a remote site backup and imports it into a local site. + * @param remoteSiteId - The remote site ID (number) + * @param localSiteId - The local site ID (string) + * @param downloadUrl - The URL to download the backup from (string) + */ +export const pullSite = createAsyncThunk( + 'syncSites/pullSite', + async ( + { + remoteSiteId, + localSiteId, + downloadUrl, + }: { remoteSiteId: number; localSiteId: string; downloadUrl: string }, + { rejectWithValue } + ) => { + try { + // 1. Download backup from remote + const backupFilePath = await getIpcApi().downloadSyncBackup( remoteSiteId, downloadUrl ); + // 2. Construct BackupArchiveInfo + const backupFile: BackupArchiveInfo = { path: backupFilePath, type: 'tar.gz' }; + // 3. Import backup into local site + const result = await getIpcApi().importSite( { id: localSiteId, backupFile } ); + // 4. Remove backup file + await getIpcApi().removeSyncBackup( remoteSiteId ); + return result; + } catch ( error ) { + return rejectWithValue( error ); + } + } +); + +/** + * Exports a local site to a backup archive for upload to a remote site. + * @param localSiteId - The local site ID (string) + */ +export const pushSite = createAsyncThunk( + 'syncSites/pushSite', + async ( { localSiteId }: { localSiteId: string }, { rejectWithValue } ) => { + try { + // 1. Export local site to backup + const archiveInfo = await getIpcApi().exportSiteToPush( localSiteId ); + // 2. Return archive info for upload (caller must handle upload and cleanup) + return archiveInfo; + } catch ( error ) { + return rejectWithValue( error ); + } + } +); + +export const updateSiteTimestamp = createAsyncThunk( + 'syncSites/updateSiteTimestamp', + async ( + { updatedSite, localSiteId }: { updatedSite: SyncSite; localSiteId: string }, + { rejectWithValue } + ) => { + try { + await getIpcApi().updateSingleConnectedWpcomSite( updatedSite ); + // Return the updated list of connected sites + const updatedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + return updatedSites as SyncSite[]; + } catch ( error ) { + return rejectWithValue( error ); + } + } +); + +const syncSitesSlice = createSlice( { + name: 'syncSites', + initialState, + reducers: { + setIsSyncSitesSelectorOpen( state, action: PayloadAction< IsSyncSitesSelectorOpen > ) { + state.isSyncSitesSelectorOpen = action.payload; + }, + closeSyncSitesSelector( state ) { + state.isSyncSitesSelectorOpen = false; + }, + // TODO: add reducers for updating pull/push state, etc. + }, + extraReducers: ( builder ) => { + builder + // loadConnectedSites + .addCase( loadConnectedSites.pending, ( state ) => { + state.isFetching = true; + state.error = null; + } ) + .addCase( loadConnectedSites.fulfilled, ( state, action ) => { + state.isFetching = false; + state.connectedSites = action.payload; + } ) + .addCase( loadConnectedSites.rejected, ( state, action ) => { + state.isFetching = false; + state.error = { + message: String( action.error.message || 'Failed to load connected sites' ), + code: action.error.code, + }; + } ) + // refetchSites + .addCase( refetchSites.pending, ( state ) => { + state.isFetching = true; + state.error = null; + } ) + .addCase( refetchSites.fulfilled, ( state, action ) => { + state.isFetching = false; + state.syncSites = action.payload; + } ) + .addCase( refetchSites.rejected, ( state, action ) => { + state.isFetching = false; + state.error = { + message: String( action.error.message || 'Failed to refetch sites' ), + code: action.error.code, + }; + } ) + // connectSite + .addCase( connectSite.pending, ( state ) => { + state.isFetching = true; + state.error = null; + } ) + .addCase( connectSite.fulfilled, ( state, action ) => { + state.isFetching = false; + state.connectedSites = action.payload; + } ) + .addCase( connectSite.rejected, ( state, action ) => { + state.isFetching = false; + state.error = { + message: String( action.error.message || 'Failed to connect site' ), + code: action.error.code, + }; + } ) + // disconnectSite + .addCase( disconnectSite.pending, ( state ) => { + state.isFetching = true; + state.error = null; + } ) + .addCase( disconnectSite.fulfilled, ( state, action ) => { + state.isFetching = false; + state.connectedSites = action.payload; + } ) + .addCase( disconnectSite.rejected, ( state, action ) => { + state.isFetching = false; + state.error = { + message: String( action.error.message || 'Failed to disconnect site' ), + code: action.error.code, + }; + } ) + // updateSiteTimestamp + .addCase( updateSiteTimestamp.pending, ( state ) => { + state.isFetching = true; + state.error = null; + } ) + .addCase( updateSiteTimestamp.fulfilled, ( state, action ) => { + state.isFetching = false; + state.connectedSites = action.payload; + } ) + .addCase( updateSiteTimestamp.rejected, ( state, action ) => { + state.isFetching = false; + state.error = { + message: String( action.error.message || 'Failed to update site timestamp' ), + code: action.error.code, + }; + } ) + // pullSite + .addCase( pullSite.pending, ( state ) => { + state.isFetching = true; + state.error = null; + } ) + .addCase( pullSite.fulfilled, ( state ) => { + state.isFetching = false; + } ) + .addCase( pullSite.rejected, ( state, action ) => { + state.isFetching = false; + state.error = { + message: String( action.error.message || 'Failed to pull site' ), + code: action.error.code, + }; + } ) + // pushSite + .addCase( pushSite.pending, ( state ) => { + state.isFetching = true; + state.error = null; + } ) + .addCase( pushSite.fulfilled, ( state ) => { + state.isFetching = false; + } ) + .addCase( pushSite.rejected, ( state, action ) => { + state.isFetching = false; + state.error = { + message: String( action.error.message || 'Failed to push site' ), + code: action.error.code, + }; + } ); + }, +} ); + +export const syncSitesActions = syncSitesSlice.actions; +export const { reducer: syncSitesReducer } = syncSitesSlice; + +// Selectors +export const selectIsFetching = ( state: RootState ) => state.syncSites.isFetching; +export const selectError = ( state: RootState ) => state.syncSites.error; +export const selectConnectedSites = ( state: RootState ) => state.syncSites.connectedSites; +export const selectSyncSites = ( state: RootState ) => state.syncSites.syncSites; +export const selectIsSyncSitesSelectorOpen = ( state: RootState ) => + state.syncSites.isSyncSitesSelectorOpen; From 25e4fbc88061f114faf8bd69790f7dfebb67e39b Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Mon, 23 Jun 2025 20:39:50 +0100 Subject: [PATCH 3/3] Update sync sites slice --- src/stores/sync-sites-slice.ts | 199 +++++++++++++++++++++++++-------- 1 file changed, 154 insertions(+), 45 deletions(-) diff --git a/src/stores/sync-sites-slice.ts b/src/stores/sync-sites-slice.ts index def7a3689..df895083b 100644 --- a/src/stores/sync-sites-slice.ts +++ b/src/stores/sync-sites-slice.ts @@ -9,9 +9,28 @@ import type { RootState } from './index'; import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import type { BackupArchiveInfo } from 'src/lib/import-export/import/types'; -// Types for pull/push state (to be expanded as needed) -type PullState = object; -type PushState = object; +interface PullState { + status: { + key: 'in-progress' | 'downloading' | 'importing' | 'finished' | 'failed' | 'cancelled'; + message: string; + progress: number; + }; +} + +interface PushState { + status: { + key: + | 'creatingBackup' + | 'uploading' + | 'creatingRemoteBackup' + | 'applyingChanges' + | 'finishing' + | 'finished' + | 'failed'; + message: string; + progress: number; + }; +} type IsSyncSitesSelectorOpen = boolean | { disconnectSiteId?: number }; @@ -21,8 +40,8 @@ interface SyncSitesError { } interface SyncSitesState { - pullStates: Record< string, PullState >; - pushStates: Record< string, PushState >; + pullStates: Record< string, Record< number, PullState > >; + pushStates: Record< string, Record< number, PushState > >; connectedSites: SyncSite[]; isSyncSitesSelectorOpen: IsSyncSitesSelectorOpen; isFetching: boolean; @@ -40,7 +59,6 @@ const initialState: SyncSitesState = { error: null, }; -// Add a minimal type for the REST client interface WpcomRestClient { req: { get: ( @@ -50,7 +68,6 @@ interface WpcomRestClient { }; } -// Async thunks (placeholder logic) export const loadConnectedSites = createAsyncThunk( 'syncSites/loadConnectedSites', async ( localSiteId: string, { rejectWithValue } ) => { @@ -125,7 +142,6 @@ export const refetchSites = createAsyncThunk( return syncSites; } catch ( error ) { - // Optionally: Sentry.captureException(error); console.error( error ); return rejectWithValue( error ); } @@ -140,7 +156,6 @@ export const connectSite = createAsyncThunk( ) => { try { await getIpcApi().connectWpcomSites( [ { sites, localSiteId } ] ); - // Return the updated list of connected sites const updatedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); return updatedSites as SyncSite[]; } catch ( error ) { @@ -157,7 +172,6 @@ export const disconnectSite = createAsyncThunk( ) => { try { await getIpcApi().disconnectWpcomSites( [ { siteIds, localSiteId } ] ); - // Return the updated list of connected sites const updatedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); return updatedSites as SyncSite[]; } catch ( error ) { @@ -166,12 +180,6 @@ export const disconnectSite = createAsyncThunk( } ); -/** - * Pulls a remote site backup and imports it into a local site. - * @param remoteSiteId - The remote site ID (number) - * @param localSiteId - The local site ID (string) - * @param downloadUrl - The URL to download the backup from (string) - */ export const pullSite = createAsyncThunk( 'syncSites/pullSite', async ( @@ -183,13 +191,9 @@ export const pullSite = createAsyncThunk( { rejectWithValue } ) => { try { - // 1. Download backup from remote const backupFilePath = await getIpcApi().downloadSyncBackup( remoteSiteId, downloadUrl ); - // 2. Construct BackupArchiveInfo const backupFile: BackupArchiveInfo = { path: backupFilePath, type: 'tar.gz' }; - // 3. Import backup into local site const result = await getIpcApi().importSite( { id: localSiteId, backupFile } ); - // 4. Remove backup file await getIpcApi().removeSyncBackup( remoteSiteId ); return result; } catch ( error ) { @@ -198,17 +202,14 @@ export const pullSite = createAsyncThunk( } ); -/** - * Exports a local site to a backup archive for upload to a remote site. - * @param localSiteId - The local site ID (string) - */ export const pushSite = createAsyncThunk( 'syncSites/pushSite', - async ( { localSiteId }: { localSiteId: string }, { rejectWithValue } ) => { + async ( + { localSiteId, remoteSiteId }: { localSiteId: string; remoteSiteId: number }, + { rejectWithValue } + ) => { try { - // 1. Export local site to backup const archiveInfo = await getIpcApi().exportSiteToPush( localSiteId ); - // 2. Return archive info for upload (caller must handle upload and cleanup) return archiveInfo; } catch ( error ) { return rejectWithValue( error ); @@ -224,7 +225,6 @@ export const updateSiteTimestamp = createAsyncThunk( ) => { try { await getIpcApi().updateSingleConnectedWpcomSite( updatedSite ); - // Return the updated list of connected sites const updatedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); return updatedSites as SyncSite[]; } catch ( error ) { @@ -237,17 +237,39 @@ const syncSitesSlice = createSlice( { name: 'syncSites', initialState, reducers: { - setIsSyncSitesSelectorOpen( state, action: PayloadAction< IsSyncSitesSelectorOpen > ) { + setIsSyncSitesSelectorOpen: ( + state, + action: PayloadAction< boolean | { disconnectSiteId?: number } > + ) => { state.isSyncSitesSelectorOpen = action.payload; }, - closeSyncSitesSelector( state ) { - state.isSyncSitesSelectorOpen = false; + clearPullState: ( + state, + action: PayloadAction< { localSiteId: string; connectedSiteId: number } > + ) => { + const { localSiteId, connectedSiteId } = action.payload; + if ( state.pullStates[ localSiteId ] ) { + delete state.pullStates[ localSiteId ][ connectedSiteId ]; + if ( Object.keys( state.pullStates[ localSiteId ] ).length === 0 ) { + delete state.pullStates[ localSiteId ]; + } + } + }, + clearPushState: ( + state, + action: PayloadAction< { localSiteId: string; connectedSiteId: number } > + ) => { + const { localSiteId, connectedSiteId } = action.payload; + if ( state.pushStates[ localSiteId ] ) { + delete state.pushStates[ localSiteId ][ connectedSiteId ]; + if ( Object.keys( state.pushStates[ localSiteId ] ).length === 0 ) { + delete state.pushStates[ localSiteId ]; + } + } }, - // TODO: add reducers for updating pull/push state, etc. }, extraReducers: ( builder ) => { builder - // loadConnectedSites .addCase( loadConnectedSites.pending, ( state ) => { state.isFetching = true; state.error = null; @@ -263,7 +285,6 @@ const syncSitesSlice = createSlice( { code: action.error.code, }; } ) - // refetchSites .addCase( refetchSites.pending, ( state ) => { state.isFetching = true; state.error = null; @@ -279,7 +300,6 @@ const syncSitesSlice = createSlice( { code: action.error.code, }; } ) - // connectSite .addCase( connectSite.pending, ( state ) => { state.isFetching = true; state.error = null; @@ -295,7 +315,6 @@ const syncSitesSlice = createSlice( { code: action.error.code, }; } ) - // disconnectSite .addCase( disconnectSite.pending, ( state ) => { state.isFetching = true; state.error = null; @@ -311,7 +330,6 @@ const syncSitesSlice = createSlice( { code: action.error.code, }; } ) - // updateSiteTimestamp .addCase( updateSiteTimestamp.pending, ( state ) => { state.isFetching = true; state.error = null; @@ -327,13 +345,24 @@ const syncSitesSlice = createSlice( { code: action.error.code, }; } ) - // pullSite - .addCase( pullSite.pending, ( state ) => { + .addCase( pullSite.pending, ( state, action ) => { state.isFetching = true; state.error = null; + const { remoteSiteId, localSiteId } = action.meta.arg; + state.pullStates[ localSiteId ] = { + ...state.pullStates[ localSiteId ], + [ remoteSiteId ]: { + status: { key: 'in-progress', message: 'Initializing backup...', progress: 30 }, + }, + }; } ) - .addCase( pullSite.fulfilled, ( state ) => { + .addCase( pullSite.fulfilled, ( state, action ) => { state.isFetching = false; + const { remoteSiteId, localSiteId } = action.meta.arg; + delete state.pullStates[ localSiteId ][ remoteSiteId ]; + if ( Object.keys( state.pullStates[ localSiteId ] ).length === 0 ) { + delete state.pullStates[ localSiteId ]; + } } ) .addCase( pullSite.rejected, ( state, action ) => { state.isFetching = false; @@ -341,14 +370,30 @@ const syncSitesSlice = createSlice( { message: String( action.error.message || 'Failed to pull site' ), code: action.error.code, }; + const { remoteSiteId, localSiteId } = action.meta.arg; + delete state.pullStates[ localSiteId ][ remoteSiteId ]; + if ( Object.keys( state.pullStates[ localSiteId ] ).length === 0 ) { + delete state.pullStates[ localSiteId ]; + } } ) - // pushSite - .addCase( pushSite.pending, ( state ) => { + .addCase( pushSite.pending, ( state, action ) => { state.isFetching = true; state.error = null; + const { localSiteId, remoteSiteId } = action.meta.arg; + state.pushStates[ localSiteId ] = { + ...state.pushStates[ localSiteId ], + [ remoteSiteId ]: { + status: { key: 'creatingBackup', message: 'Creating backup...', progress: 20 }, + }, + }; } ) - .addCase( pushSite.fulfilled, ( state ) => { + .addCase( pushSite.fulfilled, ( state, action ) => { state.isFetching = false; + const { localSiteId, remoteSiteId } = action.meta.arg; + delete state.pushStates[ localSiteId ][ remoteSiteId ]; + if ( Object.keys( state.pushStates[ localSiteId ] ).length === 0 ) { + delete state.pushStates[ localSiteId ]; + } } ) .addCase( pushSite.rejected, ( state, action ) => { state.isFetching = false; @@ -356,17 +401,81 @@ const syncSitesSlice = createSlice( { message: String( action.error.message || 'Failed to push site' ), code: action.error.code, }; + const { localSiteId, remoteSiteId } = action.meta.arg; + delete state.pushStates[ localSiteId ][ remoteSiteId ]; + if ( Object.keys( state.pushStates[ localSiteId ] ).length === 0 ) { + delete state.pushStates[ localSiteId ]; + } } ); }, } ); -export const syncSitesActions = syncSitesSlice.actions; +export const syncSitesActions = { + ...syncSitesSlice.actions, + loadConnectedSites, + connectSite, + disconnectSite, + updateSiteTimestamp, + refetchSites, + pullSite, + pushSite, +}; + export const { reducer: syncSitesReducer } = syncSitesSlice; -// Selectors export const selectIsFetching = ( state: RootState ) => state.syncSites.isFetching; export const selectError = ( state: RootState ) => state.syncSites.error; export const selectConnectedSites = ( state: RootState ) => state.syncSites.connectedSites; export const selectSyncSites = ( state: RootState ) => state.syncSites.syncSites; export const selectIsSyncSitesSelectorOpen = ( state: RootState ) => state.syncSites.isSyncSitesSelectorOpen; +export const selectPullStates = ( state: RootState ) => state.syncSites.pullStates; +export const selectPushStates = ( state: RootState ) => state.syncSites.pushStates; + +export const selectIsAnySitePulling = ( state: RootState ) => { + const pullStates = selectPullStates( state ); + const pullingKeys = [ 'in-progress', 'downloading', 'importing' ]; + return Object.values( pullStates ).some( ( siteStates ) => + Object.values( siteStates ).some( + ( pullState ) => pullState?.status?.key && pullingKeys.includes( pullState.status.key ) + ) + ); +}; + +export const selectIsAnySitePushing = ( state: RootState ) => { + const pushStates = selectPushStates( state ); + const pushingKeys = [ + 'creatingBackup', + 'uploading', + 'creatingRemoteBackup', + 'applyingChanges', + 'finishing', + ]; + return Object.values( pushStates ).some( ( siteStates ) => + Object.values( siteStates ).some( + ( pushState ) => pushState?.status?.key && pushingKeys.includes( pushState.status.key ) + ) + ); +}; + +export const selectPullState = + ( localSiteId: string, connectedSiteId: number ) => ( state: RootState ) => { + return state.syncSites.pullStates[ localSiteId ]?.[ connectedSiteId ]; + }; + +export const selectPushState = + ( localSiteId: string, connectedSiteId: number ) => ( state: RootState ) => { + return state.syncSites.pushStates[ localSiteId ]?.[ connectedSiteId ]; + }; + +export const selectIsSiteIdPulling = + ( localSiteId: string, connectedSiteId: number ) => ( state: RootState ) => { + const pullState = selectPullState( localSiteId, connectedSiteId )( state ); + return Boolean( pullState?.status?.key?.includes( 'pulling' ) ); + }; + +export const selectIsSiteIdPushing = + ( localSiteId: string, connectedSiteId: number ) => ( state: RootState ) => { + const pushState = selectPushState( localSiteId, connectedSiteId )( state ); + return Boolean( pushState?.status?.key?.includes( 'pushing' ) ); + };