From d19a6d03ebe24c57b33646cb592675fff158c78d Mon Sep 17 00:00:00 2001 From: Cadence Banulis Date: Thu, 3 Oct 2024 01:17:40 -0700 Subject: [PATCH] Feat: Share button in zone header (#7240) * feat: share button in zone header * Wrap share button in feature flag (#7248) * chore: wrap share button in feature flag * chore: address PR comments * refactor: extract feature flag enabled into const --------- Co-authored-by: Cadence * fix: add cypress component test and fix css * chore: add translations & include another cy test * fix: navigator canShare * fix: share abort does not display error toast * chore: add share abort tests * fix: naming convention --------- Co-authored-by: Cadence --- web/src/components/Toast.test.tsx | 40 +++++-- web/src/components/Toast.tsx | 44 ++++--- .../features/panels/zone/ShareButton.cy.tsx | 66 +++++++++++ .../features/panels/zone/ShareButton.test.tsx | 108 ++++++++++++++++++ web/src/features/panels/zone/ShareButton.tsx | 103 +++++++++++++++++ .../features/panels/zone/ZoneHeaderTitle.tsx | 12 +- .../features/service-worker/UpdatePrompt.tsx | 31 +++-- .../weather-layers/wind-layer/util.ts | 4 + web/src/locales/en.json | 5 + web/src/utils/analytics.ts | 1 + 10 files changed, 374 insertions(+), 40 deletions(-) create mode 100644 web/src/features/panels/zone/ShareButton.cy.tsx create mode 100644 web/src/features/panels/zone/ShareButton.test.tsx create mode 100644 web/src/features/panels/zone/ShareButton.tsx diff --git a/web/src/components/Toast.test.tsx b/web/src/components/Toast.test.tsx index 956385df22..42919fdc9d 100644 --- a/web/src/components/Toast.test.tsx +++ b/web/src/components/Toast.test.tsx @@ -1,9 +1,12 @@ import { ToastProvider } from '@radix-ui/react-toast'; import { render, screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; +import { act, createRef } from 'react'; import { describe, expect, test } from 'vitest'; -import { Toast, ToastType } from './Toast'; +import { Toast, ToastController, ToastType } from './Toast'; + +window.HTMLElement.prototype.hasPointerCapture = vi.fn(); const props = { title: 'test', @@ -20,32 +23,40 @@ describe('Toast', () => { }); test('renders', () => { + const reference = createRef(); render( - + ); + act(() => reference.current?.publish()); expect(screen.getByTestId('toast')).toBeDefined(); }); describe('renders by type', () => { test('info', () => { + const reference = createRef(); render( - + ); + act(() => reference.current?.publish()); + expect(screen.getByTestId('toast').classList.contains('text-blue-800')).toBe(true); expect(screen.getByTestId('toast-icon')); }); test('success', () => { + const reference = createRef(); render( - ) + ); + act(() => reference.current?.publish()); + expect(screen.getByTestId('toast').classList.contains('text-emerald-800')).toBe( true ); @@ -55,11 +66,14 @@ describe('Toast', () => { }); test('warning', () => { + const reference = createRef(); render( - + ); + act(() => reference.current?.publish()); + expect(screen.getByTestId('toast').classList.contains('text-amber-700')).toBe(true); expect( screen.getByTestId('toast-icon').classList.contains('lucide-triangle-alert') @@ -67,11 +81,14 @@ describe('Toast', () => { }); test('danger', () => { + const reference = createRef(); render( - + ); + act(() => reference.current?.publish()); + expect(screen.getByTestId('toast').classList.contains('text-red-700')).toBe(true); expect( screen.getByTestId('toast-icon').classList.contains('lucide-octagon-x') @@ -79,11 +96,14 @@ describe('Toast', () => { }); test('default', () => { + const reference = createRef(); render( - + ); + act(() => reference.current?.publish()); + expect(screen.getByTestId('toast').classList.contains('text-blue-800')).toBe(true); expect(screen.getByTestId('toast-icon').classList.contains('lucide-info')).toBe( true @@ -92,13 +112,15 @@ describe('Toast', () => { }); test('clicking dismiss closes ', async () => { + const reference = createRef(); render( - + ); - + act(() => reference.current?.publish()); await userEvent.click(screen.getByTestId('toast-dismiss')); + expect(screen.queryAllByTestId('toast')).toHaveLength(0); }); }); diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx index 1db737d7ff..f34ac99b2c 100644 --- a/web/src/components/Toast.tsx +++ b/web/src/components/Toast.tsx @@ -1,6 +1,6 @@ import * as ToastPrimitive from '@radix-ui/react-toast'; import { CircleCheck, Info, OctagonX, TriangleAlert, X } from 'lucide-react'; -import { useState } from 'react'; +import { forwardRef, useImperativeHandle, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { twMerge } from 'tailwind-merge'; @@ -46,18 +46,34 @@ export const ToastTypeTheme = { }, }; -export function Toast({ - title, - description, - toastAction, - toastActionText, - toastClose, - toastCloseText, - duration, - type = ToastType.INFO, -}: ToastProps) { +export interface ToastController { + publish(): void; + close(): void; +} + +export const useToastReference = () => useRef(null); + +export const Toast = forwardRef(function Toast( + { + title, + description, + toastAction, + toastActionText, + toastClose, + toastCloseText, + duration, + type = ToastType.INFO, + }: ToastProps, + forwardedReference +) { const { t } = useTranslation(); - const [open, setOpen] = useState(true); + const [open, setOpen] = useState(false); + + useImperativeHandle(forwardedReference, () => ({ + publish: () => setOpen(true), + close: () => setOpen(false), + })); + const handleToastAction = () => { toastAction?.(); setOpen(false); @@ -79,7 +95,7 @@ export function Toast({ duration={duration} type="background" className={twMerge( - 'fixed left-1/2 top-16 z-50 flex w-11/12 max-w-md -translate-x-1/2 transform rounded-lg shadow', + 'fixed left-1/2 top-16 z-50 flex w-11/12 min-w-fit max-w-sm -translate-x-1/2 transform rounded-lg shadow', 'border border-solid border-neutral-50 bg-white/80 backdrop-blur-sm dark:border-gray-700 dark:bg-gray-900', "before:content[''] before:absolute before:block before:h-full before:w-1 before:rounded-bl-md before:rounded-tl-md", color, @@ -128,4 +144,4 @@ export function Toast({ ); -} +}); diff --git a/web/src/features/panels/zone/ShareButton.cy.tsx b/web/src/features/panels/zone/ShareButton.cy.tsx new file mode 100644 index 0000000000..820df37b5a --- /dev/null +++ b/web/src/features/panels/zone/ShareButton.cy.tsx @@ -0,0 +1,66 @@ +import { ToastProvider } from '@radix-ui/react-toast'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { ShareButton } from './ShareButton'; + +const queryClient = new QueryClient(); + +describe('Share Button', () => { + beforeEach(() => { + cy.intercept('/feature-flags', { + body: { 'share-button': true }, + }); + }); + + it('should display share icon for iOS', () => { + cy.mount( + + + + + + ); + cy.get('[data-test-id="iosShareIcon"]').should('be.visible'); + cy.get('[data-test-id="defaultShareIcon"]').should('not.exist'); + }); + + it('should display default share icon', () => { + cy.mount( + + + + + + ); + cy.get('[data-test-id="defaultShareIcon"]').should('be.visible'); + cy.get('[data-test-id="iosShareIcon"]').should('not.exist'); + }); + + it('should trigger toast on click', () => { + cy.mount( + + + + + + ); + cy.get('[data-test-id="share-btn"]').should('exist'); + cy.get('[data-test-id="toast"]').should('not.exist'); + cy.get('[data-test-id="share-btn"]').click(); + cy.get('[data-testid="toast"]').should('exist'); + }); + + it('should close toast on click', () => { + cy.mount( + + + + + + ); + cy.get('[data-test-id="share-btn"]').click(); + cy.get('[data-testid="toast"]').should('be.visible'); + cy.get('[data-testid="toast-dismiss"]').click(); + cy.get('[data-testid="toast"]').should('not.exist'); + }); +}); diff --git a/web/src/features/panels/zone/ShareButton.test.tsx b/web/src/features/panels/zone/ShareButton.test.tsx new file mode 100644 index 0000000000..b1c0345fe9 --- /dev/null +++ b/web/src/features/panels/zone/ShareButton.test.tsx @@ -0,0 +1,108 @@ +import { ToastProvider } from '@radix-ui/react-toast'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, test } from 'vitest'; + +import { ShareButton } from './ShareButton'; + +const navMocks = vi.hoisted(() => ({ + share: vi.fn(), + canShare: vi.fn(), + clipboard: { + writeText: vi.fn(), + }, +})); + +Object.defineProperty(window.navigator, 'share', { value: navMocks.share }); +Object.defineProperty(window.navigator, 'canShare', { value: navMocks.canShare }); +Object.defineProperty(window.navigator, 'clipboard', { + value: { + writeText: navMocks.clipboard.writeText, + }, +}); + +const mocks = vi.hoisted(() => ({ + isMobile: vi.fn(), + isIos: vi.fn(), +})); + +vi.mock('features/weather-layers/wind-layer/util', () => mocks); + +describe('ShareButton', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('uses navigator if on mobile and can share', async () => { + navMocks.canShare.mockReturnValue(true); + mocks.isMobile.mockReturnValue(true); + render( + + + + ); + expect(window.navigator.share).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole('button')); + expect(window.navigator.share).toHaveBeenCalled(); + }); + + test('does not display error toast on share abort', async () => { + navMocks.canShare.mockReturnValue(true); + mocks.isMobile.mockReturnValue(true); + render( + + + + ); + expect(window.navigator.share).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole('button')); + expect(window.navigator.share).toHaveBeenCalled(); + await userEvent.click(document.body); + expect(screen.queryAllByTestId('toast')).toHaveLength(0); + }); + + test('displays error toast on share error', async () => { + navMocks.canShare.mockReturnValue(true); + navMocks.share.mockRejectedValue(new Error('Error!')); + mocks.isMobile.mockReturnValue(true); + render( + + + + ); + expect(window.navigator.share).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole('button')); + expect(window.navigator.share).toHaveBeenCalled(); + expect(screen.queryAllByTestId('toast')).toHaveLength(1); + }); + + describe('copies to clipboard', () => { + test('if not on mobile', async () => { + navMocks.canShare.mockReturnValue(true); + mocks.isMobile.mockReturnValue(false); + render( + + + + ); + expect(window.navigator.clipboard.writeText).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole('button')); + expect(window.navigator.clipboard.writeText).toHaveBeenCalled(); + expect(window.navigator.share).not.toHaveBeenCalled(); + }); + + test('if navigator.share is not available', async () => { + navMocks.canShare.mockReturnValue(false); + mocks.isMobile.mockReturnValue(true); + render( + + + + ); + expect(window.navigator.clipboard.writeText).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole('button')); + expect(window.navigator.clipboard.writeText).toHaveBeenCalled(); + expect(window.navigator.share).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/web/src/features/panels/zone/ShareButton.tsx b/web/src/features/panels/zone/ShareButton.tsx new file mode 100644 index 0000000000..aa048550fc --- /dev/null +++ b/web/src/features/panels/zone/ShareButton.tsx @@ -0,0 +1,103 @@ +import { Button, ButtonProps } from 'components/Button'; +import { Toast, useToastReference } from 'components/Toast'; +import { isIos, isMobile } from 'features/weather-layers/wind-layer/util'; +import { Share, Share2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { twMerge } from 'tailwind-merge'; +import { ShareType, trackShare } from 'utils/analytics'; +import { DEFAULT_ICON_SIZE } from 'utils/constants'; + +interface ShareButtonProps + extends Omit< + ButtonProps, + 'icon' | 'children' | 'href' | 'onClick' | 'backgroundClasses' | 'foregroundClasses' + > { + iconSize?: number; + shareUrl?: string; + showIosIcon?: boolean; +} +const trackShareClick = trackShare(ShareType.SHARE); +const DURATION = 3 * 1000; + +export function ShareButton({ + iconSize = DEFAULT_ICON_SIZE, + shareUrl, + showIosIcon = isIos(), + ...restProps +}: ShareButtonProps) { + const { t } = useTranslation(); + const reference = useToastReference(); + const [toastMessage, setToastMessage] = useState(''); + + const url = shareUrl ?? window.location?.href; + + const shareData = { + title: 'Electricity Maps', + text: 'Check this out!', + url, + }; + + const share = async () => { + try { + await navigator.share(shareData); + } catch (error) { + if (error instanceof Error && !/AbortError/.test(error.toString())) { + console.error(error); + setToastMessage(t('share-button.share-error')); + reference.current?.publish(); + } + } + }; + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(url); + setToastMessage(t('share-button.clipboard')); + } catch (error) { + console.error(error); + setToastMessage(t('share-button.clipboard-error')); + } finally { + reference.current?.publish(); + } + }; + + const onClick = () => { + if (isMobile() && navigator.canShare({ url })) { + share(); + } else { + copyToClipboard(); + } + trackShareClick(); + }; + + return ( + <> +