Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/ui/src/components/selective-sync/sync-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,10 @@ export function SyncDialog( {

return (
<Modal
className="w-3/5 min-w-[550px] max-h-[84vh] [&>div]:!p-0"
className="max-h-[84vh] [&>div]:!p-0"
// Match the Share dialog's 480px width. apps/ui has no Tailwind build,
// so an arbitrary w-[480px] utility wouldn't exist — set it inline.
style={ { width: '480px', minWidth: 0, maxWidth: '100%' } }
onRequestClose={ onRequestClose }
title={ syncTexts.title }
>
Expand Down
29 changes: 29 additions & 0 deletions apps/ui/src/components/site-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -183,6 +185,31 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo
</div>

<div className={ styles.actions }>
{ /* Sharing a preview isn't a sync — it publishes a throwaway copy —
so it sits beside the primary action, not inside its dialog. */ }
{ ! isSignedOut ? (
<Tooltip.Root>
<Tooltip.Trigger
render={
<Button
variant="minimal"
tone="neutral"
size="small"
className={ styles.action }
disabled={ ! agenticEnabled }
onClick={ () => setShareOpen( true ) }
>
{ __( 'Share' ) }
</Button>
}
/>
<Tooltip.Popup positioner={ <Tooltip.Positioner side="bottom" /> }>
{ agenticEnabled
? __( 'Publish a preview link' )
: __( 'Go online to share a preview.' ) }
</Tooltip.Popup>
</Tooltip.Root>
) : null }
{ isSignedOut ? (
<Button
variant="solid"
Expand Down Expand Up @@ -273,6 +300,8 @@ export function SiteToolbar( { site, className, openPullOnLoad = false }: SiteTo
</Dialog.Popup>
</Dialog.Root>
) : null }

{ shareOpen ? <ShareDialog site={ site } open onOpenChange={ setShareOpen } /> : null }
</div>
);
}
149 changes: 149 additions & 0 deletions apps/ui/src/components/site-toolbar/share-dialog.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
160 changes: 160 additions & 0 deletions apps/ui/src/components/site-toolbar/share-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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( <ShareDialog site={ SITE } open onOpenChange={ vi.fn() } /> );
}

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()
);
} );
} );
Loading