diff --git a/apps/ui/src/components/connect-site-picker/index.tsx b/apps/ui/src/components/connect-site-picker/index.tsx new file mode 100644 index 0000000000..a0c1d42573 --- /dev/null +++ b/apps/ui/src/components/connect-site-picker/index.tsx @@ -0,0 +1,336 @@ +import { Spinner, VisuallyHidden } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { external, search } from '@wordpress/icons'; +import { Badge, Button, Icon } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { useMemo, useState } from 'react'; +import { useConnector } from '@/data/core'; +import { useUserLocale } from '@/data/queries/use-user-locale'; +import { useOffline } from '@/hooks/use-offline'; +import { getLocalizedLink } from '@/lib/docs-links'; +import { presentRemoteSites, searchRemoteSites, type ConnectSiteGroup } from './site-presentation'; +import styles from './style.module.css'; +import type { SyncSite } from '@/data/core'; + +const createWpcomSiteUrl = new URL( 'https://wordpress.com/setup/new-hosted-site' ); +createWpcomSiteUrl.searchParams.set( 'ref', 'studio' ); +createWpcomSiteUrl.searchParams.set( 'section', 'studio-sync' ); +createWpcomSiteUrl.searchParams.set( 'showDomainStep', 'true' ); + +function getEnvironmentLabel( site: SyncSite ): string { + if ( site.isPressable && site.environmentType === 'development' ) return __( 'Development' ); + if ( site.isPressable && site.environmentType === 'staging' ) return __( 'Staging' ); + if ( site.isStaging ) return __( 'Staging' ); + return __( 'Production' ); +} + +function getEnvironmentIntent( site: SyncSite ) { + if ( site.isPressable && site.environmentType === 'development' ) return 'informational'; + if ( site.isStaging || ( site.isPressable && site.environmentType === 'staging' ) ) + return 'medium'; + return 'stable'; +} + +function getSiteStatus( site: SyncSite, group: ConnectSiteGroup ): string { + if ( group === 'needs-transfer' ) { + return __( 'Enable hosting features on WordPress.com before connecting this site.' ); + } + if ( group === 'needs-upgrade' ) { + return __( 'Upgrade this site to a supported plan before connecting it.' ); + } + if ( site.syncSupport === 'missing-permissions' ) { + return __( "Your account doesn't have permission to manage this site." ); + } + if ( site.syncSupport === 'deleted' ) return __( 'This site has been deleted.' ); + return __( 'This site does not support pulling into Studio.' ); +} + +export function getSiteName( site: SyncSite ): string { + if ( site.name.trim() ) return site.name.trim(); + try { + return new URL( site.url ).hostname; + } catch { + return __( 'WordPress site' ); + } +} + +function RemoteSiteCard( { + site, + group, + isSelected, + onSelect, +}: ReturnType< typeof presentRemoteSites >[ number ] & { + isSelected: boolean; + onSelect: ( id: number ) => void; +} ) { + const connector = useConnector(); + const isAvailable = group === 'available'; + const siteName = getSiteName( site ); + const providerLabel = site.isPressable ? __( 'Pressable' ) : __( 'WP.com' ); + const environmentLabel = getEnvironmentLabel( site ); + const siteStatus = isAvailable ? '' : getSiteStatus( site, group ); + const className = clsx( + styles.siteCard, + isSelected && styles.siteCardSelected, + ! isAvailable && styles.siteCardUnavailable + ); + + return ( +
  • + + { group === 'needs-transfer' && ( + + ) } + { group === 'needs-upgrade' && ( + + ) } +
  • + ); +} + +export type ConnectSitePickerProps = { + sites: SyncSite[] | undefined; + isLoading: boolean; + isFetching: boolean; + error: unknown; + onRefresh: () => void; + selectedId: number | null; + onSelect: ( id: number ) => void; + // Shown when the account has no sites this flow can use. + emptyTitle?: string; + emptyDescription?: string; +}; + +/** + * The list of WordPress.com and Pressable sites a Studio site can be wired to, + * with its search, its grouping into what can and can't be connected, and the + * states around loading them. Shared by onboarding, which uses it to bring a + * live site down into Studio, and by publishing, which uses it to send one up — + * the choice is the same either way, so it should look the same. + */ +export function ConnectSitePicker( { + sites, + isLoading, + isFetching, + error, + onRefresh, + selectedId, + onSelect, + emptyTitle = __( 'No sites found' ), + emptyDescription = __( 'This account has no WordPress.com or Pressable sites to show.' ), +}: ConnectSitePickerProps ) { + const connector = useConnector(); + const locale = useUserLocale(); + const isOffline = useOffline(); + const [ searchQuery, setSearchQuery ] = useState( '' ); + + const presentedSites = useMemo( () => presentRemoteSites( sites ?? [] ), [ sites ] ); + const filteredSites = useMemo( + () => searchRemoteSites( presentedSites, searchQuery ), + [ presentedSites, searchQuery ] + ); + const isSingleSite = presentedSites.length === 1 && searchQuery.trim() === ''; + const isSingleAvailableSite = isSingleSite && presentedSites[ 0 ].group === 'available'; + + if ( isOffline ) { + return ( +
    +

    { __( "You're offline" ) }

    +

    { __( 'Reconnect to load your WordPress.com and Pressable sites.' ) }

    +
    + ); + } + + if ( isLoading ) { + return ( +
    + +

    { __( 'Loading your sites…' ) }

    +
    + ); + } + + if ( error ) { + return ( +
    +

    { __( "We couldn't load your sites" ) }

    +

    { __( 'Check your connection and try again.' ) }

    + +
    + ); + } + + if ( presentedSites.length === 0 ) { + return ( +
    +

    { emptyTitle }

    +

    { emptyDescription }

    + +
    + ); + } + + const sections = [ + { + key: 'available', + title: __( 'Available to connect' ), + description: __( 'Select a site to create its local copy.' ), + sites: filteredSites.filter( ( entry ) => entry.group === 'available' ), + }, + { + key: 'unavailable', + title: __( 'Unavailable' ), + description: __( 'These sites cannot currently be connected to Studio.' ), + sites: filteredSites.filter( ( entry ) => entry.group !== 'available' ), + }, + ]; + + return ( + <> +
    + { ! isSingleSite && ( + + ) } +

    + + + +

    +
    + + { filteredSites.length === 0 ? ( +
    +

    + { sprintf( + // translators: %s is the site search query. + __( 'No sites match “%s”.' ), + searchQuery + ) } +

    +
    + ) : isSingleAvailableSite ? ( + + ) : ( +
    + { sections.map( + ( section ) => + section.sites.length > 0 && ( +
    +
    +

    { section.title }

    +

    { section.description }

    +
    + +
    + ) + ) } +
    + ) } + + ); +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts b/apps/ui/src/components/connect-site-picker/site-presentation.test.ts similarity index 100% rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.test.ts rename to apps/ui/src/components/connect-site-picker/site-presentation.test.ts diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts b/apps/ui/src/components/connect-site-picker/site-presentation.ts similarity index 100% rename from apps/ui/src/ui-classic/router/route-onboarding-connect/site-presentation.ts rename to apps/ui/src/components/connect-site-picker/site-presentation.ts diff --git a/apps/ui/src/components/connect-site-picker/style.module.css b/apps/ui/src/components/connect-site-picker/style.module.css new file mode 100644 index 0000000000..39e8fbe5df --- /dev/null +++ b/apps/ui/src/components/connect-site-picker/style.module.css @@ -0,0 +1,226 @@ +/* The site cards, search, and list states shared by onboarding and the + publish flow. */ + +.state p { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + max-width: 520px; + margin-inline: auto; + padding: 0 20px 32px; +} + +.state h2 { + margin: 0; + font-size: var(--wpds-typography-font-size-lg); + font-weight: 500; +} + +.siteControls { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin-bottom: 32px; +} + +.search { + display: flex; + align-items: center; + gap: 8px; + width: min(100%, 520px); + padding: 0 12px; + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); + border-radius: 6px; + background: var(--wpds-color-bg-surface-neutral); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.search:focus-within { + border-color: var(--wpds-color-stroke-focus-brand); + box-shadow: 0 0 0 1px var(--wpds-color-stroke-focus-brand); +} + +.search input { + width: 100%; + height: 40px; + padding: 0; + border: 0; + outline: 0; + background: transparent; + color: var(--wpds-color-fg-content-neutral); + font: inherit; +} + +.search input::placeholder { + color: var(--wpds-color-fg-content-neutral-weak); +} + +.helperLinks { + display: flex; + align-items: center; + gap: 2px; + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak); +} + +.helperLinks button { + padding-inline: 2px; +} + +.sections { + display: flex; + flex-direction: column; + gap: 40px; + text-align: start; +} + +.section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionHeader { + text-align: center; +} + +.sectionHeader h2 { + margin: 0 0 4px; + font-size: var(--wpds-typography-font-size-lg); + font-weight: 500; +} + +.sectionHeader p { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-sm); +} + +.siteGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); + gap: 20px; + margin: 0; + padding: 0; + list-style: none; +} + +.singleSiteGrid { + grid-template-columns: minmax(0, 440px); + justify-content: center; +} + +.siteCardWrapper { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; +} + +.siteCard { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + padding: 6px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--wpds-color-fg-content-neutral); + cursor: pointer; + text-align: start; +} + +.siteCardUnavailable, +.siteCardUnavailable:hover { + cursor: default; +} + +.siteCardSelected .siteThumb { + box-shadow: 0 0 0 1px var(--wpds-color-stroke-interactive-brand); +} + +.siteCard:focus-visible { + outline: 2px solid var(--wpds-color-stroke-focus-brand); + outline-offset: 2px; +} + +.siteThumb { + position: relative; + display: block; + width: 100%; + aspect-ratio: 3 / 2; + overflow: hidden; + border-radius: 8px; + background: var(--wpds-color-bg-surface-neutral-strong); + box-shadow: 0 0 0 var(--wpds-border-width-xs) var(--wpds-color-stroke-surface-neutral); + transition: box-shadow 0.15s ease; +} + +.siteThumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.siteCardUnavailable .siteThumb { + opacity: 0.65; +} + +.siteText { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 10px 8px 8px; +} + +.siteName { + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteUrl { + overflow: hidden; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-sm); + text-overflow: ellipsis; + white-space: nowrap; +} + +.siteStatus { + margin-top: 4px; + color: var(--wpds-color-fg-content-neutral-weak); + font-size: var(--wpds-typography-font-size-sm); + line-height: 1.4; +} + +.badges { + position: absolute; + inset-inline-end: 8px; + inset-block-end: 8px; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.siteAction { + align-self: center; + margin-top: 4px; +} + + +@media (max-width: 600px) { + .siteGrid { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/ui/src/components/menu/index.tsx b/apps/ui/src/components/menu/index.tsx index b13a5e68f0..878beaf1b1 100644 --- a/apps/ui/src/components/menu/index.tsx +++ b/apps/ui/src/components/menu/index.tsx @@ -29,6 +29,8 @@ type PopupProps = { align?: 'start' | 'center' | 'end'; sideOffset?: number; alignOffset?: number; + /** Raises the menu above modal surfaces. Set when the trigger is inside a dialog. */ + aboveOverlays?: boolean; className?: string; onClick?: MouseEventHandler< HTMLElement >; onPointerDown?: PointerEventHandler< HTMLElement >; @@ -45,6 +47,7 @@ export function Popup( { align = 'start', sideOffset = 4, alignOffset, + aboveOverlays, className, onClick, onPointerDown, @@ -56,7 +59,9 @@ export function Popup( { align={ align } sideOffset={ sideOffset } alignOffset={ alignOffset } - className={ styles.positioner } + className={ `${ styles.positioner }${ + aboveOverlays ? ` ${ styles.positionerAboveOverlays }` : '' + }` } > { /* Portals mount into document.body, escaping the app-root ThemeProvider's `data-wpds-density='compact'` wrapper and diff --git a/apps/ui/src/components/menu/style.module.css b/apps/ui/src/components/menu/style.module.css index 97c5621dbd..1cf3c40f9b 100644 --- a/apps/ui/src/components/menu/style.module.css +++ b/apps/ui/src/components/menu/style.module.css @@ -10,6 +10,12 @@ outline: none; } +/* Above the dialog scrim (700) so a menu opened from inside a dialog isn't + trapped behind it — matches the select tier. */ +.positionerAboveOverlays { + z-index: var(--wp-ui-select-z-index, 750); +} + .popup { min-width: 160px; padding: var(--wpds-dimension-padding-xs); diff --git a/apps/ui/src/components/selective-sync/sync-dialog.tsx b/apps/ui/src/components/selective-sync/sync-dialog.tsx index a47a5219db..560d3e231f 100644 --- a/apps/ui/src/components/selective-sync/sync-dialog.tsx +++ b/apps/ui/src/components/selective-sync/sync-dialog.tsx @@ -147,13 +147,16 @@ const useDynamicTreeState = ( }; export function SyncDialog( { - type, + type: initialType, localSite, remoteSite, onPush, onPull, onRequestClose, }: SyncDialogProps ) { + // Direction is chosen inside the dialog now (the header opens one Sync + // action), so it lives in state rather than a fixed prop. + const [ type, setType ] = useState< 'push' | 'pull' >( initialType ); const locale = useI18nLocale(); const { __, _n } = useI18n(); const siteEnv = getSiteEnvironment( remoteSite ); @@ -162,6 +165,21 @@ export function SyncDialog( { const [ showAllFiles, setShowAllFiles ] = useState( false ); const [ treeState, setTreeState ] = useState< TreeNode[] >( defaultTree ); + + const handleDirectionChange = useCallback( + ( next: 'push' | 'pull' ) => { + if ( next === type ) { + return; + } + setType( next ); + // Push browses the local site, pull the remote backup — different + // filesystems, so the current selection can't carry over. + setTreeState( defaultTree ); + setShowAllFiles( false ); + }, + [ type, defaultTree ] + ); + const isSubmitDisabled = treeState.every( ( node ) => ! node.checked && ! node.indeterminate ); const { isPushSelectionOverLimit, @@ -322,12 +340,59 @@ export function SyncDialog( { return (
    { syncTexts.description }
    +
    + { /* apps/ui has no Tailwind build (utilities here are generated + statically from the copied classic sources), so this control + styles itself inline from the frame tokens rather than relying + on utility classes that may not exist. */ } +
    + { ( [ 'push', 'pull' ] as const ).map( ( dir ) => { + const active = type === dir; + return ( + + ); + } ) } +
    +
    { /* translators: %1$s is the source site name, %2$s is the destination site name */ } diff --git a/apps/ui/src/components/site-overview-view/style.module.css b/apps/ui/src/components/site-overview-view/style.module.css index 2b4dab4d89..ebc6cdd1ba 100644 --- a/apps/ui/src/components/site-overview-view/style.module.css +++ b/apps/ui/src/components/site-overview-view/style.module.css @@ -23,7 +23,9 @@ /* Hug the panel's top-left corner with the same breathing room the preview toolbar gives its controls. */ padding-block: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-sm); - padding-inline: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-2xl); + /* The toolbar owns its own inline padding and pins its actions to the panel + edge; the host only needs the left inset. */ + padding-inline: var(--wpds-dimension-padding-sm) 0; min-height: 46px; font-size: var(--wpds-typography-font-size-sm); color: var(--wpds-color-fg-content-neutral); diff --git a/apps/ui/src/components/site-toolbar/index.tsx b/apps/ui/src/components/site-toolbar/index.tsx index e2d1c1aa7d..3b695e41d1 100644 --- a/apps/ui/src/components/site-toolbar/index.tsx +++ b/apps/ui/src/components/site-toolbar/index.tsx @@ -1,16 +1,10 @@ import { useIsMutating } from '@tanstack/react-query'; import { __ } from '@wordpress/i18n'; -import { arrowDown, arrowUp, external, Icon, moreVertical } from '@wordpress/icons'; -import { Button, Dialog, IconButton, Tooltip } from '@wordpress/ui'; +import { external, Icon, moreVertical } from '@wordpress/icons'; +import { Button, IconButton, Tooltip } from '@wordpress/ui'; import { clsx } from 'clsx'; import { useEffect, useMemo, useRef, useState } from 'react'; import * as Menu from '@/components/menu'; -import { - convertTreeToPullOptions, - convertTreeToPushOptions, -} from '@/components/selective-sync/lib/convert-tree-to-sync-options'; -import { registerSelectiveSyncConnector } from '@/components/selective-sync/lib/get-ipc-api'; -import { SyncDialog } from '@/components/selective-sync/sync-dialog'; import { SiteIcon } from '@/components/site-icon'; import { SiteStatusButton } from '@/components/site-status-button'; import { useConnector } from '@/data/core'; @@ -27,12 +21,13 @@ import { import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed'; import { getSiteDisplayUrl, getSiteUrl } from '@/lib/get-site-url'; import { DisconnectSiteDialog } from './disconnect-site-dialog'; -import { PublishPickerView } from './publish-picker-view'; +import { PublishSiteDialog } from './publish-site-dialog'; +import { ShareDialog } from './share-dialog'; import styles from './style.module.css'; -import { ensureProtocol, pickLiveSite } from './utils'; -import '@/components/selective-sync/selective-sync.css'; -import type { TreeNode } from '@/components/selective-sync/tree-view'; -import type { SiteDetails } from '@/data/core'; +import { SyncDialog, type SyncDirection } from './sync-dialog'; +import { ensureProtocol, pickLiveSite, sortConnections } from './utils'; +import type { SiteDetails, SyncSite } from '@/data/core'; +import type { PullSyncOptions, PushSyncOptions } from '@studio/common/types/sync'; interface SiteToolbarProps { site: SiteDetails; @@ -75,32 +70,31 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo const pushSiteToLive = usePushSiteToLive(); const pullSiteFromLive = usePullSiteFromLive(); - const [ syncDialogType, setSyncDialogType ] = useState< 'push' | 'pull' | null >( null ); + const [ syncOpen, setSyncOpen ] = useState( false ); const [ publishOpen, setPublishOpen ] = useState( false ); const [ disconnectOpen, setDisconnectOpen ] = useState( false ); + const [ shareOpen, setShareOpen ] = useState( false ); const isStarting = useIsSiteStarting( site.id ); const isStopping = useIsSiteStopping( site.id ); const isBusy = useIsSiteBusy( site.id ); const { data: connectedSites } = useConnectedWpcomSites( site.id ); + // The dialog offers every connection; the header's connected/disconnect + // affordances key off whichever one is the primary (production) target. + const targets = useMemo( () => sortConnections( connectedSites ), [ connectedSites ] ); const liveSite = useMemo( () => pickLiveSite( connectedSites ), [ connectedSites ] ); - // The ported selective-sync modules resolve their data calls through the - // active connector (see selective-sync/lib/get-ipc-api.ts). - useEffect( () => { - registerSelectiveSyncConnector( connector ); - }, [ connector ] ); - - // Honour the onboarding deep link once the connection is known: open Pull so - // a freshly connected site can bring the live content down. Fires once. - const pullOpenedRef = useRef( false ); + // Honour the onboarding deep link once the connection is known: open the sync + // dialog (defaulting to Pull) so a freshly connected site can bring the live + // content down. Fires once. + const syncOpenedRef = useRef( false ); useEffect( () => { - if ( openPullOnLoad && liveSite && ! pullOpenedRef.current ) { - pullOpenedRef.current = true; - setSyncDialogType( 'pull' ); + if ( openPullOnLoad && targets.length > 0 && ! syncOpenedRef.current ) { + syncOpenedRef.current = true; + setSyncOpen( true ); } - }, [ openPullOnLoad, liveSite ] ); + }, [ openPullOnLoad, targets ] ); const isSignedOut = agenticReason === 'signed-out'; const isOffline = agenticReason === 'offline'; @@ -110,29 +104,22 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo void connector.openExternalUrl( url ); }; - const handleDialogPush = ( tree: TreeNode[] ) => { - if ( ! liveSite ) { + const runSync = ( + direction: SyncDirection, + target: SyncSite, + options: PushSyncOptions | PullSyncOptions | undefined + ) => { + if ( isBusy ) { return; } - const options = convertTreeToPushOptions( tree ); - pushSiteToLive.mutate( - { siteId: site.id, remoteSiteId: liveSite.id, options }, - { onSuccess: () => openExternal( ensureProtocol( liveSite.url ) ) } - ); - setSyncDialogType( null ); - }; - - const handleDialogPull = ( tree: TreeNode[] ) => { - if ( ! liveSite ) { + if ( direction === 'pull' ) { + pullSiteFromLive.mutate( { siteId: site.id, remoteSiteId: target.id, options } ); return; } - const { optionsToSync, include_path_list: includePathList } = convertTreeToPullOptions( tree ); - pullSiteFromLive.mutate( { - siteId: site.id, - remoteSiteId: liveSite.id, - options: { optionsToSync, includePathList }, - } ); - setSyncDialogType( null ); + pushSiteToLive.mutate( + { siteId: site.id, remoteSiteId: target.id, options }, + { onSuccess: () => openExternal( ensureProtocol( target.url ) ) } + ); }; const localSiteUrl = getSiteUrl( site ); @@ -198,6 +185,31 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo
    + { /* Sharing a preview isn't a sync — it publishes a throwaway copy — + so it sits beside the primary action, not inside its dialog. */ } + { ! isSignedOut ? ( + + setShareOpen( true ) } + > + { __( 'Share' ) } + + } + /> + }> + { agenticEnabled + ? __( 'Publish a preview link' ) + : __( 'Go online to share a preview.' ) } + + + ) : null } { isSignedOut ? ( - { liveSite && syncDialogType ? ( + { targets.length > 0 ? ( setSyncDialogType( null ) } + siteId={ site.id } + connections={ targets } + open={ syncOpen } + onOpenChange={ setSyncOpen } + onRun={ runSync } + initialDirection={ openPullOnLoad ? 'pull' : 'push' } /> ) : null } @@ -307,12 +294,10 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo { /* Mounted only while open: it loads the account's sites on mount. */ } { publishOpen ? ( - - - setPublishOpen( false ) } /> - - + ) : null } + + { shareOpen ? : null }
    ); } diff --git a/apps/ui/src/components/site-toolbar/publish-picker-view.module.css b/apps/ui/src/components/site-toolbar/publish-picker-view.module.css deleted file mode 100644 index 29f93bc641..0000000000 --- a/apps/ui/src/components/site-toolbar/publish-picker-view.module.css +++ /dev/null @@ -1,94 +0,0 @@ -.picker { - display: flex; - flex-direction: column; -} - -.header { - display: flex; - align-items: center; - gap: var(--wpds-dimension-padding-sm); - padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); - border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak); -} - -.title { - font-size: var(--wpds-typography-font-size-sm); - font-weight: 500; - color: var(--wpds-color-fg-content-neutral); -} - -.body { - max-height: 240px; - overflow-y: auto; -} - -.status { - padding: var(--wpds-dimension-padding-lg); - color: var(--wpds-color-fg-content-neutral-weak); - font-size: var(--wpds-typography-font-size-sm); - text-align: center; -} - -.list { - list-style: none; - margin: 0; - padding: var(--wpds-dimension-padding-xs) 0; -} - -.item { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - width: 100%; - background: transparent; - border: none; - padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-lg); - cursor: pointer; - font: inherit; - color: inherit; - text-align: left; -} - -.item:hover, -.item:focus-visible { - background-color: var(--wpds-color-bg-interactive-neutral-weak-active); - outline: none; -} - -.itemName { - font-size: var(--wpds-typography-font-size-sm); - font-weight: 500; - color: var(--wpds-color-fg-content-neutral); -} - -.itemUrl { - font-size: var(--wpds-typography-font-size-sm); - color: var(--wpds-color-fg-content-neutral-weak); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; -} - -.create { - display: flex; - align-items: center; - gap: var(--wpds-dimension-padding-sm); - width: 100%; - background: transparent; - border: none; - border-top: 1px solid var(--wpds-color-stroke-surface-neutral-weak); - padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-lg); - cursor: pointer; - font: inherit; - font-size: var(--wpds-typography-font-size-sm); - color: var(--wpds-color-fg-interactive-brand); - text-align: left; -} - -.create:hover, -.create:focus-visible { - background-color: var(--wpds-color-bg-interactive-neutral-weak-active); - outline: none; -} diff --git a/apps/ui/src/components/site-toolbar/publish-picker-view.tsx b/apps/ui/src/components/site-toolbar/publish-picker-view.tsx deleted file mode 100644 index 4f8df43205..0000000000 --- a/apps/ui/src/components/site-toolbar/publish-picker-view.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { __ } from '@wordpress/i18n'; -import { chevronLeft, plus } from '@wordpress/icons'; -import { Icon, IconButton } from '@wordpress/ui'; -import { useConnector } from '@/data/core'; -import { useAuthUser } from '@/data/queries/use-auth-user'; -import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites'; -import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites'; -import styles from './publish-picker-view.module.css'; -import { stripProtocol } from './utils'; -import type { SiteDetails, SyncSite } from '@/data/core'; - -type Props = { - site: SiteDetails; - // Fires after any action that ends the picker flow (site picked, checkout - // link opened, or the back button pressed). The parent uses this to swap - // back to the main dropdown view. - onClose: () => void; -}; - -export function PublishPickerView( { site, onClose }: Props ) { - const connector = useConnector(); - const queryClient = useQueryClient(); - const { data: authUser } = useAuthUser(); - const pickableSites = usePickableWpcomSites(); - - const openExternal = ( url: string ) => { - void connector.openExternalUrl( url ); - }; - - const handlePickSite = async ( pickedSite: SyncSite ) => { - try { - await connector.connectWpcomSite( site.id, { - ...pickedSite, - localSiteId: site.id, - syncSupport: 'already-connected', - } ); - await queryClient.invalidateQueries( { - queryKey: connectedWpcomSitesQueryKey( site.id ), - } ); - onClose(); - } catch ( error ) { - console.error( 'Failed to connect WordPress.com site:', error ); - } - }; - - const handleCreateNew = () => { - const checkoutUrl = connector.getPublishCheckoutUrl( site ); - if ( checkoutUrl ) { - // Desktop receives the new site via the wp-studio:// deep link; surfaces - // that can't (the local web server) opt into a server-side watch instead. - void connector.watchForPublishedSite?.( site.id ); - openExternal( checkoutUrl ); - } - // The connect listener (deep link on desktop, sync-connect SSE on the local - // server) handles the follow-up connection, so we just close the picker. - onClose(); - }; - - return ( -
    -
    - - { __( 'Publish this site' ) } -
    - { authUser ? ( -
    - { pickableSites.isLoading ? ( -
    { __( 'Loading sites…' ) }
    - ) : pickableSites.data && pickableSites.data.length > 0 ? ( -
      - { pickableSites.data.map( ( candidate ) => ( -
    • - -
    • - ) ) } -
    - ) : ( -
    - { __( 'No WordPress.com sites available to publish to.' ) } -
    - ) } -
    - ) : null } - -
    - ); -} diff --git a/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css b/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css new file mode 100644 index 0000000000..3201fae9ce --- /dev/null +++ b/apps/ui/src/components/site-toolbar/publish-site-dialog.module.css @@ -0,0 +1,25 @@ +.intro { + margin: 0 0 var(--wpds-dimension-padding-lg); + font-size: var(--wpds-typography-font-size-sm); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.error { + margin: 0 0 var(--wpds-dimension-padding-md); + padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-error); + border-radius: var(--wpds-border-radius-md); + background: var(--wpds-color-bg-surface-error-weak); + color: var(--wpds-color-fg-content-error); + font-size: var(--wpds-typography-font-size-sm); +} + +/* Sits opposite Cancel and Connect: making a new site is a way out of this + list, not a step in it. */ +.createButton { + margin-inline-end: auto; +} + +.createButton svg { + fill: currentColor; +} diff --git a/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx b/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx new file mode 100644 index 0000000000..5638ecda79 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/publish-site-dialog.tsx @@ -0,0 +1,143 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { __ } from '@wordpress/i18n'; +import { external, Icon } from '@wordpress/icons'; +import { Button, Dialog } from '@wordpress/ui'; +import { useState } from 'react'; +import { ConnectSitePicker } from '@/components/connect-site-picker'; +import { useConnector } from '@/data/core'; +import { connectedWpcomSitesQueryKey } from '@/data/queries/use-connected-wpcom-sites'; +import { usePickableWpcomSites } from '@/data/queries/use-wpcom-sites'; +import styles from './publish-site-dialog.module.css'; +import type { SiteDetails } from '@/data/core'; + +type Props = { + site: SiteDetails; + open: boolean; + onOpenChange: ( open: boolean ) => void; +}; + +/** + * Choosing where a Studio site goes live. The same picker onboarding uses to + * bring a site down into Studio, pointed the other way — one list of the + * WordPress.com and Pressable sites this account can reach, with room to see + * them rather than a popover to squint at. + */ +export function PublishSiteDialog( { site, open, onOpenChange }: Props ) { + const connector = useConnector(); + const queryClient = useQueryClient(); + const pickableSites = usePickableWpcomSites(); + const [ selectedId, setSelectedId ] = useState< number | null >( null ); + const [ isConnecting, setIsConnecting ] = useState( false ); + const [ error, setError ] = useState( '' ); + + const selectedSite = pickableSites.data?.find( ( candidate ) => candidate.id === selectedId ); + + const close = ( next: boolean ) => { + if ( isConnecting ) { + return; + } + onOpenChange( next ); + if ( ! next ) { + setSelectedId( null ); + setError( '' ); + } + }; + + const handleConnect = async () => { + if ( ! selectedSite || isConnecting ) { + return; + } + setIsConnecting( true ); + setError( '' ); + try { + await connector.connectWpcomSite( site.id, { + ...selectedSite, + localSiteId: site.id, + syncSupport: 'already-connected', + } ); + await queryClient.invalidateQueries( { queryKey: connectedWpcomSitesQueryKey( site.id ) } ); + close( false ); + } catch ( caught ) { + setError( + caught instanceof Error + ? caught.message + : __( 'Failed to connect the site. Please try again.' ) + ); + } finally { + setIsConnecting( false ); + } + }; + + const handleCreateNew = () => { + const checkoutUrl = connector.getPublishCheckoutUrl( site ); + if ( checkoutUrl ) { + // Desktop receives the new site via the wp-studio:// deep link; surfaces + // that can't (the local web server) opt into a server-side watch instead. + void connector.watchForPublishedSite?.( site.id ); + void connector.openExternalUrl( checkoutUrl ); + } + // The connect listener (deep link on desktop, sync-connect SSE on the local + // server) handles the follow-up connection, so we just get out of the way. + close( false ); + }; + + return ( + + + + { __( 'Publish this site' ) } + + +

    + { __( + 'Choose the WordPress.com or Pressable site to publish to. Pushing sends this Studio site’s files and database there.' + ) } +

    + { error ? ( +

    + { error } +

    + ) : null } + void pickableSites.refetch() } + selectedId={ selectedId } + onSelect={ setSelectedId } + emptyTitle={ __( 'No sites available' ) } + emptyDescription={ __( + 'Every site on this account is already connected to a Studio site, or cannot be published to.' + ) } + /> +
    + + + + { __( 'Cancel' ) } + + + +
    +
    + ); +} diff --git a/apps/ui/src/components/site-toolbar/share-dialog.module.css b/apps/ui/src/components/site-toolbar/share-dialog.module.css new file mode 100644 index 0000000000..f19bd9b8d4 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/share-dialog.module.css @@ -0,0 +1,149 @@ +/* 480px: wider than wpds `small` (400) and narrower than `medium` (560). + Unlayered, so it beats the size rule in wpds's `wp-ui-components` layer. */ +.popup { + max-width: 480px; +} + +/* Block padding only — `Dialog.Content` supplies the inline gutter. */ +.section { + padding-block: var(--wpds-dimension-padding-md); +} + +.section:first-child { + padding-block-start: 0; +} + +.section:last-child { + padding-block-end: 0; +} + +.section + .section { + border-block-start: 1px solid var(--wpds-color-stroke-surface-neutral); +} + +.heading { + margin: 0; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + font-weight: 600; + color: var(--wpds-color-fg-content-neutral); +} + +.intro { + margin: 2px 0 0; + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.empty { + margin: var(--wpds-dimension-gap-sm) 0 0; + font-size: var(--wpds-typography-font-size-sm); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.cards { + display: flex; + flex-direction: column; + gap: 0; + margin: var(--wpds-dimension-gap-sm) 0 0; + padding: 0; + list-style: none; +} + +/* Two lines: the hostname in full, then its expiry paired with the controls. + Flat rows flush with the section heading — no surface, no radius — divided by + a hairline so the list reads as a list, not a stack of boxes. */ +.card { + display: flex; + flex-direction: column; + gap: 2px; + padding-block: var(--wpds-dimension-padding-sm); +} + +.card + .card { + border-block-start: 1px solid var(--wpds-color-stroke-surface-neutral); +} + +.rowSecond { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--wpds-dimension-gap-sm); +} + +/* Wraps rather than truncates: the tail of a preview hostname is what tells + two of them apart. */ +.rowLink { + padding: 0; + border: 0; + background: transparent; + font: inherit; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral); + text-decoration: underline; + text-underline-offset: 2px; + text-align: start; + overflow-wrap: anywhere; + cursor: var(--wpds-cursor-control); +} + +.rowLink:hover { + color: var(--wpds-color-fg-content-neutral-weak); +} + +.rowLink:focus-visible { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: 2px; + border-radius: 2px; +} + +.rowMeta { + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.actions { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-xs); + flex: 0 0 auto; +} + +/* A plain Button standing in for an IconButton (which can't be a menu + trigger): square it off so it lines up with its icon-button neighbour. */ +.overflowButton { + --wp-ui-button-aspect-ratio: 1; + --wp-ui-button-padding-inline: 0; + --wp-ui-button-min-width: unset; +} + +/* `Dialog.Footer` right-aligns its children; the quota reads as a status for + the panel, so it stays on the leading edge. */ +.footer { + justify-content: space-between; +} + +.quotaLabel { + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + + + +/* Icon SVGs carry no fill of their own; without this they paint black in both + colour schemes. + + The 16px is not a style choice — it's what the rest of the app renders. The + compact-density rule in `index.css` is scoped + `[data-wpds-density='compact'] [data-ui-mode='classic'] svg`, and the popover + portals into `document.body`, outside both wrappers. @wordpress/icons then + draws at its native 24px. Restated here for the surfaces that escape. */ +.actions svg { + fill: currentColor; + width: 16px; + height: 16px; +} diff --git a/apps/ui/src/components/site-toolbar/share-dialog.test.tsx b/apps/ui/src/components/site-toolbar/share-dialog.test.tsx new file mode 100644 index 0000000000..f41492f939 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/share-dialog.test.tsx @@ -0,0 +1,160 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useConnector } from '@/data/core'; +import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites'; +import { useDeletePreviewSite, usePublishPreviewSite } from '@/data/queries/use-preview-site'; +import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots'; +import { ShareDialog } from './share-dialog'; +import type { SiteDetails, Snapshot } from '@/data/core'; + +vi.mock( '@/data/core', async ( importOriginal ) => ( { + ...( await importOriginal< object >() ), + useConnector: vi.fn(), +} ) ); +vi.mock( '@/data/queries/use-connected-wpcom-sites', () => ( { + useConnectedWpcomSites: vi.fn(), +} ) ); +vi.mock( '@/data/queries/use-preview-site', () => ( { + usePublishPreviewSite: vi.fn(), + useDeletePreviewSite: vi.fn(), +} ) ); +vi.mock( '@/data/queries/use-snapshots', () => ( { + useSnapshots: vi.fn(), + useSnapshotUsage: vi.fn(), +} ) ); + +const SITE = { id: 'riff', name: 'Riff' } as unknown as SiteDetails; + +function snapshot( overrides: Partial< Snapshot > = {} ): Snapshot { + return { + url: 'https://riff-abcde-studio.wp.build', + localSiteId: 'riff', + atomicSiteId: 1, + date: Date.now(), + ...overrides, + } as Snapshot; +} + +const publishMutate = vi.fn(); +const deleteMutate = vi.fn(); +const copyText = vi.fn().mockResolvedValue( undefined ); + +function renderDialog( snapshots: Snapshot[] = [ snapshot() ], connections: unknown[] = [] ) { + vi.mocked( useSnapshots ).mockReturnValue( { + data: snapshots, + } as ReturnType< typeof useSnapshots > ); + vi.mocked( useSnapshotUsage ).mockReturnValue( { + data: { siteCount: snapshots.length, siteLimit: 10, siteCreationBlocked: false }, + } as ReturnType< typeof useSnapshotUsage > ); + vi.mocked( useConnectedWpcomSites ).mockReturnValue( { data: connections } as never ); + vi.mocked( useConnector ).mockReturnValue( { + copyText, + openExternalUrl: vi.fn(), + } as unknown as ReturnType< typeof useConnector > ); + vi.mocked( usePublishPreviewSite ).mockReturnValue( { + mutate: publishMutate, + isPending: false, + } as unknown as ReturnType< typeof usePublishPreviewSite > ); + vi.mocked( useDeletePreviewSite ).mockReturnValue( { + mutate: deleteMutate, + isPending: false, + variables: undefined, + } as unknown as ReturnType< typeof useDeletePreviewSite > ); + + return render( ); +} + +describe( 'ShareDialog', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'lists each preview link with its expiry', () => { + renderDialog(); + + expect( screen.getByText( 'riff-abcde-studio.wp.build' ) ).toBeInTheDocument(); + expect( screen.getByText( /Expires in \d+ days?/ ) ).toBeInTheDocument(); + } ); + + it( 'offers Republish for an expired preview', async () => { + const user = userEvent.setup(); + renderDialog( [ snapshot( { date: Date.now() - 30 * 24 * 60 * 60 * 1000 } ) ] ); + + expect( screen.getByText( 'Expired' ) ).toBeInTheDocument(); + + await user.click( screen.getByRole( 'button', { name: 'More options' } ) ); + + expect( await screen.findByRole( 'menuitem', { name: 'Republish' } ) ).toBeInTheDocument(); + } ); + + it( 'republishes from the overflow menu', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click( screen.getByRole( 'button', { name: 'More options' } ) ); + await user.click( + await screen.findByRole( 'menuitem', { name: 'Update with current contents' } ) + ); + + expect( publishMutate ).toHaveBeenCalledWith( + { siteId: 'riff', existingHostname: 'riff-abcde-studio.wp.build' }, + expect.anything() + ); + } ); + + it( 'opens the overflow menu and confirms before deleting', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click( screen.getByRole( 'button', { name: 'More options' } ) ); + await user.click( await screen.findByRole( 'menuitem', { name: 'Delete preview link' } ) ); + + expect( screen.getByText( 'This link will stop working immediately.' ) ).toBeInTheDocument(); + expect( deleteMutate ).not.toHaveBeenCalled(); + + await user.click( screen.getByRole( 'button', { name: 'Delete' } ) ); + + expect( deleteMutate ).toHaveBeenCalledWith( + { hostname: 'riff-abcde-studio.wp.build' }, + expect.anything() + ); + } ); + + it( 'lists connected live sites above the preview links', async () => { + const user = userEvent.setup(); + renderDialog( + [ snapshot() ], + [ { id: 42, name: 'Riff', url: 'https://riff.com', isStaging: false } ] + ); + + expect( screen.getByText( 'riff.com' ) ).toBeInTheDocument(); + expect( screen.getByRole( 'heading', { name: 'Live' } ) ).toBeInTheDocument(); + expect( screen.getByRole( 'heading', { name: 'Preview links' } ) ).toBeInTheDocument(); + + await user.click( screen.getAllByRole( 'button', { name: 'Copy link' } )[ 0 ] ); + + expect( copyText ).toHaveBeenCalledWith( 'https://riff.com' ); + } ); + + it( 'copies the preview link with its protocol', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click( screen.getByRole( 'button', { name: 'Copy link' } ) ); + + expect( copyText ).toHaveBeenCalledWith( 'https://riff-abcde-studio.wp.build' ); + } ); + + it( 'publishes a brand-new preview with no existing hostname', async () => { + const user = userEvent.setup(); + renderDialog(); + + await user.click( screen.getByRole( 'button', { name: 'New preview' } ) ); + + expect( publishMutate ).toHaveBeenCalledWith( + { siteId: 'riff', existingHostname: undefined }, + expect.anything() + ); + } ); +} ); diff --git a/apps/ui/src/components/site-toolbar/share-dialog.tsx b/apps/ui/src/components/site-toolbar/share-dialog.tsx new file mode 100644 index 0000000000..615a4d0c68 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/share-dialog.tsx @@ -0,0 +1,306 @@ +import { DAY_MS, DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; +import { isSnapshotExpired } from '@studio/common/lib/snapshots'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { copy, Icon, moreVertical } from '@wordpress/icons'; +import { Button, Dialog, IconButton } from '@wordpress/ui'; +import { useMemo, useState } from 'react'; +import * as Menu from '@/components/menu'; +import { showToast } from '@/data/app-messages'; +import { useConnector } from '@/data/core'; +import { useConnectedWpcomSites } from '@/data/queries/use-connected-wpcom-sites'; +import { useDeletePreviewSite, usePublishPreviewSite } from '@/data/queries/use-preview-site'; +import { useSnapshots, useSnapshotUsage } from '@/data/queries/use-snapshots'; +import styles from './share-dialog.module.css'; +import { + ensureProtocol, + getConnectionLabel, + getSnapshotHostname, + sortConnections, + stripProtocol, +} from './utils'; +import type { SiteDetails, Snapshot } from '@/data/core'; + +function expirySummary( snapshot: Snapshot ): string { + if ( isSnapshotExpired( snapshot ) ) { + return __( 'Expired' ); + } + const remainingDays = Math.max( + 1, + Math.ceil( ( snapshot.date + DEMO_SITE_EXPIRATION_DAYS * DAY_MS - Date.now() ) / DAY_MS ) + ); + return sprintf( + // translators: %d: number of days before a preview link expires. + _n( 'Expires in %d day', 'Expires in %d days', remainingDays ), + remainingDays + ); +} + +type Props = { + site: SiteDetails; + open: boolean; + onOpenChange: ( open: boolean ) => void; +}; + +// Marks the "new preview" action as the one in flight, since it has no +// snapshot URL to key on. +const NEW_PREVIEW = 'new-preview'; + +/** + * Sharing a Studio site: the preview links it has published, where they point, + * how long they last, and the controls for refreshing, opening, copying and + * retiring each one. + * + * Anchored to the Share button rather than centred as a modal — publishing a + * preview is a small errand off the header, not a task worth blacking the app + * out for. Each row keeps only what it needs on the surface (the link, when it + * expires, copy); the rest sits in an overflow menu. + */ +export function ShareDialog( { site, open, onOpenChange }: Props ) { + const connector = useConnector(); + const { data: snapshots } = useSnapshots(); + const { data: usage } = useSnapshotUsage(); + const { data: connectedSites } = useConnectedWpcomSites( site.id ); + const publishPreviewSite = usePublishPreviewSite(); + const deletePreviewSite = useDeletePreviewSite(); + const [ pendingPublish, setPendingPublish ] = useState< string | null >( null ); + const [ confirmingDelete, setConfirmingDelete ] = useState< string | null >( null ); + + const connections = useMemo( () => sortConnections( connectedSites ), [ connectedSites ] ); + + const previews = useMemo( + () => + ( snapshots ?? [] ) + .filter( ( snapshot ) => snapshot.localSiteId === site.id ) + .sort( ( a, b ) => b.date - a.date ), + [ snapshots, site.id ] + ); + + const openExternal = ( url: string ) => { + void connector.openExternalUrl( ensureProtocol( url ) ); + }; + + const copyLink = ( url: string ) => { + void connector + .copyText( ensureProtocol( url ) ) + .then( () => showToast( { id: 'preview-link-copied', title: __( 'Preview link copied' ) } ) ) + .catch( ( error ) => { + showToast( { + intent: 'error', + title: __( 'Failed to copy preview link' ), + description: error instanceof Error ? error.message : String( error ), + } ); + } ); + }; + + const publish = ( existing?: Snapshot ) => { + setPendingPublish( existing ? existing.url : NEW_PREVIEW ); + publishPreviewSite.mutate( + { + siteId: site.id, + // The CLI cannot update an expired preview site — create a new one. + existingHostname: + existing && ! isSnapshotExpired( existing ) ? getSnapshotHostname( existing ) : undefined, + }, + { + onSuccess: ( { url } ) => openExternal( url ), + onSettled: () => setPendingPublish( null ), + } + ); + }; + + const isPublishing = publishPreviewSite.isPending; + + return ( + { + if ( ! next ) { + setConfirmingDelete( null ); + } + onOpenChange( next ); + } } + > + + + { __( 'Share this site' ) } + + + + { connections.length > 0 ? ( +
    +

    { __( 'Live' ) }

    +
      + { connections.map( ( connection ) => ( +
    • + +
      + { getConnectionLabel( connection ) } +
      + copyLink( connection.url ) } + /> +
      +
      +
    • + ) ) } +
    +
    + ) : null } + +
    +

    { __( 'Preview links' ) }

    +

    + { __( 'Temporary copies of this site, for sharing work before it goes live.' ) } +

    + + { previews.length === 0 ? ( +

    { __( 'No preview links yet.' ) }

    + ) : ( +
      + { previews.map( ( snapshot ) => { + const hostname = getSnapshotHostname( snapshot ); + const isDeleting = + deletePreviewSite.isPending && + deletePreviewSite.variables?.hostname === hostname; + const isConfirming = confirmingDelete === snapshot.url; + return ( +
    • + { /* The full hostname, unwrapped and unabbreviated: telling two + previews of the same site apart is the whole job of this + line. */ } + + { isConfirming ? ( +
      + + { __( 'This link will stop working immediately.' ) } + +
      + + +
      +
      + ) : ( +
      + { expirySummary( snapshot ) } +
      + copyLink( snapshot.url ) } + /> + + { /* `IconButton` renders a tooltip provider, not a button, + so it can't take the trigger's props — the menu would + never open. */ } + + } + > + + + openExternal( snapshot.url ) }> + { __( 'Open preview' ) } + + publish( snapshot ) } + > + { isSnapshotExpired( snapshot ) + ? __( 'Republish' ) + : __( 'Update with current contents' ) } + + + setConfirmingDelete( snapshot.url ) }> + { __( 'Delete preview link' ) } + + + +
      +
      + ) } +
    • + ); + } ) } +
    + ) } +
    +
    + + { usage ? ( + + { sprintf( + // translators: 1: preview links used, 2: total allowed. + __( '%1$d of %2$d preview links used' ), + usage.siteCount, + usage.siteLimit + ) } + + ) : ( + + ) } + + +
    +
    + ); +} diff --git a/apps/ui/src/components/site-toolbar/sync-dialog.module.css b/apps/ui/src/components/site-toolbar/sync-dialog.module.css new file mode 100644 index 0000000000..f11fee1dd6 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/sync-dialog.module.css @@ -0,0 +1,201 @@ +/* 480px: wider than wpds `small` (400) and narrower than `medium` (560). + Unlayered, so it beats the size rule in wpds's `wp-ui-components` layer. */ +.popup { + max-width: 480px; +} + +/* Segmented control, matching Settings' appearance picker: a sliding + indicator behind two equal segments. */ +.directionPicker { + position: relative; + display: grid; + grid-template-columns: repeat(2, 1fr); + align-items: center; + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); + border-radius: var(--wpds-border-radius-sm); + background: var(--wpds-color-bg-interactive-neutral-weak); + line-height: 1; +} + +.directionPicker::before { + content: ''; + position: absolute; + inset-block: calc(var(--wpds-border-width-xs) * -1); + inset-inline-start: 0; + box-sizing: border-box; + inline-size: 50%; + border: 1px solid var(--wpds-color-stroke-interactive-neutral); + border-radius: var(--wpds-border-radius-sm); + background: var(--wpds-color-bg-interactive-neutral-weak); + pointer-events: none; + transform: translateX( + calc(var(--direction-active-index, 0) * 100% + var(--direction-edge-shift, 0px)) + ); +} + +/* Physical translateX against a logical inset — flip the sign in RTL. */ +.directionPicker:dir(rtl)::before { + transform: translateX( + calc(var(--direction-active-index, 0) * -100% - var(--direction-edge-shift, 0px)) + ); +} + +/* At either end, slide the indicator one border-width outward so its border + overlays the container's rather than doubling it. */ +.directionPicker[data-active-index='0'] { + --direction-active-index: 0; + --direction-edge-shift: calc(var(--wpds-border-width-xs) * -1); +} + +.directionPicker[data-active-index='1'] { + --direction-active-index: 1; + --direction-edge-shift: var(--wpds-border-width-xs); +} + +@media not (prefers-reduced-motion) { + .directionPicker::before { + transition: transform 180ms cubic-bezier(0.2, 0, 0, 1); + } +} + +.directionButton { + position: relative; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--wpds-dimension-gap-xs); + min-height: 32px; + padding: 0 var(--wpds-dimension-padding-md); + border: 0; + border-radius: var(--wpds-border-radius-sm); + background: transparent; + color: var(--wpds-color-fg-content-neutral-weak); + font: inherit; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + cursor: var(--wpds-cursor-control); +} + +.directionButton:hover, +.directionButtonActive { + color: var(--wpds-color-fg-content-neutral); +} + +.directionButton:focus-visible { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: 2px; +} + +.directionIcon { + flex: 0 0 auto; + fill: currentColor; + width: 16px; + height: 16px; +} + +/* The URL identifies the connection; Production/Staging is a hint under it, + because that flag isn't always known when a connection is stored. */ +.destination { + min-width: 0; + margin-block-end: var(--wpds-dimension-padding-md); +} + +.destinationTrigger { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-sm); + inline-size: 100%; + min-width: 0; + padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md); + border: 1px solid var(--wpds-color-stroke-surface-neutral); + border-radius: var(--wpds-border-radius-md); + background: transparent; + text-align: start; + cursor: var(--wpds-cursor-control); +} + +.destinationTrigger:hover { + background: var(--wpds-color-bg-surface-neutral); +} + +.destinationTrigger:focus-visible { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: 2px; +} + +.destinationTriggerText { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.destinationChevron { + flex: 0 0 auto; + fill: var(--wpds-color-fg-content-neutral-weak); + width: 16px; + height: 16px; +} + +.destinationStatic { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +/* Wide enough that a connection's URL isn't the thing that wraps. */ +.menu { + min-width: 280px; + max-width: 420px; +} + +.menuItem { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.destinationUrl { + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.destinationMeta { + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.consequence { + margin: var(--wpds-dimension-gap-sm) 0 var(--wpds-dimension-padding-lg); + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.whatToSync { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-xs); +} + +.legend { + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +/* A stable floor so Push and Pull are the same width and the footer doesn't + reflow when the direction changes. */ +.run { + min-width: 88px; + justify-content: center; +} diff --git a/apps/ui/src/components/site-toolbar/sync-dialog.tsx b/apps/ui/src/components/site-toolbar/sync-dialog.tsx new file mode 100644 index 0000000000..08025636f4 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/sync-dialog.tsx @@ -0,0 +1,259 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { arrowDown, arrowUp, chevronDown, Icon } from '@wordpress/icons'; +import { Button, Dialog } from '@wordpress/ui'; +import { clsx } from 'clsx'; +import { useCallback, useEffect, useState } from 'react'; +import * as Menu from '@/components/menu'; +import { useConnector } from '@/data/core'; +import styles from './sync-dialog.module.css'; +import { createInitialTree, hasSelection, toPullOptions, toPushOptions } from './sync-selection'; +import { convertRawToTreeNodes, SyncTree, updateNodeById } from './sync-tree'; +import { formatSyncTimestamp, getConnectionLabel, stripProtocol } from './utils'; +import type { TreeNode } from './sync-tree'; +import type { SyncSite } from '@/data/core'; +import type { PullSyncOptions, PushSyncOptions } from '@studio/common/types/sync'; + +export type SyncDirection = 'push' | 'pull'; + +type Props = { + siteId: string; + connections: SyncSite[]; + open: boolean; + onOpenChange: ( open: boolean ) => void; + // Which direction the dialog opens on. Defaults to push; onboarding opens it + // on pull to nudge a freshly connected site's first pull. + initialDirection?: SyncDirection; + onRun: ( + direction: SyncDirection, + target: SyncSite, + options: PushSyncOptions | PullSyncOptions | undefined + ) => void; +}; + +// Just the age — "4h", "6d". The direction already says what happened then. +function lastSyncAge( connection: SyncSite, direction: SyncDirection ): string | null { + return formatSyncTimestamp( + direction === 'push' ? connection.lastPushTimestamp : connection.lastPullTimestamp + ); +} + +/** The second line under a connection's URL: what kind it is, and how stale. */ +function describeConnection( connection: SyncSite, direction: SyncDirection ): string { + const age = lastSyncAge( connection, direction ); + return [ + getConnectionLabel( connection ), + age + ? sprintf( + // translators: %s: compact relative time, e.g. "6d". + direction === 'push' ? __( 'pushed %s ago' ) : __( 'pulled %s ago' ), + age + ) + : null, + ] + .filter( Boolean ) + .join( ' · ' ); +} + +/** + * One place to answer everything a sync needs: which way it goes, which + * connected site it touches, and what it carries. + * + * Connections are identified by URL rather than by their Production/Staging + * label: that label is derived from whether the site's id appears in some other + * site's `wpcom_staging_blog_ids`, which isn't always known at the time a + * connection is stored, so two connections can both read "Production". The URL + * is always right. + */ +export function SyncDialog( { + siteId, + connections, + open, + onOpenChange, + initialDirection = 'push', + onRun, +}: Props ) { + const connector = useConnector(); + const [ direction, setDirection ] = useState< SyncDirection >( initialDirection ); + const [ targetId, setTargetId ] = useState< number | null >( null ); + const [ tree, setTree ] = useState< TreeNode[] >( createInitialTree ); + + const target = connections.find( ( candidate ) => candidate.id === targetId ) ?? connections[ 0 ]; + + // Push browses the local site; pull browses the remote backup. Switching + // direction means the tree describes a different filesystem, so start over. + useEffect( () => { + setTree( createInitialTree() ); + }, [ direction, targetId ] ); + + const expandNode = useCallback( + async ( node: TreeNode ) => { + const path = node.path ?? 'wp-content'; + try { + if ( direction === 'push' ) { + const entries = await connector.listLocalFileTree( siteId, path, 1 ); + setTree( ( prev ) => + updateNodeById( prev, node.id, { + children: convertRawToTreeNodes( entries ), + checked: node.checked, + } ) + ); + return; + } + if ( ! target ) { + return; + } + const rewindId = await connector.getLatestRewindId( target.id ); + if ( ! rewindId ) { + return; + } + const contents = await connector.listRemoteFileTree( target.id, rewindId, path ); + const entries = Object.entries( contents ).map( ( [ name, raw ] ) => { + const item = raw as { type?: string; has_children?: boolean; id?: string }; + const isDirectory = item.type === 'dir' || item.has_children === true; + return { + name, + isDirectory, + path: `${ path.replace( /\/$/, '' ) }/${ name }`, + }; + } ); + setTree( ( prev ) => + updateNodeById( prev, node.id, { + children: convertRawToTreeNodes( entries ), + checked: node.checked, + } ) + ); + } catch ( error ) { + console.error( 'Failed to list sync tree:', error ); + setTree( ( prev ) => updateNodeById( prev, node.id, { children: [] } ) ); + } + }, + [ connector, direction, siteId, target ] + ); + + const canRun = Boolean( target ) && hasSelection( tree ); + + // The label is a hint, not an identifier — see the note above. It sits with + // the age so the URL above it stands alone. + const destinationMeta = target ? describeConnection( target, direction ) : ''; + + return ( + + + + { __( 'Sync this site' ) } + + + + { /* Which site first, then which way — the destination is the + thing most easily got wrong. */ } +
    + { connections.length > 1 ? ( + + + + + { target ? stripProtocol( target.url ) : __( 'Choose a site' ) } + + { destinationMeta } + + + ) : target ? ( +
    + { stripProtocol( target.url ) } + { destinationMeta } +
    + ) : null } +
    + +
    + { ( [ 'push', 'pull' ] as const ).map( ( option ) => ( + + ) ) } +
    + +

    + { direction === 'push' + ? __( 'Replaces the live site with this one.' ) + : __( 'Replaces this site with the live one.' ) } +

    + +
    + { __( 'What to sync' ) } + +
    +
    + + + { __( 'Cancel' ) } + + + +
    +
    + ); +} diff --git a/apps/ui/src/components/site-toolbar/sync-selection.ts b/apps/ui/src/components/site-toolbar/sync-selection.ts new file mode 100644 index 0000000000..87a3c137df --- /dev/null +++ b/apps/ui/src/components/site-toolbar/sync-selection.ts @@ -0,0 +1,155 @@ +import { categorizePath } from '@studio/common/lib/sync/tree-utils'; +import { __ } from '@wordpress/i18n'; +import type { TreeNode } from './sync-tree'; +import type { PullSyncOptions, PushSyncOptions, SyncOption } from '@studio/common/types/sync'; + +// The tree's two roots. Everything a sync can carry is either the database or +// something under wp-content. +export const DATABASE_NODE_ID = 'sqls'; +export const FILES_NODE_ID = 'filesAndFolders'; +export const WP_CONTENT_NODE_ID = 'wp-content'; + +/** The tree as it stands before any directory has been listed. */ +export function createInitialTree(): TreeNode[] { + return [ + { + id: DATABASE_NODE_ID, + name: DATABASE_NODE_ID, + label: __( 'Database' ), + checked: true, + }, + { + id: FILES_NODE_ID, + name: FILES_NODE_ID, + label: __( 'Files and folders' ), + checked: true, + expanded: true, + hideExpandButton: true, + children: [ + { + id: WP_CONTENT_NODE_ID, + name: WP_CONTENT_NODE_ID, + label: 'wp-content', + checked: true, + type: 'folder', + expanded: false, + children: [], + }, + ], + }, + ]; +} + +/** + * The nodes a sync should carry: a checked node stands for its whole subtree, + * so recursion stops there. A mixed node contributes only its checked + * descendants. + */ +function collectChecked( nodes: TreeNode[] | undefined ): TreeNode[] { + if ( ! nodes?.length ) { + return []; + } + const result: TreeNode[] = []; + for ( const node of nodes ) { + if ( node.checked ) { + result.push( node ); + } else if ( node.indeterminate && node.children?.length ) { + result.push( ...collectChecked( node.children ) ); + } + } + return result; +} + +function findNode( nodes: TreeNode[], id: string ): TreeNode | undefined { + for ( const node of nodes ) { + if ( node.id === id ) { + return node; + } + const found = node.children ? findNode( node.children, id ) : undefined; + if ( found ) { + return found; + } + } + return undefined; +} + +function roots( tree: TreeNode[] ) { + return { + database: findNode( tree, DATABASE_NODE_ID ), + files: findNode( tree, FILES_NODE_ID ), + wpContent: findNode( tree, WP_CONTENT_NODE_ID ), + }; +} + +/** True when the whole site is selected, which both sides express as `all`. */ +export function isWholeSite( tree: TreeNode[] ): boolean { + const { database, files } = roots( tree ); + return Boolean( database?.checked && files?.checked ); +} + +export function hasSelection( tree: TreeNode[] ): boolean { + const { database, files } = roots( tree ); + return Boolean( + database?.checked || files?.checked || files?.indeterminate || database?.indeterminate + ); +} + +/** + * Push selects local paths. Each checked path also contributes the category it + * falls into, because the export layer decides what to archive from the + * categories and then narrows to the paths. + */ +export function toPushOptions( tree: TreeNode[] ): PushSyncOptions | undefined { + if ( isWholeSite( tree ) ) { + return undefined; + } + + const { database, wpContent } = roots( tree ); + const optionsToSync: SyncOption[] = []; + let specificSelectionPaths: string[] | undefined; + + if ( database?.checked ) { + optionsToSync.push( 'sqls' ); + } + + const paths = new Set< string >(); + const categories = new Set< SyncOption >(); + for ( const node of collectChecked( wpContent?.children ) ) { + if ( ! node.path ) { + continue; + } + const relative = node.path.replace( /^\/?wp-content\//, '' ); + paths.add( relative ); + categories.add( categorizePath( relative ) ); + } + + if ( paths.size > 0 ) { + optionsToSync.push( ...categories ); + specificSelectionPaths = [ ...paths ]; + } + + return { optionsToSync, ...( specificSelectionPaths ? { specificSelectionPaths } : {} ) }; +} + +/** + * Pull selects remote backup node ids rather than paths, and marks the run + * with `paths` so the CLI knows to read the include list. + */ +export function toPullOptions( tree: TreeNode[] ): PullSyncOptions | undefined { + if ( isWholeSite( tree ) ) { + return undefined; + } + + const { database, wpContent } = roots( tree ); + const optionsToSync: SyncOption[] = database?.checked ? [ 'sqls' ] : []; + const includePathList = collectChecked( wpContent?.children ) + .map( ( node ) => node.pathId ) + .filter( ( pathId ): pathId is string => Boolean( pathId ) ); + + if ( includePathList.length > 0 ) { + optionsToSync.unshift( 'paths' ); + return { optionsToSync, includePathList }; + } + + return { optionsToSync }; +} diff --git a/apps/ui/src/components/site-toolbar/sync-tree.module.css b/apps/ui/src/components/site-toolbar/sync-tree.module.css new file mode 100644 index 0000000000..d288f994a7 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/sync-tree.module.css @@ -0,0 +1,118 @@ +/* Tall enough to browse in, and it scrolls rather than pushing the dialog's + footer off screen once wp-content is opened. */ +.tree { + block-size: 260px; + overflow-y: auto; + overscroll-behavior: contain; + padding: var(--wpds-dimension-padding-xs); + border: 1px solid var(--wpds-color-stroke-surface-neutral); + border-radius: var(--wpds-border-radius-md); +} + +.item { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-xs); + min-block-size: 28px; +} + +.twisty { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + inline-size: 20px; + block-size: 20px; + padding: 0; + border: 0; + border-radius: var(--wpds-border-radius-sm); + background: transparent; + color: var(--wpds-color-fg-content-neutral-weak); + cursor: var(--wpds-cursor-control); +} + +.twisty:hover { + color: var(--wpds-color-fg-content-neutral); +} + +.twisty:focus-visible { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); +} + +/* Keeps the row's text aligned with its siblings when a node can't be opened, + rather than letting leaves slide back under their folder's checkbox. */ +.twistyHidden { + visibility: hidden; +} + +.twistyIcon { + fill: currentColor; + width: 16px; + height: 16px; +} + +@media not (prefers-reduced-motion) { + .twistyIcon { + transition: transform 120ms ease; + } +} + +.twistyIconOpen { + transform: rotate(90deg); +} + +.label { + display: flex; + align-items: center; + gap: var(--wpds-dimension-gap-xs); + min-width: 0; + flex: 1; + 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); +} + +.labelDisabled { + opacity: 0.6; + cursor: not-allowed; +} + +.checkbox { + flex: 0 0 auto; + margin: 0; +} + +.nodeIcon { + flex: 0 0 auto; + fill: var(--wpds-color-fg-content-neutral-weak); + width: 16px; + height: 16px; +} + +.labelText { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.spinner { + flex: 0 0 auto; + margin: 0; +} + +/* Indent one level, with a rule marking the branch the children belong to. */ +.group { + margin-inline-start: 10px; + padding-inline-start: var(--wpds-dimension-padding-md); + border-inline-start: 1px solid var(--wpds-color-stroke-surface-neutral); +} + +.empty { + margin: 0; + padding: var(--wpds-dimension-padding-xs) 0; + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} diff --git a/apps/ui/src/components/site-toolbar/sync-tree.tsx b/apps/ui/src/components/site-toolbar/sync-tree.tsx new file mode 100644 index 0000000000..2e8c836425 --- /dev/null +++ b/apps/ui/src/components/site-toolbar/sync-tree.tsx @@ -0,0 +1,303 @@ +import { shouldLimitDepth } from '@studio/common/lib/sync/tree-utils'; +import { Spinner } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { brush, chevronRight, Icon, page, plugins, file as folder } from '@wordpress/icons'; +import { clsx } from 'clsx'; +import { useEffect, useRef } from 'react'; +import styles from './sync-tree.module.css'; +import type { RawDirectoryEntry } from '@studio/common/types/sync-tree'; +import type { Dispatch, SetStateAction } from 'react'; + +export type TreeNodeType = 'folder' | 'file' | 'plugin' | 'theme'; + +export type TreeNode = { + id: string; + name: string; + label: string; + checked: boolean; + indeterminate?: boolean; + expanded?: boolean; + hideExpandButton?: boolean; + children?: TreeNode[]; + type?: TreeNodeType; + loading?: boolean; + // Remote backup node id, used by pull's `includePathList`. + pathId?: string; + // Path relative to the site root, used by push's `specificSelectionPaths`. + path?: string; +}; + +const NODE_ICONS: Record< TreeNodeType, typeof folder > = { + folder, + file: page, + plugin: plugins, + theme: brush, +}; + +/** + * Applies a patch to a node and reconciles the tri-state of everything around + * it: checking a folder checks its whole subtree, and every ancestor becomes + * checked, indeterminate, or clear depending on what its children ended up as. + */ +function updateNode( node: TreeNode, patch: Partial< TreeNode > ): TreeNode { + const updated = { ...node, ...patch }; + + if ( updated.children && updated.children.length > 0 ) { + updated.children = updated.children.map( ( child ) => + 'checked' in patch ? updateNode( child, { checked: patch.checked } ) : child + ); + const checkedCount = updated.children.filter( ( child ) => child.checked ).length; + updated.checked = checkedCount === updated.children.length; + updated.indeterminate = checkedCount > 0 && checkedCount < updated.children.length; + } + + return updated; +} + +export function updateNodeById( + nodes: TreeNode[], + id: string, + patch: Partial< TreeNode > +): TreeNode[] { + return nodes.map( ( node ) => { + if ( node.id === id ) { + return updateNode( node, patch ); + } + if ( node.children && node.children.length > 0 ) { + const children = updateNodeById( node.children, id, patch ); + const checkedCount = children.filter( ( child ) => child.checked ).length; + return { + ...node, + checked: checkedCount === children.length, + indeterminate: + ( checkedCount > 0 && checkedCount < children.length ) || + children.some( ( child ) => child.indeterminate ), + children, + }; + } + return node; + } ); +} + +/** Turns a directory listing into tree nodes, folders first then alphabetical. */ +export function convertRawToTreeNodes( rawNodes: RawDirectoryEntry[] ): TreeNode[] { + const pluginPath = /^plugins\/[^/]+$/; + const themePath = /^themes\/[^/]+$/; + + return rawNodes + .map( ( raw ): TreeNode => { + let type: TreeNodeType = raw.isDirectory ? 'folder' : 'file'; + if ( raw.isDirectory ) { + const relative = raw.path.replace( /^wp-content\//, '' ); + if ( pluginPath.test( relative ) ) { + type = 'plugin'; + } else if ( themePath.test( relative ) ) { + type = 'theme'; + } + } + + return { + id: `local-${ raw.path.replace( /[^a-zA-Z0-9/]/g, '-' ) }`, + name: raw.name, + label: raw.name, + checked: false, + type, + path: raw.path, + pathId: raw.path, + children: raw.children + ? convertRawToTreeNodes( raw.children ) + : raw.isDirectory + ? [] + : undefined, + expanded: false, + // A plugin or theme syncs whole; there's nothing useful to pick + // inside one, so don't offer to open it. + hideExpandButton: shouldLimitDepth( raw.path ), + }; + } ) + .sort( ( a, b ) => { + if ( a.type !== b.type ) { + const order = { folder: 0, plugin: 1, theme: 2, file: 3 }; + return order[ a.type as TreeNodeType ] - order[ b.type as TreeNodeType ]; + } + return a.name.toLowerCase().localeCompare( b.name.toLowerCase() ); + } ); +} + +/** Native checkbox so the indeterminate state can be set on the DOM node. */ +function TriStateCheckbox( { + checked, + indeterminate, + disabled, + onChange, + label, +}: { + checked: boolean; + indeterminate?: boolean; + disabled?: boolean; + onChange: ( checked: boolean ) => void; + label: string; +} ) { + const ref = useRef< HTMLInputElement >( null ); + + useEffect( () => { + if ( ref.current ) { + ref.current.indeterminate = Boolean( indeterminate ) && ! checked; + } + }, [ checked, indeterminate ] ); + + return ( + onChange( event.target.checked ) } + /> + ); +} + +function TreeItem( { + node, + level, + index, + siblingCount, + disabled, + onPatch, + onExpand, +}: { + node: TreeNode; + level: number; + index: number; + siblingCount: number; + disabled?: boolean; + onPatch: ( id: string, patch: Partial< TreeNode > ) => void; + onExpand?: ( node: TreeNode ) => Promise< void >; +} ) { + const expanded = node.expanded ?? true; + const canExpand = Boolean( node.children ) && ! node.hideExpandButton; + + return ( +
    +
    + + + + + { node.loading ? : null } +
    + + { expanded && node.children ? ( +
    + { node.children.length === 0 ? ( +

    { node.loading ? __( 'Loading…' ) : __( 'Empty' ) }

    + ) : ( + node.children.map( ( child, childIndex ) => ( + + ) ) + ) } +
    + ) : null } +
    + ); +} + +/** + * The file tree behind "What to sync". Folders load their contents the first + * time they're opened, and a folder's checkbox reflects its subtree — checked, + * clear, or mixed. + */ +export function SyncTree( { + tree, + setTree, + onExpand, + disabled, +}: { + tree: TreeNode[]; + setTree: Dispatch< SetStateAction< TreeNode[] > >; + onExpand?: ( node: TreeNode ) => Promise< void >; + disabled?: boolean; +} ) { + return ( +
    + { tree.map( ( node, index ) => ( + setTree( ( prev ) => updateNodeById( prev, id, patch ) ) } + onExpand={ onExpand } + /> + ) ) } +
    + ); +} diff --git a/apps/ui/src/components/site-toolbar/utils.ts b/apps/ui/src/components/site-toolbar/utils.ts index a72dc416b2..2f0e6ad5bc 100644 --- a/apps/ui/src/components/site-toolbar/utils.ts +++ b/apps/ui/src/components/site-toolbar/utils.ts @@ -1,6 +1,33 @@ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; +import { formatRelativeTime } from '@/lib/format-relative-time'; import type { Snapshot, SyncSite } from '@/data/core'; +const MINUTE_MS = 60_000; + +/** + * The shortest readable age: "3s", "4m", "2h", "6d". Seconds matter here — a + * sync is often checked moments after it lands, and "just now" holds for a + * whole minute. Returns null for timestamps we can't read. + */ +export function formatSyncTimestamp( isoTimestamp: string | null | undefined ): string | null { + if ( ! isoTimestamp ) { + return null; + } + const timestampMs = Date.parse( isoTimestamp ); + if ( ! Number.isFinite( timestampMs ) ) { + return null; + } + const elapsedMs = Math.max( 0, Date.now() - timestampMs ); + if ( elapsedMs < MINUTE_MS ) { + return sprintf( + // translators: %d: number of seconds, compact relative time (e.g. "3s"). + __( '%ds' ), + Math.max( 1, Math.floor( elapsedMs / 1000 ) ) + ); + } + return formatRelativeTime( new Date( timestampMs ).toISOString() ) || null; +} + export function stripProtocol( url: string ): string { return url.replace( /^https?:\/\//, '' ).replace( /\/$/, '' ); } diff --git a/apps/ui/src/data/core/connectors/hosted/index.ts b/apps/ui/src/data/core/connectors/hosted/index.ts index 6350fbd4b9..b719a02e58 100644 --- a/apps/ui/src/data/core/connectors/hosted/index.ts +++ b/apps/ui/src/data/core/connectors/hosted/index.ts @@ -244,6 +244,9 @@ export function createHostedConnector( { apiBaseUrl }: HostedConnectorOptions ): async deleteAllSnapshots() { // No-op: hosted mode does not create WordPress.com preview sites. }, + async deletePreviewSite(): Promise< void > { + throw new UnsupportedError( 'deletePreviewSite' ); + }, async publishPreviewSite(): Promise< { url: string } > { throw new UnsupportedError( 'publishPreviewSite' ); }, diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index c028ae4679..83de3a26d2 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -198,6 +198,44 @@ export function createIpcConnector(): Connector { } ); } + // Like awaitSnapshotOperation, but for commands that report no URL (delete): + // resolves on the matching success event, rejects on a fatal error. + function awaitSnapshotCompletion( operationId: string ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const unsubscribes: Array< () => void > = []; + const cleanup = () => { + for ( const unsubscribe of unsubscribes ) { + unsubscribe(); + } + }; + + unsubscribes.push( + ipcListener.subscribe( + 'snapshot-success', + ( _event: unknown, payload: { operationId: string } ) => { + if ( payload.operationId !== operationId ) { + return; + } + cleanup(); + resolve(); + } + ) + ); + unsubscribes.push( + ipcListener.subscribe( + 'snapshot-fatal-error', + ( _event: unknown, payload: { operationId: string; data: { message: string } } ) => { + if ( payload.operationId !== operationId ) { + return; + } + cleanup(); + reject( new Error( payload.data.message ) ); + } + ) + ); + } ); + } + return { async init() { // Install the application menu (View > Toggle DevTools, etc.). @@ -504,6 +542,13 @@ export function createIpcConnector(): Connector { await ipcApi.deleteAllSnapshots(); }, + async deletePreviewSite( hostname ): Promise< void > { + const { operationId } = ( await ipcApi.deleteSnapshot( hostname ) ) as { + operationId: string; + }; + await awaitSnapshotCompletion( operationId ); + }, + async publishPreviewSite( siteId, existingHostname ): Promise< { url: string } > { const siteFolder = await resolveSiteFolder( siteId ); // Reuses the desktop app's `createSnapshot`/`updateSnapshot` IPC diff --git a/apps/ui/src/data/core/connectors/local/index.ts b/apps/ui/src/data/core/connectors/local/index.ts index 1461c2813c..19c4b7a996 100644 --- a/apps/ui/src/data/core/connectors/local/index.ts +++ b/apps/ui/src/data/core/connectors/local/index.ts @@ -206,6 +206,26 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C } ); } + // Resolve when a snapshot command that reports no URL (delete) finishes, + // correlating the SSE stream by operationId. + function awaitSnapshotCompletion( operationId: string ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const listener = ( output: SnapshotSseOutput ) => { + if ( output.operationId !== operationId ) { + return; + } + if ( output.kind === 'success' ) { + snapshotListeners.delete( listener ); + resolve(); + } else if ( output.kind === 'fatal-error' ) { + snapshotListeners.delete( listener ); + reject( new Error( output.data.message ) ); + } + }; + snapshotListeners.add( listener ); + } ); + } + return { async init() { // The browser's EventSource reconnects automatically. @@ -526,6 +546,13 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C async deleteAllSnapshots() { // No-op: the local server has no delete-all route yet. }, + async deletePreviewSite( hostname ): Promise< void > { + const { operationId } = await api< { operationId: string } >( + `/snapshots/${ encodeURIComponent( hostname ) }`, + { method: 'DELETE' } + ); + await awaitSnapshotCompletion( operationId ); + }, async publishPreviewSite( siteId, existingHostname ): Promise< { url: string } > { // A hostname means "refresh this preview"; otherwise create a new one. // The server returns an operationId; progress + the final URL arrive on diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index c267d7892a..c239196cc4 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -253,6 +253,8 @@ export interface Connector { // quota source) so callers can fall back to static copy. getStudioAssistantQuota(): Promise< StudioAssistantQuota | null >; deleteAllSnapshots(): Promise< void >; + // Delete a single WordPress.com preview snapshot by its hostname. + deletePreviewSite( hostname: string ): Promise< void >; // Asks the user to confirm deleting every preview site on their account. // Resolves `true` only when they explicitly confirm. confirmDeleteAllPreviewSites(): Promise< boolean >; diff --git a/apps/ui/src/data/queries/use-preview-site.ts b/apps/ui/src/data/queries/use-preview-site.ts index b9c79c9fc9..efb5267638 100644 --- a/apps/ui/src/data/queries/use-preview-site.ts +++ b/apps/ui/src/data/queries/use-preview-site.ts @@ -34,3 +34,20 @@ export function usePublishPreviewSite() { }, } ); } + +// Deletes a single WordPress.com-hosted preview by its hostname and refreshes +// the snapshot list. +export function useDeletePreviewSite() { + const connector = useConnector(); + const queryClient = useQueryClient(); + return useMutation( { + mutationFn: ( { hostname }: { hostname: string } ) => connector.deletePreviewSite( hostname ), + onSuccess: () => { + void queryClient.invalidateQueries( { queryKey: SNAPSHOTS_QUERY_KEY } ); + }, + onError: ( error ) => { + const message = error instanceof Error ? error.message : String( error ); + toast.error( message || __( 'Failed to delete preview link' ) ); + }, + } ); +} diff --git a/apps/ui/src/ui-classic/components/session-view/index.tsx b/apps/ui/src/ui-classic/components/session-view/index.tsx index faad9f67c0..76db36f2b4 100644 --- a/apps/ui/src/ui-classic/components/session-view/index.tsx +++ b/apps/ui/src/ui-classic/components/session-view/index.tsx @@ -88,9 +88,9 @@ function SessionHeader( { summary }: SessionHeaderProps ) { { effectiveEnvironment === 'live' ? __( 'Live' ) : __( 'Local' ) } +
    ); } diff --git a/apps/ui/src/ui-classic/components/session-view/style.module.css b/apps/ui/src/ui-classic/components/session-view/style.module.css index e80ec3d3c1..9637f4b2b9 100644 --- a/apps/ui/src/ui-classic/components/session-view/style.module.css +++ b/apps/ui/src/ui-classic/components/session-view/style.module.css @@ -42,7 +42,9 @@ align-items: center; gap: var(--wpds-dimension-padding-sm); padding-block: var(--wpds-dimension-padding-sm); - padding-inline: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-2xl); + /* The toolbar owns its own inline padding (it pins its actions to the panel + edge); the host only pads the left for the no-site fallback. */ + padding-inline: var(--wpds-dimension-padding-sm) 0; min-height: 46px; font-size: var(--wpds-typography-font-size-sm); color: var(--wpds-color-fg-content-neutral); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx index d3f3f9c165..df75f85274 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-connect/index.tsx @@ -5,6 +5,11 @@ import { check, chevronLeft, external, search } from '@wordpress/icons'; import { Badge, Button, Icon } from '@wordpress/ui'; import { clsx } from 'clsx'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + presentRemoteSites, + searchRemoteSites, + type ConnectSiteGroup, +} from '@/components/connect-site-picker/site-presentation'; import { OnboardingFooter } from '@/components/onboarding-footer'; import { toast } from '@/data/app-messages'; import { useConnector } from '@/data/core'; @@ -18,7 +23,6 @@ import { getLocalizedLink } from '@/lib/docs-links'; import { onboardingLayoutRoute, useOnboardingProgress } from '../layout-onboarding'; import sharedStyles from '../layout-onboarding/style.module.css'; import { ConnectSiteLifecycleError, runConnectSiteLifecycle } from './connect-site'; -import { presentRemoteSites, searchRemoteSites, type ConnectSiteGroup } from './site-presentation'; import styles from './style.module.css'; import type { SyncSite } from '@/data/core';