From 57e1fb56933bce8d7d14afe65eb1b39a732b7a84 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 4 Aug 2026 18:42:20 -0400 Subject: [PATCH] Bring back preview links as a Share dialog in the header Co-Authored-By: Claude Opus 4.8 --- .../components/selective-sync/sync-dialog.tsx | 5 +- apps/ui/src/components/site-toolbar/index.tsx | 29 ++ .../site-toolbar/share-dialog.module.css | 149 +++++++++ .../site-toolbar/share-dialog.test.tsx | 160 +++++++++ .../components/site-toolbar/share-dialog.tsx | 306 ++++++++++++++++++ .../src/data/core/connectors/hosted/index.ts | 3 + apps/ui/src/data/core/connectors/ipc/index.ts | 45 +++ .../src/data/core/connectors/local/index.ts | 27 ++ apps/ui/src/data/core/types.ts | 2 + apps/ui/src/data/queries/use-preview-site.ts | 17 + 10 files changed, 742 insertions(+), 1 deletion(-) create mode 100644 apps/ui/src/components/site-toolbar/share-dialog.module.css create mode 100644 apps/ui/src/components/site-toolbar/share-dialog.test.tsx create mode 100644 apps/ui/src/components/site-toolbar/share-dialog.tsx diff --git a/apps/ui/src/components/selective-sync/sync-dialog.tsx b/apps/ui/src/components/selective-sync/sync-dialog.tsx index a47a5219db..2f06ec153e 100644 --- a/apps/ui/src/components/selective-sync/sync-dialog.tsx +++ b/apps/ui/src/components/selective-sync/sync-dialog.tsx @@ -322,7 +322,10 @@ export function SyncDialog( { return ( diff --git a/apps/ui/src/components/site-toolbar/index.tsx b/apps/ui/src/components/site-toolbar/index.tsx index b8a17db826..0fa1d81994 100644 --- a/apps/ui/src/components/site-toolbar/index.tsx +++ b/apps/ui/src/components/site-toolbar/index.tsx @@ -22,6 +22,7 @@ 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 { ShareDialog } from './share-dialog'; import styles from './style.module.css'; import { SyncDialog, type SyncDirection } from './sync-dialog'; import { ensureProtocol, pickLiveSite, sortConnections } from './utils'; @@ -72,6 +73,7 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo 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 ); @@ -183,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 ? (
); } 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/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' ) ); + }, + } ); +}