From 4b19b5a217190792a0e197cf2a6e24545e9e1728 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Thu, 6 Aug 2026 16:56:48 -0400 Subject: [PATCH] Add site overview connections --- .../site-overview-view/cards.module.css | 131 ++++++++++- .../connections-section.tsx | 215 ++++++++++++++++++ .../site-overview-view/index.test.tsx | 113 ++++++++- .../components/site-overview-view/index.tsx | 5 +- .../site-overview-view/overview-card.tsx | 40 +++- .../site-overview-view/row-link.tsx | 40 ++++ apps/ui/src/hooks/use-is-site-syncing.ts | 21 ++ 7 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 apps/ui/src/components/site-overview-view/connections-section.tsx create mode 100644 apps/ui/src/components/site-overview-view/row-link.tsx create mode 100644 apps/ui/src/hooks/use-is-site-syncing.ts diff --git a/apps/ui/src/components/site-overview-view/cards.module.css b/apps/ui/src/components/site-overview-view/cards.module.css index 0852d6133a..ba012b05e5 100644 --- a/apps/ui/src/components/site-overview-view/cards.module.css +++ b/apps/ui/src/components/site-overview-view/cards.module.css @@ -16,6 +16,134 @@ min-width: 0; } +.sectionDivider { + height: var(--wpds-border-width-xs); + background: var(--wpds-color-stroke-surface-neutral); +} + +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--wpds-dimension-padding-sm); + min-height: 24px; +} + +.cardTitle { + margin: 0; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + font-weight: var(--wpds-typography-font-weight-medium); + color: var(--wpds-color-fg-content-neutral); +} + +.empty { + margin: 0; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.rowList { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-padding-md); + min-width: 0; +} + +.row { + display: flex; + align-items: center; + gap: var(--wpds-dimension-padding-sm); + min-width: 0; +} + +.rowText { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.rowMeta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.rowActions { + display: flex; + align-items: center; + gap: var(--wpds-dimension-padding-xs); + flex: 0 0 auto; +} + +.rowDivider { + height: var(--wpds-border-width-xs); + background: var(--wpds-color-stroke-surface-neutral-weak); +} + +.rowLink { + appearance: none; + margin: 0; + padding: 0; + border: 0; + background: none; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + text-align: start; + font-family: inherit; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral); + cursor: var(--wpds-cursor-control); +} + +.rowLink:hover, +.rowLink:focus-visible { + color: var(--wpds-color-fg-interactive-brand); + text-decoration: underline; +} + +.stale { + color: var(--wpds-color-fg-content-warning); +} + +.itemIcon { + display: grid; + place-items: center; + flex: 0 0 18px; + width: 18px; + height: 18px; +} + +.itemIcon svg { + fill: currentColor; +} + +.connectionSkeleton { + height: 10px; + border-radius: var(--wpds-border-radius-sm); + background: var(--wpds-color-bg-surface-neutral-weak); + animation: connection-loading 1.4s ease-in-out infinite alternate; +} + +@keyframes connection-loading { + to { + opacity: 0.55; + } +} + +.pickerPopup { + width: 320px; + padding: 0; +} + .themeSummary { display: flex; align-items: center; @@ -264,7 +392,8 @@ @media (prefers-reduced-motion: reduce) { .storageSkeleton, - .storageLegend { + .storageLegend, + .connectionSkeleton { animation: none; transition: none; } diff --git a/apps/ui/src/components/site-overview-view/connections-section.tsx b/apps/ui/src/components/site-overview-view/connections-section.tsx new file mode 100644 index 0000000000..dac35b6966 --- /dev/null +++ b/apps/ui/src/components/site-overview-view/connections-section.tsx @@ -0,0 +1,215 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { + arrowDown, + arrowUp, + copy, + external, + Icon, + moreVertical, + plus, + reusableBlock, +} from '@wordpress/icons'; +import { Button, IconButton } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { Fragment, useState } from 'react'; +import * as Menu from '@/components/menu'; +import { PublishPickerView } from '@/components/site-dropdown/publish-picker-view'; +import { ensureProtocol, stripProtocol } from '@/components/site-dropdown/utils'; +import { useConnector } from '@/data/core'; +import { useAuthUser, useLogin } from '@/data/queries/use-auth-user'; +import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites'; +import { usePullSiteFromLive, usePushSiteToLive } from '@/data/queries/use-sync-site'; +import { useIsSiteSyncing } from '@/hooks/use-is-site-syncing'; +import { useOffline } from '@/hooks/use-offline'; +import { formatRelativeTime } from '@/lib/format-relative-time'; +import styles from './cards.module.css'; +import { CardEmptyState, CardRows, CardSection, RowDivider } from './overview-card'; +import { RowLink } from './row-link'; +import type { SiteDetails, SyncSite } from '@/data/core'; + +const STALE_SYNC_MS = 14 * 24 * 60 * 60 * 1000; + +export function ConnectionsSection( { site, busy }: { site: SiteDetails; busy: boolean } ) { + const { data: authUser } = useAuthUser(); + const login = useLogin(); + const isOffline = useOffline(); + const [ pickerOpen, setPickerOpen ] = useState( false ); + const { data: connections, isLoading } = useConnectedWpcomSites( site.id ); + + const connectAction = authUser ? ( + + + } + /> + + setPickerOpen( false ) } /> + + + ) : null; + + return ( + + { ! authUser ? ( + <> + + { __( 'Sign in to connect this site to WordPress.com and sync it.' ) } + +
+ +
+ + ) : isLoading && ! connections ? ( +
+ ) : ! connections?.length ? ( + + { __( 'Not connected to a live site yet. Connect one to pull or push changes.' ) } + + ) : ( + + { connections.map( ( connection, index ) => ( + + { index > 0 && } + + + ) ) } + + ) } + + ); +} + +function ConnectionRow( { + site, + connection, + busy, +}: { + site: SiteDetails; + connection: SyncSite; + busy: boolean; +} ) { + const connector = useConnector(); + const isOffline = useOffline(); + const pushSiteToLive = usePushSiteToLive(); + const pullSiteFromLive = usePullSiteFromLive(); + const { push: isPushing, pull: isPulling } = useIsSiteSyncing( site.id ); + const syncing = isPushing || isPulling; + const disabled = syncing || busy || isOffline; + const sync = describeLastSync( connection ); + const url = ensureProtocol( connection.url ); + + return ( +
+
+ + { sync.label } +
+
+ + + } + /> + + + pullSiteFromLive.mutate( { siteId: site.id, remoteSiteId: connection.id } ) + } + > + + { __( 'Pull from live' ) } + + + pushSiteToLive.mutate( { siteId: site.id, remoteSiteId: connection.id } ) + } + > + + { __( 'Push to live' ) } + + + + + + } + /> + + void connector.openExternalUrl( url ) }> + + { __( 'Open live site' ) } + + void connector.copyText( url ) }> + + { __( 'Copy URL' ) } + + + +
+
+ ); +} + +export function describeLastSync( connection: SyncSite ): { label: string; stale: boolean } { + const pulled = connection.lastPullTimestamp ? Date.parse( connection.lastPullTimestamp ) : NaN; + const pushed = connection.lastPushTimestamp ? Date.parse( connection.lastPushTimestamp ) : NaN; + const hasPulled = ! Number.isNaN( pulled ); + const hasPushed = ! Number.isNaN( pushed ); + + if ( ! hasPulled && ! hasPushed ) { + return { label: __( 'Never synced' ), stale: true }; + } + + const pulledLast = hasPulled && ( ! hasPushed || pulled >= pushed ); + const timestamp = ( pulledLast ? connection.lastPullTimestamp : connection.lastPushTimestamp )!; + const relative = formatRelativeTime( timestamp ); + return { + label: pulledLast + ? sprintf( __( 'Pulled %s ago' ), relative ) + : sprintf( __( 'Pushed %s ago' ), relative ), + stale: Date.now() - Math.max( hasPulled ? pulled : 0, hasPushed ? pushed : 0 ) > STALE_SYNC_MS, + }; +} diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index f5f8ec6351..cac9618843 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -3,7 +3,8 @@ import { Tooltip } from '@wordpress/ui'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; -import { useLogin } from '@/data/queries/use-auth-user'; +import { useAuthUser, useLogin } from '@/data/queries/use-auth-user'; +import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites'; import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; import { useSiteStorageUsage } from '@/data/queries/use-site-storage-usage'; import { useSiteThumbnail } from '@/data/queries/use-site-thumbnail'; @@ -18,8 +19,10 @@ import { useUpdateSite, useXdebugEnabledSite, } from '@/data/queries/use-sites'; +import { usePullSiteFromLive, usePushSiteToLive } from '@/data/queries/use-sync-site'; import { useUserPreferences } from '@/data/queries/use-user-preferences'; import { useWordPressVersions, useWpVersion } from '@/data/queries/use-wordpress-versions'; +import { useIsSiteSyncing } from '@/hooks/use-is-site-syncing'; import { useOffline } from '@/hooks/use-offline'; import styles from './style.module.css'; import { SiteOverviewView } from './index'; @@ -27,6 +30,7 @@ import type { ConnectorCapabilities, SiteDetails, SupportedEditor, + SyncSite, UserPreferences, } from '@/data/core'; @@ -41,6 +45,18 @@ const WP_VERSIONS = [ { label: '6.7.2', value: '6.7.2', isBeta: false, isDevelopment: false }, ]; +const CONNECTED_SITE: SyncSite = { + id: 42, + localSiteId: 'site-1', + name: 'Demo Live', + url: 'demo.example.com', + isStaging: false, + isPressable: false, + syncSupport: 'syncable', + lastPullTimestamp: null, + lastPushTimestamp: null, +}; + class ResizeObserverMock { observe = vi.fn(); unobserve = vi.fn(); @@ -68,6 +84,10 @@ vi.mock( '@/components/site-dropdown', () => ( { }, } ) ); +vi.mock( '@/components/site-dropdown/publish-picker-view', () => ( { + PublishPickerView: () =>
Connection picker
, +} ) ); + vi.mock( '@/data/core', () => ( { useConnector: vi.fn(), } ) ); @@ -77,9 +97,14 @@ vi.mock( '@/data/queries/use-agentic-features', () => ( { } ) ); vi.mock( '@/data/queries/use-auth-user', () => ( { + useAuthUser: vi.fn(), useLogin: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( { + useConnectedWpcomSites: vi.fn(), +} ) ); + vi.mock( '@/data/queries/use-create-site-helpers', () => ( { useExistingCustomDomains: vi.fn(), } ) ); @@ -108,6 +133,11 @@ vi.mock( '@/data/queries/use-user-preferences', () => ( { useUserPreferences: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-sync-site', () => ( { + usePullSiteFromLive: vi.fn(), + usePushSiteToLive: vi.fn(), +} ) ); + vi.mock( '@/data/queries/use-wordpress-versions', () => ( { useWordPressVersions: vi.fn(), useWpVersion: vi.fn(), @@ -121,12 +151,18 @@ vi.mock( '@/hooks/use-sidebar-collapsed', () => ( { useSidebarCollapsed: useSidebarCollapsedMock, } ) ); +vi.mock( '@/hooks/use-is-site-syncing', () => ( { + useIsSiteSyncing: vi.fn(), +} ) ); + vi.mock( '@/hooks/use-traffic-light-space', () => ( { useTrafficLightSpace: useTrafficLightSpaceMock, } ) ); const useConnectorMock = vi.mocked( useConnector, { partial: true } ); const useAgenticFeaturesMock = vi.mocked( useAgenticFeatures ); +const useAuthUserMock = vi.mocked( useAuthUser, { partial: true } ); +const useConnectedWpcomSitesMock = vi.mocked( useConnectedWpcomSites, { partial: true } ); const useLoginMock = vi.mocked( useLogin, { partial: true } ); const useExistingCustomDomainsMock = vi.mocked( useExistingCustomDomains, { partial: true } ); const useCopySiteMock = vi.mocked( useCopySite, { partial: true } ); @@ -140,10 +176,13 @@ const useSitesMock = vi.mocked( useSites, { partial: true } ); const useStartSiteMock = vi.mocked( useStartSite, { partial: true } ); const useUpdateSiteMock = vi.mocked( useUpdateSite, { partial: true } ); const useOfflineMock = vi.mocked( useOffline ); +const usePullSiteFromLiveMock = vi.mocked( usePullSiteFromLive, { partial: true } ); +const usePushSiteToLiveMock = vi.mocked( usePushSiteToLive, { partial: true } ); const useUserPreferencesMock = vi.mocked( useUserPreferences, { partial: true } ); const useWordPressVersionsMock = vi.mocked( useWordPressVersions, { partial: true } ); const useWpVersionMock = vi.mocked( useWpVersion, { partial: true } ); const useXdebugEnabledSiteMock = vi.mocked( useXdebugEnabledSite, { partial: true } ); +const useIsSiteSyncingMock = vi.mocked( useIsSiteSyncing ); describe( 'SiteOverviewView', () => { const openSiteUrl = vi.fn().mockResolvedValue( undefined ); @@ -154,6 +193,10 @@ describe( 'SiteOverviewView', () => { const copySite = vi.fn(); const exportFullSite = vi.fn(); const exportDatabase = vi.fn(); + const openExternalUrl = vi.fn().mockResolvedValue( undefined ); + const copyText = vi.fn().mockResolvedValue( undefined ); + const pullSiteFromLive = vi.fn(); + const pushSiteToLive = vi.fn(); const onTabChange = vi.fn(); const connectorStub = ( openInOS = true ) => ( { @@ -161,6 +204,8 @@ describe( 'SiteOverviewView', () => { openSiteFolder, openSiteInEditor, openSiteInTerminal, + openExternalUrl, + copyText, capabilities: { openInOS } as ConnectorCapabilities, } ); @@ -194,7 +239,14 @@ describe( 'SiteOverviewView', () => { reason: null, isReady: true, } ); + useAuthUserMock.mockReturnValue( { + data: { id: 1, email: 'person@example.com', displayName: 'Person' }, + } ); useLoginMock.mockReturnValue( { isPending: false, mutate: vi.fn() } ); + useConnectedWpcomSitesMock.mockReturnValue( { data: [], isLoading: false } ); + usePullSiteFromLiveMock.mockReturnValue( { mutate: pullSiteFromLive } ); + usePushSiteToLiveMock.mockReturnValue( { mutate: pushSiteToLive } ); + useIsSiteSyncingMock.mockReturnValue( { push: false, pull: false } ); useExistingCustomDomainsMock.mockReturnValue( [] ); useSitesMock.mockReturnValue( { data: [ createSite( { running: true } ) ], @@ -312,6 +364,65 @@ describe( 'SiteOverviewView', () => { expect( screen.getByText( 'Measuring…' ) ).toBeVisible(); } ); + it( 'offers a complete empty state for connecting a live site', () => { + renderView(); + + expect( screen.getByRole( 'heading', { name: 'Connections' } ) ).toBeVisible(); + expect( + screen.getByText( 'Not connected to a live site yet. Connect one to pull or push changes.' ) + ).toBeVisible(); + expect( screen.getByRole( 'button', { name: 'Connect a WordPress.com site' } ) ).toBeVisible(); + } ); + + it( 'offers login from Connections when signed out', () => { + const login = vi.fn(); + useAuthUserMock.mockReturnValue( { data: null } ); + useLoginMock.mockReturnValue( { isPending: false, mutate: login } ); + + renderView(); + + expect( + screen.getByText( 'Sign in to connect this site to WordPress.com and sync it.' ) + ).toBeVisible(); + fireEvent.click( screen.getByRole( 'button', { name: 'Log in with WordPress.com' } ) ); + expect( login ).toHaveBeenCalled(); + } ); + + it( 'shows a connected site and exposes pull and push actions', async () => { + useConnectedWpcomSitesMock.mockReturnValue( { + data: [ CONNECTED_SITE ], + isLoading: false, + } ); + + renderView(); + + expect( screen.getByText( 'demo.example.com' ) ).toBeVisible(); + expect( screen.getByText( 'Never synced' ) ).toBeVisible(); + + fireEvent.click( screen.getByRole( 'button', { name: 'Sync' } ) ); + fireEvent.click( await screen.findByText( 'Pull from live' ) ); + expect( pullSiteFromLive ).toHaveBeenCalledWith( { siteId: 'site-1', remoteSiteId: 42 } ); + + fireEvent.click( screen.getByRole( 'button', { name: 'Sync' } ) ); + fireEvent.click( await screen.findByText( 'Push to live' ) ); + expect( pushSiteToLive ).toHaveBeenCalledWith( { siteId: 'site-1', remoteSiteId: 42 } ); + } ); + + it( 'reflects sync work started from another surface', () => { + useConnectedWpcomSitesMock.mockReturnValue( { + data: [ CONNECTED_SITE ], + isLoading: false, + } ); + useIsSiteSyncingMock.mockReturnValue( { push: true, pull: false } ); + + renderView(); + + expect( screen.getByRole( 'button', { name: 'Pushing…' } ) ).toHaveAttribute( + 'aria-disabled', + 'true' + ); + } ); + it( 'keeps the browser action available without a cached thumbnail', () => { useSiteThumbnailMock.mockReturnValue( { data: null } ); diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx index 27b237d273..1b02bcc906 100644 --- a/apps/ui/src/components/site-overview-view/index.tsx +++ b/apps/ui/src/components/site-overview-view/index.tsx @@ -34,7 +34,8 @@ import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed'; import { useSiteManagementActions } from '@/hooks/use-site-management-actions'; import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; import { AboutSection } from './about-section'; -import { OverviewCard } from './overview-card'; +import { ConnectionsSection } from './connections-section'; +import { CardSectionDivider, OverviewCard } from './overview-card'; import styles from './style.module.css'; import type { SiteSettingsTabId } from '@/components/site-settings-view'; import type { SiteDetails } from '@/data/core'; @@ -269,6 +270,8 @@ function SiteOverviewBody( {

{ __( 'About' ) }

+ +
diff --git a/apps/ui/src/components/site-overview-view/overview-card.tsx b/apps/ui/src/components/site-overview-view/overview-card.tsx index b88a6f2172..fb40bcf6a7 100644 --- a/apps/ui/src/components/site-overview-view/overview-card.tsx +++ b/apps/ui/src/components/site-overview-view/overview-card.tsx @@ -2,9 +2,43 @@ import styles from './cards.module.css'; import type { ReactNode } from 'react'; export function OverviewCard( { children }: { children: ReactNode } ) { - return
{ children }
; + return
{ children }
; } -export function CardSection( { children }: { children: ReactNode } ) { - return
{ children }
; +export function CardSection( { + title, + action, + children, +}: { + title?: string; + action?: ReactNode; + children: ReactNode; +} ) { + return ( +
+ { ( title || action ) && ( +
+ { title &&

{ title }

} + { action } +
+ ) } + { children } +
+ ); +} + +export function CardSectionDivider() { + return
; +} + +export function CardEmptyState( { children }: { children: ReactNode } ) { + return

{ children }

; +} + +export function CardRows( { children }: { children: ReactNode } ) { + return
{ children }
; +} + +export function RowDivider() { + return
; } diff --git a/apps/ui/src/components/site-overview-view/row-link.tsx b/apps/ui/src/components/site-overview-view/row-link.tsx new file mode 100644 index 0000000000..4db4c08a06 --- /dev/null +++ b/apps/ui/src/components/site-overview-view/row-link.tsx @@ -0,0 +1,40 @@ +import { sprintf, __ } from '@wordpress/i18n'; +import { Tooltip } from '@wordpress/ui'; +import { useConnector } from '@/data/core'; +import styles from './cards.module.css'; + +export function RowLink( { + label, + url, + tooltip, +}: { + label: string; + url: string; + tooltip?: string; +} ) { + const connector = useConnector(); + + return ( + + void connector.openExternalUrl( url ) } + > + { label } + + } + /> + }> + { tooltip || url } + + + ); +} diff --git a/apps/ui/src/hooks/use-is-site-syncing.ts b/apps/ui/src/hooks/use-is-site-syncing.ts new file mode 100644 index 0000000000..cc680d2cd6 --- /dev/null +++ b/apps/ui/src/hooks/use-is-site-syncing.ts @@ -0,0 +1,21 @@ +import { useIsMutating } from '@tanstack/react-query'; +import { + PULL_FROM_LIVE_MUTATION_KEY, + PUSH_TO_LIVE_MUTATION_KEY, +} from '@/data/queries/use-sync-site'; + +export function useIsSiteSyncing( siteId: string ): { push: boolean; pull: boolean } { + const push = + useIsMutating( { + mutationKey: PUSH_TO_LIVE_MUTATION_KEY, + predicate: ( mutation ) => + ( mutation.state.variables as { siteId: string } | undefined )?.siteId === siteId, + } ) > 0; + const pull = + useIsMutating( { + mutationKey: PULL_FROM_LIVE_MUTATION_KEY, + predicate: ( mutation ) => + ( mutation.state.variables as { siteId: string } | undefined )?.siteId === siteId, + } ) > 0; + return { push, pull }; +}