From 5542c97484ae39b6412b1d63d7259574b1d0f506 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Fri, 13 Sep 2024 19:20:33 +0100 Subject: [PATCH] Make app responsive and toggle sidebar (#533) * Create a new top bar * Move Authentication buttons to that top bar * Move offline indicator to the TopBar * Add a new button to toggle the sidebar * Fix tests (I'll move the topbar tests to its own file) * Both states have size memory --- src/components/app.tsx | 28 ++++- src/components/buttons-section.tsx | 2 +- src/components/content-tab-import-export.tsx | 2 +- src/components/gravatar.tsx | 12 +- src/components/main-sidebar.tsx | 113 +---------------- src/components/tests/main-sidebar.test.tsx | 59 +-------- src/components/tests/topbar.test.tsx | 95 ++++++++++++++ src/components/top-bar.tsx | 123 +++++++++++++++++++ src/components/user-settings.tsx | 2 +- src/components/windows-titlebar.tsx | 9 +- src/constants.ts | 1 + src/ipc-handlers.ts | 24 +++- src/preload.ts | 2 + 13 files changed, 288 insertions(+), 184 deletions(-) create mode 100644 src/components/tests/topbar.test.tsx create mode 100644 src/components/top-bar.tsx diff --git a/src/components/app.tsx b/src/components/app.tsx index ad9c956b..f2c576f9 100644 --- a/src/components/app.tsx +++ b/src/components/app.tsx @@ -2,19 +2,27 @@ import { __experimentalVStack as VStack, __experimentalHStack as HStack, } from '@wordpress/components'; +import { useState } from 'react'; import { useLocalizationSupport } from '../hooks/use-localization-support'; import { useOnboarding } from '../hooks/use-onboarding'; import { isWindows } from '../lib/app-globals'; import { cx } from '../lib/cx'; +import { getIpcApi } from '../lib/get-ipc-api'; import MainSidebar from './main-sidebar'; import Onboarding from './onboarding'; import { SiteContentTabs } from './site-content-tabs'; +import TopBar from './top-bar'; import UserSettings from './user-settings'; import WindowsTitlebar from './windows-titlebar'; export default function App() { useLocalizationSupport(); const { needsOnboarding } = useOnboarding(); + const [ isSidebarVisible, setIsSidebarVisible ] = useState( true ); + const toggleSidebar = () => { + getIpcApi().toggleMinWindowWidth( isSidebarVisible ); + setIsSidebarVisible( ! isSidebarVisible ); + }; return ( <> @@ -35,12 +43,26 @@ export default function App() { ) } spacing="0" > - { isWindows() && } + { isWindows() ? ( + + + + ) : ( +
+ +
+ ) } + - +
diff --git a/src/components/buttons-section.tsx b/src/components/buttons-section.tsx index 8fb6e801..a83173c4 100644 --- a/src/components/buttons-section.tsx +++ b/src/components/buttons-section.tsx @@ -17,7 +17,7 @@ export function ButtonsSection( { buttonsArray, title, className = '' }: Buttons return (

{ title }

-
+
{ buttonsArray.map( ( button, index ) => ( - -
  • - -
  • - - - ); - } - - return ( - - - - ); -} - -function SidebarToolbar() { - const isOffline = useOffline(); - const offlineMessage = [ - __( 'You’re currently offline.' ), - __( 'Some features will be unavailable.' ), - ]; - return ( -
    - { isOffline && ( - - { offlineMessage[ 0 ] } -
    - { offlineMessage[ 1 ] } - - } - > - -
    - ) } -
    - ); -} - export default function MainSidebar( { className }: MainSidebarProps ) { return (
    -
    - -
    - -
    +
    diff --git a/src/components/tests/main-sidebar.test.tsx b/src/components/tests/main-sidebar.test.tsx index 52f29ab7..c235fc58 100644 --- a/src/components/tests/main-sidebar.test.tsx +++ b/src/components/tests/main-sidebar.test.tsx @@ -1,7 +1,6 @@ -import { fireEvent, render, act, waitFor, screen } from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { useAuth } from '../../hooks/use-auth'; -import { useOffline } from '../../hooks/use-offline'; import MainSidebar from '../main-sidebar'; jest.mock( '../../hooks/use-auth' ); @@ -66,23 +65,6 @@ describe( 'MainSidebar Footer', () => { expect( screen.getByRole( 'button', { name: 'Add site' } ) ).toBeVisible(); } ); - it( 'Test unauthenticated footer has the Log in button', async () => { - const user = userEvent.setup(); - const authenticate = jest.fn(); - ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: false, authenticate } ); - await act( async () => render( ) ); - expect( screen.getByRole( 'button', { name: 'Log in' } ) ).toBeVisible(); - await user.click( screen.getByRole( 'button', { name: 'Log in' } ) ); - expect( authenticate ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'Test authenticated footer does not have the log in button and it has the settings and account buttons', async () => { - ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: true } ); - await act( async () => render( ) ); - expect( screen.queryByRole( 'button', { name: 'Log in' } ) ).not.toBeInTheDocument(); - expect( screen.getByRole( 'button', { name: 'Account' } ) ).toBeVisible(); - } ); - it( 'applies className prop', async () => { const { container } = await act( async () => render( ) @@ -94,30 +76,6 @@ describe( 'MainSidebar Footer', () => { await act( async () => render( ) ); expect( screen.getByRole( 'button', { name: 'Stop all' } ) ).toBeVisible(); } ); - - it( 'disables log in button when offline', async () => { - ( useOffline as jest.Mock ).mockReturnValue( true ); - ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: false } ); - await act( async () => render( ) ); - const loginButton = screen.getByRole( 'button', { name: 'Log in' } ); - expect( loginButton ).toHaveAttribute( 'aria-disabled', 'true' ); - fireEvent.mouseOver( loginButton ); - expect( screen.getByRole( 'tooltip', { name: 'You’re currently offline.' } ) ).toBeVisible(); - } ); - - it( 'shows offline indicator', async () => { - ( useOffline as jest.Mock ).mockReturnValue( true ); - await act( async () => render( ) ); - const offlineIndicator = screen.getByRole( 'button', { - name: 'Offline indicator', - } ); - fireEvent.mouseOver( offlineIndicator ); - expect( - screen.getByRole( 'tooltip', { - name: 'You’re currently offline. Some features will be unavailable.', - } ) - ).toBeVisible(); - } ); } ); describe( 'MainSidebar Site Menu', () => { @@ -155,19 +113,4 @@ describe( 'MainSidebar Site Menu', () => { '0e9e237b-335a-43fa-b439-9b078a613333' ); } ); - - it( 'opens the support URL', async () => { - const user = userEvent.setup(); - ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: true } ); - - render( ); - - const helpIconButton = screen.getByRole( 'button', { name: 'Help' } ); - await user.click( helpIconButton ); - await waitFor( () => - expect( mockOpenURL ).toHaveBeenCalledWith( - `https://developer.wordpress.com/docs/developer-tools/studio/` - ) - ); - } ); } ); diff --git a/src/components/tests/topbar.test.tsx b/src/components/tests/topbar.test.tsx new file mode 100644 index 00000000..c0e5f368 --- /dev/null +++ b/src/components/tests/topbar.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, act, waitFor, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { useAuth } from '../../hooks/use-auth'; +import { useOffline } from '../../hooks/use-offline'; +import TopBar from '../top-bar'; + +jest.mock( '../../hooks/use-auth' ); + +const mockOpenURL = jest.fn(); +const toggleMinWindowWidth = jest.fn(); +jest.mock( '../../lib/get-ipc-api', () => ( { + __esModule: true, + default: jest.fn(), + getIpcApi: () => ( { + showOpenFolderDialog: jest.fn(), + generateProposedSitePath: jest.fn(), + openURL: mockOpenURL, + toggleMinWindowWidth, + } ), +} ) ); + +describe( 'TopBar', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + it( 'Test unauthenticated TopBar has the Log in button', async () => { + const user = userEvent.setup(); + const authenticate = jest.fn(); + ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: false, authenticate } ); + await act( async () => render( ) ); + expect( screen.getByRole( 'button', { name: 'Log in' } ) ).toBeVisible(); + await user.click( screen.getByRole( 'button', { name: 'Log in' } ) ); + expect( authenticate ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'Test authenticated TopBar does not have the log in button and it has the settings and account buttons', async () => { + ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: true } ); + await act( async () => render( ) ); + expect( screen.queryByRole( 'button', { name: 'Log in' } ) ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'Account' } ) ).toBeVisible(); + } ); + it( 'disables log in button when offline', async () => { + ( useOffline as jest.Mock ).mockReturnValue( true ); + ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: false } ); + await act( async () => render( ) ); + const loginButton = screen.getByRole( 'button', { name: 'Log in' } ); + expect( loginButton ).toHaveAttribute( 'aria-disabled', 'true' ); + fireEvent.mouseOver( loginButton ); + expect( screen.getByRole( 'tooltip', { name: 'You’re currently offline.' } ) ).toBeVisible(); + } ); + + it( 'shows offline indicator', async () => { + ( useOffline as jest.Mock ).mockReturnValue( true ); + await act( async () => render( ) ); + const offlineIndicator = screen.getByRole( 'button', { + name: 'Offline indicator', + } ); + fireEvent.mouseOver( offlineIndicator ); + expect( + screen.getByRole( 'tooltip', { + name: 'You’re currently offline. Some features will be unavailable.', + } ) + ).toBeVisible(); + } ); + + it( 'opens the support URL', async () => { + const user = userEvent.setup(); + ( useAuth as jest.Mock ).mockReturnValue( { isAuthenticated: true } ); + + render( ); + + const helpIconButton = screen.getByRole( 'button', { name: 'Help' } ); + await user.click( helpIconButton ); + await waitFor( () => + expect( mockOpenURL ).toHaveBeenCalledWith( + `https://developer.wordpress.com/docs/developer-tools/studio/` + ) + ); + } ); + + it( 'calls toggleMinWindowWidth when sidebar toggle button is clicked', async () => { + const user = userEvent.setup(); + const onToggleSidebar = jest.fn().mockImplementation( () => { + toggleMinWindowWidth( true ); + } ); + + render( ); + + const toggleButton = screen.getByRole( 'button', { name: 'Toggle Sidebar' } ); + await user.click( toggleButton ); + + expect( onToggleSidebar ).toHaveBeenCalledTimes( 1 ); + expect( toggleMinWindowWidth ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/src/components/top-bar.tsx b/src/components/top-bar.tsx new file mode 100644 index 00000000..cd156dbd --- /dev/null +++ b/src/components/top-bar.tsx @@ -0,0 +1,123 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, help, drawerLeft } from '@wordpress/icons'; +import { STUDIO_DOCS_URL } from '../constants'; +import { useAuth } from '../hooks/use-auth'; +import { useOffline } from '../hooks/use-offline'; +import { getIpcApi } from '../lib/get-ipc-api'; +import Button from './button'; +import { Gravatar } from './gravatar'; +import offlineIcon from './offline-icon'; +import Tooltip from './tooltip'; +import { WordPressLogo } from './wordpress-logo'; + +interface TopBarProps { + onToggleSidebar: () => void; +} + +function OfflineIndicator() { + const isOffline = useOffline(); + const offlineMessage = [ + __( 'You’re currently offline.' ), + __( 'Some features will be unavailable.' ), + ]; + return ( + isOffline && ( +
    + + { offlineMessage[ 0 ] } +
    + { offlineMessage[ 1 ] } + + } + className="h-6" + > + +
    +
    + ) + ); +} + +function Authentication() { + const { isAuthenticated, authenticate, user } = useAuth(); + const isOffline = useOffline(); + const offlineMessage = __( 'You’re currently offline.' ); + if ( isAuthenticated ) { + return ( + + ); + } + + return ( + + + + ); +} + +export default function TopBar( { onToggleSidebar }: TopBarProps ) { + const openDocs = async () => { + await getIpcApi().openURL( STUDIO_DOCS_URL ); + }; + + return ( +
    +
    + + + +
    + +
    + + +
    +
    + ); +} diff --git a/src/components/user-settings.tsx b/src/components/user-settings.tsx index ccc560d3..48e42f15 100644 --- a/src/components/user-settings.tsx +++ b/src/components/user-settings.tsx @@ -37,7 +37,7 @@ const UserInfo = ( { aria-label={ __( 'Profile link' ) } className="py-0 px-0" > - +
    { user?.displayName } diff --git a/src/components/windows-titlebar.tsx b/src/components/windows-titlebar.tsx index 5f936643..42aa47d5 100644 --- a/src/components/windows-titlebar.tsx +++ b/src/components/windows-titlebar.tsx @@ -6,7 +6,13 @@ import { cx } from '../lib/cx'; import { getIpcApi } from '../lib/get-ipc-api'; import Button from './button'; -export default function WindowsTitlebar( { className }: { className?: string } ) { +export default function WindowsTitlebar( { + className, + children, +}: { + className?: string; + children?: React.ReactNode; +} ) { return (
    +
    { children }
    ); } diff --git a/src/constants.ts b/src/constants.ts index 204fb152..decacb39 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,6 @@ export const MAIN_MIN_WIDTH = 900; export const MAIN_MIN_HEIGHT = 600; +export const SIDEBAR_WIDTH = 268; export const SCREENSHOT_WIDTH = 1040; export const SCREENSHOT_HEIGHT = 1248; export const LIMIT_OF_ZIP_SITES_PER_USER = 5; diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 4f95c219..e9ce5d4f 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -16,7 +16,7 @@ import * as Sentry from '@sentry/electron/main'; import { LocaleData, defaultI18n } from '@wordpress/i18n'; import archiver from 'archiver'; import { DEFAULT_PHP_VERSION } from '../vendor/wp-now/src/constants'; -import { SIZE_LIMIT_BYTES } from './constants'; +import { MAIN_MIN_HEIGHT, MAIN_MIN_WIDTH, SIDEBAR_WIDTH, SIZE_LIMIT_BYTES } from './constants'; import { isEmptyDir, pathExists, isWordPressDirectory, sanitizeFolderName } from './lib/fs-utils'; import { getImageData } from './lib/get-image-data'; import { exportBackup } from './lib/import-export/export/export-manager'; @@ -726,3 +726,25 @@ export function setDefaultLocaleData( _event: IpcMainInvokeEvent, locale?: Local export function resetDefaultLocaleData( _event: IpcMainInvokeEvent ) { defaultI18n.resetLocaleData(); } + +const previousWidths = { + sidebar: MAIN_MIN_WIDTH, + noSidebar: MAIN_MIN_WIDTH - SIDEBAR_WIDTH, +}; +export function toggleMinWindowWidth( event: IpcMainInvokeEvent, isSidebarVisible: boolean ) { + const parentWindow = BrowserWindow.fromWebContents( event.sender ); + if ( ! parentWindow || parentWindow.isDestroyed() || event.sender.isDestroyed() ) { + return; + } + const [ currentWidth, currentHeight ] = parentWindow.getSize(); + if ( isSidebarVisible ) { + previousWidths.sidebar = currentWidth; + } else { + previousWidths.noSidebar = currentWidth; + } + const padding = 20; + const newMinWidth = isSidebarVisible ? MAIN_MIN_WIDTH - SIDEBAR_WIDTH + padding : MAIN_MIN_WIDTH; + const newWidth = isSidebarVisible ? previousWidths.noSidebar : previousWidths.sidebar; + parentWindow.setMinimumSize( newMinWidth, MAIN_MIN_HEIGHT ); + parentWindow.setSize( newWidth, currentHeight, true ); +} diff --git a/src/preload.ts b/src/preload.ts index 850b2b1d..dd1bc6dd 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -70,6 +70,8 @@ const api: IpcApi = { setDefaultLocaleData: ( locale?: LocaleData ) => ipcRenderer.invoke( 'setDefaultLocaleData', locale ), resetDefaultLocaleData: () => ipcRenderer.invoke( 'resetDefaultLocaleData' ), + toggleMinWindowWidth: ( isSidebarVisible: boolean ) => + ipcRenderer.invoke( 'toggleMinWindowWidth', isSidebarVisible ), }; contextBridge.exposeInMainWorld( 'ipcApi', api );