Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions packages-internal/core-docs/src/DocsApp/AnalyticsProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, createRenderer, screen, waitFor } from '@mui/internal-test-utils';
import Router from 'next/router';
import { AnalyticsProvider } from './AnalyticsProvider';

vi.mock('../branding/BrandingCssVarsProvider', async (importOriginal) => ({
...(await importOriginal<typeof import('../branding/BrandingCssVarsProvider')>()),
BrandingCssThemeProvider: ({ children }: { children: React.ReactNode }) => children,
}));

describe('AnalyticsProvider', () => {
const { render } = createRenderer();
const doNotTrackDescriptor = Object.getOwnPropertyDescriptor(navigator, 'doNotTrack');

async function findDialog() {
return waitFor(async () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(32);
});
return screen.getByRole('dialog');
});
}

beforeEach(() => {
// Advance the opening animation explicitly, including in background browser tabs.
vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame'] });
const storage = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
});
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '0' });
window.history.replaceState(null, '', '/');
vi.stubGlobal('gtag', vi.fn());
});

afterEach(() => {
window.localStorage.removeItem('docs-cookie-consent');
window.history.replaceState(null, '', '/');
if (doNotTrackDescriptor) {
Object.defineProperty(navigator, 'doNotTrack', doNotTrackDescriptor);
} else {
Reflect.deleteProperty(navigator, 'doNotTrack');
}
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it.each(['analytics', 'essential'])('reopens saved %s consent on page load', async (consent) => {
window.localStorage.setItem('docs-cookie-consent', consent);
window.history.replaceState({ existing: true }, '', '/material-ui/?test=1#cookie-preferences');
const { user } = render(<AnalyticsProvider>{null}</AnalyticsProvider>);

await findDialog();
expect(screen.getByRole('dialog')).toHaveAccessibleDescription(
`We use cookies to understand site usage and improve our content. This includes third-party analytics. Current preference: ${consent === 'analytics' ? 'Analytics allowed' : 'Essential only'}`,
);
expect(window.localStorage.getItem('docs-cookie-consent')).to.equal(consent);
await user.click(screen.getByRole('button', { name: 'Essential only' }));

await waitFor(() => expect(screen.queryByRole('dialog')).to.equal(null));
expect(window.localStorage.getItem('docs-cookie-consent')).to.equal('essential');
expect(window.location.pathname + window.location.search).to.equal('/material-ui/?test=1');
expect(window.location.hash).to.equal('');
expect(window.history.state).to.deep.equal({ existing: true });
expect(window.gtag).toHaveBeenLastCalledWith('consent', 'update', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
});
});

it('does not show a current preference before the first choice', async () => {
render(<AnalyticsProvider>{null}</AnalyticsProvider>);
await findDialog();
expect(screen.queryByText(/Current preference:/)).to.equal(null);
});

it('allows reopening repeatedly through a hash link', async () => {
window.localStorage.setItem('docs-cookie-consent', 'essential');
const { user } = render(
<AnalyticsProvider>
<a href="#cookie-preferences">Cookie settings</a>
</AnalyticsProvider>,
);
expect(screen.queryByRole('dialog')).to.equal(null);

const reopenAndAccept = async (currentPreference: string) => {
await user.click(screen.getByRole('link', { name: 'Cookie settings' }));
await findDialog();
expect(screen.getByText(`Current preference: ${currentPreference}`)).not.to.equal(null);
await user.click(screen.getByRole('button', { name: 'Allow analytics' }));
await waitFor(() => expect(screen.queryByRole('dialog')).to.equal(null));
expect(window.localStorage.getItem('docs-cookie-consent')).to.equal('analytics');
};
await reopenAndAccept('Essential only');
await reopenAndAccept('Analytics allowed');
});

it.each(['hashChangeComplete', 'routeChangeComplete'] as const)(
'opens after Next.js %s navigation',
async (event) => {
window.localStorage.setItem('docs-cookie-consent', 'essential');
render(<AnalyticsProvider>{null}</AnalyticsProvider>);
act(() => {
window.history.pushState(null, '', '/#cookie-preferences');
Router.events.emit(event, '/#cookie-preferences', { shallow: false });
});
await findDialog();
},
);

it('ignores unrelated hashes', () => {
window.localStorage.setItem('docs-cookie-consent', 'essential');
window.history.replaceState(null, '', '/#other-section');
render(<AnalyticsProvider>{null}</AnalyticsProvider>);
expect(screen.queryByRole('dialog')).to.equal(null);
});

it('continues to respect Do Not Track', () => {
Object.defineProperty(navigator, 'doNotTrack', { configurable: true, value: '1' });
window.history.replaceState(null, '', '/#cookie-preferences');
render(<AnalyticsProvider>{null}</AnalyticsProvider>);
expect(screen.queryByRole('dialog')).to.equal(null);
expect(window.gtag).toHaveBeenLastCalledWith('consent', 'update', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
});
});
});
48 changes: 43 additions & 5 deletions packages-internal/core-docs/src/DocsApp/AnalyticsProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import Router from 'next/router';
import Button from '@mui/material/Button';
import Fade from '@mui/material/Fade';
import Paper from '@mui/material/Paper';
Expand All @@ -13,6 +14,7 @@ import CookieOutlinedIcon from '@mui/icons-material/CookieOutlined';
import { BrandingCssThemeProvider } from '../branding/BrandingCssVarsProvider';

const COOKIE_CONSENT_KEY = 'docs-cookie-consent';
const COOKIE_PREFERENCES_HASH = '#cookie-preferences';

type ConsentStatus = 'analytics' | 'essential' | null;

Expand Down Expand Up @@ -58,7 +60,8 @@ export function useAnalyticsConsent() {
}

export function CookieConsentDialog() {
const { needsConsent, setAnalyticsConsent, setEssentialOnly } = useAnalyticsConsent();
const { consentStatus, needsConsent, setAnalyticsConsent, setEssentialOnly } =
useAnalyticsConsent();
const [show, setShow] = React.useState(false);

React.useEffect(() => {
Expand Down Expand Up @@ -142,7 +145,13 @@ export function CookieConsentDialog() {
sx={{ textAlign: { xs: 'center', sm: 'start' } }}
>
We use cookies to understand site usage and improve our content. This includes
third-party analytics.
third-party analytics.{' '}
{consentStatus !== null && (
<Box component="span" sx={{ display: 'block', mt: 1 }}>
Current preference:{' '}
{consentStatus === 'analytics' ? 'Analytics allowed' : 'Essential only'}
</Box>
)}
</Typography>
</Stack>
</Stack>
Expand Down Expand Up @@ -183,9 +192,36 @@ function updateGoogleConsent(hasAnalytics: boolean) {
export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
const [consentStatus, setConsentStatus] = useLocalStorageState(COOKIE_CONSENT_KEY, null);
const doNotTrack = useDoNotTrack();
const [preferencesRequested, setPreferencesRequested] = React.useState(false);

React.useEffect(() => {
const handleHashChange = () => {
setPreferencesRequested(window.location.hash === COOKIE_PREFERENCES_HASH);
};
handleHashChange();
window.addEventListener('hashchange', handleHashChange);
Router.events.on('hashChangeComplete', handleHashChange);
Router.events.on('routeChangeComplete', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
Router.events.off('hashChangeComplete', handleHashChange);
Router.events.off('routeChangeComplete', handleHashChange);
};
}, []);

const closePreferences = React.useCallback(() => {
setPreferencesRequested(false);
if (window.location.hash === COOKIE_PREFERENCES_HASH) {
window.history.replaceState(
window.history.state,
'',
window.location.pathname + window.location.search,
);
}
}, []);

// Respect Do Not Track - don't show dialog and treat as essential only
const needsConsent = consentStatus === null && !doNotTrack;
const needsConsent = (consentStatus === null || preferencesRequested) && !doNotTrack;

// Update Google consent when status changes or on mount if already set
React.useEffect(() => {
Expand All @@ -199,11 +235,13 @@ export function AnalyticsProvider({ children }: { children: React.ReactNode }) {

const setAnalyticsConsent = React.useCallback(() => {
setConsentStatus('analytics');
}, [setConsentStatus]);
closePreferences();
}, [setConsentStatus, closePreferences]);

const setEssentialOnly = React.useCallback(() => {
setConsentStatus('essential');
}, [setConsentStatus]);
closePreferences();
}, [setConsentStatus, closePreferences]);

const contextValue = React.useMemo<AnalyticsContextValue>(
() => ({
Expand Down
Loading