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 {
Button,
Card,
CardActions,
CardTitle,
CloseButton,
CopyIcon,
IconButton,
Input,
InputGroup,
LockIcon,
Spinner,
TabList,
Tab,
} 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 { withServices } from '../service-context';
......@@ -16,10 +25,73 @@ import { copyText } from '../util/copy-to-clipboard';
import ShareLinks from './ShareLinks';
import SidebarPanel from './SidebarPanel';
export type ShareAnnotationPanelProps = {
// injected
toastMessenger: ToastMessengerService;
};
function LoadingSpinner() {
return (
<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 = {
loading: boolean;
......@@ -31,7 +103,7 @@ type SharePanelContentProps = {
};
/**
* Render content for "share" panel or tab
* Render content for "share" panel or tab.
*/
function SharePanelContent({
groupName,
......@@ -41,11 +113,7 @@ function SharePanelContent({
shareURI,
}: SharePanelContentProps) {
if (loading) {
return (
<div className="flex flex-row items-center justify-center">
<Spinner size="md" />
</div>
);
return <LoadingSpinner />;
}
return (
......@@ -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.
*
......@@ -126,14 +231,21 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) {
const focusedGroup = store.focusedGroup();
const groupName = (focusedGroup && focusedGroup.name) || '...';
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
// be available
const sharingReady = focusedGroup && mainFrame;
// Show a loading spinner in the export tab if annotations are loading
const exportReady = focusedGroup && !store.isLoading();
const shareURI =
sharingReady && pageSharingLink(mainFrame.uri, focusedGroup.id);
// TODO: Move into Share-panel-content component once extracted
const copyShareLink = useCallback(() => {
try {
if (shareURI) {
......@@ -146,7 +258,65 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) {
}, [shareURI, toastMessenger]);
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
groupName={focusedGroup?.name}
groupType={focusedGroup?.type}
......@@ -154,6 +324,7 @@ function ShareAnnotationsPanel({ toastMessenger }: ShareAnnotationPanelProps) {
onCopyShareLink={copyShareLink}
shareURI={shareURI}
/>
)}
</SidebarPanel>
);
}
......
import { mount } from 'enzyme';
import { act } from 'preact/test-utils';
import { checkAccessibility } from '../../../test-util/accessibility';
import { mockImportedComponents } from '../../../test-util/mock-imported-components';
......@@ -36,7 +37,10 @@ describe('ShareAnnotationsPanel', () => {
};
fakeStore = {
allAnnotations: sinon.stub().returns(0),
focusedGroup: sinon.stub().returns(fakePrivateGroup),
isLoading: sinon.stub().returns(false),
isFeatureEnabled: sinon.stub().returns(false),
mainFrame: () => ({
uri: 'https://www.example.com',
}),
......@@ -56,8 +60,8 @@ describe('ShareAnnotationsPanel', () => {
$imports.$restore();
});
describe('panel title', () => {
it("sets sidebar panel title to include group's name", () => {
describe('panel dialog title', () => {
it("sets sidebar panel dialog title to include group's name", () => {
const wrapper = createShareAnnotationsPanel();
assert.equal(
......@@ -77,8 +81,8 @@ describe('ShareAnnotationsPanel', () => {
});
});
describe('panel content', () => {
it('does not render panel content if needed info not available', () => {
describe('share panel content', () => {
it('does not render share panel content if needed info not available', () => {
fakeStore.focusedGroup.returns(undefined);
const wrapper = createShareAnnotationsPanel();
......@@ -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(
'should pass a11y checks',
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