diff --git a/src/modules/sync/components/connect-button.tsx b/src/modules/sync/components/connect-button.tsx index 136c18a33..83979e9ba 100644 --- a/src/modules/sync/components/connect-button.tsx +++ b/src/modules/sync/components/connect-button.tsx @@ -3,40 +3,40 @@ import Button, { ButtonVariant } from 'src/components/button'; import offlineIcon from 'src/components/offline-icon'; import { Tooltip } from 'src/components/tooltip'; import { useOffline } from 'src/hooks/use-offline'; -import { cx } from 'src/lib/cx'; interface ConnectButtonProps { variant: ButtonVariant; connectSite?: () => void; - disableConnectButtonStyle?: boolean; + disabled?: boolean; className?: string; children?: React.ReactNode; + tooltipText?: string; } export const ConnectButton = ( { variant, connectSite, - disableConnectButtonStyle, + disabled, className, children, + tooltipText, }: ConnectButtonProps ) => { const isOffline = useOffline(); + const tooltipContent = tooltipText ?? __( 'Connecting a site requires an internet connection.' ); + const isDisabled = disabled || isOffline; return ( diff --git a/src/modules/sync/components/sync-sites-modal-selector.tsx b/src/modules/sync/components/sync-sites-modal-selector.tsx index c06e4b1f8..290aaa105 100644 --- a/src/modules/sync/components/sync-sites-modal-selector.tsx +++ b/src/modules/sync/components/sync-sites-modal-selector.tsx @@ -17,6 +17,7 @@ import { EnvironmentBadge } from 'src/modules/sync/components/environment-badge' 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'; const SearchControl = process.env.NODE_ENV === 'test' ? () => null : SearchControlWp; @@ -32,6 +33,7 @@ export function SyncSitesModalSelector( { syncSites, onInitialRender, selectedSite, + mode = 'connect', }: { isLoading?: boolean; onRequestClose: () => void; @@ -39,6 +41,7 @@ export function SyncSitesModalSelector( { onConnect: ( siteId: number ) => void; onInitialRender?: () => void; selectedSite: SiteDetails; + mode?: SyncModalMode; } ) { const { __ } = useI18n(); const [ selectedSiteId, setSelectedSiteId ] = useState< number | null >( null ); @@ -53,6 +56,18 @@ export function SyncSitesModalSelector( { } ); const isEmpty = filteredSites.length === 0; + const getModalTitle = () => { + switch ( mode ) { + case 'push': + return __( 'Publish your site' ); + case 'pull': + return __( 'Select a site to import' ); + case 'connect': + default: + return __( 'Connect your site' ); + } + }; + useEffect( () => { if ( onInitialRender ) { onInitialRender(); @@ -63,7 +78,7 @@ export function SyncSitesModalSelector( {
@@ -100,11 +115,12 @@ export function SyncSitesModalSelector( { } } disabled={ ! selectedSiteId } selectedSite={ selectedSite } + mode={ mode } /> { isOffline && (
- +
) }
@@ -335,14 +351,27 @@ function Footer( { onConnect, disabled, selectedSite, + mode = 'connect', }: { onRequestClose: () => void; onConnect: () => void; disabled: boolean; selectedSite: SiteDetails; + mode?: SyncModalMode; } ) { const { __ } = useI18n(); + const getButtonText = () => { + switch ( mode ) { + case 'push': + case 'pull': + return __( 'Next' ); + case 'connect': + default: + return __( 'Connect' ); + } + }; + useEffect( () => { if ( ! disabled ) { focusConnectButton(); @@ -361,20 +390,31 @@ function Footer( { { __( 'Cancel' ) } ); } -const SyncSitesOfflineView = () => { - const offlineMessage = __( 'Connecting a site requires an internet connection.' ); +const SyncSitesOfflineView = ( { mode = 'connect' }: { mode?: SyncModalMode } ) => { + const { __ } = useI18n(); + const getOfflineMessage = () => { + switch ( mode ) { + case 'push': + return __( 'Publishing your site requires an internet connection.' ); + case 'pull': + return __( 'Importing a remote site requires an internet connection.' ); + case 'connect': + default: + return __( 'Connecting a site requires an internet connection.' ); + } + }; return (
- { offlineMessage } + { getOfflineMessage() }
); }; diff --git a/src/modules/sync/index.tsx b/src/modules/sync/index.tsx index 37fdb384e..dd08a82ca 100644 --- a/src/modules/sync/index.tsx +++ b/src/modules/sync/index.tsx @@ -1,17 +1,23 @@ import { check, Icon } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; -import { PropsWithChildren, useEffect } from 'react'; +import { PropsWithChildren, useEffect, 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 { getIpcApi } from 'src/lib/get-ipc-api'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; import { SyncConnectedSites } from 'src/modules/sync/components/sync-connected-sites'; +import { SyncDialog } from 'src/modules/sync/components/sync-dialog'; import { SyncSitesModalSelector } from 'src/modules/sync/components/sync-sites-modal-selector'; import { SyncTabImage } from 'src/modules/sync/components/sync-tab-image'; +import { + convertTreeToPullOptions, + convertTreeToPushOptions, +} from 'src/modules/sync/lib/convert-tree-to-sync-options'; import { useAppDispatch, useRootSelector } from 'src/stores'; import { useConnectedSitesData, @@ -21,6 +27,7 @@ import { connectedSitesActions, } from 'src/stores/sync'; import type { SyncSite } from 'src/hooks/use-fetch-wpcom-sites/types'; +import type { SyncModalMode } from 'src/modules/sync/types'; function SiteSyncDescription( { children }: PropsWithChildren ) { const { __ } = useI18n(); @@ -34,13 +41,13 @@ function SiteSyncDescription( { children }: PropsWithChildren ) {
{ __( - 'Connect your existing WordPress.com or Pressable sites with Jetpack activated, or create a new one. Then share your work with the world.' + 'Launch your existing WordPress.com or Jetpack-activated Pressable sites, or import an existing one. Then, share your work with the world.' ) }
{ [ __( 'Push and pull changes from your live site.' ), - __( 'Connect multiple environments.' ), + __( 'Supports staging and production sites.' ), __( 'Sync database and file changes.' ), ].map( ( text ) => (
@@ -120,9 +127,14 @@ 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 { pushSite, pullSite, isAnySitePulling, isAnySitePushing } = useSyncSites(); + const isAnySiteSyncing = isAnySitePulling || isAnySitePushing; + + const [ selectedRemoteSite, setSelectedRemoteSite ] = useState< SyncSite | null >( null ); const { isAuthenticated } = useAuth(); @@ -147,6 +159,41 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } } }; + const handleLaunchSite = () => { + dispatch( connectedSitesActions.openModal( 'push' ) ); + }; + + const handleImportSite = () => { + dispatch( connectedSitesActions.openModal( '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( { + title: __( 'Failed to select site' ), + message: __( 'Please try again.' ), + } ); + return; + } + + if ( mode === 'push' || mode === 'pull' ) { + dispatch( connectedSitesActions.setModalMode( mode ) ); + setSelectedRemoteSite( selectedSiteFromList ); + } else { + await handleConnect( selectedSiteFromList ); + dispatch( connectedSitesActions.setModalMode( null ) ); + dispatch( connectedSitesActions.closeModal() ); + } + }; + return (
{ connectedSites.length > 0 ? ( @@ -159,8 +206,7 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
dispatch( connectedSitesActions.openModal() ) } - disableConnectButtonStyle={ true } + connectSite={ () => dispatch( connectedSitesActions.openModal( 'connect' ) ) } > { __( 'Connect another site' ) } @@ -168,13 +214,29 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails }
) : ( -
+
dispatch( connectedSitesActions.openModal() ) } - disableConnectButtonStyle={ true } + connectSite={ handleLaunchSite } + disabled={ isAnySiteSyncing } + tooltipText={ + 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' ) } + + - { __( 'Connect site' ) } + { __( 'Pull site' ) }
@@ -182,31 +244,41 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } { isModalOpen && ( dispatch( connectedSitesActions.closeModal() ) } + onRequestClose={ () => { + dispatch( connectedSitesActions.closeModal() ); + } } syncSites={ syncSites } onInitialRender={ refetchSites } - onConnect={ async ( siteId ) => { - const disconnectSiteId = - typeof isModalOpen === 'object' ? isModalOpen.disconnectSiteId : undefined; - - if ( disconnectSiteId ) { - await disconnectSite( disconnectSiteId ); - } - - const newConnectedSite = syncSites.find( ( site ) => site.id === siteId ); - if ( ! newConnectedSite ) { - getIpcApi().showErrorMessageBox( { - title: __( 'Failed to connect to site' ), - message: __( 'Please try again.' ), - } ); - return; - } - void handleConnect( newConnectedSite ); + onConnect={ async ( siteId: number ) => { + await handleSiteSelection( siteId, reduxModalMode ); } } selectedSite={ selectedSite } /> ) } + + { reduxModalMode && reduxModalMode !== 'connect' && selectedRemoteSite && ( + { + await handleConnect( selectedRemoteSite ); + const pushOptions = convertTreeToPushOptions( tree ); + void pushSite( selectedRemoteSite, selectedSite, pushOptions ); + } } + onPull={ async ( tree ) => { + await handleConnect( selectedRemoteSite ); + const pullOptions = convertTreeToPullOptions( tree ); + void pullSite( selectedRemoteSite, selectedSite, pullOptions ); + } } + onRequestClose={ () => { + setSelectedRemoteSite( null ); + dispatch( connectedSitesActions.setModalMode( null ) ); + } } + /> + ) }
); } diff --git a/src/modules/sync/tests/index.test.tsx b/src/modules/sync/tests/index.test.tsx index e8b06ca67..a3f551f65 100644 --- a/src/modules/sync/tests/index.test.tsx +++ b/src/modules/sync/tests/index.test.tsx @@ -45,11 +45,15 @@ jest.mock( 'src/stores/sync', () => ( { useConnectedSitesOperations: jest.fn(), connectedSitesSelectors: { selectIsModalOpen: jest.fn(), + selectModalMode: jest.fn(), }, connectedSitesActions: { openModal: jest.fn().mockImplementation( () => { return { type: 'connectedSites/openModal' }; } ), + setModalMode: jest.fn().mockImplementation( () => { + return { type: 'connectedSites/setModalMode' }; + } ), closeModal: jest.fn().mockImplementation( () => { return { type: 'connectedSites/closeModal' }; } ), @@ -190,6 +194,7 @@ describe( 'ContentTabSync', () => { 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( [ { @@ -263,21 +268,24 @@ describe( 'ContentTabSync', () => { expect( getIpcApi().authenticate ).toHaveBeenCalled(); } ); - it( 'displays connect site button to authenticated user', () => { + it( 'displays publish and import actions to authenticated user', () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); renderWithProvider( ); - const connectSiteButton = screen.getByRole( 'button', { name: /Connect site/i } ); + const publishButton = screen.getByRole( 'button', { name: /Publish site/i } ); + const importButton = screen.getByRole( 'button', { name: /Pull site/i } ); - expect( connectSiteButton ).toBeInTheDocument(); + expect( publishButton ).toBeInTheDocument(); + expect( importButton ).toBeInTheDocument(); } ); - it( 'opens the site selector modal to connect a site authenticated user', () => { + it( 'opens the site selector modal when clicking import button', () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); renderWithProvider( ); - const connectSiteButton = screen.getByRole( 'button', { name: /Connect site/i } ); - fireEvent.click( connectSiteButton ); + 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(); @@ -356,10 +364,11 @@ describe( 'ContentTabSync', () => { it( 'opens the modal and displays the create new site button', () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); renderWithProvider( ); - const connectSiteButton = screen.getByRole( 'button', { name: /Connect site/i } ); - expect( connectSiteButton ).toBeInTheDocument(); - fireEvent.click( connectSiteButton ); + 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, @@ -367,12 +376,15 @@ describe( 'ContentTabSync', () => { expect( createNewSiteButton ).toBeInTheDocument(); } ); - it( 'displays ConnectButton when there are no connected sites', () => { + it( 'displays publish and import buttons when there are no connected sites', () => { ( useAuth as jest.Mock ).mockReturnValue( createAuthMock( true ) ); renderWithProvider( ); - const connectButton = screen.getByRole( 'button', { name: /Connect site/i } ); - expect( connectButton ).toBeInTheDocument(); + const publishButton = screen.getByRole( 'button', { name: /Publish site/i } ); + const importButton = screen.getByRole( 'button', { name: /Pull site/i } ); + + expect( publishButton ).toBeInTheDocument(); + expect( importButton ).toBeInTheDocument(); } ); it( 'displays environment badges for Pressable sites with production, staging and development environments', () => { diff --git a/src/modules/sync/types.ts b/src/modules/sync/types.ts index 54a9f1fa0..e921c51b4 100644 --- a/src/modules/sync/types.ts +++ b/src/modules/sync/types.ts @@ -4,3 +4,5 @@ export type RawDirectoryEntry = { path: string; children?: RawDirectoryEntry[]; }; + +export type SyncModalMode = 'push' | 'pull' | 'connect'; diff --git a/src/stores/sync/connected-sites-slice.ts b/src/stores/sync/connected-sites-slice.ts index debe8fb63..5157ad018 100644 --- a/src/stores/sync/connected-sites-slice.ts +++ b/src/stores/sync/connected-sites-slice.ts @@ -2,6 +2,7 @@ import { createAsyncThunk, createSelector, createSlice, PayloadAction } from '@r 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'; type ConnectedSites = SyncSite[]; type ModalState = false | true | { disconnectSiteId?: number }; @@ -9,6 +10,7 @@ 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 { @@ -24,6 +26,7 @@ interface DisconnectSiteParams { const initialState: ConnectedSitesState = { sites: {}, isModalOpen: false, + modalMode: null, }; export const loadAllConnectedSites = createAsyncThunk( 'connectedSites/loadAll', async () => { @@ -96,8 +99,15 @@ const connectedSitesSlice = createSlice( { delete state.sites[ action.payload ]; }, - openModal: ( state ) => { + 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 ) => { @@ -125,6 +135,7 @@ 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,