Skip to content

Commit

Permalink
Feat: Share button in zone header (#7240)
Browse files Browse the repository at this point in the history
* 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 <cadence@Cadences-MacBook-Pro.local>

* 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 <cadence@Cadences-MacBook-Pro.local>
  • Loading branch information
cadeban and Cadence authored Oct 3, 2024
1 parent 7c72fbe commit d19a6d0
Show file tree
Hide file tree
Showing 10 changed files with 374 additions and 40 deletions.
40 changes: 31 additions & 9 deletions web/src/components/Toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -20,32 +23,40 @@ describe('Toast', () => {
});

test('renders', () => {
const reference = createRef<ToastController>();
render(
<ToastProvider>
<Toast {...props} />
<Toast ref={reference} {...props} />
</ToastProvider>
);
act(() => reference.current?.publish());

expect(screen.getByTestId('toast')).toBeDefined();
});

describe('renders by type', () => {
test('info', () => {
const reference = createRef<ToastController>();
render(
<ToastProvider>
<Toast {...props} type={ToastType.INFO} />
<Toast ref={reference} {...props} type={ToastType.INFO} />
</ToastProvider>
);
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<ToastController>();
render(
<ToastProvider>
<Toast {...props} type={ToastType.SUCCESS} />)
<Toast ref={reference} {...props} type={ToastType.SUCCESS} />
</ToastProvider>
);
act(() => reference.current?.publish());

expect(screen.getByTestId('toast').classList.contains('text-emerald-800')).toBe(
true
);
Expand All @@ -55,35 +66,44 @@ describe('Toast', () => {
});

test('warning', () => {
const reference = createRef<ToastController>();
render(
<ToastProvider>
<Toast {...props} type={ToastType.WARNING} />
<Toast ref={reference} {...props} type={ToastType.WARNING} />
</ToastProvider>
);
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')
).toBe(true);
});

test('danger', () => {
const reference = createRef<ToastController>();
render(
<ToastProvider>
<Toast {...props} type={ToastType.DANGER} />
<Toast ref={reference} {...props} type={ToastType.DANGER} />
</ToastProvider>
);
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')
).toBe(true);
});

test('default', () => {
const reference = createRef<ToastController>();
render(
<ToastProvider>
<Toast {...props} />
<Toast ref={reference} {...props} />
</ToastProvider>
);
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
Expand All @@ -92,13 +112,15 @@ describe('Toast', () => {
});

test('clicking dismiss closes ', async () => {
const reference = createRef<ToastController>();
render(
<ToastProvider>
<Toast {...props} />
<Toast ref={reference} {...props} />
</ToastProvider>
);

act(() => reference.current?.publish());
await userEvent.click(screen.getByTestId('toast-dismiss'));

expect(screen.queryAllByTestId('toast')).toHaveLength(0);
});
});
44 changes: 30 additions & 14 deletions web/src/components/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<ToastController>(null);

export const Toast = forwardRef<ToastController, ToastProps>(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);
Expand All @@ -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,
Expand Down Expand Up @@ -128,4 +144,4 @@ export function Toast({
<ToastPrimitive.Viewport />
</>
);
}
});
66 changes: 66 additions & 0 deletions web/src/features/panels/zone/ShareButton.cy.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ShareButton showIosIcon={true} />
</ToastProvider>
</QueryClientProvider>
);
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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ShareButton showIosIcon={false} />
</ToastProvider>
</QueryClientProvider>
);
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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ShareButton showIosIcon={false} />
</ToastProvider>
</QueryClientProvider>
);
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(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<ShareButton showIosIcon={false} />
</ToastProvider>
</QueryClientProvider>
);
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');
});
});
108 changes: 108 additions & 0 deletions web/src/features/panels/zone/ShareButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ToastProvider>
<ShareButton />
</ToastProvider>
);
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(
<ToastProvider>
<ShareButton />
</ToastProvider>
);
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(
<ToastProvider>
<ShareButton />
</ToastProvider>
);
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(
<ToastProvider>
<ShareButton />
</ToastProvider>
);
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(
<ToastProvider>
<ShareButton />
</ToastProvider>
);
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();
});
});
});
Loading

0 comments on commit d19a6d0

Please sign in to comment.