diff --git a/package.json b/package.json index 083fa9a279..f04fb71b7b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts", "rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir .", "reinstall:nautilus-extension": "NODE_ENV=development ts-node src/apps/nautilus-extension/reload.ts", - "lint": "cross-env NODE_ENV=development eslint . --ext .ts,.tsx --max-warnings=220", + "lint": "cross-env NODE_ENV=development eslint . --ext .ts,.tsx --max-warnings=210", "lint:fix": "npm run lint --fix", "format": "prettier src --check", "format:fix": "prettier src --write", diff --git a/src/apps/main/interface.d.ts b/src/apps/main/interface.d.ts index 5e149d92b8..4c7f2e4ba0 100644 --- a/src/apps/main/interface.d.ts +++ b/src/apps/main/interface.d.ts @@ -125,12 +125,15 @@ export interface IElectronAPI { removeInfectedFiles: (infectedFiles: string[]) => Promise; cancelScan: () => Promise; }; + chooseSyncRootWithDialog(): Promise; getBackupErrorByFolder(folderId: number): Promise; getLastBackupHadIssues(): Promise; onBackupFatalErrorsChanged(fn: (backupErrors: Array) => void): () => void; getBackupFatalErrors(): Promise>; onBackupProgress(func: (value: number) => void): () => void; startRemoteSync(): Promise; + getRemoteSyncStatus(): Promise; + onRemoteSyncStatusChange(callback: (status: import('./remote-sync/helpers').RemoteSyncStatus) => void): () => void; pathChanged(path: string): void; isUserLoggedIn(): Promise; diff --git a/src/apps/main/preload.d.ts b/src/apps/main/preload.d.ts index e74f594efb..8a2e61e55c 100644 --- a/src/apps/main/preload.d.ts +++ b/src/apps/main/preload.d.ts @@ -53,10 +53,6 @@ declare interface Window { getUser(): Promise>; - startSyncProcess(): void; - - stopSyncProcess(): void; - openProcessIssuesWindow(): void; openLogs(): void; diff --git a/src/apps/main/preload.js b/src/apps/main/preload.js index 082fb5d34e..6c033b6684 100644 --- a/src/apps/main/preload.js +++ b/src/apps/main/preload.js @@ -47,21 +47,6 @@ contextBridge.exposeInMainWorld('electron', { getUser() { return ipcRenderer.invoke('get-user'); }, - startSyncProcess() { - return ipcRenderer.send('start-sync-process'); - }, - stopSyncProcess() { - return ipcRenderer.send('stop-sync-process'); - }, - getSyncStatus() { - return ipcRenderer.invoke('get-sync-status'); - }, - onSyncStatusChanged(func) { - const eventName = 'sync-status-changed'; - const callback = (_, v) => func(v); - ipcRenderer.on(eventName, callback); - return () => ipcRenderer.removeListener(eventName, callback); - }, onSyncStopped(func) { const eventName = 'sync-stopped'; const callback = (_, v) => func(v); diff --git a/src/apps/renderer/App.tsx b/src/apps/renderer/App.tsx index 3c8bec8bbb..495529abd9 100644 --- a/src/apps/renderer/App.tsx +++ b/src/apps/renderer/App.tsx @@ -3,6 +3,7 @@ import './localize/i18n.service'; import { Suspense, useEffect, useRef } from 'react'; import { HashRouter as Router, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { TranslationProvider } from './context/LocalContext'; +import { SyncProvider } from './context/SyncContext'; import Login from './pages/Login'; import Onboarding from './pages/Onboarding'; import IssuesPage from './pages/Issues/IssuesPage'; @@ -76,17 +77,19 @@ export default function App() { }> - - - - } /> - } /> - } /> - } /> - } /> - - - + + + + + } /> + } /> + } /> + } /> + } /> + + + + diff --git a/src/apps/renderer/context/SyncContext.test.tsx b/src/apps/renderer/context/SyncContext.test.tsx new file mode 100644 index 0000000000..89ef64887d --- /dev/null +++ b/src/apps/renderer/context/SyncContext.test.tsx @@ -0,0 +1,113 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { SyncProvider, useSyncContext } from './SyncContext'; +import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; +import { partialSpyOn } from '../../../../tests/vitest/utils.helper'; + +describe('SyncContext', () => { + void window.electron.getRemoteSyncStatus; + void window.electron.onRemoteSyncStatusChange; + + const getRemoteSyncStatusMock = partialSpyOn(window.electron, 'getRemoteSyncStatus', false); + const onRemoteSyncStatusChangeMock = partialSpyOn(window.electron, 'onRemoteSyncStatusChange', false); + const unsubscribeMock = vi.fn(); + + beforeEach(() => { + onRemoteSyncStatusChangeMock.mockReturnValue(unsubscribeMock); + }); + + function renderSyncHook() { + return renderHook(() => useSyncContext(), { + wrapper: ({ children }) => {children}, + }); + } + + it('should have STANDBY as default status', () => { + getRemoteSyncStatusMock.mockReturnValue(new Promise(() => {})); + + const { result } = renderSyncHook(); + + expect(result.current.syncStatus).toBe('STANDBY'); + }); + + it('should fetch remote sync status on mount', () => { + getRemoteSyncStatusMock.mockReturnValue(new Promise(() => {})); + + renderSyncHook(); + + expect(getRemoteSyncStatusMock).toHaveBeenCalledOnce(); + }); + + it('should subscribe to remote sync status changes on mount', () => { + getRemoteSyncStatusMock.mockReturnValue(new Promise(() => {})); + + renderSyncHook(); + + expect(onRemoteSyncStatusChangeMock).toBeCalledWith(expect.any(Function)); + }); + + it('should unsubscribe from remote sync status changes on unmount', () => { + getRemoteSyncStatusMock.mockReturnValue(new Promise(() => {})); + + const { unmount } = renderSyncHook(); + + unmount(); + + expect(unsubscribeMock).toHaveBeenCalledOnce(); + }); + + it.each([ + ['SYNCING', 'RUNNING'], + ['SYNC_FAILED', 'FAILED'], + ] as [RemoteSyncStatus, string][])( + 'should map remote status %s to %s on initial fetch', + async (remoteStatus, expectedStatus) => { + getRemoteSyncStatusMock.mockResolvedValue(remoteStatus); + + const { result, waitForNextUpdate } = renderSyncHook(); + + await waitForNextUpdate(); + + expect(result.current.syncStatus).toBe(expectedStatus); + }, + ); + + it.each([ + ['IDLE', 'STANDBY'], + ['SYNCED', 'STANDBY'], + ] as [RemoteSyncStatus, string][])( + 'should keep STANDBY when remote status %s resolves (same as default)', + async (remoteStatus, expectedStatus) => { + getRemoteSyncStatusMock.mockResolvedValue(remoteStatus); + + const { result } = renderSyncHook(); + + await vi.waitFor(() => { + expect(getRemoteSyncStatusMock).toHaveBeenCalledOnce(); + }); + + expect(result.current.syncStatus).toBe(expectedStatus); + }, + ); + + it.each([ + ['SYNCING', 'RUNNING'], + ['IDLE', 'STANDBY'], + ['SYNCED', 'STANDBY'], + ['SYNC_FAILED', 'FAILED'], + ] as [RemoteSyncStatus, string][])( + 'should map remote status %s to %s on status change event', + async (remoteStatus, expectedStatus) => { + getRemoteSyncStatusMock.mockReturnValue(new Promise(() => {})); + + const { result } = renderSyncHook(); + + const changeCallback = onRemoteSyncStatusChangeMock.mock.calls[0][0]; + + act(() => { + changeCallback(remoteStatus); + }); + + expect(result.current.syncStatus).toBe(expectedStatus); + }, + ); +}); diff --git a/src/apps/renderer/context/SyncContext.tsx b/src/apps/renderer/context/SyncContext.tsx new file mode 100644 index 0000000000..2d6ffa6bee --- /dev/null +++ b/src/apps/renderer/context/SyncContext.tsx @@ -0,0 +1,38 @@ +import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { SyncStatus } from '../../../context/desktop/sync/domain/SyncStatus'; +import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; + +const statusesMap: Record = { + SYNCING: 'RUNNING', + IDLE: 'STANDBY', + SYNCED: 'STANDBY', + SYNC_FAILED: 'FAILED', +}; + +interface SyncContextValue { + syncStatus: SyncStatus; +} + +const SyncContext = createContext({ syncStatus: 'STANDBY' }); + +export function SyncProvider({ children }: { readonly children: ReactNode }) { + const [syncStatus, setSyncStatus] = useState('STANDBY'); + + const setSyncStatusFromRemote = useCallback((remote: RemoteSyncStatus): void => { + setSyncStatus(statusesMap[remote]); + }, []); + + useEffect(() => { + window.electron.getRemoteSyncStatus().then(setSyncStatusFromRemote); + const removeListener = window.electron.onRemoteSyncStatusChange(setSyncStatusFromRemote); + return removeListener; + }, [setSyncStatusFromRemote]); + + const value = useMemo(() => ({ syncStatus }), [syncStatus]); + + return {children}; +} + +export function useSyncContext(): SyncContextValue { + return useContext(SyncContext); +} diff --git a/src/apps/renderer/hooks/useOnSyncRunning.test.tsx b/src/apps/renderer/hooks/useOnSyncRunning.test.tsx new file mode 100644 index 0000000000..0fae939d0b --- /dev/null +++ b/src/apps/renderer/hooks/useOnSyncRunning.test.tsx @@ -0,0 +1,88 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useOnSyncRunning } from './useOnSyncRunning'; +import { SyncProvider } from '../context/SyncContext'; +import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; +import { partialSpyOn } from '../../../../tests/vitest/utils.helper'; + +describe('useOnSyncRunning', () => { + void window.electron.getRemoteSyncStatus; + void window.electron.onRemoteSyncStatusChange; + + const getRemoteSyncStatusMock = partialSpyOn(window.electron, 'getRemoteSyncStatus', false); + const onRemoteSyncStatusChangeMock = partialSpyOn(window.electron, 'onRemoteSyncStatusChange', false); + + beforeEach(() => { + onRemoteSyncStatusChangeMock.mockReturnValue(vi.fn()); + }); + + function renderOnSyncRunningHook(fn: () => void) { + return renderHook(() => useOnSyncRunning(fn), { + wrapper: ({ children }) => {children}, + }); + } + + it('should call callback when sync status changes to RUNNING', async () => { + getRemoteSyncStatusMock.mockResolvedValue('SYNCING' as RemoteSyncStatus); + const callback = vi.fn(); + + const { waitForNextUpdate } = renderOnSyncRunningHook(callback); + + await waitForNextUpdate(); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should not call callback when sync status is STANDBY', async () => { + getRemoteSyncStatusMock.mockResolvedValue('SYNCED' as RemoteSyncStatus); + const callback = vi.fn(); + + renderOnSyncRunningHook(callback); + + await vi.waitFor(() => { + expect(getRemoteSyncStatusMock).toHaveBeenCalledOnce(); + }); + + expect(callback).not.toBeCalled(); + }); + + it('should not call callback when sync status is FAILED', async () => { + getRemoteSyncStatusMock.mockResolvedValue('SYNC_FAILED' as RemoteSyncStatus); + const callback = vi.fn(); + + const { waitForNextUpdate } = renderOnSyncRunningHook(callback); + + await waitForNextUpdate(); + + expect(callback).not.toBeCalled(); + }); + + it('should call callback when status changes to RUNNING via event', () => { + getRemoteSyncStatusMock.mockReturnValue(new Promise(() => {})); + const callback = vi.fn(); + + renderOnSyncRunningHook(callback); + + const changeCallback = onRemoteSyncStatusChangeMock.mock.calls[0][0]; + + act(() => { + changeCallback('SYNCING' as RemoteSyncStatus); + }); + + expect(callback).toHaveBeenCalledOnce(); + }); + + it('should not call callback when status changes to non-RUNNING via event', () => { + getRemoteSyncStatusMock.mockReturnValue(new Promise(() => {})); + const callback = vi.fn(); + + renderOnSyncRunningHook(callback); + + const changeCallback = onRemoteSyncStatusChangeMock.mock.calls[0][0]; + + act(() => { + changeCallback('SYNCED' as RemoteSyncStatus); + }); + + expect(callback).not.toBeCalled(); + }); +}); diff --git a/src/apps/renderer/hooks/useOnSyncRunning.tsx b/src/apps/renderer/hooks/useOnSyncRunning.tsx index 288d1743bd..dfde001a52 100644 --- a/src/apps/renderer/hooks/useOnSyncRunning.tsx +++ b/src/apps/renderer/hooks/useOnSyncRunning.tsx @@ -1,14 +1,12 @@ -import { SyncStatus } from '../../../context/desktop/sync/domain/SyncStatus'; -import useSyncStatus from './useSyncStatus'; +import { useEffect } from 'react'; +import { useSyncContext } from '../context/SyncContext'; export function useOnSyncRunning(fn: () => void) { - function isRunning(status: SyncStatus) { - return status === 'RUNNING'; - } + const { syncStatus } = useSyncContext(); - useSyncStatus((status) => { - if (!isRunning(status)) return; - - fn(); - }); + useEffect(() => { + if (syncStatus === 'RUNNING') { + fn(); + } + }, [syncStatus]); } diff --git a/src/apps/renderer/hooks/useSyncStatus.tsx b/src/apps/renderer/hooks/useSyncStatus.tsx deleted file mode 100644 index 21e4504939..0000000000 --- a/src/apps/renderer/hooks/useSyncStatus.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from 'react'; -import { SyncStatus } from '../../../context/desktop/sync/domain/SyncStatus'; -import { RemoteSyncStatus } from '../../main/remote-sync/helpers'; - -const statusesMap: Record = { - SYNCING: 'RUNNING', - IDLE: 'STANDBY', - SYNCED: 'STANDBY', - SYNC_FAILED: 'FAILED', -}; - -export default function useSyncStatus(onChange?: (currentState: SyncStatus) => void) { - const [syncStatus, setSyncStatus] = useState('RUNNING'); - - const setSyncStatusFromRemote = (remote: RemoteSyncStatus): void => { - setSyncStatus(statusesMap[remote]); - }; - - useEffect(() => { - window.electron.getRemoteSyncStatus().then(setSyncStatusFromRemote); - - const removeListener = window.electron.onRemoteSyncStatusChange(setSyncStatusFromRemote); - - return removeListener; - }, []); - - useEffect(() => { - if (onChange) onChange(syncStatus); - }, [syncStatus]); - - return { syncStatus }; -} diff --git a/src/apps/renderer/localize/locales/en.json b/src/apps/renderer/localize/locales/en.json index 0903ec4085..83980f1f7b 100644 --- a/src/apps/renderer/localize/locales/en.json +++ b/src/apps/renderer/localize/locales/en.json @@ -127,7 +127,8 @@ "quit": "Quit", "antivirus": "Antivirus", "cleaner": "Cleaner", - "new": "New" + "new": "New", + "sync": "Sync" } }, "body": { diff --git a/src/apps/renderer/localize/locales/es.json b/src/apps/renderer/localize/locales/es.json index cf1ba85b5f..565ee7dbf1 100644 --- a/src/apps/renderer/localize/locales/es.json +++ b/src/apps/renderer/localize/locales/es.json @@ -126,7 +126,8 @@ "logout": "Cerrar sesión", "quit": "Salir", "cleaner": "Cleaner", - "new": "Nuevo" + "new": "Nuevo", + "sync": "Sincronizar" } }, "body": { diff --git a/src/apps/renderer/localize/locales/fr.json b/src/apps/renderer/localize/locales/fr.json index fa90e98939..c4e3fc2dbc 100644 --- a/src/apps/renderer/localize/locales/fr.json +++ b/src/apps/renderer/localize/locales/fr.json @@ -125,7 +125,8 @@ "logout": "Déconnecter", "quit": "Fermer", "cleaner": "Cleaner", - "new": "Nouveau" + "new": "Nouveau", + "sync": "Synchroniser" } }, "body": { diff --git a/src/apps/renderer/pages/Widget/AccountSection.test.tsx b/src/apps/renderer/pages/Widget/AccountSection.test.tsx new file mode 100644 index 0000000000..c7c4aaefa1 --- /dev/null +++ b/src/apps/renderer/pages/Widget/AccountSection.test.tsx @@ -0,0 +1,85 @@ +import { render, screen } from '@testing-library/react'; +import { type Mock } from 'vitest'; +import { useTranslationContext } from '../../context/LocalContext'; +import { useUsage } from '../../context/UsageContext/useUsage'; +import { AccountSection } from './AccountSection'; + +vi.mock('../../context/LocalContext'); +vi.mock('../../context/UsageContext/useUsage'); + +describe('AccountSection', () => { + const getUserMock = vi.mocked(window.electron.getUser); + + beforeEach(() => { + vi.clearAllMocks(); + (useTranslationContext as Mock).mockReturnValue({ translate: (key: string) => key }); + getUserMock.mockResolvedValue(null as any); + }); + + it('renders the account section container', () => { + (useUsage as Mock).mockReturnValue({ status: 'loading', usage: null }); + + render(); + + expect(document.querySelector('[data-automation-id="headerAccountSection"]')).toBeInTheDocument(); + }); + + it('shows user initials when user is loaded', async () => { + (useUsage as Mock).mockReturnValue({ status: 'ready', usage: null }); + getUserMock.mockResolvedValue({ name: 'John', lastname: 'Doe', email: 'john@example.com' } as any); + + render(); + + expect(await screen.findByText('JD')).toBeInTheDocument(); + }); + + it('shows user email when user is loaded', async () => { + (useUsage as Mock).mockReturnValue({ status: 'ready', usage: null }); + getUserMock.mockResolvedValue({ name: 'John', lastname: 'Doe', email: 'john@example.com' } as any); + + render(); + + expect(await screen.findByTitle('john@example.com')).toBeInTheDocument(); + }); + + it('shows "Loading..." when usage status is loading', () => { + (useUsage as Mock).mockReturnValue({ status: 'loading', usage: null }); + + render(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('shows empty usage when status is error', () => { + (useUsage as Mock).mockReturnValue({ status: 'error', usage: null }); + + render(); + + const usageParagraph = document + .querySelector('[data-automation-id="headerAccountSection"]') + ?.querySelector('p:last-child'); + expect(usageParagraph?.textContent).toBe(''); + }); + + it('shows formatted usage when usage data is available', () => { + (useUsage as Mock).mockReturnValue({ + status: 'ready', + usage: { usageInBytes: 1024 * 1024, limitInBytes: 1024 * 1024 * 1024, isInfinite: false }, + }); + + render(); + + expect(screen.getByText(/1MB/)).toBeInTheDocument(); + }); + + it('shows ∞ as limit when plan is infinite', () => { + (useUsage as Mock).mockReturnValue({ + status: 'ready', + usage: { usageInBytes: 1024 * 1024, limitInBytes: 0, isInfinite: true }, + }); + + render(); + + expect(screen.getByText(/∞/)).toBeInTheDocument(); + }); +}); diff --git a/src/apps/renderer/pages/Widget/AccountSection.tsx b/src/apps/renderer/pages/Widget/AccountSection.tsx new file mode 100644 index 0000000000..543232ee62 --- /dev/null +++ b/src/apps/renderer/pages/Widget/AccountSection.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from 'react'; +import bytes from 'bytes'; + +import { User } from '../../../main/types'; +import { useTranslationContext } from '../../context/LocalContext'; +import { useUsage } from '../../context/UsageContext/useUsage'; + +export function AccountSection() { + const { translate } = useTranslationContext(); + const [user, setUser] = useState(null); + + useEffect(() => { + window.electron.getUser().then(setUser); + }, []); + + const { usage, status } = useUsage(); + + let displayUsage: string; + + if (status === 'loading') { + displayUsage = 'Loading...'; + } else if (usage) { + displayUsage = `${bytes.format(usage.usageInBytes)} ${translate( + 'widget.header.usage.of', + )} ${usage.isInfinite ? '∞' : bytes.format(usage.limitInBytes)}`; + } else { + displayUsage = ''; + } + + return ( +
+
+ {`${user?.name.charAt(0) ?? ''}${user?.lastname.charAt(0) ?? ''}`} +
+ +
+

+ {user?.email} +

+

{displayUsage}

+
+
+ ); +} diff --git a/src/apps/renderer/pages/Widget/DropdownItem.test.tsx b/src/apps/renderer/pages/Widget/DropdownItem.test.tsx new file mode 100644 index 0000000000..46c3af37af --- /dev/null +++ b/src/apps/renderer/pages/Widget/DropdownItem.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { DropdownItem } from './DropdownItem'; + +describe('DropdownItem', () => { + it('renders children', () => { + render( + + My option + , + ); + + expect(screen.getByText('My option')).toBeInTheDocument(); + }); + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + + render( + + Click me + , + ); + + fireEvent.click(screen.getByRole('button')); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('calls onClick on key down', () => { + const onClick = vi.fn(); + + render( + + Press me + , + ); + + fireEvent.keyDown(screen.getByRole('button')); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('is disabled when disabled prop is true', () => { + render( + + Disabled + , + ); + + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('applies active styles when active', () => { + render( + + Active + , + ); + + expect(screen.getByRole('button').className).toContain('bg-gray-1'); + }); + + it('does not apply active styles when not active', () => { + render( + + Inactive + , + ); + + expect(screen.getByRole('button').className).not.toContain('bg-gray-1 dark:bg-gray-5'); + }); +}); diff --git a/src/apps/renderer/pages/Widget/DropdownItem.tsx b/src/apps/renderer/pages/Widget/DropdownItem.tsx new file mode 100644 index 0000000000..c75e81807f --- /dev/null +++ b/src/apps/renderer/pages/Widget/DropdownItem.tsx @@ -0,0 +1,21 @@ +type Props = { + readonly children: React.ReactNode; + readonly active?: boolean; + onClick?: () => void; + readonly disabled?: boolean; +}; + +export function DropdownItem({ children, active, onClick, disabled }: Props) { + return ( + + ); +} diff --git a/src/apps/renderer/pages/Widget/Header.tsx b/src/apps/renderer/pages/Widget/Header.tsx index 95b380e27c..ff010b430a 100644 --- a/src/apps/renderer/pages/Widget/Header.tsx +++ b/src/apps/renderer/pages/Widget/Header.tsx @@ -1,42 +1,17 @@ -import { useEffect, useRef, useState } from 'react'; -import { FolderSimple, Gear, Globe } from '@phosphor-icons/react'; -import { Menu, Transition } from '@headlessui/react'; -import bytes from 'bytes'; - -import { User } from '../../../main/types'; -import { useTranslationContext } from '../../context/LocalContext'; import useBackupErrors from '../../hooks/backups/useBackupErrors'; import useGeneralIssues from '../../hooks/GeneralIssues'; import useVirtualDriveIssues from '../../hooks/ProcessIssues'; -import { useUsage } from '../../context/UsageContext/useUsage'; +import { AccountSection } from './AccountSection'; +import { ItemsSection } from './ItemsSection'; export default function Header() { - const { translate } = useTranslationContext(); const processIssues = useVirtualDriveIssues(); const { generalIssues } = useGeneralIssues(); const { backupErrors } = useBackupErrors(); const numberOfIssues: number = processIssues.length + backupErrors.length + generalIssues.length; - const numberOfIssuesDisplay = numberOfIssues > 99 ? '99+' : numberOfIssues; - /* Electron on MacOS kept focusing the first focusable - element on start so we had to create a dummy element - to get that focus, remove it and make itself - non-focusable */ - const dummyRef = useRef(null); - - useEffect(() => { - if (process.env.platform === 'darwin') { - const listener = () => { - dummyRef.current?.blur(); - dummyRef.current?.removeEventListener('focus', listener); - dummyRef.current?.setAttribute('tabindex', '-1'); - }; - dummyRef.current?.addEventListener('focus', listener); - } - }, []); - function onQuitClick() { window.electron.quit(); } @@ -49,212 +24,15 @@ export default function Header() { } }; - const AccountSection = () => { - const { translate } = useTranslationContext(); - const [user, setUser] = useState(null); - - useEffect(() => { - window.electron.getUser().then(setUser); - }, []); - - const { usage, status } = useUsage(); - console.log('usage in header', usage); - console.log('status in header', status); - - let displayUsage: string; - - if (status === 'loading') { - displayUsage = 'Loading...'; - } else if (status === 'error') { - displayUsage = ''; - } else if (usage) { - displayUsage = `${bytes.format(usage.usageInBytes)} ${translate( - 'widget.header.usage.of', - )} ${usage.isInfinite ? '∞' : bytes.format(usage.limitInBytes)}`; - } else { - displayUsage = ''; - } - console.log('displayUsage', displayUsage); - return ( -
-
- {`${user?.name.charAt(0) ?? ''}${user?.lastname.charAt(0) ?? ''}`} -
- -
-

- {user?.email} -

-

{displayUsage}

-
-
- ); - }; - - const HeaderItemWrapper = ({ - children, - active = false, - onClick, - disabled, - }: { - children: JSX.Element; - active?: boolean; - onClick?: any; - disabled?: boolean; - }) => { - return ( -
- {children} -
- ); - }; - - const DropdownItem = ({ - children, - active, - onClick, - }: { - children: JSX.Element; - active?: boolean; - onClick?: () => void; - }) => { - return ( - - ); - }; - - const ItemsSection = () => ( -
- {process.env.platform === 'darwin' &&
} - handleOpenURL('https://drive.internxt.com')}> - - - window.electron.openVirtualDriveFolder()} - data-automation-id="openVirtualDriveFolder"> - - - - - {({ open }) => ( - <> - - - - - - - - - - {({ active }) => ( -
- window.electron.openSettingsWindow()}> - {translate('widget.header.dropdown.preferences')} - -
- )} -
- - {({ active }) => ( -
- -
-

{translate('widget.header.dropdown.issues')}

- {numberOfIssues > 0 && ( -

{numberOfIssuesDisplay}

- )} -
-
-
- )} -
- - {({ active }) => ( -
- handleOpenURL('https://help.internxt.com')} - data-automation-id="menuItemSupport"> - {translate('widget.header.dropdown.support')} - -
- )} -
- {true && ( - - {({ active }) => ( -
- window.electron.openSettingsWindow('CLEANER')} - data-automation-id="menuItemCleaner"> -
- {translate('widget.header.dropdown.cleaner')} -
- {translate('widget.header.dropdown.new')} -
-
-
-
- )} -
- )} - - {({ active }) => ( -
- - {translate('widget.header.dropdown.logout')} - -
- )} -
- - {({ active }) => ( -
- - {translate('widget.header.dropdown.quit')} - -
- )} -
-
-
- - )} -
-
- ); - return (
- +
); } diff --git a/src/apps/renderer/pages/Widget/HeaderItemWrapper.test.tsx b/src/apps/renderer/pages/Widget/HeaderItemWrapper.test.tsx new file mode 100644 index 0000000000..a54fc35152 --- /dev/null +++ b/src/apps/renderer/pages/Widget/HeaderItemWrapper.test.tsx @@ -0,0 +1,59 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { HeaderItemWrapper } from './HeaderItemWrapper'; + +describe('HeaderItemWrapper', () => { + it('renders children', () => { + render( + + Icon + , + ); + + expect(screen.getByText('Icon')).toBeInTheDocument(); + }); + + it('calls onClick when clicked', () => { + const onClick = vi.fn(); + + render( + + Click + , + ); + + fireEvent.click(screen.getByText('Click').parentElement!); + + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('applies active styles when active is true', () => { + const { container } = render( + + Active + , + ); + + expect(container.firstChild).toHaveClass('bg-surface'); + }); + + it('does not apply active styles when active is false', () => { + const { container } = render( + + Inactive + , + ); + + expect(container.firstChild).not.toHaveClass('ring-1'); + }); + + it('applies disabled styles when disabled is true', () => { + const { container } = render( + + Disabled + , + ); + + expect(container.firstChild).toHaveClass('pointer-events-none'); + expect(container.firstChild).toHaveClass('text-gray-40'); + }); +}); diff --git a/src/apps/renderer/pages/Widget/HeaderItemWrapper.tsx b/src/apps/renderer/pages/Widget/HeaderItemWrapper.tsx new file mode 100644 index 0000000000..bdfe0c28d3 --- /dev/null +++ b/src/apps/renderer/pages/Widget/HeaderItemWrapper.tsx @@ -0,0 +1,20 @@ +type Props = { + readonly children: React.ReactNode; + readonly active?: boolean; + onClick?: () => void; + readonly disabled?: boolean; +}; + +export function HeaderItemWrapper({ children, active = false, onClick, disabled }: Props) { + return ( + + ); +} diff --git a/src/apps/renderer/pages/Widget/ItemsSection.test.tsx b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx new file mode 100644 index 0000000000..71a246cebf --- /dev/null +++ b/src/apps/renderer/pages/Widget/ItemsSection.test.tsx @@ -0,0 +1,148 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ElementType, ReactNode } from 'react'; +import { type Mock } from 'vitest'; +import { useTranslationContext } from '../../context/LocalContext'; +import { useSyncContext } from '../../context/SyncContext'; +import { ItemsSection } from './ItemsSection'; + +vi.mock('../../context/LocalContext'); +vi.mock('../../context/SyncContext'); + +vi.mock('@headlessui/react', () => ({ + Menu: Object.assign( + ({ + children, + as: Tag = 'div', + className, + }: { + children: ReactNode | ((bag: { open: boolean }) => ReactNode); + as?: ElementType; + className?: string; + }) => { + const content = typeof children === 'function' ? children({ open: false }) : children; + return {content}; + }, + { + Button: ({ children, className }: { children: ReactNode; className?: string }) => ( + + ), + Items: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + Item: ({ children }: { children: ReactNode | ((bag: { active: boolean }) => ReactNode) }) => ( +
{typeof children === 'function' ? children({ active: false }) : children}
+ ), + }, + ), + Transition: ({ children }: { children: ReactNode }) => <>{children}, +})); + +const defaultProps = { + numberOfIssues: 0, + numberOfIssuesDisplay: 0, + onQuitClick: vi.fn(), + onOpenURL: vi.fn(), +}; + +describe('ItemsSection', () => { + beforeEach(() => { + (useTranslationContext as Mock).mockReturnValue({ translate: (key: string) => key }); + (useSyncContext as Mock).mockReturnValue({ syncStatus: 'STANDBY' }); + }); + + it('calls onOpenURL with drive URL when globe icon wrapper is clicked', () => { + const onOpenURL = vi.fn(); + const { container } = render(); + + const wrappers = container.querySelectorAll('.cursor-pointer'); + fireEvent.click(wrappers[0]); + + expect(onOpenURL).toHaveBeenCalledWith('https://drive.internxt.com'); + }); + + it('opens virtual drive folder when folder icon wrapper is clicked', () => { + const { container } = render(); + + const wrappers = container.querySelectorAll('.cursor-pointer'); + fireEvent.click(wrappers[1]); + + expect(window.electron.openVirtualDriveFolder).toHaveBeenCalledOnce(); + }); + + it('shows issues count when numberOfIssues is greater than 0', () => { + render(); + + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('does not show issues count when numberOfIssues is 0', () => { + render(); + + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); + + it('disables sync menu item when syncing', () => { + (useSyncContext as Mock).mockReturnValue({ syncStatus: 'RUNNING' }); + + render(); + + const syncButton = screen.getByText('widget.header.dropdown.sync').closest('button'); + expect(syncButton).toBeDisabled(); + }); + + it('enables sync menu item when not syncing', () => { + render(); + + const syncButton = screen.getByText('widget.header.dropdown.sync').closest('button'); + expect(syncButton).not.toBeDisabled(); + }); + + it('calls onQuitClick when quit is clicked', () => { + const onQuitClick = vi.fn(); + + render(); + + const quitButton = screen.getByText('widget.header.dropdown.quit').closest('button')!; + fireEvent.click(quitButton); + + expect(onQuitClick).toHaveBeenCalledOnce(); + }); + + it('opens settings window when preferences is clicked', () => { + render(); + + const prefsButton = screen.getByText('widget.header.dropdown.preferences').closest('button')!; + fireEvent.click(prefsButton); + + expect(window.electron.openSettingsWindow).toHaveBeenCalledOnce(); + }); + + it('opens process issues window when issues is clicked', () => { + render(); + + const issuesButton = screen.getByText('widget.header.dropdown.issues').closest('button')!; + fireEvent.click(issuesButton); + + expect(window.electron.openProcessIssuesWindow).toHaveBeenCalledOnce(); + }); + + it('calls onOpenURL with support URL when support is clicked', () => { + const onOpenURL = vi.fn(); + + render(); + + const supportButton = screen.getByText('widget.header.dropdown.support').closest('button')!; + fireEvent.click(supportButton); + + expect(onOpenURL).toHaveBeenCalledWith('https://help.internxt.com'); + }); + + it('calls logout when logout is clicked', () => { + render(); + + const logoutButton = screen.getByText('widget.header.dropdown.logout').closest('button')!; + fireEvent.click(logoutButton); + + expect(window.electron.logout).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/apps/renderer/pages/Widget/ItemsSection.tsx b/src/apps/renderer/pages/Widget/ItemsSection.tsx new file mode 100644 index 0000000000..2a84b21dab --- /dev/null +++ b/src/apps/renderer/pages/Widget/ItemsSection.tsx @@ -0,0 +1,147 @@ +import { FolderSimple, Gear, Globe } from '@phosphor-icons/react'; +import { Menu, Transition } from '@headlessui/react'; + +import { useTranslationContext } from '../../context/LocalContext'; +import { useSyncContext } from '../../context/SyncContext'; +import { DropdownItem } from './DropdownItem'; +import { HeaderItemWrapper } from './HeaderItemWrapper'; + +type Props = { + readonly numberOfIssues: number; + readonly numberOfIssuesDisplay: number | string; + onQuitClick: () => void; + onOpenURL: (url: string) => void; +}; + +export function ItemsSection({ numberOfIssues, numberOfIssuesDisplay, onQuitClick, onOpenURL }: Props) { + const { translate } = useTranslationContext(); + const { syncStatus } = useSyncContext(); + const isSyncing = syncStatus === 'RUNNING'; + + const handleManualSync = () => { + if (isSyncing) return; + window.electron.startRemoteSync().catch(reportError); + }; + + return ( +
+ onOpenURL('https://drive.internxt.com')}> + + + window.electron.openVirtualDriveFolder()} + data-automation-id="openVirtualDriveFolder"> + + + + + {({ open }) => ( + <> + + + + + + + + + + {({ active }) => ( +
+ window.electron.openSettingsWindow()}> + {translate('widget.header.dropdown.preferences')} + +
+ )} +
+ + {({ active }) => ( +
+ + {translate('widget.header.dropdown.sync')} + +
+ )} +
+ + {({ active }) => ( +
+ +
+

{translate('widget.header.dropdown.issues')}

+ {numberOfIssues > 0 && ( +

{numberOfIssuesDisplay}

+ )} +
+
+
+ )} +
+ + {({ active }) => ( +
+ onOpenURL('https://help.internxt.com')} + data-automation-id="menuItemSupport"> + {translate('widget.header.dropdown.support')} + +
+ )} +
+ + {({ active }) => ( +
+ window.electron.openSettingsWindow('CLEANER')} + data-automation-id="menuItemCleaner"> +
+ {translate('widget.header.dropdown.cleaner')} +
+ {translate('widget.header.dropdown.new')} +
+
+
+
+ )} +
+ + {({ active }) => ( +
+ + {translate('widget.header.dropdown.logout')} + +
+ )} +
+ + {({ active }) => ( +
+ + {translate('widget.header.dropdown.quit')} + +
+ )} +
+
+
+ + )} +
+
+ ); +} diff --git a/src/apps/renderer/pages/Widget/SyncAction.tsx b/src/apps/renderer/pages/Widget/SyncAction.tsx index 8451f2af55..ab10bd42c8 100644 --- a/src/apps/renderer/pages/Widget/SyncAction.tsx +++ b/src/apps/renderer/pages/Widget/SyncAction.tsx @@ -5,7 +5,6 @@ import Spinner from '../../assets/spinner.svg'; import Button from '../../components/Button'; import { useTranslationContext } from '../../context/LocalContext'; import useVirtualDriveStatus from '../../hooks/useVirtualDriveStatus'; -import useSyncStatus from '../../hooks/useSyncStatus'; import { useOnlineStatus } from '../../hooks/useOnlineStatus/useOnlineStatus'; import { useUsage } from '../../context/UsageContext/useUsage'; @@ -14,9 +13,8 @@ export default function SyncAction(props: { syncStatus: SyncStatus }) { const isOnLine = useOnlineStatus(); const { usage, status } = useUsage(); const { virtualDriveStatus } = useVirtualDriveStatus(); - const { syncStatus } = useSyncStatus(); - const isSyncStopped = virtualDriveStatus && syncStatus && syncStatus === 'FAILED'; + const isSyncStopped = virtualDriveStatus && props.syncStatus && props.syncStatus === 'FAILED'; const handleOpenUpgrade = async () => { try { diff --git a/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx b/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx index 66b76497c0..4f1a6d78ee 100644 --- a/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx +++ b/src/apps/renderer/pages/Widget/SyncErrorBanner.tsx @@ -1,9 +1,8 @@ -import { SyncStatus } from '../../../../context/desktop/sync/domain/SyncStatus'; import { FatalError } from '../../../../shared/issues/FatalError'; import Error from '../../assets/error.svg'; import Warn from '../../assets/warn.svg'; import { useTranslationContext } from '../../context/LocalContext'; -import useSyncStatus from '../../hooks/useSyncStatus'; +import { useOnSyncRunning } from '../../hooks/useOnSyncRunning'; import useSyncStopped from '../../hooks/useSyncStopped'; import SyncFatalErrorMessages from '../../messages/fatal-error'; @@ -15,7 +14,7 @@ const fatalErrorActionMap: Record void } func: async () => { const result = await window.electron.chooseSyncRootWithDialog(); if (result) { - window.electron.startSyncProcess(); + window.electron.startRemoteSync(); } }, }, @@ -24,7 +23,7 @@ const fatalErrorActionMap: Record void } func: async () => { const result = await window.electron.chooseSyncRootWithDialog(); if (result) { - window.electron.startSyncProcess(); + window.electron.startRemoteSync(); } }, }, @@ -40,13 +39,7 @@ export default function SyncErrorBanner() { const [stopReason, setStopReason] = useSyncStopped(); const { translate } = useTranslationContext(); - function onSyncStatusChanged(value: SyncStatus) { - if (value === 'RUNNING') { - setStopReason(null); - } - } - - useSyncStatus(onSyncStatusChanged); + useOnSyncRunning(() => setStopReason(null)); const severity = 'FATAL' as ErrorSeverity; const show = stopReason !== null && stopReason?.reason !== 'STOPPED_BY_USER'; diff --git a/src/apps/renderer/pages/Widget/index.tsx b/src/apps/renderer/pages/Widget/index.tsx index 03e08cb576..e49df69d51 100644 --- a/src/apps/renderer/pages/Widget/index.tsx +++ b/src/apps/renderer/pages/Widget/index.tsx @@ -2,7 +2,7 @@ import Header from './Header'; import SyncAction from './SyncAction'; import SyncErrorBanner from './SyncErrorBanner'; import SyncInfo from './SyncInfo'; -import useSyncStatus from '../../hooks/useSyncStatus'; +import { useSyncContext } from '../../context/SyncContext'; import { SyncFailed } from './SyncFailed'; import { useEffect, useState } from 'react'; import useVirtualDriveStatus from '../../hooks/useVirtualDriveStatus'; @@ -16,7 +16,7 @@ const handleRetrySync = () => { }; export default function Widget() { - const { syncStatus } = useSyncStatus(); + const { syncStatus } = useSyncContext(); const [displayErrorInWidget, setDisplayErrorInWidget] = useState(false); const { virtualDriveStatus } = useVirtualDriveStatus();