diff --git a/src/components/publish-site-button.tsx b/src/components/publish-site-button.tsx index 0f92b81ad3..280625c176 100644 --- a/src/components/publish-site-button.tsx +++ b/src/components/publish-site-button.tsx @@ -1,18 +1,22 @@ import { cloudUpload } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; -import Button from 'src/components/button'; -import { Tooltip } from 'src/components/tooltip'; +import { useCallback } from 'react'; import { useSyncSites } from 'src/hooks/sync-sites'; import { useAuth } from 'src/hooks/use-auth'; import { useContentTabs } from 'src/hooks/use-content-tabs'; import { useSiteDetails } from 'src/hooks/use-site-details'; +import { ConnectButton } from 'src/modules/sync/components/connect-button'; import { useAppDispatch } from 'src/stores'; import { connectedSitesActions, useGetConnectedSitesForLocalSiteQuery, } from 'src/stores/sync/connected-sites'; -export const PublishSiteButton = () => { +export const PublishSiteButton = ( { + redirectToSync = true, +}: { + redirectToSync?: boolean; +} = {} ) => { const { __ } = useI18n(); const dispatch = useAppDispatch(); const { setSelectedTab } = useContentTabs(); @@ -24,30 +28,33 @@ export const PublishSiteButton = () => { } ); const { isAnySitePulling, isAnySitePushing } = useSyncSites(); const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; - const handlePublishClick = () => { - setSelectedTab( 'sync' ); + + const handlePublishClick = useCallback( () => { + if ( redirectToSync ) { + setSelectedTab( 'sync' ); + } dispatch( connectedSitesActions.openModal( 'push' ) ); - }; + }, [ redirectToSync, setSelectedTab, dispatch ] ); - if ( connectedSites.length !== 0 ) return null; + // Don't show the button if there are already connected sites + // (only when redirectToSync is true, meaning it's used outside the sync tab) + if ( redirectToSync && connectedSites.length !== 0 ) return null; return ( - - - + { __( 'Publish site' ) } + ); }; diff --git a/src/components/tests/app.test.tsx b/src/components/tests/app.test.tsx index 4f4a249f8a..3072c5a228 100644 --- a/src/components/tests/app.test.tsx +++ b/src/components/tests/app.test.tsx @@ -12,6 +12,7 @@ import { certificateTrustApi } from 'src/stores/certificate-trust-api'; import { installedAppsApi } from 'src/stores/installed-apps-api'; import { setProviderConstants } from 'src/stores/provider-constants-slice'; import { connectedSitesApi } from 'src/stores/sync/connected-sites'; +import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import { wordpressVersionsApi } from 'src/stores/wordpress-versions-api'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; @@ -90,7 +91,8 @@ describe( 'App', () => { .concat( wpcomApi.middleware ) .concat( wpcomPublicApi.middleware ) .concat( certificateTrustApi.middleware ) - .concat( connectedSitesApi.middleware ), + .concat( connectedSitesApi.middleware ) + .concat( wpcomSitesApi.middleware ), } ); store.dispatch( setProviderConstants( { diff --git a/src/hooks/sync-sites/sync-sites-context.tsx b/src/hooks/sync-sites/sync-sites-context.tsx index 5ad78dc83a..980d1b9254 100644 --- a/src/hooks/sync-sites/sync-sites-context.tsx +++ b/src/hooks/sync-sites/sync-sites-context.tsx @@ -10,22 +10,16 @@ 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 { - useGetConnectedSitesForLocalSiteQuery, - useUpdateSiteTimestampMutation, -} from 'src/stores/sync/connected-sites'; +import { useUpdateSiteTimestampMutation } from 'src/stores/sync/connected-sites'; import type { ImportResponse } from 'src/hooks/use-sync-states-progress-info'; type GetLastSyncTimeText = ( timestamp: string | null, type: 'pull' | 'push' ) => string; export type SyncSitesContextType = Omit< UseSyncPull, 'pullStates' > & - Omit< UseSyncPush, 'pushStates' > & - ReturnType< typeof useFetchWpComSites > & { + Omit< UseSyncPush, 'pushStates' > & { getLastSyncTimeText: GetLastSyncTimeText; }; @@ -53,13 +47,6 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) [ formatRelativeTime ] ); - const { selectedSite } = useSiteDetails(); - const { user } = useAuth(); - const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { - localSiteId: selectedSite?.id, - userId: user?.id, - } ); - const [ updateSiteTimestamp ] = useUpdateSiteTimestampMutation(); const { pullSite, isAnySitePulling, isSiteIdPulling, clearPullState, getPullState, cancelPull } = @@ -79,10 +66,7 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) updateSiteTimestamp( { siteId: remoteSiteId, localSiteId, type: 'push' } ), } ); - const { syncSites, isFetching, refetchSites } = useFetchWpComSites( - connectedSites.map( ( { id } ) => id ) - ); - useListenDeepLinkConnection( { refetchSites } ); + useListenDeepLinkConnection(); const { client } = useAuth(); const { pushStatesProgressInfo } = useSyncStatesProgressInfo(); @@ -154,9 +138,6 @@ export function SyncSitesProvider( { children }: { children: React.ReactNode } ) isSiteIdPulling, clearPullState, cancelPull, - syncSites, - refetchSites, - isFetching, getPullState, getPushState, pushSite, 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 b90fe6ece0..5273b64be4 100644 --- a/src/hooks/sync-sites/use-listen-deep-link-connection.ts +++ b/src/hooks/sync-sites/use-listen-deep-link-connection.ts @@ -1,21 +1,32 @@ -import { SyncSitesContextType } from 'src/hooks/sync-sites/sync-sites-context'; +import { useAuth } from 'src/hooks/use-auth'; 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 { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; +import { + useConnectSiteMutation, + useGetConnectedSitesForLocalSiteQuery, +} from 'src/stores/sync/connected-sites'; +import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; -export function useListenDeepLinkConnection( { - refetchSites, -}: { - refetchSites: SyncSitesContextType[ 'refetchSites' ]; -} ) { +export function useListenDeepLinkConnection() { const [ connectSite ] = useConnectSiteMutation(); const { selectedSite, setSelectedSiteId } = useSiteDetails(); const { setSelectedTab, selectedTab } = useContentTabs(); + const { user } = useAuth(); + const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite?.id, + userId: user?.id, + } ); + const connectedSiteIds = connectedSites.map( ( { id } ) => id ); + const { refetch: refetchWpComSites } = useGetWpComSitesQuery( { + connectedSiteIds, + userId: user?.id, + } ); useIpcListener( 'sync-connect-site', async ( _event, { remoteSiteId, studioSiteId } ) => { // Fetch latest sites from network before checking - const latestSites = await refetchSites(); + const result = await refetchWpComSites(); + const latestSites = result.data ?? []; const newConnectedSite = latestSites.find( ( site ) => site.id === remoteSiteId ); if ( newConnectedSite ) { if ( selectedSite?.id && selectedSite.id !== studioSiteId ) { diff --git a/src/hooks/sync-sites/use-sync-pull.ts b/src/hooks/sync-sites/use-sync-pull.ts index 3908cc9f25..6965164f8b 100644 --- a/src/hooks/sync-sites/use-sync-pull.ts +++ b/src/hooks/sync-sites/use-sync-pull.ts @@ -20,7 +20,7 @@ import { } 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 { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; export type SyncBackupState = { diff --git a/src/hooks/sync-sites/use-sync-push.ts b/src/hooks/sync-sites/use-sync-push.ts index 3e0bc2cfb7..1bb778c872 100644 --- a/src/hooks/sync-sites/use-sync-push.ts +++ b/src/hooks/sync-sites/use-sync-push.ts @@ -17,8 +17,8 @@ import { } 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'; +import type { SyncSite } from 'src/modules/sync/types'; import type { SyncOption } from 'src/types'; export type SyncPushState = { diff --git a/src/hooks/tests/get-sync-support.test.ts b/src/hooks/tests/get-sync-support.test.ts index 2975280182..d912758255 100644 --- a/src/hooks/tests/get-sync-support.test.ts +++ b/src/hooks/tests/get-sync-support.test.ts @@ -1,4 +1,4 @@ -import { getSyncSupport } from 'src/hooks/use-fetch-wpcom-sites'; +import { getSyncSupport } from 'src/modules/sync/lib/sync-support'; // Mocks for site shapes const baseSite = { diff --git a/src/hooks/tests/reconcile-connected-sites.test.ts b/src/hooks/tests/reconcile-connected-sites.test.ts index 646a2fd719..e13e6192d5 100644 --- a/src/hooks/tests/reconcile-connected-sites.test.ts +++ b/src/hooks/tests/reconcile-connected-sites.test.ts @@ -1,5 +1,5 @@ -import { reconcileConnectedSites } from 'src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import { reconcileConnectedSites } from 'src/modules/sync/lib/reconcile-connected-sites'; +import type { SyncSite } from 'src/modules/sync/types'; describe( 'reconcileConnectedSites', () => { test( 'should update relevant properties', () => { diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index d2b7206785..c56433daf6 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -9,7 +9,7 @@ import { useSiteDetails } from 'src/hooks/use-site-details'; import { getWordPressProvider } from 'src/lib/wordpress-provider'; import { store } from 'src/stores'; import { setProviderConstants } from 'src/stores/provider-constants-slice'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import type { SyncSite } from 'src/modules/sync/types'; jest.mock( 'src/hooks/use-site-details' ); jest.mock( 'src/hooks/use-feature-flags' ); diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index edc323309b..5349a088d0 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -14,7 +14,7 @@ import { selectDefaultWordPressVersion, } from 'src/stores/provider-constants-slice'; import { useConnectSiteMutation } from 'src/stores/sync/connected-sites'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import type { SyncSite } from 'src/modules/sync/types'; import type { Blueprint } from 'src/stores/wpcom-api'; import type { SyncOption } from 'src/types'; diff --git a/src/hooks/use-fetch-wpcom-sites/index.tsx b/src/hooks/use-fetch-wpcom-sites/index.tsx deleted file mode 100644 index cc226545e3..0000000000 --- a/src/hooks/use-fetch-wpcom-sites/index.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import * as Sentry from '@sentry/electron/renderer'; -import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { z } from 'zod'; -import { useAuth } from 'src/hooks/use-auth'; -import { reconcileConnectedSites } from 'src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites'; -import { useOffline } from 'src/hooks/use-offline'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import type { SyncSite, SyncSupport } from 'src/hooks/use-fetch-wpcom-sites/types'; - -const sitesEndpointSiteSchema = z.object( { - ID: z.number(), - is_wpcom_atomic: z.boolean(), - name: z.string(), - URL: z.string(), - jetpack: z.boolean().optional(), - is_deleted: z.boolean(), - hosting_provider_guess: z.string().optional(), - environment_type: z - .enum( [ 'production', 'staging', 'development', 'sandbox', 'local' ] ) - .nullable() - .optional(), - is_a8c: z.boolean().optional(), - options: z - .object( { - created_at: z.string(), - wpcom_staging_blog_ids: z.array( z.number() ), - } ) - .optional(), - capabilities: z - .object( { - manage_options: z.boolean(), - } ) - .optional(), - plan: z - .object( { - expired: z.boolean().optional(), - features: z.object( { - active: z.array( z.string() ), - available: z.record( z.string(), z.array( z.string() ) ).optional(), - } ), - is_free: z.boolean().optional(), - product_id: z.coerce.number(), - product_name_short: z.string(), - product_slug: z.string(), - user_is_owner: z.boolean().optional(), - } ) - .optional(), -} ); - -type SitesEndpointSite = z.infer< typeof sitesEndpointSiteSchema >; - -// We use a permissive schema for the API response to fail gracefully if a single site is malformed -const sitesEndpointResponseSchema = z.object( { - sites: z.array( z.unknown() ), -} ); - -const STUDIO_SYNC_FEATURE_NAME = 'studio-sync'; - -function isPressableSite( site: SitesEndpointSite ): boolean { - return site.hosting_provider_guess === 'pressable'; -} - -function isAtomicSite( site: SitesEndpointSite ): boolean { - return site.is_wpcom_atomic; -} - -function hasSupportedPlan( site: SitesEndpointSite ): boolean { - return site.plan?.features.active.includes( STUDIO_SYNC_FEATURE_NAME ) ?? false; -} - -function isJetpackSite( site: SitesEndpointSite ): boolean { - return !! site.jetpack && ! isAtomicSite( site ) && ! isPressableSite( site ); -} - -function needsTransfer( site: SitesEndpointSite ): boolean { - return ! isJetpackSite( site ) && ! isPressableSite( site ) && ! isAtomicSite( site ); -} - -export function getSyncSupport( site: SitesEndpointSite, connectedSiteIds: number[] ): SyncSupport { - if ( site.is_deleted ) { - return 'deleted'; - } - if ( ! site.capabilities?.manage_options ) { - return 'missing-permissions'; - } - if ( isJetpackSite( site ) ) { - return 'unsupported'; - } - if ( ! hasSupportedPlan( site ) && ! isPressableSite( site ) ) { - return 'needs-upgrade'; - } - if ( needsTransfer( site ) ) { - return 'needs-transfer'; - } - if ( connectedSiteIds.some( ( id ) => id === site.ID ) ) { - return 'already-connected'; - } - return 'syncable'; -} - -function transformSingleSiteResponse( - site: SitesEndpointSite, - syncSupport: SyncSupport, - isStaging: boolean -): SyncSite { - return { - id: site.ID, - localSiteId: '', - name: site.name, - url: site.URL, - isStaging, - isPressable: isPressableSite( site ), - environmentType: site.environment_type, - syncSupport, - lastPullTimestamp: null, - lastPushTimestamp: null, - }; -} - -function transformSitesResponse( sites: unknown[], connectedSiteIds: number[] ): SyncSite[] { - const validatedSites = sites.reduce< SitesEndpointSite[] >( ( acc, rawSite ) => { - try { - const site = sitesEndpointSiteSchema.parse( rawSite ); - return [ ...acc, site ]; - } catch ( error ) { - Sentry.captureException( error ); - return acc; - } - }, [] ); - - const allStagingSiteIds = validatedSites.flatMap( ( site ) => { - return site.options?.wpcom_staging_blog_ids ?? []; - } ); - - return validatedSites - .filter( ( site ) => ! site.is_a8c ) - .filter( ( site ) => ! site.is_deleted || connectedSiteIds.some( ( id ) => id === site.ID ) ) - .map( ( site ) => { - // The API returns the wrong value for the `is_wpcom_staging_site` prop while staging sites - // are being created. Hence the check in other sites' `wpcom_staging_blog_ids` arrays. - const isStaging = allStagingSiteIds.includes( site.ID ); - const syncSupport = getSyncSupport( site, connectedSiteIds ); - - return transformSingleSiteResponse( site, syncSupport, isStaging ); - } ); -} - -type FetchSites = () => Promise< SyncSite[] >; - -export const useFetchWpComSites = ( connectedSiteIdsOnlyForSelectedSite: number[] ) => { - const [ rawSyncSites, setRawSyncSites ] = useState< unknown[] >( [] ); - const { isAuthenticated, client } = useAuth(); - const isFetchingSites = useRef( false ); - const isOffline = useOffline(); - - const joinedConnectedSiteIds = connectedSiteIdsOnlyForSelectedSite.join( ',' ); - // we need this trick to avoid unnecessary re-renders, - // as a result different instances of the same array don't trigger refetching - const memoizedConnectedSiteIds: number[] = useMemo( - () => - joinedConnectedSiteIds - ? joinedConnectedSiteIds.split( ',' ).map( ( id ) => parseInt( id, 10 ) ) - : [], - [ joinedConnectedSiteIds ] - ); - - const fetchSites = useCallback< FetchSites >( async () => { - if ( ! client?.req || isFetchingSites.current || ! isAuthenticated || isOffline ) { - return []; - } - - isFetchingSites.current = true; - - 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.map( ( { id } ) => id ) - ); - - // whenever array of syncSites changes, we need to update connectedSites to keep them updated with wordpress.com - const { updatedConnectedSites } = reconcileConnectedSites( allConnectedSites, syncSites ); - await getIpcApi().updateConnectedWpcomSites( updatedConnectedSites ); - - setRawSyncSites( parsedResponse.sites ); - - return syncSites; - } catch ( error ) { - Sentry.captureException( error ); - console.error( error ); - return []; - } finally { - isFetchingSites.current = false; - } - }, [ client?.req, isAuthenticated, isOffline ] ); - - useEffect( () => { - void fetchSites(); - }, [ fetchSites ] ); - - const syncSitesWithSyncSupportForSelectedSite = useMemo( - () => transformSitesResponse( rawSyncSites, memoizedConnectedSiteIds ), - [ rawSyncSites, memoizedConnectedSiteIds ] - ); - - return { - syncSites: syncSitesWithSyncSupportForSelectedSite, - isFetching: isFetchingSites.current, - refetchSites: fetchSites, - }; -}; diff --git a/src/hooks/use-fetch-wpcom-sites/types.ts b/src/hooks/use-fetch-wpcom-sites/types.ts deleted file mode 100644 index e6e58d080f..0000000000 --- a/src/hooks/use-fetch-wpcom-sites/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type SyncSupport = - | 'unsupported' - | 'syncable' - | 'needs-transfer' - | 'already-connected' - | 'needs-upgrade' - | 'deleted' - | 'missing-permissions'; - -export type SyncSite = { - id: number; - localSiteId: string; - name: string; - url: string; - isStaging: boolean; - isPressable: boolean; - environmentType?: string | null; - syncSupport: SyncSupport; - lastPullTimestamp: string | null; - lastPushTimestamp: string | null; -}; diff --git a/src/lib/feature-flags.ts b/src/lib/feature-flags.ts index 98497c1d82..e186f6dc93 100644 --- a/src/lib/feature-flags.ts +++ b/src/lib/feature-flags.ts @@ -16,7 +16,7 @@ export const FEATURE_FLAGS_DEFINITION: Record< keyof FeatureFlags, FeatureFlagDe label: 'Streamline Onboarding', env: 'STREAMLINE_ONBOARDING', flag: 'streamlineOnboarding', - default: false, + default: true, }, } as const; diff --git a/src/modules/add-site/components/pull-remote-site.tsx b/src/modules/add-site/components/pull-remote-site.tsx index 3fca50cfec..fd2a1e6e54 100644 --- a/src/modules/add-site/components/pull-remote-site.tsx +++ b/src/modules/add-site/components/pull-remote-site.tsx @@ -1,22 +1,23 @@ import { - useNavigator, __experimentalHeading as Heading, __experimentalVStack as VStack, } from '@wordpress/components'; 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 { useOffline } from 'src/hooks/use-offline'; +import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { ListSites, SearchSites } from 'src/modules/sync/components/sync-sites-modal-selector'; import { SyncTabImage } from 'src/modules/sync/components/sync-tab-image'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; +import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; +import type { SyncSite } from 'src/modules/sync/types'; function SiteSyncDescription( { children }: PropsWithChildren ) { const { __ } = useI18n(); @@ -118,9 +119,22 @@ export function PullRemoteSite( { setSelectedRemoteSite: ( site?: SyncSite ) => void; } ) { const { __ } = useI18n(); - const { isAuthenticated } = useAuth(); - const { location } = useNavigator(); - const { syncSites, refetchSites } = useSyncSites(); + const { isAuthenticated, user } = useAuth(); + + const { selectedSite } = useSiteDetails(); + const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite?.id, + userId: user?.id, + } ); + const connectedSiteIds = connectedSites.map( ( { id } ) => id ); + const { data: syncSites = [] } = useGetWpComSitesQuery( + { + connectedSiteIds, + userId: user?.id, + }, + { refetchOnMountOrArgChange: true } + ); + const [ searchQuery, setSearchQuery ] = useState< string >( '' ); const filteredSites = syncSites.filter( ( site ) => { @@ -131,12 +145,6 @@ export function PullRemoteSite( { ); } ); - useEffect( () => { - if ( location.path === '/pullRemote' && isAuthenticated ) { - void refetchSites(); - } - }, [ location.path, isAuthenticated, refetchSites ] ); - const handleSiteSelect = ( siteId: number ) => { const site = syncSites.find( ( s ) => s.id === siteId ); setSelectedRemoteSite( site ); diff --git a/src/modules/add-site/index.tsx b/src/modules/add-site/index.tsx index 64d98ec05f..7dc12b7a7e 100644 --- a/src/modules/add-site/index.tsx +++ b/src/modules/add-site/index.tsx @@ -7,11 +7,11 @@ import { BlueprintValidationWarning } from 'common/lib/blueprint-validation'; import Button from 'src/components/button'; import { FullscreenModal } from 'src/components/fullscreen-modal'; import { useAddSite } from 'src/hooks/use-add-site'; -import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import { useImportExport } from 'src/hooks/use-import-export'; import { useIpcListener } from 'src/hooks/use-ipc-listener'; import { generateSiteName } from 'src/lib/generate-site-name'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { SyncSite } from 'src/modules/sync/types'; import { useRootSelector } from 'src/stores'; import { formatRtkError } from 'src/stores/format-rtk-error'; import { diff --git a/src/modules/sync/components/sync-connected-sites.tsx b/src/modules/sync/components/sync-connected-sites.tsx index 172116b438..218d3044bc 100644 --- a/src/modules/sync/components/sync-connected-sites.tsx +++ b/src/modules/sync/components/sync-connected-sites.tsx @@ -34,7 +34,7 @@ import { connectedSitesActions, useGetConnectedSitesForLocalSiteQuery, } from 'src/stores/sync/connected-sites'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import type { SyncSite } from 'src/modules/sync/types'; const SyncConnectedSiteControls = ( { connectedSite, diff --git a/src/modules/sync/components/sync-dialog.tsx b/src/modules/sync/components/sync-dialog.tsx index 29bf9b5fca..1ead8966ef 100644 --- a/src/modules/sync/components/sync-dialog.tsx +++ b/src/modules/sync/components/sync-dialog.tsx @@ -27,7 +27,7 @@ import { useI18nLocale } from 'src/stores'; import { useLatestRewindId, useRemoteFileTree, useLocalFileTree } from 'src/stores/sync'; import { useGetWordPressVersions } from 'src/stores/wordpress-versions-api'; import { TreeViewLoadingSkeleton } from './tree-view-loading-skeleton'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import type { SyncSite } from 'src/modules/sync/types'; type SyncDialogProps = { type: 'push' | 'pull'; diff --git a/src/modules/sync/components/sync-sites-modal-selector.tsx b/src/modules/sync/components/sync-sites-modal-selector.tsx index 5495feb9a9..bae8d4ba0a 100644 --- a/src/modules/sync/components/sync-sites-modal-selector.tsx +++ b/src/modules/sync/components/sync-sites-modal-selector.tsx @@ -8,16 +8,19 @@ import Modal from 'src/components/modal'; import offlineIcon from 'src/components/offline-icon'; import { PressableLogo } from 'src/components/pressable-logo'; import { WordPressLogoCircle } from 'src/components/wordpress-logo-circle'; +import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { getLocalizedLink } from 'src/lib/get-localized-link'; import { CreateButton } from 'src/modules/sync/components/create-button'; import { EnvironmentBadge } from 'src/modules/sync/components/environment-badge'; +import { NoWpcomSitesModal } from 'src/modules/sync/components/no-wpcom-sites-modal'; import { getSiteEnvironment } from 'src/modules/sync/lib/environment-utils'; import { useI18nLocale } from 'src/stores'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; -import type { SyncModalMode } from 'src/modules/sync/types'; +import { useGetConnectedSitesForLocalSiteQuery } from 'src/stores/sync/connected-sites'; +import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; +import type { SyncSite, SyncModalMode } from 'src/modules/sync/types'; const SearchControl = process.env.NODE_ENV === 'test' ? () => null : SearchControlWp; @@ -27,26 +30,37 @@ const focusConnectButton = () => { }; export function SyncSitesModalSelector( { - isLoading, onRequestClose, onConnect, - syncSites, - onInitialRender, selectedSite, mode = 'connect', }: { - isLoading?: boolean; onRequestClose: () => void; - syncSites: SyncSite[]; onConnect: ( siteId: number ) => void; - onInitialRender?: () => void; selectedSite: SiteDetails; mode?: SyncModalMode; } ) { const { __ } = useI18n(); + const { user } = useAuth(); const [ selectedSiteId, setSelectedSiteId ] = useState< number | null >( null ); const [ searchQuery, setSearchQuery ] = useState< string >( '' ); const isOffline = useOffline(); + + const { data: connectedSites = [] } = useGetConnectedSitesForLocalSiteQuery( { + localSiteId: selectedSite.id, + userId: user?.id, + } ); + const connectedSiteIds = connectedSites.map( ( { id } ) => id ); + + const { + data: syncSites = [], + isLoading, + isSuccess, + } = useGetWpComSitesQuery( + { connectedSiteIds, userId: user?.id }, + { refetchOnMountOrArgChange: true } + ); + const filteredSites = syncSites.filter( ( site ) => { const searchQueryLower = searchQuery.toLowerCase(); return ( @@ -56,6 +70,10 @@ export function SyncSitesModalSelector( { } ); const isEmpty = filteredSites.length === 0; + if ( syncSites.length === 0 && isSuccess && ! isLoading ) { + return ; + } + const getModalTitle = () => { switch ( mode ) { case 'push': @@ -68,12 +86,6 @@ export function SyncSitesModalSelector( { } }; - useEffect( () => { - if ( onInitialRender ) { - onInitialRender(); - } - }, [ onInitialRender ] ); - return ( id ) ); const [ connectSite ] = useConnectSiteMutation(); const [ disconnectSite ] = useDisconnectSiteMutation(); const { pushSite, pullSite, isAnySitePulling, isAnySitePushing } = useSyncSites(); + + const connectedSiteIds = connectedSites.map( ( { id } ) => id ); + const { data: syncSites = [] } = useGetWpComSitesQuery( { + connectedSiteIds, + userId: user?.id, + } ); + const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; const { streamlineOnboarding } = useFeatureFlags(); const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | null >( null ); - const [ pendingModalMode, setPendingModalMode ] = useState< 'push' | 'pull' | null >( null ); - - // Open modal after fetch completes and state updates - useEffect( () => { - if ( pendingModalMode && ! isFetchingSyncSites ) { - dispatch( connectedSitesActions.openModal( pendingModalMode ) ); - setPendingModalMode( null ); - } - }, [ pendingModalMode, isFetchingSyncSites, syncSites.length, dispatch ] ); if ( ! isAuthenticated ) { return ; @@ -173,7 +165,7 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } } }; - const handleSiteSelection = async ( siteId: number, mode: SyncModalMode | null ) => { + const handleSiteSelection = async ( siteId: number ) => { const selectedSiteFromList = syncSites.find( ( site ) => site.id === siteId ); if ( ! selectedSiteFromList ) { getIpcApi().showErrorMessageBox( { @@ -183,8 +175,8 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } return; } - if ( mode === 'push' || mode === 'pull' ) { - dispatch( connectedSitesActions.openModal( mode ) ); + if ( reduxModalMode === 'push' || reduxModalMode === 'pull' ) { + dispatch( connectedSitesActions.openModal( reduxModalMode ) ); setSelectedRemoteSite( selectedSiteFromList ); } else { await handleConnect( selectedSiteFromList ); @@ -216,34 +208,14 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } { streamlineOnboarding ? (
- setPendingModalMode( 'push' ) } - disabled={ isAnySiteSyncing || pendingModalMode !== null } - isBusy={ pendingModalMode === 'push' } - tooltipText={ - pendingModalMode === 'pull' - ? __( 'Please wait for the current operation to finish.' ) - : isAnySiteSyncing - ? __( - 'Another site is syncing. Please wait for the sync to finish before you publish your site.' - ) - : __( 'Publishing your site requires an internet connection.' ) - } - > - { __( 'Publish site' ) } - + setPendingModalMode( 'pull' ) } + connectSite={ () => dispatch( connectedSitesActions.openModal( 'pull' ) ) } className={ isAnySiteSyncing ? '' : '!text-a8c-blue-50 !shadow-a8c-blue-50' } - disabled={ isAnySiteSyncing || pendingModalMode !== null } - isBusy={ pendingModalMode === 'pull' } + disabled={ isAnySiteSyncing } tooltipText={ - pendingModalMode === 'push' - ? __( 'Please wait for the current operation to finish.' ) - : isAnySiteSyncing + isAnySiteSyncing ? __( 'Another site is syncing. Please wait for the sync to finish before you pull a site.' ) @@ -267,44 +239,16 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } ) } { isModalOpen && ( - <> - { reduxModalMode === 'connect' ? ( - { - dispatch( connectedSitesActions.closeModal() ); - } } - syncSites={ syncSites } - onInitialRender={ refetchSites } - onConnect={ async ( siteId: number ) => { - await handleSiteSelection( siteId, reduxModalMode ); - } } - selectedSite={ selectedSite } - /> - ) : syncSites.length === 0 && ! isFetchingSyncSites ? ( - { - dispatch( connectedSitesActions.closeModal() ); - } } - selectedSite={ selectedSite } - /> - ) : ( - { - dispatch( connectedSitesActions.closeModal() ); - } } - syncSites={ syncSites } - onInitialRender={ refetchSites } - onConnect={ async ( siteId: number ) => { - await handleSiteSelection( siteId, reduxModalMode ); - } } - selectedSite={ selectedSite } - /> - ) } - + { + dispatch( connectedSitesActions.closeModal() ); + } } + onConnect={ async ( siteId: number ) => { + await handleSiteSelection( siteId ); + } } + selectedSite={ selectedSite } + /> ) } { reduxModalMode && reduxModalMode !== 'connect' && selectedRemoteSite && ( diff --git a/src/modules/sync/lib/environment-utils.ts b/src/modules/sync/lib/environment-utils.ts index 47cb4499d3..bd30a8488a 100644 --- a/src/modules/sync/lib/environment-utils.ts +++ b/src/modules/sync/lib/environment-utils.ts @@ -1,6 +1,6 @@ import { __ } from '@wordpress/i18n'; import { z } from 'zod'; -import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import { SyncSite } from 'src/modules/sync/types'; const EnvironmentSchema = z.enum( [ 'production', 'staging', 'development' ] ); export type EnvironmentType = z.infer< typeof EnvironmentSchema >; diff --git a/src/modules/sync/lib/ipc-handlers.ts b/src/modules/sync/lib/ipc-handlers.ts index 3a93cdb268..956d9764f8 100644 --- a/src/modules/sync/lib/ipc-handlers.ts +++ b/src/modules/sync/lib/ipc-handlers.ts @@ -5,7 +5,6 @@ import path from 'node:path'; import * as Sentry from '@sentry/electron/main'; import { z } from 'zod'; import { isErrnoException } from 'common/lib/is-errno-exception'; -import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; import { PullStateProgressInfo, PushStateProgressInfo, @@ -19,6 +18,7 @@ import { getAuthenticationToken } from 'src/lib/oauth'; import { keepSqliteIntegrationUpdated } from 'src/lib/sqlite-versions'; import wpcomFactory from 'src/lib/wpcom-factory'; import wpcomXhrRequest from 'src/lib/wpcom-xhr-request-factory'; +import { SyncSite } from 'src/modules/sync/types'; import { SiteServer } from 'src/site-server'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; import { SyncOption } from 'src/types'; diff --git a/src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx b/src/modules/sync/lib/reconcile-connected-sites.tsx similarity index 92% rename from src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx rename to src/modules/sync/lib/reconcile-connected-sites.tsx index 18dafaff4e..faea53b67d 100644 --- a/src/hooks/use-fetch-wpcom-sites/reconcile-connected-sites.tsx +++ b/src/modules/sync/lib/reconcile-connected-sites.tsx @@ -1,4 +1,4 @@ -import { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import { SyncSite } from 'src/modules/sync/types'; /** * Generate updated site data to be stored in `appdata-v1.json`: diff --git a/src/modules/sync/lib/sync-support.ts b/src/modules/sync/lib/sync-support.ts new file mode 100644 index 0000000000..82aa373e9c --- /dev/null +++ b/src/modules/sync/lib/sync-support.ts @@ -0,0 +1,46 @@ +import type { SyncSupport } from 'src/modules/sync/types'; +import type { SitesEndpointSite } from 'src/stores/sync/wpcom-sites'; + +const STUDIO_SYNC_FEATURE_NAME = 'studio-sync'; + +export function isPressableSite( site: SitesEndpointSite ): boolean { + return site.hosting_provider_guess === 'pressable'; +} + +export function isAtomicSite( site: SitesEndpointSite ): boolean { + return site.is_wpcom_atomic; +} + +export function hasSupportedPlan( site: SitesEndpointSite ): boolean { + return site.plan?.features.active.includes( STUDIO_SYNC_FEATURE_NAME ) ?? false; +} + +export function isJetpackSite( site: SitesEndpointSite ): boolean { + return !! site.jetpack && ! isAtomicSite( site ) && ! isPressableSite( site ); +} + +export function needsTransfer( site: SitesEndpointSite ): boolean { + return ! isJetpackSite( site ) && ! isPressableSite( site ) && ! isAtomicSite( site ); +} + +export function getSyncSupport( site: SitesEndpointSite, connectedSiteIds: number[] ): SyncSupport { + if ( site.is_deleted ) { + return 'deleted'; + } + if ( ! site.capabilities?.manage_options ) { + return 'missing-permissions'; + } + if ( isJetpackSite( site ) ) { + return 'unsupported'; + } + if ( ! hasSupportedPlan( site ) && ! isPressableSite( site ) ) { + return 'needs-upgrade'; + } + if ( needsTransfer( site ) ) { + return 'needs-transfer'; + } + if ( connectedSiteIds.some( ( id ) => id === site.ID ) ) { + return 'already-connected'; + } + return 'syncable'; +} diff --git a/src/modules/sync/tests/index.test.tsx b/src/modules/sync/tests/index.test.tsx index 98726d3eea..707d5b3e04 100644 --- a/src/modules/sync/tests/index.test.tsx +++ b/src/modules/sync/tests/index.test.tsx @@ -6,20 +6,23 @@ 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 { useFeatureFlags } from 'src/hooks/use-feature-flags'; -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 { SyncSite } from 'src/modules/sync/types'; import { store } from 'src/stores'; import { useLatestRewindId, useRemoteFileTree } from 'src/stores/sync'; +import { useGetWpComSitesQuery } from 'src/stores/sync/wpcom-sites'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; store.replaceReducer( testReducer ); jest.mock( 'src/lib/get-ipc-api' ); jest.mock( 'src/hooks/use-auth' ); -jest.mock( 'src/hooks/use-fetch-wpcom-sites' ); +jest.mock( 'src/stores/sync/wpcom-sites', () => ( { + ...jest.requireActual( 'src/stores/sync/wpcom-sites' ), + useGetWpComSitesQuery: jest.fn(), +} ) ); jest.mock( 'src/hooks/use-feature-flags' ); jest.mock( 'src/hooks/sync-sites/sync-sites-context', () => ( { ...jest.requireActual( '../../../hooks/sync-sites/sync-sites-context' ), @@ -115,10 +118,11 @@ describe( 'ContentTabSync', () => { const currentMock = ( getIpcApi as jest.Mock )(); currentMock.getConnectedWpcomSites.mockResolvedValue( connectedSites ); - ( useFetchWpComSites as jest.Mock ).mockReturnValue( { - syncSites, + ( useGetWpComSitesQuery as jest.Mock ).mockReturnValue( { + data: syncSites, + isLoading: false, isFetching: false, - refetchSites: jest.fn(), + refetch: jest.fn().mockResolvedValue( { data: syncSites } ), } ); }; @@ -179,10 +183,11 @@ describe( 'ContentTabSync', () => { error: null, } ); - ( useFetchWpComSites as jest.Mock ).mockReturnValue( { - syncSites: [], + ( useGetWpComSitesQuery as jest.Mock ).mockReturnValue( { + data: [], + isLoading: false, isFetching: false, - refetchSites: jest.fn(), + refetch: jest.fn().mockResolvedValue( { data: [] } ), } ); ( useRemoteFileTree as jest.Mock ).mockReturnValue( { diff --git a/src/modules/sync/types.ts b/src/modules/sync/types.ts index e921c51b4f..f2ea55a873 100644 --- a/src/modules/sync/types.ts +++ b/src/modules/sync/types.ts @@ -6,3 +6,25 @@ export type RawDirectoryEntry = { }; export type SyncModalMode = 'push' | 'pull' | 'connect'; + +export type SyncSupport = + | 'unsupported' + | 'syncable' + | 'needs-transfer' + | 'already-connected' + | 'needs-upgrade' + | 'deleted' + | 'missing-permissions'; + +export type SyncSite = { + id: number; + localSiteId: string; + name: string; + url: string; + isStaging: boolean; + isPressable: boolean; + environmentType?: string | null; + syncSupport: SyncSupport; + lastPullTimestamp: string | null; + lastPushTimestamp: string | null; +}; diff --git a/src/storage/storage-types.ts b/src/storage/storage-types.ts index 246c47817f..70d2e52f6c 100644 --- a/src/storage/storage-types.ts +++ b/src/storage/storage-types.ts @@ -1,7 +1,7 @@ import { Snapshot } from 'common/types/snapshot'; import { StoredToken } from 'src/lib/oauth'; import { SupportedEditor } from 'src/modules/user-settings/lib/editor'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import type { SyncSite } from 'src/modules/sync/types'; import type { SupportedTerminal } from 'src/modules/user-settings/lib/terminal'; export interface WindowBounds { diff --git a/src/stores/index.ts b/src/stores/index.ts index 708e06ccd1..72cb2477c2 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -25,6 +25,7 @@ import { } from 'src/stores/snapshot-slice'; import { syncReducer } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; +import { wpcomSitesApi } from 'src/stores/sync/wpcom-sites'; import { wpcomApi, wpcomPublicApi } from 'src/stores/wpcom-api'; import { wordpressVersionsApi } from './wordpress-versions-api'; import type { SupportedLocale } from 'common/lib/locale'; @@ -40,6 +41,7 @@ export type RootState = { sync: ReturnType< typeof syncReducer >; connectedSitesApi: ReturnType< typeof connectedSitesApi.reducer >; connectedSites: ReturnType< typeof connectedSitesReducer >; + wpcomSitesApi: ReturnType< typeof wpcomSitesApi.reducer >; wordpressVersionsApi: ReturnType< typeof wordpressVersionsApi.reducer >; wpcomApi: ReturnType< typeof wpcomApi.reducer >; wpcomPublicApi: ReturnType< typeof wpcomPublicApi.reducer >; @@ -93,6 +95,7 @@ export const rootReducer = combineReducers( { installedAppsApi: installedAppsApi.reducer, connectedSitesApi: connectedSitesApi.reducer, connectedSites: connectedSitesReducer, + wpcomSitesApi: wpcomSitesApi.reducer, onboarding: onboardingReducer, providerConstants: providerConstantsReducer, snapshot: snapshotReducer, @@ -112,6 +115,7 @@ export const store = configureStore( { .concat( appVersionApi.middleware ) .concat( installedAppsApi.middleware ) .concat( connectedSitesApi.middleware ) + .concat( wpcomSitesApi.middleware ) .concat( wordpressVersionsApi.middleware ) .concat( wpcomApi.middleware ) .concat( wpcomPublicApi.middleware ) diff --git a/src/stores/sync/connected-sites.ts b/src/stores/sync/connected-sites.ts index 20a49ededc..1be946e232 100644 --- a/src/stores/sync/connected-sites.ts +++ b/src/stores/sync/connected-sites.ts @@ -2,8 +2,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { RootState } from 'src/stores'; -import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; -import type { SyncModalMode } from 'src/modules/sync/types'; +import type { SyncSite, SyncModalMode } from 'src/modules/sync/types'; type ConnectedSitesState = { isModalOpen: boolean; diff --git a/src/stores/sync/wpcom-sites.ts b/src/stores/sync/wpcom-sites.ts new file mode 100644 index 0000000000..3719ced118 --- /dev/null +++ b/src/stores/sync/wpcom-sites.ts @@ -0,0 +1,197 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import * as Sentry from '@sentry/electron/renderer'; +import { z } from 'zod'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { reconcileConnectedSites } from 'src/modules/sync/lib/reconcile-connected-sites'; +import { getSyncSupport, isPressableSite } from 'src/modules/sync/lib/sync-support'; +import { withOfflineCheck } from 'src/stores/utils/with-offline-check'; +import { getWpcomClient } from 'src/stores/wpcom-api'; +import type { SyncSite, SyncSupport } from 'src/modules/sync/types'; + +// Schema for WordPress.com sites endpoint +const sitesEndpointSiteSchema = z.object( { + ID: z.number(), + is_wpcom_atomic: z.boolean(), + name: z.string(), + URL: z.string(), + jetpack: z.boolean().optional(), + is_deleted: z.boolean(), + hosting_provider_guess: z.string().optional(), + environment_type: z + .enum( [ 'production', 'staging', 'development', 'sandbox', 'local' ] ) + .nullable() + .optional(), + is_a8c: z.boolean().optional(), + options: z + .object( { + created_at: z.string(), + wpcom_staging_blog_ids: z.array( z.number() ), + } ) + .optional(), + capabilities: z + .object( { + manage_options: z.boolean(), + } ) + .optional(), + plan: z + .object( { + expired: z.boolean().optional(), + features: z.object( { + active: z.array( z.string() ), + available: z.record( z.string(), z.array( z.string() ) ).optional(), + } ), + is_free: z.boolean().optional(), + product_id: z.coerce.number(), + product_name_short: z.string(), + product_slug: z.string(), + user_is_owner: z.boolean().optional(), + } ) + .optional(), +} ); + +export type SitesEndpointSite = z.infer< typeof sitesEndpointSiteSchema >; + +// We use a permissive schema for the API response to fail gracefully if a single site is malformed +const sitesEndpointResponseSchema = z.object( { + sites: z.array( z.unknown() ), +} ); + +function transformSingleSiteResponse( + site: SitesEndpointSite, + syncSupport: SyncSupport, + isStaging: boolean +): SyncSite { + return { + id: site.ID, + localSiteId: '', + name: site.name, + url: site.URL, + isStaging, + isPressable: isPressableSite( site ), + environmentType: site.environment_type, + syncSupport, + lastPullTimestamp: null, + lastPushTimestamp: null, + }; +} + +/** + * Transforms the WordPress.com sites API response into SyncSite objects. + * + * @param sites - Raw site data from the WordPress.com API + * @param connectedSiteIds - IDs of sites already connected to the current local site. + * Used to: 1) keep deleted sites in the list if they're connected, and + * 2) determine sync support status (already-connected vs syncable) + */ +function transformSitesResponse( sites: unknown[], connectedSiteIds: number[] ): SyncSite[] { + const validatedSites = sites.reduce< SitesEndpointSite[] >( ( acc, rawSite ) => { + try { + const site = sitesEndpointSiteSchema.parse( rawSite ); + return [ ...acc, site ]; + } catch ( error ) { + Sentry.captureException( error ); + return acc; + } + }, [] ); + + const allStagingSiteIds = validatedSites.flatMap( ( site ) => { + return site.options?.wpcom_staging_blog_ids ?? []; + } ); + + return validatedSites + .filter( ( site ) => ! site.is_a8c ) + .filter( ( site ) => ! site.is_deleted || connectedSiteIds.some( ( id ) => id === site.ID ) ) + .map( ( site ) => { + // The API returns the wrong value for the `is_wpcom_staging_site` prop while staging sites + // are being created. Hence the check in other sites' `wpcom_staging_blog_ids` arrays. + const isStaging = allStagingSiteIds.includes( site.ID ); + const syncSupport = getSyncSupport( site, connectedSiteIds ); + + return transformSingleSiteResponse( site, syncSupport, isStaging ); + } ); +} + +export const wpcomSitesApi = createApi( { + reducerPath: 'wpcomSitesApi', + baseQuery: fetchBaseQuery(), + tagTypes: [ 'WpComSites' ], + endpoints: ( builder ) => ( { + getWpComSites: builder.query< SyncSite[], { connectedSiteIds: number[]; userId?: number } >( { + queryFn: async ( { connectedSiteIds } ) => { + const wpcomClient = getWpcomClient(); + if ( ! wpcomClient ) { + return { error: { status: 401, data: 'Not authenticated' } }; + } + + 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 wpcomClient.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 syncSitesForReconciliation = transformSitesResponse( + parsedResponse.sites, + allConnectedSites.map( ( { id } ) => id ) + ); + + const { updatedConnectedSites } = reconcileConnectedSites( + allConnectedSites, + syncSitesForReconciliation + ); + await getIpcApi().updateConnectedWpcomSites( updatedConnectedSites ); + + const syncSitesForSelectedSite = transformSitesResponse( + parsedResponse.sites, + connectedSiteIds + ); + + return { data: syncSitesForSelectedSite }; + } catch ( error ) { + Sentry.captureException( error ); + console.error( error ); + return { + error: { + status: 500, + data: error, + }, + }; + } + }, + providesTags: ( _result, _error, arg ) => [ { type: 'WpComSites', userId: arg.userId } ], + keepUnusedDataFor: 60, + } ), + } ), +} ); + +const { useGetWpComSitesQuery: useGetWpComSitesQueryBase } = wpcomSitesApi; + +// Wrap the query hook with offline check +// Authentication is already handled in queryFn which checks wpcomClient +export const useGetWpComSitesQuery = withOfflineCheck( useGetWpComSitesQueryBase ); diff --git a/src/stores/wpcom-api.ts b/src/stores/wpcom-api.ts index 1f3e390de9..398ba34c93 100644 --- a/src/stores/wpcom-api.ts +++ b/src/stores/wpcom-api.ts @@ -77,6 +77,10 @@ export const setWpcomClient = ( client: WPCOM | undefined ) => { wpcomClient = client; }; +export const getWpcomClient = (): WPCOM | undefined => { + return wpcomClient; +}; + const wpcomBaseQuery: BaseQueryFn< { path: string; apiNamespace?: string }, unknown,