Commit eda8c127 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner Committed by Lyza Gardner

Add Export tab to `ShareAnnotationsPanel` when export feature flag on

Add basic Export tab with placeholder UI, if `export_annotations`
feature flag is enabled. For other users, share panel looks the same as
before.
parent 4b9255fb
import { import {
Button,
Card,
CardActions,
CardTitle,
CloseButton,
CopyIcon, CopyIcon,
IconButton, IconButton,
Input, Input,
InputGroup, InputGroup,
LockIcon, LockIcon,
Spinner, Spinner,
TabList,
Tab,
} from '@hypothesis/frontend-shared'; } from '@hypothesis/frontend-shared';
import { useCallback } from 'preact/hooks'; import classnames from 'classnames';
import type { ComponentChildren, JSX } from 'preact';
import { useCallback, useState } from 'preact/hooks';
import { pageSharingLink } from '../helpers/annotation-sharing'; import { pageSharingLink } from '../helpers/annotation-sharing';
import { withServices } from '../service-context'; import { withServices } from '../service-context';
...@@ -16,10 +25,73 @@ import { copyText } from '../util/copy-to-clipboard'; ...@@ -16,10 +25,73 @@ import { copyText } from '../util/copy-to-clipboard';
import ShareLinks from './ShareLinks'; import ShareLinks from './ShareLinks';
import SidebarPanel from './SidebarPanel'; import SidebarPanel from './SidebarPanel';
export type ShareAnnotationPanelProps = { function LoadingSpinner() {
// injected return (
toastMessenger: ToastMessengerService; <div
}; className="flex flex-row items-center justify-center"
data-testid="loading-spinner"
>
<Spinner size="md" />
</div>
);
}
/**
* Render a header to go above a Card, with contents in a TabList
*/
function TabHeader({ children }: { children: ComponentChildren }) {
return (
<div data-testid="tab-header" className="flex items-center">
<CloseButton
classes={classnames(
// This element comes first in source order before tabs, but is
// positioned last. This puts this button earlier in the tab
// sequence than the tabs, allowing tabs to be immediately adjacent
// to their controlled tab panels/tab content in the tab sequence.
'order-last',
// Always render this button at 16px square regardless of parent
// font size
'text-[16px]',
'text-grey-6 hover:text-grey-7 hover:bg-grey-3/50'
)}
title="Close"
variant="custom"
size="sm"
/>
<TabList classes="grow gap-x-1 -mb-[1px] z-2">{children}</TabList>
</div>
);
}
type TabPanelProps = {
active?: boolean;
title?: ComponentChildren;
} & JSX.HTMLAttributes<HTMLDivElement>;
/**
* Render a `role="tabpanel"` element within a Card layout. It will be
* visually hidden unless `active`.
*/
function TabPanel({
children,
active,
title,
...htmlAttributes
}: TabPanelProps) {
return (
<div
role="tabpanel"
{...htmlAttributes}
className={classnames('p-3 focus-visible-ring ring-inset', {
hidden: !active,
})}
hidden={!active}
>
{title && <CardTitle>{title}</CardTitle>}
<div className="space-y-3 pt-2">{children}</div>
</div>
);
}
type SharePanelContentProps = { type SharePanelContentProps = {
loading: boolean; loading: boolean;
...@@ -31,7 +103,7 @@ type SharePanelContentProps = { ...@@ -31,7 +103,7 @@ type SharePanelContentProps = {
}; };
/** /**
* Render content for "share" panel or tab * Render content for "share" panel or tab.
*/ */
function SharePanelContent({ function SharePanelContent({
groupName, groupName,
...@@ -41,11 +113,7 @@ function SharePanelContent({ ...@@ -41,11 +113,7 @@ function SharePanelContent({
shareURI, shareURI,
}: SharePanelContentProps) { }: SharePanelContentProps) {
if (loading) { if (loading) {
return ( return <LoadingSpinner />;
<div className="flex flex-row items-center justify-center">
<Spinner size="md" />
</div>
);
} }
return ( return (
...@@ -113,6 +181,43 @@ function SharePanelContent({ ...@@ -113,6 +181,43 @@ function SharePanelContent({
); );
} }
type ExportPanelContentProps = {
loading: boolean;
annotationCount: number;
};
/**
* Render content for "export" tab panel
*/
function ExportPanelContent({
loading,
annotationCount,
}: ExportPanelContentProps) {
if (loading) {
return <LoadingSpinner />;
}
// TODO: Handle 0 annotations
return (
<>
<p>
Export <strong>{annotationCount} annotations</strong> in a file named:
</p>
<Input id="export-filename" value="filename-tbd-export.json" />
<CardActions>
<Button variant="primary" disabled>
Export
</Button>
</CardActions>
</>
);
}
export type ShareAnnotationPanelProps = {
// injected
toastMessenger: ToastMessengerService;
};
/** /**
* A panel for sharing the current group's annotations on the current document. * A panel for sharing the current group's annotations on the current document.
* *
...@@ -126,14 +231,21 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) { ...@@ -126,14 +231,21 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) {
const focusedGroup = store.focusedGroup(); const focusedGroup = store.focusedGroup();
const groupName = (focusedGroup && focusedGroup.name) || '...'; const groupName = (focusedGroup && focusedGroup.name) || '...';
const panelTitle = `Share Annotations in ${groupName}`; const panelTitle = `Share Annotations in ${groupName}`;
const allAnnotations = store.allAnnotations();
const tabbedDialog = store.isFeatureEnabled('export_annotations');
const [selectedTab, setSelectedTab] = useState<'share' | 'export'>('share');
// To be able to concoct a sharing link, a focused group and frame need to // To be able to concoct a sharing link, a focused group and frame need to
// be available // be available
const sharingReady = focusedGroup && mainFrame; const sharingReady = focusedGroup && mainFrame;
// Show a loading spinner in the export tab if annotations are loading
const exportReady = focusedGroup && !store.isLoading();
const shareURI = const shareURI =
sharingReady && pageSharingLink(mainFrame.uri, focusedGroup.id); sharingReady && pageSharingLink(mainFrame.uri, focusedGroup.id);
// TODO: Move into Share-panel-content component once extracted
const copyShareLink = useCallback(() => { const copyShareLink = useCallback(() => {
try { try {
if (shareURI) { if (shareURI) {
...@@ -146,7 +258,65 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) { ...@@ -146,7 +258,65 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) {
}, [shareURI, toastMessenger]); }, [shareURI, toastMessenger]);
return ( return (
<SidebarPanel title={panelTitle} panelName="shareGroupAnnotations"> <SidebarPanel
title={panelTitle}
panelName="shareGroupAnnotations"
variant={tabbedDialog ? 'custom' : 'panel'}
>
{tabbedDialog && (
<>
<TabHeader>
<Tab
id="share-panel-tab"
aria-controls="share-panel"
variant="tab"
selected={selectedTab === 'share'}
onClick={() => setSelectedTab('share')}
textContent={'Share'}
>
Share
</Tab>
<Tab
id="export-panel-tab"
aria-controls="export-panel"
variant="tab"
selected={selectedTab === 'export'}
onClick={() => setSelectedTab('export')}
textContent="Export"
>
Export
</Tab>
</TabHeader>
<Card>
<TabPanel
id="share-panel"
active={selectedTab === 'share'}
aria-labelledby="share-panel-tab"
title={panelTitle}
>
<SharePanelContent
groupName={focusedGroup?.name}
groupType={focusedGroup?.type}
loading={!sharingReady}
onCopyShareLink={copyShareLink}
shareURI={shareURI}
/>
</TabPanel>
<TabPanel
id="export-panel"
active={selectedTab === 'export'}
aria-labelledby="export-panel-tab"
title={`Export from ${focusedGroup?.name ?? '...'}`}
>
<ExportPanelContent
annotationCount={allAnnotations.length}
loading={!exportReady}
/>
</TabPanel>
</Card>
</>
)}
{!tabbedDialog && (
<SharePanelContent <SharePanelContent
groupName={focusedGroup?.name} groupName={focusedGroup?.name}
groupType={focusedGroup?.type} groupType={focusedGroup?.type}
...@@ -154,6 +324,7 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) { ...@@ -154,6 +324,7 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) {
onCopyShareLink={copyShareLink} onCopyShareLink={copyShareLink}
shareURI={shareURI} shareURI={shareURI}
/> />
)}
</SidebarPanel> </SidebarPanel>
); );
} }
......
import { mount } from 'enzyme'; import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { checkAccessibility } from '../../../test-util/accessibility'; import { checkAccessibility } from '../../../test-util/accessibility';
import { mockImportedComponents } from '../../../test-util/mock-imported-components'; import { mockImportedComponents } from '../../../test-util/mock-imported-components';
...@@ -36,7 +37,10 @@ describe('ShareAnnotationsPanel', () => { ...@@ -36,7 +37,10 @@ describe('ShareAnnotationsPanel', () => {
}; };
fakeStore = { fakeStore = {
allAnnotations: sinon.stub().returns(0),
focusedGroup: sinon.stub().returns(fakePrivateGroup), focusedGroup: sinon.stub().returns(fakePrivateGroup),
isLoading: sinon.stub().returns(false),
isFeatureEnabled: sinon.stub().returns(false),
mainFrame: () => ({ mainFrame: () => ({
uri: 'https://www.example.com', uri: 'https://www.example.com',
}), }),
...@@ -56,8 +60,8 @@ describe('ShareAnnotationsPanel', () => { ...@@ -56,8 +60,8 @@ describe('ShareAnnotationsPanel', () => {
$imports.$restore(); $imports.$restore();
}); });
describe('panel title', () => { describe('panel dialog title', () => {
it("sets sidebar panel title to include group's name", () => { it("sets sidebar panel dialog title to include group's name", () => {
const wrapper = createShareAnnotationsPanel(); const wrapper = createShareAnnotationsPanel();
assert.equal( assert.equal(
...@@ -77,8 +81,8 @@ describe('ShareAnnotationsPanel', () => { ...@@ -77,8 +81,8 @@ describe('ShareAnnotationsPanel', () => {
}); });
}); });
describe('panel content', () => { describe('share panel content', () => {
it('does not render panel content if needed info not available', () => { it('does not render share panel content if needed info not available', () => {
fakeStore.focusedGroup.returns(undefined); fakeStore.focusedGroup.returns(undefined);
const wrapper = createShareAnnotationsPanel(); const wrapper = createShareAnnotationsPanel();
...@@ -200,6 +204,92 @@ describe('ShareAnnotationsPanel', () => { ...@@ -200,6 +204,92 @@ describe('ShareAnnotationsPanel', () => {
}); });
}); });
describe('tabbed dialog panel', () => {
it('does not render a tabbed dialog if export feature flag is not enabled', () => {
const wrapper = createShareAnnotationsPanel();
assert.isFalse(wrapper.find('TabHeader').exists());
});
context('export feature enabled', () => {
beforeEach(() => {
fakeStore.isFeatureEnabled.withArgs('export_annotations').returns(true);
});
it('renders a tabbed dialog with share panel active', () => {
const wrapper = createShareAnnotationsPanel();
assert.isTrue(wrapper.find('TabHeader').exists());
assert.isTrue(
wrapper.find('Tab[aria-controls="share-panel"]').props().selected
);
assert.isTrue(
wrapper.find('TabPanel[id="share-panel"]').props().active
);
});
it('shows the export tab panel when export tab clicked', () => {
const wrapper = createShareAnnotationsPanel();
const shareTabSelector = 'Tab[aria-controls="share-panel"]';
const exportTabSelector = 'Tab[aria-controls="export-panel"]';
act(() => {
wrapper
.find(exportTabSelector)
.getDOMNode()
.dispatchEvent(new Event('click'));
});
wrapper.update();
const selectedTab = wrapper.find('Tab').filter({ selected: true });
assert.equal(selectedTab.text(), 'Export');
assert.equal(selectedTab.props()['aria-controls'], 'export-panel');
const activeTabPanel = wrapper
.find('TabPanel')
.filter({ active: true });
assert.equal(activeTabPanel.props().id, 'export-panel');
assert.isTrue(activeTabPanel.find('Input').exists());
// Now, reselect share tab
act(() => {
wrapper
.find(shareTabSelector)
.getDOMNode()
.dispatchEvent(new Event('click'));
});
wrapper.update();
const shareTabPanel = wrapper.find('TabPanel').filter({ active: true });
assert.equal(shareTabPanel.props().id, 'share-panel');
});
it('shows a loading indicator on the export tab if not ready', () => {
const wrapper = createShareAnnotationsPanel();
const exportTabSelector = 'Tab[aria-controls="export-panel"]';
fakeStore.isLoading.returns(true);
act(() => {
wrapper
.find(exportTabSelector)
.getDOMNode()
.dispatchEvent(new Event('click'));
});
wrapper.update();
const activeTabPanel = wrapper
.find('TabPanel')
.filter({ active: true });
assert.equal(activeTabPanel.props().id, 'export-panel');
assert.isFalse(activeTabPanel.find('Input').exists());
assert.isTrue(
activeTabPanel.find('[data-testid="loading-spinner"]').exists()
);
});
});
});
// TODO: Add a11y test for tabbed interface
it( it(
'should pass a11y checks', 'should pass a11y checks',
checkAccessibility({ checkAccessibility({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment