diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 483c1c3b9e..7c71ee9d2d 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,5 +1,4 @@ import { resolve } from 'path'; -import { pathToFileURL } from 'url'; import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import react from '@vitejs/plugin-react'; import topLevelAwait from 'vite-plugin-top-level-await'; diff --git a/src/components/content-tab-import-export.tsx b/src/components/content-tab-import-export.tsx index f91b83ff92..a01003edd7 100644 --- a/src/components/content-tab-import-export.tsx +++ b/src/components/content-tab-import-export.tsx @@ -11,6 +11,7 @@ import ProgressBar from 'src/components/progress-bar'; import { Tooltip } from 'src/components/tooltip'; import { ACCEPTED_IMPORT_FILE_TYPES } from 'src/constants'; import { useSyncSites } from 'src/hooks/sync-sites/sync-sites-context'; +import { useAuth } from 'src/hooks/use-auth'; import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog'; import { useDragAndDropFile } from 'src/hooks/use-drag-and-drop-file'; import { useImportExport } from 'src/hooks/use-import-export'; @@ -19,7 +20,7 @@ import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; import { useI18nLocale } from 'src/stores'; -import { useConnectedSitesData } from 'src/stores/sync'; +import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites-api'; interface ContentTabImportExportProps { selectedSite: SiteDetails; @@ -320,7 +321,11 @@ export function ContentTabImportExport( { selectedSite }: ContentTabImportExport const { __ } = useI18n(); const [ isSupported, setIsSupported ] = useState< boolean | null >( null ); const { isSiteIdPulling, isSiteIdPushing } = useSyncSites(); - const { connectedSites } = useConnectedSitesData(); + const { user } = useAuth(); + const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite.id, + userId: user?.id, + } ); const isPulling = connectedSites.some( ( site ) => isSiteIdPulling( selectedSite.id, site.id ) ); const isPushing = connectedSites.some( ( site ) => isSiteIdPushing( selectedSite.id, site.id ) ); const isThisSiteSyncing = isPulling || isPushing; diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx index 9a88072662..c046cc1e1a 100644 --- a/src/hooks/sync-sites/sync-sites-context.tsx +++ b/src/hooks/sync-sites/sync-sites-context.tsx @@ -10,23 +10,22 @@ import { mapImportResponseToPushState, } from 'src/hooks/sync-sites/use-sync-push'; import { useAuth } from 'src/hooks/use-auth'; +import { useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites'; import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps'; +import { useSiteDetails } from 'src/hooks/use-site-details'; import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; import { getIpcApi } from 'src/lib/get-ipc-api'; -import { useAppDispatch } from 'src/stores'; -import { useConnectedSitesData, useSyncSitesData, connectedSitesActions } from 'src/stores/sync'; +import { + useGetConnectedSitesForLocalSiteQuery, + useUpdateSiteTimestampMutation, +} from 'src/stores/sync/connected-sites-api'; import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; -type UpdateSiteTimestamp = ( - siteId: number | undefined, - localSiteId: string, - type: 'pull' | 'push' -) => Promise< void >; export type SyncSitesContextType = Omit< UseSyncPull, 'pullStates' > & Omit< UseSyncPush, 'pushStates' > & - ReturnType< typeof useSyncSitesData > & { + ReturnType< typeof useFetchWpComSites > & { getLastSyncTimeText: GetLastSyncTimeText; }; @@ -54,46 +53,21 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) [ formatRelativeTime ] ); - const { connectedSites } = useConnectedSitesData(); - const dispatch = useAppDispatch(); + const { selectedSite } = useSiteDetails(); + const { user } = useAuth(); + const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite?.id, + userId: user?.id, + } ); - const updateSiteTimestamp = useCallback< UpdateSiteTimestamp >( - async ( siteId, localSiteIdParam, type ) => { - const site = connectedSites.find( - ( { id, localSiteId: siteLocalId } ) => siteId === id && localSiteIdParam === siteLocalId - ); - - if ( ! site ) { - return; - } - - try { - const updatedSite = { - ...site, - [ type === 'pull' ? 'lastPullTimestamp' : 'lastPushTimestamp' ]: new Date().toISOString(), - }; - - await getIpcApi().updateSingleConnectedWpcomSite( updatedSite ); - - dispatch( - connectedSitesActions.updateSite( { - localSiteId: localSiteIdParam, - site: updatedSite, - } ) - ); - } catch ( error ) { - console.error( 'Failed to update timestamp:', error ); - } - }, - [ connectedSites, dispatch ] - ); + const [ updateSiteTimestamp ] = useUpdateSiteTimestampMutation(); const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState, cancelPull } = useSyncPull( { pullStates, setPullStates, onPullSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( remoteSiteId, localSiteId, 'pull' ), + updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'pull' } ), } ); const [ pushStates, setPushStates ] = useState< PushStates >( {} ); @@ -102,10 +76,12 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) pushStates, setPushStates, onPushSuccess: ( remoteSiteId, localSiteId ) => - updateSiteTimestamp( remoteSiteId, localSiteId, 'push' ), + updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'push' } ), } ); - const { syncSites, isFetching, refetchSites } = useSyncSitesData(); + const { syncSites, isFetching, refetchSites } = useFetchWpComSites( + connectedSites.map( ( { id } ) => id ) + ); useListenDeepLinkConnection( { refetchSites } ); const { client } = useAuth(); diff --git a/src/hooks/sync-sites/use-listen-deep-link-connection.ts b/src/hooks/sync-sites/use-listen-deep-link-connection.ts index b704831151..531bd80227 100644 --- a/src/hooks/sync-sites/use-listen-deep-link-connection.ts +++ b/src/hooks/sync-sites/use-listen-deep-link-connection.ts @@ -2,14 +2,14 @@ import { SyncSitesContextType } from 'src/hooks/sync-sites/sync-sites-context'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { useSiteDetails } from 'src/hooks/use-site-details'; -import { useConnectedSitesOperations } from 'src/stores/sync'; +import { useConnectSiteMutation } from 'src/stores/sync/connected-sites-api'; export function useListenDeepLinkConnection( { refetchSites, }: { refetchSites: SyncSitesContextType[ 'refetchSites' ]; } ) { - const { connectSite } = useConnectedSitesOperations(); + const [ connectSite ] = useConnectSiteMutation(); const { selectedSite, setSelectedSiteId } = useSiteDetails(); const { setSelectedTab, selectedTab } = useContentTabs(); @@ -22,7 +22,7 @@ export function useListenDeepLinkConnection( { // Select studio site that started the sync setSelectedSiteId( studioSiteId ); } - await connectSite( newConnectedSite, studioSiteId ); + await connectSite( { site: newConnectedSite, localSiteId: studioSiteId } ); if ( selectedTab !== 'sync' ) { // Switch to sync tab setSelectedTab( 'sync' ); diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index 5fcb6d9818..ac74e164b2 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -1,5 +1,4 @@ // Run tests: yarn test -- src/hooks/tests/use-add-site.test.tsx -import { configureStore } from '@reduxjs/toolkit'; import { renderHook, act } from '@testing-library/react'; import nock from 'nock'; import { Provider } from 'react-redux'; @@ -8,8 +7,8 @@ import { useAddSite } from 'src/hooks/use-add-site'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getWordPressProvider } from 'src/lib/wordpress-provider'; -import providerConstantsReducer from 'src/stores/provider-constants-slice'; -import { useConnectedSitesOperations } from 'src/stores/sync'; +import { store } from 'src/stores'; +import { setProviderConstants } from 'src/stores/provider-constants-slice'; import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; jest.mock( 'src/hooks/use-site-details' ); @@ -23,10 +22,7 @@ jest.mock( 'src/hooks/use-import-export', () => ( { } ), } ) ); -jest.mock( 'src/stores/sync', () => ( { - useConnectedSitesOperations: jest.fn(), -} ) ); - +const mockConnectWpcomSites = jest.fn().mockResolvedValue( undefined ); jest.mock( 'src/lib/get-ipc-api', () => ( { getIpcApi: () => ( { generateProposedSitePath: jest.fn().mockResolvedValue( { @@ -37,32 +33,12 @@ jest.mock( 'src/lib/get-ipc-api', () => ( { } ), showNotification: jest.fn(), getAllCustomDomains: jest.fn().mockResolvedValue( [] ), + connectWpcomSites: mockConnectWpcomSites, + getConnectedWpcomSites: jest.fn().mockResolvedValue( [] ), } ), } ) ); -// Helper to create a store with preloaded provider constants -function makeStoreWithProviderConstants( overrides = {} ) { - return configureStore( { - reducer: { - providerConstants: providerConstantsReducer, - // ...add other reducers as needed - }, - preloadedState: { - providerConstants: { - defaultPhpVersion: '8.3', - defaultWordPressVersion: 'latest', - allowedPhpVersions: [ '8.0', '8.1', '8.2', '8.3' ], - minimumWordPressVersion: '5.9.9', - ...overrides, - }, - }, - } ); -} - -const renderHookWithProvider = ( - hook: () => ReturnType< typeof useAddSite >, - store = makeStoreWithProviderConstants() -) => { +const renderHookWithProvider = ( hook: () => ReturnType< typeof useAddSite > ) => { return renderHook< ReturnType< typeof useAddSite >, void >( hook, { wrapper: ( { children } ) => { children }, } ); @@ -72,13 +48,22 @@ describe( 'useAddSite', () => { const mockCreateSite = jest.fn(); const mockUpdateSite = jest.fn(); const mockStartServer = jest.fn(); - const mockConnectSite = jest.fn(); const mockPullSite = jest.fn(); const mockSetSelectedTab = jest.fn(); beforeEach( () => { jest.clearAllMocks(); + // Prepopulate store with provider constants + store.dispatch( + setProviderConstants( { + defaultPhpVersion: '8.3', + defaultWordPressVersion: 'latest', + allowedPhpVersions: [ '8.0', '8.1', '8.2', '8.3' ], + minimumWordPressVersion: '5.9.9', + } ) + ); + ( useSiteDetails as jest.Mock ).mockReturnValue( { createSite: mockCreateSite, updateSite: mockUpdateSite, @@ -87,12 +72,6 @@ describe( 'useAddSite', () => { startServer: mockStartServer, } ); - mockConnectSite.mockResolvedValue( undefined ); - - ( useConnectedSitesOperations as jest.Mock ).mockReturnValue( { - connectSite: mockConnectSite, - } ); - mockPullSite.mockReset(); ( useSyncSites as jest.Mock ).mockReturnValue( { pullSite: mockPullSite, @@ -296,7 +275,12 @@ describe( 'useAddSite', () => { await result.current.handleAddSiteClick(); } ); - expect( mockConnectSite ).toHaveBeenCalledWith( remoteSite, createdSite.id ); + expect( mockConnectWpcomSites ).toHaveBeenCalledWith( [ + { + sites: [ remoteSite ], + localSiteId: createdSite.id, + }, + ] ); expect( mockPullSite ).toHaveBeenCalledWith( remoteSite, createdSite, { optionsToSync: [ 'all' ], } ); diff --git a/src/hooks/tests/use-connected-sites-operations.test.tsx b/src/hooks/tests/use-connected-sites-operations.test.tsx deleted file mode 100644 index ff18f1d1d5..0000000000 --- a/src/hooks/tests/use-connected-sites-operations.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { renderHook, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { useAuth } from 'src/hooks/use-auth'; -import { useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites'; -import { useSiteDetails } from 'src/hooks/use-site-details'; -import { store } from 'src/stores'; -import { useConnectedSitesData, useConnectedSitesOperations } from 'src/stores/sync'; -import { SyncSite } from '../use-fetch-wpcom-sites/types'; - -jest.mock( 'src/hooks/use-auth' ); -jest.mock( 'src/hooks/use-site-details' ); -jest.mock( 'src/hooks/use-fetch-wpcom-sites' ); -jest.mock( 'src/stores/sync', () => ( { - ...jest.requireActual( 'src/stores/sync' ), - useConnectedSitesData: jest.fn(), - useConnectedSitesOperations: jest.fn(), -} ) ); - -const mockConnectedWpcomSites: SyncSite[] = [ - { - id: 6, - localSiteId: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c', - name: 'My simple business site', - url: 'https://developer.wordpress.com/studio/', - isStaging: false, - isPressable: false, - 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, - syncSupport: 'syncable', - lastPullTimestamp: null, - lastPushTimestamp: null, - }, -]; - -const mockSyncSites: SyncSite[] = [ - { - id: 8, - localSiteId: '', - name: 'My simple store', - url: 'https://developer.wordpress.com/studio/store', - isStaging: false, - isPressable: false, - 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, - syncSupport: 'syncable', - lastPullTimestamp: null, - lastPushTimestamp: null, - }, -]; - -jest.mock( 'src/lib/get-ipc-api', () => ( { - getIpcApi: () => ( { - getConnectedWpcomSites: jest.fn().mockResolvedValue( mockConnectedWpcomSites ), - connectWpcomSites: jest.fn(), - disconnectWpcomSites: jest.fn(), - updateConnectedWpcomSites: jest.fn(), - } ), -} ) ); - -describe( 'useConnectedSitesOperations', () => { - const wrapper = ( { children }: { children: React.ReactNode } ) => ( - { children } - ); - - const mockDisconnectSite = jest.fn().mockResolvedValue( [] ); - const mockConnectSite = jest.fn().mockResolvedValue( [ ...mockConnectedWpcomSites, { id: 6 } ] ); - - 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, - } ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: mockConnectedWpcomSites, - localSiteId: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c', - } ); - ( useConnectedSitesOperations as jest.Mock ).mockReturnValue( { - connectSite: mockConnectSite, - disconnectSite: mockDisconnectSite, - } ); - } ); - - afterEach( () => { - jest.clearAllMocks(); - } ); - - it( 'loads connected sites on mount when authenticated', async () => { - const { result } = renderHook( () => useConnectedSitesData(), { 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 } ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [], - loading: false, - localSiteId: '788a7e0c-62d2-427e-8b1a-e6d5ac84b61c', - } ); - const { result } = renderHook( () => useConnectedSitesData(), { wrapper } ); - - await waitFor( () => { - expect( result.current.connectedSites ).toEqual( [] ); - } ); - } ); - - it( 'connects a site and its staging sites successfully', async () => { - const { result } = renderHook( () => useConnectedSitesOperations(), { wrapper } ); - const siteToConnect = mockSyncSites[ 0 ]; - - await waitFor( async () => { - await result.current.connectSite( { - ...siteToConnect, - isPressable: false, - syncSupport: 'syncable', - } ); - } ); - - await waitFor( () => { - expect( mockConnectSite ).toHaveBeenCalledWith( siteToConnect ); - } ); - } ); - - it( 'disconnects a site and its staging sites successfully', async () => { - const { result } = renderHook( () => useConnectedSitesOperations(), { wrapper } ); - const { result: resultConnectedSites } = renderHook( () => useConnectedSitesData(), { - wrapper, - } ); - const siteToDisconnect = mockConnectedWpcomSites[ 0 ]; - - await waitFor( () => { - expect( resultConnectedSites.current.connectedSites ).toBeDefined(); - expect( resultConnectedSites.current.connectedSites ).toEqual( mockConnectedWpcomSites ); - } ); - - await waitFor( async () => { - await result.current.disconnectSite( siteToDisconnect.id ); - } ); - - expect( mockDisconnectSite ).toHaveBeenCalledWith( siteToDisconnect.id ); - } ); -} ); diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index f8762494e7..cc487cdcf2 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -13,7 +13,7 @@ import { selectDefaultPhpVersion, selectDefaultWordPressVersion, } from 'src/stores/provider-constants-slice'; -import { useConnectedSitesOperations } from 'src/stores/sync'; +import { useConnectSiteMutation } from 'src/stores/sync/connected-sites-api'; import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import type { Blueprint } from 'src/stores/wpcom-api'; import type { SyncOption } from 'src/types'; @@ -22,7 +22,7 @@ export function useAddSite() { const { __ } = useI18n(); const { createSite, data: sites, loadingSites, startServer, updateSite } = useSiteDetails(); const { importFile, clearImportState } = useImportExport(); - const { connectSite } = useConnectedSitesOperations(); + const [ connectSite ] = useConnectSiteMutation(); const { pullSite } = useSyncSites(); const { setSelectedTab } = useContentTabs(); const defaultPhpVersion = useRootSelector( selectDefaultPhpVersion ); @@ -153,7 +153,7 @@ export function useAddSite() { } ); } else { if ( selectedRemoteSite ) { - await connectSite( selectedRemoteSite, newSite.id ); + await connectSite( { site: selectedRemoteSite, localSiteId: newSite.id } ); const pullOptions: SyncOption[] = [ 'all' ]; pullSite( selectedRemoteSite, newSite, { optionsToSync: pullOptions, diff --git a/src/index.html b/src/index.html deleted file mode 100644 index dd5c766413..0000000000 --- a/src/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - WordPress Studio - - -
- - diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index 47ed1c6ca3..08a15460e0 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -14,6 +14,7 @@ import ProgressBar from 'src/components/progress-bar'; import { Tooltip, DynamicTooltip } from 'src/components/tooltip'; import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; import { useSyncSites } from 'src/hooks/sync-sites'; +import { useAuth } from 'src/hooks/use-auth'; import { useImportExport } from 'src/hooks/use-import-export'; import { useOffline } from 'src/hooks/use-offline'; import { useSyncStatesProgressInfo } from 'src/hooks/use-sync-states-progress-info'; @@ -28,8 +29,8 @@ import { convertTreeToPushOptions, } from 'src/modules/sync/lib/convert-tree-to-sync-options'; import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; -import { useAppDispatch, useI18nLocale } from 'src/stores'; -import { connectedSitesActions, useConnectedSitesData } from 'src/stores/sync'; +import { useI18nLocale } from 'src/stores'; +import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites-api'; import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; const SyncConnectedSiteControls = ( { @@ -51,7 +52,11 @@ const SyncConnectedSiteControls = ( { isSiteIdPushing, getLastSyncTimeText, } = useSyncSites(); - const { connectedSites } = useConnectedSitesData(); + const { user } = useAuth(); + const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite.id, + userId: user?.id, + } ); const isAnyConnectedSiteSyncing = connectedSites.some( ( site ) => isSiteIdPulling( selectedSite.id, site.id ) || isSiteIdPushing( selectedSite.id, site.id ) @@ -348,16 +353,17 @@ const SyncConnectedSitesSectionItem = ( { type SyncConnectedSiteSectionProps = { connectedSite: SyncSite; disconnectSite: ( id: number ) => void; + openModal: () => void; selectedSite: SiteDetails; }; const SyncConnectedSiteSection = ( { connectedSite, disconnectSite, + openModal, selectedSite, }: SyncConnectedSiteSectionProps ) => { const { __ } = useI18n(); - const dispatch = useAppDispatch(); const locale = useI18nLocale(); const { clearPullState, isSiteIdPulling, isSiteIdPushing } = useSyncSites(); const isOffline = useOffline(); @@ -466,7 +472,7 @@ const SyncConnectedSiteSection = ( {
diff --git a/src/modules/sync/index.tsx b/src/modules/sync/index.tsx index dd08a82caf..2ae7d36661 100644 --- a/src/modules/sync/index.tsx +++ b/src/modules/sync/index.tsx @@ -1,12 +1,13 @@ import { check, Icon } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; -import { PropsWithChildren, useEffect, useState } from 'react'; +import { PropsWithChildren, useState } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; import Button from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; import { useSyncSites } from 'src/hooks/sync-sites'; import { useAuth } from 'src/hooks/use-auth'; +import { useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; @@ -18,14 +19,11 @@ import { convertTreeToPullOptions, convertTreeToPushOptions, } from 'src/modules/sync/lib/convert-tree-to-sync-options'; -import { useAppDispatch, useRootSelector } from 'src/stores'; import { - useConnectedSitesData, - useSyncSitesData, - useConnectedSitesOperations, - connectedSitesSelectors, - connectedSitesActions, -} from 'src/stores/sync'; + useConnectSiteMutation, + useDisconnectSiteMutation, + useGetConnectedSitesForLocalSiteQuery, +} from 'src/stores/sync/connected-sites-api'; import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import type { SyncModalMode } from 'src/modules/sync/types'; @@ -125,32 +123,30 @@ export type OpenSitesSyncSelector = ( options?: { disconnectSiteId?: number } ) export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } ) { const { __ } = useI18n(); - const dispatch = useAppDispatch(); - const isModalOpen = useRootSelector( connectedSitesSelectors.selectIsModalOpen ); - const reduxModalMode = useRootSelector( connectedSitesSelectors.selectModalMode ); - const { connectedSites } = useConnectedSitesData(); - const { syncSites, isFetching, refetchSites } = useSyncSitesData(); - const { connectSite, disconnectSite } = useConnectedSitesOperations(); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + const [ modalMode, setModalMode ] = useState< SyncModalMode | null >( null ); + const { isAuthenticated, user } = useAuth(); + const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite.id, + userId: user?.id, + } ); + const { syncSites, isFetching, refetchSites } = useFetchWpComSites( + connectedSites.map( ( { id } ) => id ) + ); + const [ connectSite ] = useConnectSiteMutation(); + const [ disconnectSite ] = useDisconnectSiteMutation(); const { pushSite, pullSite, isAnySitePulling, isAnySitePushing } = useSyncSites(); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | null >( null ); - const { isAuthenticated } = useAuth(); - - useEffect( () => { - if ( isAuthenticated ) { - void refetchSites(); - } - }, [ isAuthenticated, refetchSites ] ); - if ( ! isAuthenticated ) { return ; } const handleConnect = async ( newConnectedSite: SyncSite ) => { try { - await connectSite( newConnectedSite ); + await connectSite( { site: newConnectedSite, localSiteId: selectedSite.id } ); } catch ( error ) { getIpcApi().showErrorMessageBox( { title: __( 'Failed to connect to site' ), @@ -160,21 +156,16 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } }; const handleLaunchSite = () => { - dispatch( connectedSitesActions.openModal( 'push' ) ); + setIsModalOpen( true ); + setModalMode( 'push' ); }; const handleImportSite = () => { - dispatch( connectedSitesActions.openModal( 'pull' ) ); + setIsModalOpen( true ); + setModalMode( 'pull' ); }; const handleSiteSelection = async ( siteId: number, mode: SyncModalMode | null ) => { - const disconnectSiteId = - typeof isModalOpen === 'object' ? isModalOpen.disconnectSiteId : undefined; - - if ( disconnectSiteId ) { - await disconnectSite( disconnectSiteId ); - } - const selectedSiteFromList = syncSites.find( ( site ) => site.id === siteId ); if ( ! selectedSiteFromList ) { getIpcApi().showErrorMessageBox( { @@ -185,12 +176,12 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } } if ( mode === 'push' || mode === 'pull' ) { - dispatch( connectedSitesActions.setModalMode( mode ) ); + setModalMode( mode ); setSelectedRemoteSite( selectedSiteFromList ); } else { await handleConnect( selectedSiteFromList ); - dispatch( connectedSitesActions.setModalMode( null ) ); - dispatch( connectedSitesActions.closeModal() ); + setModalMode( null ); + setIsModalOpen( false ); } }; @@ -201,12 +192,18 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } + disconnectSite( { siteId: id, localSiteId: selectedSite.id } ) + } + openModal={ () => setIsModalOpen( true ) } />
dispatch( connectedSitesActions.openModal( 'connect' ) ) } + connectSite={ () => { + setIsModalOpen( true ); + setModalMode( 'connect' ); + } } > { __( 'Connect another site' ) } @@ -244,23 +241,23 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } { isModalOpen && ( { - dispatch( connectedSitesActions.closeModal() ); + setIsModalOpen( false ); } } syncSites={ syncSites } onInitialRender={ refetchSites } onConnect={ async ( siteId: number ) => { - await handleSiteSelection( siteId, reduxModalMode ); + await handleSiteSelection( siteId, modalMode ); } } selectedSite={ selectedSite } /> ) } - { reduxModalMode && reduxModalMode !== 'connect' && selectedRemoteSite && ( + { modalMode && modalMode !== 'connect' && selectedRemoteSite && ( { @@ -275,7 +272,7 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } } } onRequestClose={ () => { setSelectedRemoteSite( null ); - dispatch( connectedSitesActions.setModalMode( null ) ); + setModalMode( null ); } } /> ) } diff --git a/src/modules/sync/tests/index.test.tsx b/src/modules/sync/tests/index.test.tsx index 870fb7fa4b..43f549ecfd 100644 --- a/src/modules/sync/tests/index.test.tsx +++ b/src/modules/sync/tests/index.test.tsx @@ -5,22 +5,17 @@ import { SyncSitesProvider, useSyncSites } from 'src/hooks/sync-sites'; import { SyncPushState } from 'src/hooks/sync-sites/use-sync-push'; 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 { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ContentTabSync } from 'src/modules/sync'; import { useSelectedItemsPushSize } from 'src/modules/sync/hooks/use-selected-items-push-size'; import { store } from 'src/stores'; -import { - useLatestRewindId, - useRemoteFileTree, - useConnectedSitesData, - useSyncSitesData, - useConnectedSitesOperations, - connectedSitesSelectors, -} from 'src/stores/sync'; +import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync'; -jest.mock( 'src/hooks/use-auth' ); jest.mock( 'src/lib/get-ipc-api' ); +jest.mock( 'src/hooks/use-auth' ); +jest.mock( 'src/hooks/use-fetch-wpcom-sites' ); jest.mock( 'src/hooks/sync-sites/sync-sites-context', () => ( { ...jest.requireActual( '../../../hooks/sync-sites/sync-sites-context' ), useSyncSites: jest.fn(), @@ -39,9 +34,6 @@ jest.mock( 'src/stores/sync', () => ( { error: null, isLoading: false, } ), - useConnectedSitesData: jest.fn(), - useSyncSitesData: jest.fn(), - useConnectedSitesOperations: jest.fn(), connectedSitesSelectors: { selectIsModalOpen: jest.fn(), selectModalMode: jest.fn(), @@ -64,6 +56,7 @@ jest.mock( 'src/modules/sync/hooks/use-selected-items-push-size' ); const createAuthMock = ( isAuthenticated: boolean = false ) => ( { isAuthenticated, authenticate: jest.fn(), + user: isAuthenticated ? { id: 123, email: 'user@example.com' } : null, } ); const selectedSite: SiteDetails = { @@ -87,24 +80,30 @@ const inProgressPushState: SyncPushState = { remoteSiteUrl: 'https://example.com', }; -const fakeSyncSite = { +const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', syncSupport: 'already-connected', + isStaging: false, + isPressable: false, + localSiteId: 'site-id', + lastPullTimestamp: null, + lastPushTimestamp: null, }; describe( 'ContentTabSync', () => { const mockSyncSites = { pullSite: jest.fn(), + pushSite: jest.fn(), isAnySitePulling: false, isAnySitePushing: false, getPullState: jest.fn(), - getPushState: jest.fn().mockReturnValue( inProgressPushState ), + getPushState: jest.fn(), updateTimestamp: jest.fn(), - getLastSyncTimeWithType: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), + getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), + isSiteIdPulling: jest.fn().mockReturnValue( false ), + isSiteIdPushing: jest.fn().mockReturnValue( false ), clearTimeout: jest.fn(), }; @@ -112,12 +111,11 @@ describe( 'ContentTabSync', () => { connectedSites: SyncSite[] = [], syncSites: SyncSite[] = [] ) => { - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites, - loading: false, - localSiteId: 'site-id', - } ); - ( useSyncSitesData as jest.Mock ).mockReturnValue( { + // Update the IPC API mock to return the connected sites + const currentMock = ( getIpcApi as jest.Mock )(); + currentMock.getConnectedWpcomSites.mockResolvedValue( connectedSites ); + + ( useFetchWpComSites as jest.Mock ).mockReturnValue( { syncSites, isFetching: false, refetchSites: jest.fn(), @@ -125,6 +123,8 @@ describe( 'ContentTabSync', () => { }; beforeEach( () => { jest.resetAllMocks(); + // Clear RTK Query cache + store.dispatch( { type: 'connectedSitesApi/resetApiState' } ); ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( false ) ); ( getIpcApi as jest.Mock ).mockReturnValue( { authenticate: jest.fn(), @@ -134,6 +134,7 @@ describe( 'ContentTabSync', () => { updateConnectedWpcomSites: jest.fn(), getConnectedWpcomSites: jest.fn().mockResolvedValue( [] ), getDirectorySize: jest.fn().mockResolvedValue( 0 ), + connectWpcomSites: jest.fn(), listLocalFileTree: jest.fn().mockResolvedValue( [ { name: 'plugins', @@ -172,28 +173,15 @@ describe( 'ContentTabSync', () => { error: null, } ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [], - loading: false, - localSiteId: 'site-id', - } ); - - ( useSyncSitesData as jest.Mock ).mockReturnValue( { + ( useFetchWpComSites as jest.Mock ).mockReturnValue( { syncSites: [], isFetching: false, refetchSites: jest.fn(), } ); - ( useConnectedSitesOperations as jest.Mock ).mockReturnValue( { - connectSite: jest.fn(), - disconnectSite: jest.fn(), - } ); - const { useAppDispatch } = jest.requireMock( 'src/stores' ); useAppDispatch.mockReturnValue( jest.fn() ); - ( connectedSitesSelectors.selectIsModalOpen as jest.Mock ).mockReturnValue( false ); - ( connectedSitesSelectors.selectModalMode as jest.Mock ).mockReturnValue( null ); ( useRemoteFileTree as jest.Mock ).mockReturnValue( { fetchChildren: jest.fn().mockResolvedValue( [ { @@ -283,76 +271,50 @@ describe( 'ContentTabSync', () => { const importButton = screen.getByRole( 'button', { name: /Pull site/i } ); fireEvent.click( importButton ); - ( connectedSitesSelectors.selectIsModalOpen as jest.Mock ).mockReturnValue( true ); - ( connectedSitesSelectors.selectModalMode as jest.Mock ).mockReturnValue( null ); - - renderWithProvider( ); expect( screen.getByTestId( 'sync-sites-modal-selector' ) ).toBeInTheDocument(); } ); it( 'displays the list of connected sites', async () => { - const fakeSyncSite = { + const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', + isPressable: false, isStaging: false, + lastPullTimestamp: null, + lastPushTimestamp: null, + localSiteId: 'site-id', syncSupport: 'already-connected', }; ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - loading: false, - localSiteId: 'site-id', - } ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - pullSite: jest.fn(), - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn().mockReturnValue( undefined ), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - expect( screen.getByText( fakeSyncSite.name ) ).toBeInTheDocument(); + await screen.findByText( fakeSyncSite.name ); expect( screen.getByRole( 'button', { name: /Disconnect/i } ) ).toBeInTheDocument(); - expect( screen.getByRole( 'button', { name: /Pull/i } ) ).toBeInTheDocument(); - expect( screen.getByRole( 'button', { name: /Push/i } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Pull' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Push' } ) ).toBeInTheDocument(); expect( screen.getByText( 'Production' ) ).toBeInTheDocument(); } ); it( 'opens URL for connected sites', async () => { - const fakeSyncSite = { + const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', isStaging: false, syncSupport: 'already-connected', + localSiteId: 'site-id', + isPressable: false, + lastPullTimestamp: null, + lastPushTimestamp: null, }; ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - loading: false, - localSiteId: 'site-id', - } ); - ( useSyncSites as jest.Mock ).mockReturnValue( { - pullSite: jest.fn(), - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn().mockReturnValue( inProgressPushState ), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const readableUrl = fakeSyncSite.url.replace( 'https://', '' ); - const urlButton = screen.getByRole( 'button', { + const readableUrl = fakeSyncSite.url.replace( /^https?:\/\//, '' ); + const urlButton = await screen.findByRole( 'button', { name: ( content ) => content.includes( readableUrl ), } ); expect( urlButton ).toBeInTheDocument(); @@ -367,9 +329,6 @@ describe( 'ContentTabSync', () => { const importButton = screen.getByRole( 'button', { name: /Pull site/i } ); expect( importButton ).toBeInTheDocument(); fireEvent.click( importButton ); - ( connectedSitesSelectors.selectIsModalOpen as jest.Mock ).mockReturnValue( true ); - ( connectedSitesSelectors.selectModalMode as jest.Mock ).mockReturnValue( null ); - renderWithProvider( ); const createNewSiteButton = screen.getByRole( 'button', { name: /Create a new WordPress.com site ↗/i, } ); @@ -387,7 +346,7 @@ describe( 'ContentTabSync', () => { expect( importButton ).toBeInTheDocument(); } ); - it( 'displays environment badges for Pressable sites with production, staging and development environments', () => { + it( 'displays environment badges for Pressable sites with production, staging and development environments', async () => { const fakePressableProductionSite: SyncSite = { id: 6, name: 'My Pressable Production site', @@ -432,24 +391,9 @@ describe( 'ContentTabSync', () => { fakePressableDevelopmentSite, ]; setupConnectedSitesMocks( allSites, [ fakePressableProductionSite ] ); - - ( useSyncSites as jest.Mock ).mockReturnValue( { - connectedSites: allSites, - syncSites: [ fakePressableProductionSite ], - pullSite: jest.fn(), - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn().mockReturnValue( undefined ), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), - } ); - renderWithProvider( ); - expect( screen.getByText( fakePressableProductionSite.name ) ).toBeInTheDocument(); + await screen.findByText( fakePressableProductionSite.name ); expect( screen.getByText( fakePressableStagingSite.name ) ).toBeInTheDocument(); expect( screen.getByText( fakePressableDevelopmentSite.name ) ).toBeInTheDocument(); @@ -457,7 +401,7 @@ describe( 'ContentTabSync', () => { expect( screen.getByText( 'Staging' ) ).toBeInTheDocument(); expect( screen.getByText( 'Development' ) ).toBeInTheDocument(); } ); - it( 'displays the progress bar when the site is being pushed', () => { + it( 'displays the progress bar when the site is being pushed', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); const fakeSyncSite: SyncSite = { id: 6, @@ -472,55 +416,41 @@ describe( 'ContentTabSync', () => { }; setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); ( useSyncSites as jest.Mock ).mockReturnValue( { - pullSite: jest.fn(), - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), + ...mockSyncSites, getPushState: jest.fn().mockReturnValue( inProgressPushState ), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), isSiteIdPushing: jest.fn().mockReturnValue( true ), - clearTimeout: jest.fn(), } ); renderWithProvider( ); - expect( screen.getByRole( 'progressbar' ) ).toBeInTheDocument(); + await screen.findByRole( 'progressbar' ); } ); it( 'opens sync pullSite dialog with development environment label', async () => { const mockPullSite = jest.fn(); ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - const fakeSyncSite = { + const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', syncSupport: 'already-connected', + isStaging: false, isPressable: true, environmentType: 'development', + localSiteId: 'site-id', + lastPullTimestamp: null, + lastPushTimestamp: null, }; - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSyncSites as jest.Mock ).mockReturnValue( { + ...mockSyncSites, syncSites: [ fakeSyncSite ], pullSite: mockPullSite, - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn(), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), } ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( pullButton ).toBeInTheDocument(); fireEvent.click( pullButton ); @@ -532,33 +462,28 @@ describe( 'ContentTabSync', () => { it( 'opens sync pullSite dialog and displays production when the environment is not supported', async () => { const mockPullSite = jest.fn(); - ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: true, authenticate: jest.fn() } ); - const fakeSyncSite = { + ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); + const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', syncSupport: 'already-connected', + isStaging: false, isPressable: true, environmentType: 'non-supported-environment-example-or-sandbox', + localSiteId: 'site-id', + lastPullTimestamp: null, + lastPushTimestamp: null, }; - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSyncSites as jest.Mock ).mockReturnValue( { + ...mockSyncSites, pullSite: mockPullSite, - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn(), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), } ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( pullButton ).toBeInTheDocument(); fireEvent.click( pullButton ); @@ -571,30 +496,26 @@ describe( 'ContentTabSync', () => { it( 'calls pullSite with correct optionsToSync when all options are selected', async () => { const mockPullSite = jest.fn(); ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - const fakeSyncSite = { + const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', syncSupport: 'already-connected', + localSiteId: 'site-id', + isStaging: false, + isPressable: false, + lastPullTimestamp: null, + lastPushTimestamp: null, }; - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSyncSites as jest.Mock ).mockReturnValue( { + ...mockSyncSites, pullSite: mockPullSite, - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn(), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), } ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( pullButton ).toBeInTheDocument(); fireEvent.click( pullButton ); @@ -605,8 +526,8 @@ describe( 'ContentTabSync', () => { const databaseCheckbox = screen.getByRole( 'checkbox', { name: 'Database' } ); fireEvent.click( databaseCheckbox ); - const dialogPullButton = screen.getAllByRole( 'button', { name: /Pull/i } ); - fireEvent.click( dialogPullButton[ 1 ] ); + const dialogPullButton = await screen.findByRole( 'button', { name: 'Pull' } ); + fireEvent.click( dialogPullButton ); expect( mockPullSite ).toHaveBeenCalledWith( fakeSyncSite, selectedSite, { optionsToSync: [ 'all' ], @@ -616,30 +537,26 @@ describe( 'ContentTabSync', () => { it( 'calls pullSite with correct optionsToSync when only database is selected', async () => { const mockPullSite = jest.fn(); ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - const fakeSyncSite = { + const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', syncSupport: 'already-connected', + localSiteId: 'site-id', + isStaging: false, + isPressable: false, + lastPullTimestamp: null, + lastPushTimestamp: null, }; - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSyncSites as jest.Mock ).mockReturnValue( { + ...mockSyncSites, pullSite: mockPullSite, - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn(), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), } ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( pullButton ).toBeInTheDocument(); fireEvent.click( pullButton ); @@ -648,8 +565,8 @@ describe( 'ContentTabSync', () => { const databaseCheckbox = screen.getByRole( 'checkbox', { name: 'Database' } ); fireEvent.click( databaseCheckbox ); - const dialogPullButton = screen.getAllByRole( 'button', { name: /Pull/i } ); - fireEvent.click( dialogPullButton[ 1 ] ); + const dialogPullButton = await screen.findByRole( 'button', { name: 'Pull' } ); + fireEvent.click( dialogPullButton ); expect( mockPullSite ).toHaveBeenCalledWith( fakeSyncSite, selectedSite, { optionsToSync: [ 'sqls' ], @@ -724,30 +641,26 @@ describe( 'ContentTabSync', () => { error: null, isLoading: false, } ); - const fakeSyncSite = { + const fakeSyncSite: SyncSite = { id: 6, name: 'My simple business site that needs a transfer', url: 'https://developer.wordpress.com/studio/', syncSupport: 'already-connected', + isStaging: false, + localSiteId: 'site-id', + isPressable: false, + lastPullTimestamp: null, + lastPushTimestamp: null, }; - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSyncSites as jest.Mock ).mockReturnValue( { + ...mockSyncSites, pullSite: mockPullSite, - isAnySitePulling: false, - isAnySitePushing: false, - getPullState: jest.fn(), - getPushState: jest.fn(), - getLastSyncTimeText: jest.fn().mockReturnValue( 'You have not pulled this site yet.' ), - isSiteIdPulling: jest.fn(), - isSiteIdPushing: jest.fn(), - clearTimeout: jest.fn(), } ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( pullButton ).toBeInTheDocument(); fireEvent.click( pullButton ); @@ -766,8 +679,8 @@ describe( 'ContentTabSync', () => { const uploadsCheckbox = screen.getByRole( 'checkbox', { name: 'uploads' } ); fireEvent.click( uploadsCheckbox ); - const dialogPullButton = screen.getAllByRole( 'button', { name: /Pull/i } ); - fireEvent.click( dialogPullButton[ 1 ] ); + const dialogPullButton = await screen.findByRole( 'button', { name: 'Pull' } ); + fireEvent.click( dialogPullButton ); expect( mockPullSite ).toHaveBeenCalledWith( fakeSyncSite, selectedSite, { optionsToSync: [ 'paths', 'sqls' ], @@ -777,45 +690,39 @@ describe( 'ContentTabSync', () => { it( 'disables the pull button when all checkboxes are unchecked, which is the initial state', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); fireEvent.click( pullButton ); await screen.findByText( 'Pull from Production' ); - const dialogPullButton = screen.getAllByRole( 'button', { name: /Pull/i } )[ 1 ]; + const dialogPullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( dialogPullButton ).toBeDisabled(); } ); it( 'disables the push button when all checkboxes are unchecked, which is the initial state', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const pushButton = screen.getByRole( 'button', { name: /Push/i } ); + const pushButton = await screen.findByRole( 'button', { name: 'Push' } ); fireEvent.click( pushButton ); await screen.findByText( 'Push to Production' ); - const dialogPushButton = screen.getAllByRole( 'button', { name: /Push/i } )[ 1 ]; + const dialogPushButton = await screen.findByRole( 'button', { name: 'Push' } ); expect( dialogPushButton ).toBeDisabled(); } ); it( 'enables the pull button when at least one checkbox is checked', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); fireEvent.click( pullButton ); await screen.findByText( 'Pull from Production' ); @@ -824,19 +731,17 @@ describe( 'ContentTabSync', () => { const databaseCheckbox = screen.getByRole( 'checkbox', { name: 'Database' } ); fireEvent.click( databaseCheckbox ); - const dialogPullButton = screen.getAllByRole( 'button', { name: /Pull/i } )[ 1 ]; + const dialogPullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( dialogPullButton ).toBeEnabled(); } ); it( 'enables the pull button when at least one checkbox children is checked', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); fireEvent.click( pullButton ); await screen.findByText( 'Pull from Production' ); @@ -852,53 +757,48 @@ describe( 'ContentTabSync', () => { expect( databaseCheckbox ).not.toBeChecked(); expect( filesAndFoldersCheckbox ).not.toBeChecked(); - const dialogPullButton = screen.getAllByRole( 'button', { name: /Pull/i } )[ 1 ]; + const dialogPullButton = await screen.findByRole( 'button', { name: 'Pull' } ); expect( dialogPullButton ).toBeEnabled(); } ); + it( 'disables the push button when all checkboxes are unchecked', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const pushButton = screen.getByRole( 'button', { name: /Push/i } ); + const pushButton = await screen.findByRole( 'button', { name: 'Push' } ); fireEvent.click( pushButton ); await screen.findByText( 'Push to Production' ); - const dialogPushButton = screen.getAllByRole( 'button', { name: /Push/i } )[ 1 ]; + const dialogPushButton = await screen.findByRole( 'button', { name: 'Push' } ); expect( dialogPushButton ).toBeDisabled(); } ); it( 'enables the push button when at least one checkbox is checked', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const pushButton = screen.getByRole( 'button', { name: /Push/i } ); + const pushButton = await screen.findByRole( 'button', { name: 'Push' } ); fireEvent.click( pushButton ); await screen.findByText( 'Push to Production' ); const databaseCheckbox = screen.getByRole( 'checkbox', { name: 'Database' } ); fireEvent.click( databaseCheckbox ); - const dialogPushButton = screen.getAllByRole( 'button', { name: /Push/i } )[ 1 ]; + const dialogPushButton = await screen.findByRole( 'button', { name: 'Push' } ); expect( dialogPushButton ).toBeEnabled(); } ); it( 'enables the push button when at least one checkbox children is checked', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); renderWithProvider( ); - const pushButton = screen.getByRole( 'button', { name: /Push/i } ); + const pushButton = await screen.findByRole( 'button', { name: 'Push' } ); fireEvent.click( pushButton ); await screen.findByText( 'Push to Production' ); @@ -915,18 +815,14 @@ describe( 'ContentTabSync', () => { expect( databaseCheckbox ).not.toBeChecked(); expect( filesAndFoldersCheckbox ).not.toBeChecked(); - const dialogPushButton = screen.getAllByRole( 'button', { name: /Push/i } )[ 1 ]; + const dialogPushButton = await screen.findByRole( 'button', { name: 'Push' } ); expect( dialogPushButton ).toBeEnabled(); } ); describe( 'Sync Dialog Push Selection Over Limit Notice', () => { it( 'shows warning notice when push selection exceeds limit', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - loading: false, - localSiteId: 'site-id', - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSelectedItemsPushSize as jest.Mock ).mockReturnValue( { isPushSelectionOverLimit: true, isLoading: false, @@ -934,7 +830,7 @@ describe( 'ContentTabSync', () => { renderWithProvider( ); - const pushButton = screen.getByRole( 'button', { name: /Push/i } ); + const pushButton = await screen.findByRole( 'button', { name: 'Push' } ); fireEvent.click( pushButton ); await screen.findByText( 'Push to Production' ); @@ -942,17 +838,13 @@ describe( 'ContentTabSync', () => { const warningNotice = screen.getByTestId( 'push-selection-over-limit-notice' ); expect( warningNotice ).toBeInTheDocument(); - const dialogPushButton = screen.getAllByRole( 'button', { name: /Push/i } )[ 1 ]; + const dialogPushButton = await screen.findByRole( 'button', { name: 'Push' } ); expect( dialogPushButton ).toBeDisabled(); } ); it( 'does not show warning notice when push selection is within limit', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - loading: false, - localSiteId: 'site-id', - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSelectedItemsPushSize as jest.Mock ).mockReturnValue( { isPushSelectionOverLimit: false, isLoading: false, @@ -960,7 +852,7 @@ describe( 'ContentTabSync', () => { renderWithProvider( ); - const pushButton = screen.getByRole( 'button', { name: /Push/i } ); + const pushButton = await screen.findByRole( 'button', { name: 'Push' } ); fireEvent.click( pushButton ); await screen.findByText( 'Push to Production' ); @@ -971,11 +863,7 @@ describe( 'ContentTabSync', () => { it( 'does not show warning notice for pull operations even when limit exceeded', async () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); - ( useConnectedSitesData as jest.Mock ).mockReturnValue( { - connectedSites: [ fakeSyncSite ], - loading: false, - localSiteId: 'site-id', - } ); + setupConnectedSitesMocks( [ fakeSyncSite ], [ fakeSyncSite ] ); ( useSelectedItemsPushSize as jest.Mock ).mockReturnValue( { isPushSelectionOverLimit: true, isLoading: false, @@ -983,7 +871,7 @@ describe( 'ContentTabSync', () => { renderWithProvider( ); - const pullButton = screen.getByRole( 'button', { name: /Pull/i } ); + const pullButton = await screen.findByRole( 'button', { name: 'Pull' } ); fireEvent.click( pullButton ); await screen.findByText( 'Pull from Production' ); diff --git a/src/stores/index.ts b/src/stores/index.ts index a3b9140a5e..790281fda8 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -24,10 +24,7 @@ import { snapshotActions, } from 'src/stores/snapshot-slice'; import { syncReducer } from 'src/stores/sync'; -import { - connectedSitesReducer, - loadAllConnectedSites, -} from 'src/stores/sync/connected-sites-slice'; +import { connectedSitesApi } from 'src/stores/sync/connected-sites-api'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from 'common/lib/locale'; @@ -41,7 +38,7 @@ export type RootState = { providerConstants: ReturnType< typeof providerConstantsReducer >; snapshot: ReturnType< typeof snapshotReducer >; sync: ReturnType< typeof syncReducer >; - connectedSites: ReturnType< typeof connectedSitesReducer >; + connectedSitesApi: ReturnType< typeof connectedSitesApi.reducer >; wordpressVersionsApi: ReturnType< typeof wordpressVersionsApi.reducer >; wpcomApi: ReturnType< typeof wpcomApi.reducer >; wpcomPublicApi: ReturnType< typeof wpcomPublicApi.reducer >; @@ -93,11 +90,11 @@ export const rootReducer = combineReducers( { chat: chatReducer, newSites: newSitesReducer, installedAppsApi: installedAppsApi.reducer, + connectedSitesApi: connectedSitesApi.reducer, onboarding: onboardingReducer, providerConstants: providerConstantsReducer, snapshot: snapshotReducer, sync: syncReducer, - connectedSites: connectedSitesReducer, wordpressVersionsApi: wordpressVersionsApi.reducer, wpcomApi: wpcomApi.reducer, wpcomPublicApi: wpcomPublicApi.reducer, @@ -112,6 +109,7 @@ export const store = configureStore( { .prepend( listenerMiddleware.middleware ) .concat( appVersionApi.middleware ) .concat( installedAppsApi.middleware ) + .concat( connectedSitesApi.middleware ) .concat( wordpressVersionsApi.middleware ) .concat( wpcomApi.middleware ) .concat( wpcomPublicApi.middleware ) @@ -140,8 +138,6 @@ async function initializeProviderConstants() { // Initialize provider constants immediately, but skip in test environment if ( typeof jest === 'undefined' && process.env.NODE_ENV !== 'test' ) { void initializeProviderConstants(); - // Initialize connected sites on store initialization only in non-test environment - void store.dispatch( loadAllConnectedSites() ); } export type AppDispatch = typeof store.dispatch; diff --git a/src/stores/sync/connected-sites-api.ts b/src/stores/sync/connected-sites-api.ts new file mode 100644 index 0000000000..906678bff9 --- /dev/null +++ b/src/stores/sync/connected-sites-api.ts @@ -0,0 +1,98 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; + +export const connectedSitesApi = createApi( { + reducerPath: 'connectedSitesApi', + baseQuery: fetchBaseQuery(), + tagTypes: [ 'ConnectedSites' ], + endpoints: ( builder ) => ( { + getConnectedSitesForLocalSite: builder.query< + SyncSite[], + { localSiteId?: string; userId?: number } + >( { + queryFn: async ( { localSiteId } ) => { + if ( ! localSiteId ) { + return { data: [] }; + } + + const sites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + return { data: sites }; + }, + providesTags: ( result, error, arg ) => [ + { type: 'ConnectedSites', localSiteId: arg.localSiteId, userId: arg.userId }, + ], + } ), + + connectSite: builder.mutation< SyncSite[], { site: SyncSite; localSiteId: string } >( { + queryFn: async ( { site, localSiteId } ) => { + await getIpcApi().connectWpcomSites( [ + { + sites: [ site ], + localSiteId, + }, + ] ); + + const actualConnectedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + + return { data: actualConnectedSites }; + }, + invalidatesTags: ( result, error, { localSiteId } ) => [ + { type: 'ConnectedSites', localSiteId }, + ], + } ), + + disconnectSite: builder.mutation< SyncSite[], { siteId: number; localSiteId: string } >( { + queryFn: async ( { siteId, localSiteId } ) => { + await getIpcApi().disconnectWpcomSites( [ + { + siteIds: [ siteId ], + localSiteId, + }, + ] ); + + const actualConnectedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + + return { data: actualConnectedSites }; + }, + invalidatesTags: ( result, error, { localSiteId } ) => [ + { type: 'ConnectedSites', localSiteId }, + ], + } ), + + updateSiteTimestamp: builder.mutation< + void, + { siteId: number; localSiteId: string; type: 'pull' | 'push' } + >( { + queryFn: async ( { siteId, localSiteId, type } ) => { + const sites = await getIpcApi().getConnectedWpcomSites( localSiteId ); + const site = sites.find( + ( { id, localSiteId: siteLocalId } ) => siteId === id && localSiteId === siteLocalId + ); + + if ( ! site ) { + return { error: { status: 'CUSTOM_ERROR', error: 'Site not found' } }; + } + + const updatedSite = { + ...site, + [ type === 'pull' ? 'lastPullTimestamp' : 'lastPushTimestamp' ]: new Date().toISOString(), + }; + + await getIpcApi().updateSingleConnectedWpcomSite( updatedSite ); + + return { data: undefined }; + }, + invalidatesTags: ( result, error, { localSiteId } ) => [ + { type: 'ConnectedSites', localSiteId }, + ], + } ), + } ), +} ); + +export const { + useGetConnectedSitesForLocalSiteQuery, + useConnectSiteMutation, + useDisconnectSiteMutation, + useUpdateSiteTimestampMutation, +} = connectedSitesApi; diff --git a/src/stores/sync/connected-sites-hooks.ts b/src/stores/sync/connected-sites-hooks.ts deleted file mode 100644 index f91f64ad98..0000000000 --- a/src/stores/sync/connected-sites-hooks.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useCallback } from 'react'; -import { useFetchWpComSites } from 'src/hooks/use-fetch-wpcom-sites'; -import { useSiteDetails } from 'src/hooks/use-site-details'; -import { useAppDispatch, useRootSelector } from 'src/stores'; -import { - connectedSitesActions, - connectedSitesSelectors, - connectSite, - disconnectSite, - loadAllConnectedSites, -} from 'src/stores/sync/connected-sites-slice'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; - -export const useConnectedSitesData = () => { - const { selectedSite } = useSiteDetails(); - const localSiteId = selectedSite?.id; - const connectedSites = useRootSelector( ( state ) => - connectedSitesSelectors.selectSitesByLocalSiteId( state, localSiteId ) - ); - - return { connectedSites, localSiteId }; -}; - -export const useSyncSitesData = () => { - const { connectedSites } = useConnectedSitesData(); - const { syncSites, isFetching, refetchSites } = useFetchWpComSites( - connectedSites.map( ( { id } ) => id ) - ); - - return { syncSites, isFetching, refetchSites }; -}; - -export const useConnectedSitesOperations = () => { - const dispatch = useAppDispatch(); - const { localSiteId, connectedSites } = useConnectedSitesData(); - - const connectSiteToLocal = useCallback( - async ( site: SyncSite, overrideLocalSiteId?: string ) => { - const targetLocalSiteId = overrideLocalSiteId || localSiteId; - - if ( ! targetLocalSiteId ) { - throw new Error( 'No local site ID available' ); - } - - try { - await dispatch( - connectSite( { - site, - localSiteId: targetLocalSiteId, - } ) - ).unwrap(); - - dispatch( connectedSitesActions.closeModal() ); - - if ( overrideLocalSiteId && overrideLocalSiteId !== localSiteId ) { - await dispatch( loadAllConnectedSites() ); - } - } catch ( error ) { - console.error( 'Failed to connect site:', error ); - throw error; - } - }, - [ dispatch, localSiteId ] - ); - - const disconnectSiteFromLocal = useCallback( - async ( siteId: number ) => { - if ( ! localSiteId ) { - throw new Error( 'No local site ID available' ); - } - - try { - const siteToDisconnect = connectedSites.find( ( site ) => site.id === siteId ); - - if ( ! siteToDisconnect ) { - throw new Error( 'Site not found' ); - } - - await dispatch( disconnectSite( { siteId, localSiteId } ) ).unwrap(); - } catch ( error ) { - console.error( 'Failed to disconnect site:', error ); - throw error; - } - }, - [ dispatch, localSiteId, connectedSites ] - ); - - return { - connectSite: connectSiteToLocal, - disconnectSite: disconnectSiteFromLocal, - }; -}; diff --git a/src/stores/sync/connected-sites-slice.ts b/src/stores/sync/connected-sites-slice.ts deleted file mode 100644 index 2e78853e10..0000000000 --- a/src/stores/sync/connected-sites-slice.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; -import fastDeepEqual from 'fast-deep-equal'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import { RootState, store } from 'src/stores'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; -import type { SyncModalMode } from 'src/modules/sync/types'; - -type ConnectedSites = SyncSite[]; -type ModalState = false | true | { disconnectSiteId?: number }; - -interface ConnectedSitesState { - sites: Record< string, ConnectedSites >; // Keyed by localSiteId for efficient lookups - isModalOpen: ModalState; - modalMode: SyncModalMode | null; -} - -interface ConnectSiteParams { - site: SyncSite; - localSiteId: string; -} - -interface DisconnectSiteParams { - siteId: number; - localSiteId: string; -} - -const initialState: ConnectedSitesState = { - sites: {}, - isModalOpen: false, - modalMode: null, -}; - -export const loadAllConnectedSites = createAsyncThunk( 'connectedSites/loadAll', async () => { - const allSites = await getIpcApi().getConnectedWpcomSites(); - - const sitesByLocalSiteId: Record< string, ConnectedSites > = {}; - allSites.forEach( ( site ) => { - if ( ! sitesByLocalSiteId[ site.localSiteId ] ) { - sitesByLocalSiteId[ site.localSiteId ] = []; - } - sitesByLocalSiteId[ site.localSiteId ].push( site ); - } ); - - return sitesByLocalSiteId; -} ); - -export const connectSite = createAsyncThunk( - 'connectedSites/connect', - async ( { site, localSiteId }: ConnectSiteParams ) => { - await getIpcApi().connectWpcomSites( [ - { - sites: [ site ], - localSiteId, - }, - ] ); - - const actualConnectedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); - - return { - localSiteId, - connectedSites: actualConnectedSites, - }; - } -); - -export const disconnectSite = createAsyncThunk( - 'connectedSites/disconnect', - async ( { siteId, localSiteId }: DisconnectSiteParams ) => { - await getIpcApi().disconnectWpcomSites( [ - { - siteIds: [ siteId ], - localSiteId, - }, - ] ); - - const actualConnectedSites = await getIpcApi().getConnectedWpcomSites( localSiteId ); - - return { - localSiteId, - connectedSites: actualConnectedSites, - }; - } -); - -const connectedSitesSlice = createSlice( { - name: 'connectedSites', - initialState, - reducers: { - updateSite: ( state, action: PayloadAction< { localSiteId: string; site: SyncSite } > ) => { - const { localSiteId, site } = action.payload; - const sites = state.sites[ localSiteId ] || []; - const index = sites.findIndex( ( s ) => s.id === site.id ); - - if ( index !== -1 ) { - sites[ index ] = site; - } - }, - - clearSitesForLocalSite: ( state, action: PayloadAction< string > ) => { - delete state.sites[ action.payload ]; - }, - - openModal: ( state, action: PayloadAction< SyncModalMode | undefined > ) => { - state.isModalOpen = true; - if ( action.payload ) { - state.modalMode = action.payload; - } - }, - - setModalMode: ( state, action: PayloadAction< SyncModalMode | null > ) => { - state.modalMode = action.payload; - }, - - closeModal: ( state ) => { - state.isModalOpen = false; - }, - }, - extraReducers: ( builder ) => { - builder - .addCase( loadAllConnectedSites.fulfilled, ( state, action ) => { - state.sites = action.payload; - } ) - .addCase( connectSite.fulfilled, ( state, action ) => { - const { localSiteId, connectedSites } = action.payload; - state.sites[ localSiteId ] = connectedSites; - } ) - .addCase( disconnectSite.fulfilled, ( state, action ) => { - const { localSiteId, connectedSites } = action.payload; - state.sites[ localSiteId ] = connectedSites; - } ); - }, -} ); - -export const connectedSitesActions = connectedSitesSlice.actions; -export const connectedSitesReducer = connectedSitesSlice.reducer; - -export const connectedSitesSelectors = { - selectIsModalOpen: ( state: RootState ) => state.connectedSites.isModalOpen, - selectModalMode: ( state: RootState ) => state.connectedSites.modalMode, - selectSitesByLocalSiteId: createSelector( - [ - ( state: RootState ) => state.connectedSites, - ( _: RootState, localSiteId: string | undefined ) => localSiteId, - ], - ( connectedSitesState, localSiteId ) => - localSiteId ? connectedSitesState.sites[ localSiteId ] || [] : [] - ), -}; - -window.ipcListener.subscribe( 'user-data-updated', async ( _, userData ) => { - const state = store.getState(); - const currentUserId = userData.authToken?.id; - - if ( ! currentUserId ) { - return; - } - - const connectedSitesFromUserData = userData.connectedWpcomSites?.[ currentUserId ] || []; - const connectedSitesFromState = Object.values( state.connectedSites.sites ).flat(); - - if ( ! fastDeepEqual( connectedSitesFromUserData, connectedSitesFromState ) ) { - void store.dispatch( loadAllConnectedSites() ); - } -} ); diff --git a/src/stores/sync/index.ts b/src/stores/sync/index.ts index c462f4f349..1b36091b56 100644 --- a/src/stores/sync/index.ts +++ b/src/stores/sync/index.ts @@ -1,17 +1,4 @@ export { syncReducer, syncActions, syncSelectors } from './sync-slice'; export { useLatestRewindId, useRemoteFileTree, useLocalFileTree } from './sync-hooks'; export { useGetLatestRewindIdQuery, fetchRemoteFileTree } from './sync-api'; -export { - connectedSitesReducer, - connectedSitesActions, - connectedSitesSelectors, - loadAllConnectedSites, - connectSite, - disconnectSite, -} from './connected-sites-slice'; -export { - useConnectedSitesData, - useSyncSitesData, - useConnectedSitesOperations, -} from './connected-sites-hooks'; export * from './sync-types';