Skip to content
5 changes: 3 additions & 2 deletions src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Container, Flex, useMediaQuery } from '@chakra-ui/react';
import { SkipNavLink } from '@chakra-ui/skip-nav';
import { useRouter } from 'next/router';
import { FC } from 'react';
import { FC, ReactNode } from 'react';
import { Footer } from '../Footer';
import { NavBar } from '../NavBar';
import dynamic from 'next/dynamic';
Expand All @@ -24,7 +24,7 @@ const LandingTabs = dynamic(
);

const LANDING_PAGES = ['/', '/classic-form', '/paper-form'];
export const Layout: FC = ({ children }) => {
export const Layout: FC<{ banner?: ReactNode }> = ({ children, banner }) => {
const router = useRouter();

const isLandingPage = LANDING_PAGES.includes(router.pathname);
Expand All @@ -42,6 +42,7 @@ export const Layout: FC = ({ children }) => {
{isPrint ? null : <SiteAlert />}
{isPrint ? null : <NavBar />}
{isPrint ? null : <Notification />}
{banner}
<main>
{isLandingPage && <LandingTabs />}
<Container maxW={isLandingPage ? 'container.md' : 'container.xl'} id="main-content">
Expand Down
7 changes: 4 additions & 3 deletions src/components/NavBar/useTour.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useShepherd } from 'react-shepherd';
import { safeLocalStorageSet } from '@/lib/browserStorage';
import { Step, StepOptions } from 'shepherd.js';
import { offset } from '@floating-ui/react-dom';
import { useRouter } from 'next/router';
Expand Down Expand Up @@ -46,11 +47,11 @@ export const useTour = (type?: 'home' | 'results' | 'abstract') => {

tour.on('start', () => {
if (tourType === 'home') {
localStorage.setItem(LocalSettings.SEEN_LANDING_TOUR, 'true');
safeLocalStorageSet(LocalSettings.SEEN_LANDING_TOUR, 'true');
} else if (tourType === 'results') {
localStorage.setItem(LocalSettings.SEEN_RESULTS_TOUR, 'true');
safeLocalStorageSet(LocalSettings.SEEN_RESULTS_TOUR, 'true');
} else if (tourType === 'abstract') {
localStorage.setItem(LocalSettings.SEEN_ABSTRACT_TOUR, 'true');
safeLocalStorageSet(LocalSettings.SEEN_ABSTRACT_TOUR, 'true');
}

sendGTMEvent({ event: 'tour_start', tour_type: tourType, is_mobile: !!isMobile });
Expand Down
7 changes: 4 additions & 3 deletions src/components/SiteAlert/SiteAlert.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useGetSiteWideMsg } from '@/api/vault/vault';
import { useSession } from '@/lib/useSession';
import { useSettings } from '@/lib/useSettings';
import { safeLocalStorageGet, safeLocalStorageSet } from '@/lib/browserStorage';
import { Alert, AlertDescription, CloseButton, Flex } from '@chakra-ui/react';
import { useEffect, useState } from 'react';

Expand All @@ -27,7 +28,7 @@ export const SiteAlert = () => {
if (isAuthenticated) {
updateSettings({ last_seen_message: systemMsg });
} else {
localStorage.setItem(LAST_DISMISSED_SYS_MSG, systemMsg);
safeLocalStorageSet(LAST_DISMISSED_SYS_MSG, systemMsg);
}
};

Expand All @@ -39,8 +40,8 @@ export const SiteAlert = () => {
setLastDismissedMsg(settings.last_seen_message);
setInitialized(true);
}
} else if (typeof window !== 'undefined' && window.localStorage) {
setLastDismissedMsg(localStorage.getItem(LAST_DISMISSED_SYS_MSG) ?? '');
} else {
setLastDismissedMsg(safeLocalStorageGet(LAST_DISMISSED_SYS_MSG) ?? '');
setInitialized(true);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import { describe, expect, it } from 'vitest';
import { ChakraProvider } from '@chakra-ui/react';
import { StorageDegradedBanner } from './StorageDegradedBanner';

const renderWithChakra = (ui: ReactElement) => render(<ChakraProvider>{ui}</ChakraProvider>);

describe('StorageDegradedBanner', () => {
it('renders the warning message', () => {
renderWithChakra(<StorageDegradedBanner />);
expect(screen.getByText(/browser is blocking site storage/i)).toBeInTheDocument();
});

it('hides the banner when dismiss button is clicked', async () => {
const user = userEvent.setup();
renderWithChakra(<StorageDegradedBanner />);
const closeButton = screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
expect(screen.queryByText(/browser is blocking site storage/i)).not.toBeInTheDocument();
});
});
19 changes: 19 additions & 0 deletions src/components/StorageDegradedBanner/StorageDegradedBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Alert, AlertDescription, CloseButton } from '@chakra-ui/react';
import { useState } from 'react';

export const StorageDegradedBanner = () => {
const [dismissed, setDismissed] = useState(false);

if (dismissed) {
return null;
}

return (
<Alert status="warning" variant="subtle" justifyContent="space-between" alignItems="center">
<AlertDescription>
Your browser is blocking site storage. Preferences and settings won&apos;t be saved between sessions.
</AlertDescription>
<CloseButton aria-label="Close" onClick={() => setDismissed(true)} />
</Alert>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import type { ReactElement } from 'react';
import { describe, expect, it } from 'vitest';
import { ChakraProvider } from '@chakra-ui/react';
import { StorageUnavailableNotice } from './StorageUnavailableNotice';

const renderWithChakra = (ui: ReactElement) => render(<ChakraProvider>{ui}</ChakraProvider>);

describe('StorageUnavailableNotice', () => {
it('renders the heading', () => {
renderWithChakra(<StorageUnavailableNotice />);
expect(screen.getByRole('heading', { name: /cookies are required/i })).toBeInTheDocument();
});

it('renders a link for Chrome', () => {
renderWithChakra(<StorageUnavailableNotice />);
expect(screen.getByRole('link', { name: /chrome/i })).toBeInTheDocument();
});

it('renders a link for Firefox', () => {
renderWithChakra(<StorageUnavailableNotice />);
expect(screen.getByRole('link', { name: /firefox/i })).toBeInTheDocument();
});

it('renders a link for Safari', () => {
renderWithChakra(<StorageUnavailableNotice />);
expect(screen.getByRole('link', { name: /safari/i })).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Box, Heading, Link, Text, VStack } from '@chakra-ui/react';

export const StorageUnavailableNotice = () => {
return (
<Box display="flex" alignItems="center" justifyContent="center" minHeight="100vh" px={4}>
<VStack spacing={6} maxWidth="600px" textAlign="center">
<Heading as="h1" size="lg">
Cookies are required
</Heading>
<Text>
SciX uses cookies and site data to maintain your session and preferences. Cookies appear to be blocked by your
browser settings.
</Text>
<Text>To use SciX, enable cookies and site data in your browser:</Text>
<VStack spacing={2} alignItems="center" width="100%">
<Link href="https://support.google.com/chrome/answer/95647" isExternal color="blue.500">
How to enable cookies in Chrome
</Link>
<Link
href="https://support.mozilla.org/en-US/kb/cookies-information-websites-store-on-your-computer"
isExternal
color="blue.500"
>
How to enable cookies in Firefox
</Link>
<Link
href="https://support.apple.com/guide/safari/enable-cookies-ibrw850f6c51/26.0/mac/26"
isExternal
color="blue.500"
>
How to enable cookies in Safari
</Link>
</VStack>
<Text fontSize="sm" color="gray.500">
After enabling cookies, reload this page.
</Text>
</VStack>
</Box>
);
};
170 changes: 170 additions & 0 deletions src/lib/__tests__/browserStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createSafeStorage,
isCookiesAvailable,
isLocalStorageAvailable,
safeLocalStorageGet,
safeLocalStorageSet,
safeSessionStorageGet,
safeSessionStorageRemove,
safeSessionStorageSet,
} from '@/lib/browserStorage';

describe('isCookiesAvailable', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('returns false when navigator.cookieEnabled is false', () => {
vi.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(false);
expect(isCookiesAvailable()).toBe(false);
});

it('returns false when document.cookie access throws SecurityError', () => {
vi.spyOn(navigator, 'cookieEnabled', 'get').mockReturnValue(true);
Object.defineProperty(document, 'cookie', {
get() {
throw new DOMException('Access denied', 'SecurityError');
},
configurable: true,
});
expect(isCookiesAvailable()).toBe(false);
Object.defineProperty(document, 'cookie', {
get() {
return '';
},
// eslint-disable-next-line @typescript-eslint/no-empty-function
set() {},
configurable: true,
});
});

it('returns true when cookies are accessible', () => {
expect(isCookiesAvailable()).toBe(true);
});
});

describe('isLocalStorageAvailable', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('returns false when localStorage.setItem throws SecurityError', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
expect(isLocalStorageAvailable()).toBe(false);
});

it('returns true when localStorage probe succeeds', () => {
expect(isLocalStorageAvailable()).toBe(true);
});
});

describe('createSafeStorage', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('returns null from getItem when localStorage.getItem throws', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
const storage = createSafeStorage();
expect(storage.getItem('key')).toBeNull();
});

it('returns stored value from getItem when localStorage works', () => {
localStorage.setItem('key', 'value');
const storage = createSafeStorage();
expect(storage.getItem('key')).toBe('value');
localStorage.removeItem('key');
});

it('does not throw from setItem when localStorage.setItem throws', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
const storage = createSafeStorage();
expect(() => storage.setItem('key', 'value')).not.toThrow();
});

it('does not throw from removeItem when localStorage.removeItem throws', () => {
vi.spyOn(Storage.prototype, 'removeItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
const storage = createSafeStorage();
expect(() => storage.removeItem?.('key')).not.toThrow();
});
});

describe('safeLocalStorageGet', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('returns null when localStorage.getItem throws', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
expect(safeLocalStorageGet('key')).toBeNull();
});

it('returns the value when localStorage works', () => {
localStorage.setItem('key', 'stored');
expect(safeLocalStorageGet('key')).toBe('stored');
localStorage.removeItem('key');
});
});

describe('safeLocalStorageSet', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('does not throw when localStorage.setItem throws', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
expect(() => safeLocalStorageSet('key', 'value')).not.toThrow();
});
});

describe('safeSessionStorageGet', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('returns null when sessionStorage.getItem throws', () => {
vi.spyOn(window.sessionStorage, 'getItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
expect(safeSessionStorageGet('key')).toBeNull();
});
});

describe('safeSessionStorageSet', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('does not throw when sessionStorage.setItem throws', () => {
vi.spyOn(window.sessionStorage, 'setItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
expect(() => safeSessionStorageSet('key', 'value')).not.toThrow();
});
});

describe('safeSessionStorageRemove', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('does not throw when sessionStorage.removeItem throws', () => {
vi.spyOn(window.sessionStorage, 'removeItem').mockImplementationOnce(() => {
throw new DOMException('Access denied', 'SecurityError');
});
expect(() => safeSessionStorageRemove('key')).not.toThrow();
});
});
Loading
Loading