diff --git a/src/components/content-tab-previews.tsx b/src/components/content-tab-previews.tsx
new file mode 100644
index 000000000..69da1a362
--- /dev/null
+++ b/src/components/content-tab-previews.tsx
@@ -0,0 +1,208 @@
+import { createInterpolateElement } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { check, external, Icon } from '@wordpress/icons';
+import { useI18n } from '@wordpress/react-i18n';
+import { PropsWithChildren } from 'react';
+import {
+ CLIENT_ID,
+ PROTOCOL_PREFIX,
+ SCOPES,
+ WP_AUTHORIZE_ENDPOINT,
+ LIMIT_OF_ZIP_SITES_PER_USER,
+} from 'src/constants';
+import { useArchiveSite } from 'src/hooks/use-archive-site';
+import { useAuth } from 'src/hooks/use-auth';
+import { useOffline } from 'src/hooks/use-offline';
+import { useSnapshots } from 'src/hooks/use-snapshots';
+import { getIpcApi } from 'src/lib/get-ipc-api';
+import { CreatePreviewButton } from 'src/modules/preview-site/components/create-preview-button';
+import { PreviewSiteRow } from 'src/modules/preview-site/components/preview-site-row';
+import { PreviewSitesTableHeader } from 'src/modules/preview-site/components/preview-sites-table-header';
+import { ProgressRow } from 'src/modules/preview-site/components/progress-row';
+import Button from './button';
+import offlineIcon from './offline-icon';
+import { ScreenshotDemoSite } from './screenshot-demo-site';
+import { Tooltip } from './tooltip';
+
+interface ContentTabPreviewsProps {
+ selectedSite: SiteDetails;
+}
+
+function EmptyGeneric( {
+ children,
+ selectedSite,
+}: PropsWithChildren< { selectedSite: SiteDetails } > ) {
+ const { __ } = useI18n();
+ return (
+
{ name === 'overview' &&
}
{ name === 'share' &&
}
+ { name === 'previews' &&
}
{ name === 'sync' &&
}
{ name === 'settings' &&
}
{ name === 'assistant' && (
diff --git a/src/hooks/tests/use-update-demo-site.test.tsx b/src/hooks/tests/use-update-demo-site.test.tsx
index 7b0c2540a..83b5fba88 100644
--- a/src/hooks/tests/use-update-demo-site.test.tsx
+++ b/src/hooks/tests/use-update-demo-site.test.tsx
@@ -108,7 +108,7 @@ describe( 'useUpdateDemoSite', () => {
} );
// Assert that 'isDemoSiteUpdating' is set back to false
- expect( result.current.isDemoSiteUpdating( mockLocalSite.id ) ).toBe( false );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot.atomicSiteId ) ).toBe( false );
// Assert that demo site is updated with a new expiration date
expect( updateSnapshotMock ).toHaveBeenCalledWith(
@@ -144,7 +144,7 @@ describe( 'useUpdateDemoSite', () => {
} );
// Assert that 'isDemoSiteUpdating' is set back to false
- expect( result.current.isDemoSiteUpdating( mockLocalSite.id ) ).toBe( false );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot.atomicSiteId ) ).toBe( false );
} );
it( 'should allow updating two sites independently with different completion times', async () => {
@@ -184,8 +184,8 @@ describe( 'useUpdateDemoSite', () => {
} );
// Initially, both sites should be marked as updating
- expect( result.current.isDemoSiteUpdating( mockLocalSite.id ) ).toBe( true );
- expect( result.current.isDemoSiteUpdating( mockLocalSite2.id ) ).toBe( true );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot.atomicSiteId ) ).toBe( true );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot2.atomicSiteId ) ).toBe( true );
// Wait for the first site to complete
await act( async () => {
@@ -194,8 +194,8 @@ describe( 'useUpdateDemoSite', () => {
} );
// After 1000ms, the first site should be done, but the second should still be updating
- expect( result.current.isDemoSiteUpdating( mockLocalSite.id ) ).toBe( false );
- expect( result.current.isDemoSiteUpdating( mockLocalSite2.id ) ).toBe( true );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot.atomicSiteId ) ).toBe( false );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot2.atomicSiteId ) ).toBe( true );
// Wait for the second site to complete
await act( async () => {
@@ -204,8 +204,8 @@ describe( 'useUpdateDemoSite', () => {
} );
// After another 1000ms, both sites should be done updating
- expect( result.current.isDemoSiteUpdating( mockLocalSite.id ) ).toBe( false );
- expect( result.current.isDemoSiteUpdating( mockLocalSite2.id ) ).toBe( false );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot.atomicSiteId ) ).toBe( false );
+ expect( result.current.isDemoSiteUpdating( mockSnapshot2.atomicSiteId ) ).toBe( false );
// Assert that the update function was called for both sites
expect( clientReqPost ).toHaveBeenCalledTimes( 2 );
diff --git a/src/hooks/use-confirmation-dialog.ts b/src/hooks/use-confirmation-dialog.ts
index 6d134d831..04ea66744 100644
--- a/src/hooks/use-confirmation-dialog.ts
+++ b/src/hooks/use-confirmation-dialog.ts
@@ -7,7 +7,8 @@ interface ConfirmationDialogOptions {
checkboxLabel?: string;
confirmButtonLabel: string;
cancelButtonLabel?: string;
- localStorageKey: string;
+ type?: 'none' | 'info' | 'error' | 'question' | 'warning';
+ localStorageKey?: string;
}
export function useConfirmationDialog( options: ConfirmationDialogOptions ) {
@@ -19,10 +20,11 @@ export function useConfirmationDialog( options: ConfirmationDialogOptions ) {
confirmButtonLabel,
cancelButtonLabel = __( 'Cancel' ),
localStorageKey,
+ type,
} = options;
return async ( onConfirm: () => void, { detail: detailOverride }: { detail?: string } = {} ) => {
- if ( localStorage.getItem( localStorageKey ) === 'true' ) {
+ if ( localStorageKey && localStorage.getItem( localStorageKey ) === 'true' ) {
onConfirm();
return;
}
@@ -30,16 +32,19 @@ export function useConfirmationDialog( options: ConfirmationDialogOptions ) {
const CONFIRM_BUTTON_INDEX = 0;
const CANCEL_BUTTON_INDEX = 1;
const { response, checkboxChecked } = await getIpcApi().showMessageBox( {
+ type,
message,
detail: detailOverride ?? detail,
- checkboxLabel,
+ ...( localStorageKey && {
+ checkboxLabel: checkboxLabel ?? __( "Don't show this warning again" ),
+ } ),
buttons: [ confirmButtonLabel, cancelButtonLabel ],
cancelId: CANCEL_BUTTON_INDEX,
} );
if ( response === CONFIRM_BUTTON_INDEX ) {
// Confirm button is always the first button
- if ( checkboxChecked ) {
+ if ( localStorageKey && checkboxChecked ) {
localStorage.setItem( localStorageKey, 'true' );
}
onConfirm();
diff --git a/src/hooks/use-content-tabs.tsx b/src/hooks/use-content-tabs.tsx
index 945ea365a..0fdab2136 100644
--- a/src/hooks/use-content-tabs.tsx
+++ b/src/hooks/use-content-tabs.tsx
@@ -1,14 +1,23 @@
import { TabPanel } from '@wordpress/components';
import { useI18n } from '@wordpress/react-i18n';
import { createContext, ReactNode, useContext, useMemo, useState } from 'react';
+import { useFeatureFlags } from './use-feature-flags';
-export type TabName = 'overview' | 'share' | 'sync' | 'settings' | 'assistant' | 'import-export';
+export type TabName =
+ | 'overview'
+ | 'share'
+ | 'sync'
+ | 'settings'
+ | 'assistant'
+ | 'import-export'
+ | 'previews';
type Tab = React.ComponentProps< typeof TabPanel >[ 'tabs' ][ number ] & {
name: TabName;
};
function useTabs() {
const { __ } = useI18n();
+ const { quickDeploysEnabled } = useFeatureFlags();
return useMemo( () => {
const tabs: Tab[] = [
@@ -22,11 +31,23 @@ function useTabs() {
name: 'sync',
title: __( 'Sync' ),
},
- {
+ ];
+
+ if ( quickDeploysEnabled ) {
+ tabs.push( {
+ order: 3,
+ name: 'previews',
+ title: __( 'Previews' ),
+ } );
+ } else {
+ tabs.push( {
order: 3,
name: 'share',
title: __( 'Share' ),
- },
+ } );
+ }
+
+ tabs.push(
{
order: 4,
name: 'import-export',
@@ -36,8 +57,8 @@ function useTabs() {
order: 5,
name: 'settings',
title: __( 'Settings' ),
- },
- ];
+ }
+ );
tabs.push( {
order: 6,
@@ -47,7 +68,7 @@ function useTabs() {
} );
return tabs.sort( ( a, b ) => a.order - b.order );
- }, [ __ ] );
+ }, [ __, quickDeploysEnabled ] );
}
interface ContentTabsContextType {
selectedTab: TabName;
diff --git a/src/hooks/use-update-demo-site.tsx b/src/hooks/use-update-demo-site.tsx
index 38c637b17..7563ea136 100644
--- a/src/hooks/use-update-demo-site.tsx
+++ b/src/hooks/use-update-demo-site.tsx
@@ -5,11 +5,12 @@ import { useCallback, useState, createContext, useContext, useMemo, ReactNode }
import { DEMO_SITE_SIZE_LIMIT_BYTES, DEMO_SITE_SIZE_LIMIT_GB } from '../constants';
import { getIpcApi } from '../lib/get-ipc-api';
import { useAuth } from './use-auth';
+import { useFeatureFlags } from './use-feature-flags';
import { useSnapshots } from './use-snapshots';
interface DemoSiteUpdateContextType {
updateDemoSite: ( snapshot: Snapshot, localSite: SiteDetails ) => Promise< void >;
- isDemoSiteUpdating: ( siteId: string ) => boolean;
+ isDemoSiteUpdating: ( atomicSiteId: number ) => boolean;
}
const DemoSiteUpdateContext = createContext< DemoSiteUpdateContextType >( {
@@ -24,8 +25,9 @@ interface DemoSiteUpdateProviderProps {
export const DemoSiteUpdateProvider: React.FC< DemoSiteUpdateProviderProps > = ( { children } ) => {
const { client } = useAuth();
const { __ } = useI18n();
- const [ updatingSites, setUpdatingSites ] = useState< Set< string > >( new Set() );
+ const [ updatingSites, setUpdatingSites ] = useState< Set< number > >( new Set() );
const { updateSnapshot } = useSnapshots();
+ const { quickDeploysEnabled } = useFeatureFlags();
const updateDemoSite = useCallback(
async ( snapshot: Snapshot, localSite: SiteDetails ) => {
@@ -33,7 +35,7 @@ export const DemoSiteUpdateProvider: React.FC< DemoSiteUpdateProviderProps > = (
// No-op if logged out
return;
}
- setUpdatingSites( ( prev ) => new Set( prev ).add( localSite.id ) );
+ setUpdatingSites( ( prev ) => new Set( prev ).add( snapshot.atomicSiteId ) );
let archivePath = '';
try {
@@ -45,7 +47,9 @@ export const DemoSiteUpdateProvider: React.FC< DemoSiteUpdateProviderProps > = (
if ( archiveSizeInBytes > DEMO_SITE_SIZE_LIMIT_BYTES ) {
getIpcApi().showErrorMessageBox( {
- title: __( 'Updating demo site failed' ),
+ title: quickDeploysEnabled
+ ? __( 'Updating preview site failed' )
+ : __( 'Updating demo site failed' ),
message: sprintf(
__(
'The site exceeds the maximum size of %dGB. Please remove some files and try again.'
@@ -56,7 +60,7 @@ export const DemoSiteUpdateProvider: React.FC< DemoSiteUpdateProviderProps > = (
setUpdatingSites( ( prev ) => {
const newSet = new Set( prev );
- newSet.delete( localSite.id );
+ newSet.delete( snapshot.atomicSiteId );
return newSet;
} );
getIpcApi().removeTemporalFile( archivePath );
@@ -92,35 +96,44 @@ export const DemoSiteUpdateProvider: React.FC< DemoSiteUpdateProviderProps > = (
} );
await getIpcApi().showNotification( {
title: __( 'Update Successful' ),
- body: sprintf( __( "Demo site for '%s' has been updated." ), localSite.name ),
+ body: quickDeploysEnabled
+ ? sprintf( __( "Preview site for '%s' has been updated." ), localSite.name )
+ : sprintf( __( "Demo site for '%s' has been updated." ), localSite.name ),
} );
return response;
} catch ( error ) {
getIpcApi().showErrorMessageBox( {
title: __( 'Update failed' ),
- message: sprintf(
- __( "We couldn't update the %s demo site. Please try again." ),
- localSite.name
- ),
+ message: quickDeploysEnabled
+ ? sprintf(
+ __( "We couldn't update the %s preview site. Please try again." ),
+ localSite.name
+ )
+ : sprintf(
+ __( "We couldn't update the %s demo site. Please try again." ),
+ localSite.name
+ ),
error,
} );
Sentry.captureException( error );
} finally {
setUpdatingSites( ( prev ) => {
- const newSet = new Set( prev );
- newSet.delete( localSite.id );
- return newSet;
+ const next = new Set( prev );
+ next.delete( snapshot.atomicSiteId );
+ return next;
} );
if ( archivePath ) {
getIpcApi().removeTemporalFile( archivePath );
}
}
},
- [ __, client, updateSnapshot ]
+ [ __, client, updateSnapshot, quickDeploysEnabled ]
);
const isDemoSiteUpdating = useCallback(
- ( siteId: string ) => updatingSites.has( siteId ),
+ ( atomicSiteId: number ) => {
+ return updatingSites.has( atomicSiteId );
+ },
[ updatingSites ]
);
diff --git a/src/modules/preview-site/components/create-preview-button.tsx b/src/modules/preview-site/components/create-preview-button.tsx
new file mode 100644
index 000000000..b27174a5c
--- /dev/null
+++ b/src/modules/preview-site/components/create-preview-button.tsx
@@ -0,0 +1,87 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { useI18n } from '@wordpress/react-i18n';
+import Button from 'src/components/button';
+import offlineIcon from 'src/components/offline-icon';
+import { Tooltip } from 'src/components/tooltip';
+import { DEMO_SITE_SIZE_LIMIT_GB } from 'src/constants';
+import { useArchiveErrorMessages } from 'src/hooks/use-archive-error-messages';
+import { useArchiveSite } from 'src/hooks/use-archive-site';
+import { useOffline } from 'src/hooks/use-offline';
+import { useSiteSize } from 'src/hooks/use-site-size';
+import { useSnapshots } from 'src/hooks/use-snapshots';
+
+interface CreatePreviewButtonProps {
+ onClick: () => void;
+ selectedSite: SiteDetails;
+}
+
+export function CreatePreviewButton( { onClick, selectedSite }: CreatePreviewButtonProps ) {
+ const { __, _n } = useI18n();
+ const { isAnySiteArchiving } = useArchiveSite();
+ const { activeSnapshotCount, snapshotQuota, isLoadingSnapshotUsage, snapshotCreationBlocked } =
+ useSnapshots();
+ const isLimitUsed = activeSnapshotCount >= snapshotQuota;
+ const { isOverLimit } = useSiteSize( selectedSite.id );
+ const isOffline = useOffline();
+ const errorMessages = useArchiveErrorMessages();
+
+ const isDisabled =
+ isAnySiteArchiving ||
+ isLoadingSnapshotUsage ||
+ isLimitUsed ||
+ isOffline ||
+ snapshotCreationBlocked;
+
+ const siteArchivingMessage = __(
+ 'A different preview site is being created. Please wait for it to finish before creating another.'
+ );
+ const allotmentConsumptionMessage = sprintf(
+ _n(
+ "You've used %s preview sites available on your account.",
+ "You've used all %s preview sites available on your account.",
+ snapshotQuota
+ ),
+ snapshotQuota
+ );
+ const offlineMessage = __( 'Creating a preview site requires an internet connection.' );
+ const overLimitMessage = sprintf(
+ __(
+ 'Your site exceeds %s GB in size. Creating a preview site for a larger site may take considerable amount of time and could exceed the maximum allowed size for a preview site.'
+ ),
+ DEMO_SITE_SIZE_LIMIT_GB
+ );
+
+ let tooltipContent;
+ if ( isOffline ) {
+ tooltipContent = {
+ icon: offlineIcon,
+ text: offlineMessage,
+ };
+ } else if ( isLimitUsed ) {
+ tooltipContent = { text: allotmentConsumptionMessage };
+ } else if ( isAnySiteArchiving ) {
+ tooltipContent = { text: siteArchivingMessage };
+ } else if ( snapshotCreationBlocked ) {
+ tooltipContent = { text: errorMessages.rest_site_creation_blocked };
+ } else if ( isOverLimit ) {
+ tooltipContent = { text: overLimitMessage };
+ }
+
+ return (
+
+ {
+ if ( isDisabled ) {
+ return;
+ }
+ onClick();
+ } }
+ >
+ { __( 'Create preview site' ) }
+
+
+ );
+}
diff --git a/src/modules/preview-site/components/preview-action-buttons-menu.tsx b/src/modules/preview-site/components/preview-action-buttons-menu.tsx
new file mode 100644
index 000000000..356669432
--- /dev/null
+++ b/src/modules/preview-site/components/preview-action-buttons-menu.tsx
@@ -0,0 +1,84 @@
+import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { moreVertical } from '@wordpress/icons';
+import { useI18n } from '@wordpress/react-i18n';
+import { useConfirmationDialog } from 'src/hooks/use-confirmation-dialog';
+import { useSnapshots } from 'src/hooks/use-snapshots';
+import { useUpdateDemoSite } from 'src/hooks/use-update-demo-site';
+
+interface PreviewActionButtonsMenuProps {
+ snapshot: Snapshot;
+ selectedSite: SiteDetails;
+}
+
+export function PreviewActionButtonsMenu( {
+ snapshot,
+ selectedSite,
+}: PreviewActionButtonsMenuProps ) {
+ const { __ } = useI18n();
+ const { deleteSnapshot } = useSnapshots();
+ const { updateDemoSite } = useUpdateDemoSite();
+
+ const showUpdatePreviewConfirmation = useConfirmationDialog( {
+ localStorageKey: 'dontShowUpdateWarning',
+ message: __( 'Overwrite preview' ),
+ detail: __(
+ "Updating will replace the existing files and database with a copy from your local site. Any changes you've made to your preview site will be permanently lost."
+ ),
+ confirmButtonLabel: __( 'Update' ),
+ } );
+
+ const handleUpdatePreviewSite = async () => {
+ showUpdatePreviewConfirmation( () => {
+ updateDemoSite( snapshot, selectedSite );
+ } );
+ };
+
+ const showDeletePreviewConfirmation = useConfirmationDialog( {
+ type: 'warning',
+ message: __( 'Delete preview' ),
+ detail: __(
+ 'Your previews files and database along with all posts, pages, comments and media will be lost.'
+ ),
+ confirmButtonLabel: __( 'Delete' ),
+ } );
+
+ const handleDeletePreviewSite = async () => {
+ showDeletePreviewConfirmation( () => {
+ deleteSnapshot( snapshot );
+ } );
+ };
+
+ return (
+
+ { ( { onClose }: { onClose: () => void } ) => (
+
+
+ { __( 'Rename' ) }
+
+ {
+ handleUpdatePreviewSite();
+ onClose();
+ } }
+ >
+ { __( 'Update' ) }
+
+ {
+ handleDeletePreviewSite();
+ onClose();
+ } }
+ >
+ { __( 'Delete' ) }
+
+
+ ) }
+
+ );
+}
diff --git a/src/modules/preview-site/components/preview-site-row.tsx b/src/modules/preview-site/components/preview-site-row.tsx
new file mode 100644
index 000000000..70c2c4bec
--- /dev/null
+++ b/src/modules/preview-site/components/preview-site-row.tsx
@@ -0,0 +1,86 @@
+import { Spinner } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+import { useI18n } from '@wordpress/react-i18n';
+import { useEffect } from 'react';
+import { ArrowIcon } from 'src/components/arrow-icon';
+import Button from 'src/components/button';
+import { useExpirationDate } from 'src/hooks/use-expiration-date';
+import { useFormatLocalizedTimestamps } from 'src/hooks/use-format-localized-timestamps';
+import { useSnapshots } from 'src/hooks/use-snapshots';
+import { useUpdateDemoSite } from 'src/hooks/use-update-demo-site';
+import { getIpcApi } from 'src/lib/get-ipc-api';
+import { PreviewActionButtonsMenu } from './preview-action-buttons-menu';
+import { ProgressRow } from './progress-row';
+
+interface PreviewSiteRowProps {
+ snapshot: Snapshot;
+ selectedSite: SiteDetails;
+}
+
+export function PreviewSiteRow( { snapshot, selectedSite }: PreviewSiteRowProps ) {
+ const { __ } = useI18n();
+ const { url, date, isDeleting } = snapshot;
+ const { countDown } = useExpirationDate( date );
+ const { fetchSnapshotUsage } = useSnapshots();
+ const { isDemoSiteUpdating } = useUpdateDemoSite();
+ const isPreviewSiteUpdating = isDemoSiteUpdating( snapshot.atomicSiteId );
+ const { formatRelativeTime } = useFormatLocalizedTimestamps();
+
+ const getLastUpdateTimeText = () => {
+ if ( ! date ) {
+ return '-';
+ }
+ const timeDistance = formatRelativeTime( new Date( date ).toISOString() );
+ return sprintf( __( '%s ago' ), timeDistance );
+ };
+
+ useEffect( () => {
+ fetchSnapshotUsage();
+ }, [ fetchSnapshotUsage ] );
+
+ const urlWithHTTPS = `https://${ url }`;
+
+ if ( isDeleting ) {
+ return
;
+ }
+
+ return (
+
+
+
+
+
+ { selectedSite.name }
+
+
+
{
+ getIpcApi().openURL( urlWithHTTPS );
+ } }
+ >
+ { urlWithHTTPS }
+
+
+
+
+
+ { isPreviewSiteUpdating ? (
+
+
+ { __( 'Updating' ) }
+
+ ) : (
+ getLastUpdateTimeText()
+ ) }
+
+
{ countDown }
+
+
+
+
+ );
+}
diff --git a/src/modules/preview-site/components/preview-sites-table-header.tsx b/src/modules/preview-site/components/preview-sites-table-header.tsx
new file mode 100644
index 000000000..3ba8efca7
--- /dev/null
+++ b/src/modules/preview-site/components/preview-sites-table-header.tsx
@@ -0,0 +1,18 @@
+import { __ } from '@wordpress/i18n';
+import { useI18n } from '@wordpress/react-i18n';
+
+export function PreviewSitesTableHeader() {
+ const { __ } = useI18n();
+ return (
+
+
+
{ __( 'Preview site' ) }
+
+
{ __( 'Updated' ) }
+
{ __( 'Expires' ) }
+
{ __( 'Actions' ) }
+
+
+
+ );
+}
diff --git a/src/modules/preview-site/components/progress-row.tsx b/src/modules/preview-site/components/progress-row.tsx
new file mode 100644
index 000000000..d36f02bde
--- /dev/null
+++ b/src/modules/preview-site/components/progress-row.tsx
@@ -0,0 +1,34 @@
+import { __ } from '@wordpress/i18n';
+import ProgressBar from 'src/components/progress-bar';
+import { useProgressTimer } from 'src/hooks/use-progress-timer';
+
+interface ProgressRowProps {
+ text: string;
+}
+
+export function ProgressRow( { text }: ProgressRowProps ) {
+ const { progress } = useProgressTimer( {
+ initialProgress: 20,
+ paused: false,
+ interval: 300,
+ maxValue: 95,
+ } );
+
+ return (
+
+ );
+}