Skip to content

Commit

Permalink
Studio: Create new Previews tab and apply design changes (#846)
Browse files Browse the repository at this point in the history
* Create new tab

* Add no snapshots screen and copy

* Add logic for creating a preview link and button

* Add header on the top

* Add icon by Uploads

* Refactor columns structure

* Add action items to the menu

* Ensure site updates work

* Implement loading state for updating

* Center items horizontally

* Extract Create preview link message

* Add create link button at the bottom of the connect sites

* Ensure that we display all snapshots per site

* Fix snapshots tab

* Add initial loading state for the first snapshot

* Add loading row

* Cleanup

* Handle deleting progress

* Cleanup unused imports

* Minor design cleanup

* Add small design fixes

* Stick Create preview link button at the bottom

* Fix progress bar

* Fix minor styling

* Adjust padding

* Create separate file for create button

* Fix create button positions

* Move preview buttons

* Architecture changes

* Cleanup

* Split files

* Remove unused variable

* Fix unit tests

* Fix onClick handler

* Ensure we do not prematurely expose the site

* Improve progress bar and remove an extra prop

* Remove icon

* Rename component

* Fix filename

* Update preview links to preview sites

* Use confirmation dialog hook

* Refactor confirmation dialog jook to use delete site

* Fix notifications

* Fix logic with loading snapshot

* Remove unused prop

* Change wording for generating a preview

* Use constant for limit of sites per user

* Fix feature flag that was removed

* Rename header

* Improve naming of preview site updating

* Remove status text prop and default to -

* Improve conditions

* Speed up the progress bar

---------

Co-authored-by: Kateryna Kodonenko <[email protected]>
  • Loading branch information
katinthehatsite and Kateryna Kodonenko authored Jan 30, 2025
1 parent 4dce063 commit 43ccb3a
Show file tree
Hide file tree
Showing 12 changed files with 592 additions and 34 deletions.
208 changes: 208 additions & 0 deletions src/components/content-tab-previews.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-8 flex justify-between max-w-3xl gap-4">
<div className="flex flex-col">
<div className="a8c-subtitle mb-1">{ __( 'Share a preview of your Studio site' ) }</div>
<div className="w-[40ch] text-a8c-gray-70 a8c-body">
{ __(
'Get feedback from anyone, anywhere with a free hosted preview of your Studio site.'
) }
</div>
<div className="mt-6">
{ [
__( `Create up to ${ LIMIT_OF_ZIP_SITES_PER_USER } preview sites for free.` ),
__( 'Preview sites expire 7 days after the last update.' ),
createInterpolateElement( __( 'Powered by <a> WordPress.com</a>.' ), {
a: (
<Button
variant="link"
className="whitespace-pre"
onClick={ () =>
getIpcApi().openURL(
'https://wordpress.com/?utm_source=studio&utm_medium=referral&utm_campaign=demo_sites_onboarding'
)
}
/>
),
} ),
].map( ( text ) => (
<div
key={ typeof text === 'string' ? text : 'wordpress-com' }
className="text-a8c-gray-70 a8c-body flex items-center"
>
<Icon className="fill-a8c-blueberry ltr:mr-2 rtl:ml-2 shrink-0" icon={ check } />
{ text }
</div>
) ) }
</div>
{ children }
</div>
<div className="flex flex-col shrink-0 items-end">
<ScreenshotDemoSite site={ selectedSite } />
</div>
</div>
);
}

function NoAuth( { selectedSite }: React.ComponentProps< typeof EmptyGeneric > ) {
const isOffline = useOffline();
const { __ } = useI18n();
const { authenticate } = useAuth();
const offlineMessage = __( "You're currently offline." );

return (
<EmptyGeneric selectedSite={ selectedSite }>
<div className="mt-8">
<Tooltip disabled={ ! isOffline } icon={ offlineIcon } text={ offlineMessage }>
<Button
aria-description={ isOffline ? offlineMessage : '' }
aria-disabled={ isOffline }
variant="primary"
onClick={ () => {
if ( isOffline ) {
return;
}
authenticate();
} }
>
{ __( 'Log in to WordPress.com' ) }
<Icon className="ltr:ml-1 rtl:mr-1 rtl:scale-x-[-1]" icon={ external } size={ 21 } />
</Button>
</Tooltip>
</div>
<div className="mt-3 w-[40ch] text-a8c-gray-70 a8c-body">
<Tooltip
disabled={ ! isOffline }
icon={ offlineIcon }
text={ offlineMessage }
placement="bottom-start"
>
{ createInterpolateElement(
__(
'A WordPress.com account is required to create preview sites. <a>Create a free account</a>'
),
{
a: (
<Button
aria-description={ isOffline ? offlineMessage : '' }
aria-disabled={ isOffline }
className="!p-0 text-a8c-blueberry hover:opacity-80 h-auto"
onClick={ () => {
if ( isOffline ) {
return;
}
const baseURL = 'https://wordpress.com/log-in/link';
const authURL = encodeURIComponent(
`${ WP_AUTHORIZE_ENDPOINT }?response_type=token&client_id=${ CLIENT_ID }&redirect_uri=${ PROTOCOL_PREFIX }%3A%2F%2Fauth&scope=${ SCOPES }&from-calypso=1`
);
const finalURL = `${ baseURL }?redirect_to=${ authURL }&client_id=${ CLIENT_ID }`;
getIpcApi().openURL( finalURL );
} }
/>
),
}
) }
</Tooltip>
</div>
</EmptyGeneric>
);
}

function NoPreviews( { selectedSite }: React.ComponentProps< typeof EmptyGeneric > ) {
const { archiveSite } = useArchiveSite();

return (
<EmptyGeneric selectedSite={ selectedSite }>
<div className="mt-8">
<CreatePreviewButton
onClick={ () => archiveSite( selectedSite.id ) }
selectedSite={ selectedSite }
/>
</div>
</EmptyGeneric>
);
}

export function ContentTabPreviews( { selectedSite }: ContentTabPreviewsProps ) {
const { __ } = useI18n();
const { snapshots } = useSnapshots();
const { isAuthenticated } = useAuth();
const { archiveSite, isUploadingSiteId } = useArchiveSite();
const isUploading = isUploadingSiteId( selectedSite.id );
const snapshotsOnSite = snapshots.filter(
( snapshot ) => snapshot.localSiteId === selectedSite.id
);
const isSnapshotLoading = snapshotsOnSite.some( ( snapshot ) => snapshot.isLoading );

if ( ! isAuthenticated ) {
return <NoAuth selectedSite={ selectedSite } />;
}

if ( ! snapshotsOnSite.length && ! isUploading && ! isSnapshotLoading ) {
return <NoPreviews selectedSite={ selectedSite } />;
}

return (
<div className="relative min-h-full flex flex-col">
<div className="w-full flex flex-col flex-1">
<div className="flex-1">
<PreviewSitesTableHeader />
<div className="[&>*:not(:last-child)]:border-b [&>*]:border-a8c-gray-5">
{ ( isUploading || isSnapshotLoading ) && (
<ProgressRow text={ __( 'Creating preview site' ) } />
) }
{ snapshotsOnSite
.filter( ( snapshot ) => ! snapshot.isLoading )
.map( ( snapshot ) => (
<PreviewSiteRow
snapshot={ snapshot }
selectedSite={ selectedSite }
key={ snapshot.atomicSiteId }
/>
) ) }
</div>
</div>
<div className="sticky bottom-0 bg-white/[0.8] backdrop-blur-sm w-full px-8 py-6 mt-auto">
<CreatePreviewButton
onClick={ () => archiveSite( selectedSite.id ) }
selectedSite={ selectedSite }
/>
</div>
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/content-tab-snapshots.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function SnapshotRow( {
const isUploading = isUploadingSiteId( selectedSite.id );
const { updateDemoSite, isDemoSiteUpdating } = useUpdateDemoSite();
const errorMessages = useArchiveErrorMessages();
const isSiteDemoUpdating = isDemoSiteUpdating( snapshot.localSiteId );
const isSiteDemoUpdating = isDemoSiteUpdating( snapshot.atomicSiteId );
const { formatRelativeTime } = useFormatLocalizedTimestamps();

const { isOverLimit } = useSiteSize( selectedSite.id );
Expand Down
2 changes: 2 additions & 0 deletions src/components/site-content-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WelcomeMessagesProvider } from '../hooks/use-welcome-messages';
import { ContentTabAssistant } from './content-tab-assistant';
import { ContentTabImportExport } from './content-tab-import-export';
import { ContentTabOverview } from './content-tab-overview';
import { ContentTabPreviews } from './content-tab-previews';
import { ContentTabSettings } from './content-tab-settings';
import { ContentTabSnapshots } from './content-tab-snapshots';
import { ContentTabSync } from './content-tab-sync';
Expand Down Expand Up @@ -47,6 +48,7 @@ export function SiteContentTabs() {
<div className="h-full">
{ name === 'overview' && <ContentTabOverview selectedSite={ selectedSite } /> }
{ name === 'share' && <ContentTabSnapshots selectedSite={ selectedSite } /> }
{ name === 'previews' && <ContentTabPreviews selectedSite={ selectedSite } /> }
{ name === 'sync' && <ContentTabSync selectedSite={ selectedSite } /> }
{ name === 'settings' && <ContentTabSettings selectedSite={ selectedSite } /> }
{ name === 'assistant' && (
Expand Down
16 changes: 8 additions & 8 deletions src/hooks/tests/use-update-demo-site.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 );
Expand Down
13 changes: 9 additions & 4 deletions src/hooks/use-confirmation-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -19,27 +20,31 @@ 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;
}

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

0 comments on commit 43ccb3a

Please sign in to comment.