Commit 19aed53e authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Add copy to clipboard button to export annotations

parent d823e0f6
...@@ -85,6 +85,7 @@ function createSidebarIframe(config: SidebarConfig): HTMLIFrameElement { ...@@ -85,6 +85,7 @@ function createSidebarIframe(config: SidebarConfig): HTMLIFrameElement {
sidebarFrame.src = sidebarAppSrc; sidebarFrame.src = sidebarAppSrc;
sidebarFrame.title = 'Hypothesis annotation viewer'; sidebarFrame.title = 'Hypothesis annotation viewer';
sidebarFrame.className = 'sidebar-frame'; sidebarFrame.className = 'sidebar-frame';
sidebarFrame.allow = 'clipboard-write';
return sidebarFrame; return sidebarFrame;
} }
......
...@@ -19,25 +19,6 @@ function downloadFile( ...@@ -19,25 +19,6 @@ function downloadFile(
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
/**
* Download a file containing JSON-serialized `object` as `filename`
*
* @param data - JSON-serializable object
* @return The contents of the downloaded file
* @throws {Error} If provided data cannot be JSON-serialized
*/
export function downloadJSONFile(
data: object,
filename: string,
/* istanbul ignore next - test seam */
_document = document,
): string {
const fileContent = JSON.stringify(data, null, 2);
downloadFile(fileContent, 'application/json', filename, _document);
return fileContent;
}
function buildTextFileDownloader(type: string) { function buildTextFileDownloader(type: string) {
return ( return (
text: string, text: string,
...@@ -47,6 +28,8 @@ function buildTextFileDownloader(type: string) { ...@@ -47,6 +28,8 @@ function buildTextFileDownloader(type: string) {
) => downloadFile(text, type, filename, _document); ) => downloadFile(text, type, filename, _document);
} }
export const downloadJSONFile = buildTextFileDownloader('application/json');
export const downloadTextFile = buildTextFileDownloader('text/plain'); export const downloadTextFile = buildTextFileDownloader('text/plain');
export const downloadCSVFile = buildTextFileDownloader('text/csv'); export const downloadCSVFile = buildTextFileDownloader('text/csv');
......
...@@ -48,13 +48,12 @@ describe('download-file', () => { ...@@ -48,13 +48,12 @@ describe('download-file', () => {
} }
it('downloadJSONFile generates JSON file with provided data', () => { it('downloadJSONFile generates JSON file with provided data', () => {
const data = { foo: ['bar', 'baz'] }; const data = JSON.stringify({ foo: ['bar', 'baz'] }, null, 2);
const filename = 'my-file.json'; const filename = 'my-file.json';
const fileContent = downloadJSONFile(data, filename, fakeDocument); downloadJSONFile(data, filename, fakeDocument);
assert.equal(fileContent, JSON.stringify(data, null, 2)); assertDownloadHappened(filename, data, 'application/json');
assertDownloadHappened(filename, fileContent, 'application/json');
}); });
it('downloadTextFile generates text file with provided data', () => { it('downloadTextFile generates text file with provided data', () => {
......
...@@ -4,6 +4,7 @@ import { ...@@ -4,6 +4,7 @@ import {
Link, Link,
Input, Input,
SelectNext, SelectNext,
CopyIcon,
} from '@hypothesis/frontend-shared'; } from '@hypothesis/frontend-shared';
import { useCallback, useId, useMemo, useState } from 'preact/hooks'; import { useCallback, useId, useMemo, useState } from 'preact/hooks';
...@@ -22,6 +23,7 @@ import { withServices } from '../../service-context'; ...@@ -22,6 +23,7 @@ import { withServices } from '../../service-context';
import type { AnnotationsExporter } from '../../services/annotations-exporter'; import type { AnnotationsExporter } from '../../services/annotations-exporter';
import type { ToastMessengerService } from '../../services/toast-messenger'; import type { ToastMessengerService } from '../../services/toast-messenger';
import { useSidebarStore } from '../../store'; import { useSidebarStore } from '../../store';
import { copyPlainText, copyHTML } from '../../util/copy-to-clipboard';
import LoadingSpinner from './LoadingSpinner'; import LoadingSpinner from './LoadingSpinner';
import { UserAnnotationsListItem } from './UserAnnotationsListItem'; import { UserAnnotationsListItem } from './UserAnnotationsListItem';
...@@ -133,30 +135,20 @@ function ExportAnnotations({ ...@@ -133,30 +135,20 @@ function ExportAnnotations({
); );
const [customFilename, setCustomFilename] = useState<string>(); const [customFilename, setCustomFilename] = useState<string>();
if (!exportReady) { const buildExportContent = useCallback(
return <LoadingSpinner />; (format: ExportFormat['value']): string => {
}
const exportAnnotations = (e: Event) => {
e.preventDefault();
try {
const format = exportFormat.value;
const annotationsToExport = const annotationsToExport =
selectedUser?.annotations ?? exportableAnnotations; selectedUser?.annotations ?? exportableAnnotations;
const filename = `${customFilename ?? defaultFilename}.${format}`;
switch (format) { switch (format) {
case 'json': { case 'json': {
const exportData = annotationsExporter.buildJSONExportContent( const data = annotationsExporter.buildJSONExportContent(
annotationsToExport, annotationsToExport,
{ profile }, { profile },
); );
downloadJSONFile(exportData, filename); return JSON.stringify(data, null, 2);
break;
} }
case 'txt': { case 'txt': {
const exportData = annotationsExporter.buildTextExportContent( return annotationsExporter.buildTextExportContent(
annotationsToExport, annotationsToExport,
{ {
groupName: group?.name, groupName: group?.name,
...@@ -164,11 +156,9 @@ function ExportAnnotations({ ...@@ -164,11 +156,9 @@ function ExportAnnotations({
displayNamesEnabled, displayNamesEnabled,
}, },
); );
downloadTextFile(exportData, filename);
break;
} }
case 'csv': { case 'csv': {
const exportData = annotationsExporter.buildCSVExportContent( return annotationsExporter.buildCSVExportContent(
annotationsToExport, annotationsToExport,
{ {
groupName: group?.name, groupName: group?.name,
...@@ -176,11 +166,9 @@ function ExportAnnotations({ ...@@ -176,11 +166,9 @@ function ExportAnnotations({
displayNamesEnabled, displayNamesEnabled,
}, },
); );
downloadCSVFile(exportData, filename);
break;
} }
case 'html': { case 'html': {
const exportData = annotationsExporter.buildHTMLExportContent( return annotationsExporter.buildHTMLExportContent(
annotationsToExport, annotationsToExport,
{ {
groupName: group?.name, groupName: group?.name,
...@@ -188,6 +176,45 @@ function ExportAnnotations({ ...@@ -188,6 +176,45 @@ function ExportAnnotations({
displayNamesEnabled, displayNamesEnabled,
}, },
); );
}
/* istanbul ignore next - This should never happen */
default:
throw new Error(`Invalid format: ${format}`);
}
},
[
annotationsExporter,
defaultAuthority,
displayNamesEnabled,
exportableAnnotations,
group?.name,
profile,
selectedUser?.annotations,
],
);
const exportAnnotations = useCallback(
(e: Event) => {
e.preventDefault();
try {
const format = exportFormat.value;
const filename = `${customFilename ?? defaultFilename}.${format}`;
const exportData = buildExportContent(format);
switch (format) {
case 'json': {
downloadJSONFile(exportData, filename);
break;
}
case 'txt': {
downloadTextFile(exportData, filename);
break;
}
case 'csv': {
downloadCSVFile(exportData, filename);
break;
}
case 'html': {
downloadHTMLFile(exportData, filename); downloadHTMLFile(exportData, filename);
break; break;
} }
...@@ -195,7 +222,35 @@ function ExportAnnotations({ ...@@ -195,7 +222,35 @@ function ExportAnnotations({
} catch (e) { } catch (e) {
toastMessenger.error('Exporting annotations failed'); toastMessenger.error('Exporting annotations failed');
} }
}; },
[
buildExportContent,
customFilename,
defaultFilename,
exportFormat.value,
toastMessenger,
],
);
const copyAnnotationsExport = useCallback(async () => {
const format = exportFormat.value;
const exportData = buildExportContent(format);
try {
if (format === 'html') {
await copyHTML(exportData);
} else {
await copyPlainText(exportData);
}
toastMessenger.success('Annotations copied');
} catch (e) {
toastMessenger.error('Copying annotations failed');
}
}, [buildExportContent, exportFormat.value, toastMessenger]);
if (!exportReady) {
return <LoadingSpinner />;
}
// Naive simple English pluralization // Naive simple English pluralization
const pluralize = (count: number, singular: string, plural: string) => { const pluralize = (count: number, singular: string, plural: string) => {
...@@ -310,6 +365,16 @@ function ExportAnnotations({ ...@@ -310,6 +365,16 @@ function ExportAnnotations({
</p> </p>
)} )}
<CardActions> <CardActions>
{exportFormatsEnabled && (
<Button
data-testid="copy-button"
icon={CopyIcon}
onClick={copyAnnotationsExport}
disabled={exportableAnnotations.length === 0}
>
Copy to clipboard
</Button>
)}
<Button <Button
data-testid="export-button" data-testid="export-button"
variant="primary" variant="primary"
......
...@@ -19,6 +19,8 @@ describe('ExportAnnotations', () => { ...@@ -19,6 +19,8 @@ describe('ExportAnnotations', () => {
let fakeDownloadCSVFile; let fakeDownloadCSVFile;
let fakeDownloadHTMLFile; let fakeDownloadHTMLFile;
let fakeSuggestedFilename; let fakeSuggestedFilename;
let fakeCopyPlainText;
let fakeCopyHTML;
const fakePrivateGroup = { const fakePrivateGroup = {
type: 'private', type: 'private',
...@@ -44,6 +46,7 @@ describe('ExportAnnotations', () => { ...@@ -44,6 +46,7 @@ describe('ExportAnnotations', () => {
}; };
fakeToastMessenger = { fakeToastMessenger = {
error: sinon.stub(), error: sinon.stub(),
success: sinon.stub(),
}; };
fakeDownloadJSONFile = sinon.stub(); fakeDownloadJSONFile = sinon.stub();
fakeDownloadTextFile = sinon.stub(); fakeDownloadTextFile = sinon.stub();
...@@ -64,6 +67,8 @@ describe('ExportAnnotations', () => { ...@@ -64,6 +67,8 @@ describe('ExportAnnotations', () => {
.returns([fixtures.oldAnnotation(), fixtures.oldAnnotation()]), .returns([fixtures.oldAnnotation(), fixtures.oldAnnotation()]),
}; };
fakeSuggestedFilename = sinon.stub().returns('suggested-filename'); fakeSuggestedFilename = sinon.stub().returns('suggested-filename');
fakeCopyPlainText = sinon.stub().resolves(undefined);
fakeCopyHTML = sinon.stub().resolves(undefined);
$imports.$mock(mockImportedComponents()); $imports.$mock(mockImportedComponents());
...@@ -78,6 +83,10 @@ describe('ExportAnnotations', () => { ...@@ -78,6 +83,10 @@ describe('ExportAnnotations', () => {
suggestedFilename: fakeSuggestedFilename, suggestedFilename: fakeSuggestedFilename,
}, },
'../../store': { useSidebarStore: () => fakeStore }, '../../store': { useSidebarStore: () => fakeStore },
'../../util/copy-to-clipboard': {
copyPlainText: fakeCopyPlainText,
copyHTML: fakeCopyHTML,
},
}); });
$imports.$restore({ $imports.$restore({
...@@ -96,6 +105,15 @@ describe('ExportAnnotations', () => { ...@@ -96,6 +105,15 @@ describe('ExportAnnotations', () => {
const waitForTestId = async (wrapper, testId) => const waitForTestId = async (wrapper, testId) =>
waitForElement(wrapper, `[data-testid="${testId}"]`); waitForElement(wrapper, `[data-testid="${testId}"]`);
const selectExportFormat = async (wrapper, format) => {
const select = await waitForElement(
wrapper,
'[data-testid="export-format-select"]',
);
select.props().onChange({ value: format });
wrapper.update();
};
context('export annotations not ready (loading)', () => { context('export annotations not ready (loading)', () => {
it('renders a loading spinner if there is no focused group', () => { it('renders a loading spinner if there is no focused group', () => {
fakeStore.focusedGroup.returns(null); fakeStore.focusedGroup.returns(null);
...@@ -288,14 +306,6 @@ describe('ExportAnnotations', () => { ...@@ -288,14 +306,6 @@ describe('ExportAnnotations', () => {
describe('export form submitted', () => { describe('export form submitted', () => {
const submitExportForm = wrapper => const submitExportForm = wrapper =>
wrapper.find('[data-testid="export-form"]').simulate('submit'); wrapper.find('[data-testid="export-form"]').simulate('submit');
const selectExportFormat = async (wrapper, format) => {
const select = await waitForElement(
wrapper,
'[data-testid="export-format-select"]',
);
select.props().onChange({ value: format });
wrapper.update();
};
[ [
{ {
...@@ -456,6 +466,70 @@ describe('ExportAnnotations', () => { ...@@ -456,6 +466,70 @@ describe('ExportAnnotations', () => {
}); });
}); });
context('when copying annotations export to clipboard', () => {
[true, false].forEach(exportFormatsEnabled => {
it('displays copy button if `export_formats` FF is enabled', () => {
fakeStore.isFeatureEnabled.callsFake(
ff => exportFormatsEnabled || ff !== 'export_formats',
);
const wrapper = createComponent();
assert.equal(
wrapper.exists('[data-testid="copy-button"]'),
exportFormatsEnabled,
);
});
});
[
{
format: 'json',
getExpectedInvokedCallback: () => fakeCopyPlainText,
},
{
format: 'txt',
getExpectedInvokedCallback: () => fakeCopyPlainText,
},
{
format: 'csv',
getExpectedInvokedCallback: () => fakeCopyPlainText,
},
{
format: 'html',
getExpectedInvokedCallback: () => fakeCopyHTML,
},
].forEach(({ format, getExpectedInvokedCallback }) => {
it('copies export content as rich or plain text depending on format', async () => {
fakeStore.isFeatureEnabled.callsFake(ff => ff === 'export_formats');
const wrapper = createComponent();
const copyButton = wrapper.find('button[data-testid="copy-button"]');
await selectExportFormat(wrapper, format);
await act(() => {
copyButton.simulate('click');
});
assert.called(getExpectedInvokedCallback());
assert.calledWith(fakeToastMessenger.success, 'Annotations copied');
});
});
it('adds error toast message when copying annotations fails', async () => {
fakeStore.isFeatureEnabled.callsFake(ff => ff === 'export_formats');
fakeCopyPlainText.rejects(new Error('Something failed'));
const wrapper = createComponent();
const copyButton = wrapper.find('button[data-testid="copy-button"]');
await act(() => {
copyButton.simulate('click');
});
assert.calledWith(fakeToastMessenger.error, 'Copying annotations failed');
});
});
context('no annotations available to export', () => { context('no annotations available to export', () => {
beforeEach(() => { beforeEach(() => {
fakeStore.savedAnnotations.returns([]); fakeStore.savedAnnotations.returns([]);
......
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
* @throws {Error} * @throws {Error}
* This function may throw an exception if the browser rejects the attempt * This function may throw an exception if the browser rejects the attempt
* to copy text. * to copy text.
*
* @deprecated Use copyPlainText instead
*/ */
export function copyText(text: string) { export function copyText(text: string) {
const temp = document.createElement('textarea'); // use textarea instead of input to preserve line breaks const temp = document.createElement('textarea'); // use textarea instead of input to preserve line breaks
...@@ -30,3 +32,35 @@ export function copyText(text: string) { ...@@ -30,3 +32,35 @@ export function copyText(text: string) {
temp.remove(); temp.remove();
} }
} }
/**
* Copy the string `text` to the clipboard verbatim.
*
* @throws {Error}
* This function may throw an error if the `clipboard-write` permission was
* not allowed.
*/
export async function copyPlainText(text: string, navigator_ = navigator) {
await navigator_.clipboard.writeText(text);
}
/**
* Copy the string `text` to the clipboard, rendering HTML if any, instead of
* raw markup.
*
* If the browser does not support this, it will fall back to copy the string
* as plain text.
*
* @throws {Error}
* This function may throw an error if the `clipboard-write` permission was
* not allowed.
*/
export async function copyHTML(text: string, navigator_ = navigator) {
if (!navigator_.clipboard.write) {
await copyPlainText(text, navigator_);
} else {
const type = 'text/html';
const blob = new Blob([text], { type });
await navigator_.clipboard.write([new ClipboardItem({ [type]: blob })]);
}
}
import { copyText } from '../copy-to-clipboard'; import { copyPlainText, copyHTML, copyText } from '../copy-to-clipboard';
describe('copy-to-clipboard', () => { describe('copy-to-clipboard', () => {
const createFakeNavigator = ({ supportsWrite = true } = {}) => ({
clipboard: {
writeText: sinon.stub(),
write: supportsWrite ? sinon.stub() : undefined,
},
});
describe('copyText', () => {
beforeEach(() => { beforeEach(() => {
sinon.stub(document, 'execCommand'); sinon.stub(document, 'execCommand');
}); });
...@@ -9,7 +17,6 @@ describe('copy-to-clipboard', () => { ...@@ -9,7 +17,6 @@ describe('copy-to-clipboard', () => {
document.execCommand.restore(); document.execCommand.restore();
}); });
describe('copyText', () => {
/** /**
* Returns the temporary element used to hold text being copied. * Returns the temporary element used to hold text being copied.
*/ */
...@@ -51,4 +58,37 @@ describe('copy-to-clipboard', () => { ...@@ -51,4 +58,37 @@ describe('copy-to-clipboard', () => {
assert.isNull(tempSpan()); assert.isNull(tempSpan());
}); });
}); });
describe('copyPlainText', () => {
it('writes provided text to clipboard', async () => {
const text = 'Lorem ipsum dolor sit amet';
const navigator = createFakeNavigator();
await copyPlainText(text, navigator);
assert.calledWith(navigator.clipboard.writeText, text);
assert.notCalled(navigator.clipboard.write);
});
});
describe('copyHTML', () => {
it('writes provided text to clipboard', async () => {
const text = 'Lorem ipsum dolor sit amet';
const navigator = createFakeNavigator();
await copyHTML(text, navigator);
assert.called(navigator.clipboard.write);
assert.notCalled(navigator.clipboard.writeText);
});
it('falls back to plain text if rich text is not supported', async () => {
const text = 'Lorem ipsum dolor sit amet';
const navigator = createFakeNavigator({ supportsWrite: false });
await copyHTML(text, navigator);
assert.calledWith(navigator.clipboard.writeText, text);
});
});
}); });
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