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 {
sidebarFrame.src = sidebarAppSrc;
sidebarFrame.title = 'Hypothesis annotation viewer';
sidebarFrame.className = 'sidebar-frame';
sidebarFrame.allow = 'clipboard-write';
return sidebarFrame;
}
......
......@@ -19,25 +19,6 @@ function downloadFile(
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) {
return (
text: string,
......@@ -47,6 +28,8 @@ function buildTextFileDownloader(type: string) {
) => downloadFile(text, type, filename, _document);
}
export const downloadJSONFile = buildTextFileDownloader('application/json');
export const downloadTextFile = buildTextFileDownloader('text/plain');
export const downloadCSVFile = buildTextFileDownloader('text/csv');
......
......@@ -48,13 +48,12 @@ describe('download-file', () => {
}
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 fileContent = downloadJSONFile(data, filename, fakeDocument);
downloadJSONFile(data, filename, fakeDocument);
assert.equal(fileContent, JSON.stringify(data, null, 2));
assertDownloadHappened(filename, fileContent, 'application/json');
assertDownloadHappened(filename, data, 'application/json');
});
it('downloadTextFile generates text file with provided data', () => {
......
......@@ -4,6 +4,7 @@ import {
Link,
Input,
SelectNext,
CopyIcon,
} from '@hypothesis/frontend-shared';
import { useCallback, useId, useMemo, useState } from 'preact/hooks';
......@@ -22,6 +23,7 @@ import { withServices } from '../../service-context';
import type { AnnotationsExporter } from '../../services/annotations-exporter';
import type { ToastMessengerService } from '../../services/toast-messenger';
import { useSidebarStore } from '../../store';
import { copyPlainText, copyHTML } from '../../util/copy-to-clipboard';
import LoadingSpinner from './LoadingSpinner';
import { UserAnnotationsListItem } from './UserAnnotationsListItem';
......@@ -133,30 +135,20 @@ function ExportAnnotations({
);
const [customFilename, setCustomFilename] = useState<string>();
if (!exportReady) {
return <LoadingSpinner />;
}
const exportAnnotations = (e: Event) => {
e.preventDefault();
try {
const format = exportFormat.value;
const buildExportContent = useCallback(
(format: ExportFormat['value']): string => {
const annotationsToExport =
selectedUser?.annotations ?? exportableAnnotations;
const filename = `${customFilename ?? defaultFilename}.${format}`;
switch (format) {
case 'json': {
const exportData = annotationsExporter.buildJSONExportContent(
const data = annotationsExporter.buildJSONExportContent(
annotationsToExport,
{ profile },
);
downloadJSONFile(exportData, filename);
break;
return JSON.stringify(data, null, 2);
}
case 'txt': {
const exportData = annotationsExporter.buildTextExportContent(
return annotationsExporter.buildTextExportContent(
annotationsToExport,
{
groupName: group?.name,
......@@ -164,11 +156,9 @@ function ExportAnnotations({
displayNamesEnabled,
},
);
downloadTextFile(exportData, filename);
break;
}
case 'csv': {
const exportData = annotationsExporter.buildCSVExportContent(
return annotationsExporter.buildCSVExportContent(
annotationsToExport,
{
groupName: group?.name,
......@@ -176,11 +166,9 @@ function ExportAnnotations({
displayNamesEnabled,
},
);
downloadCSVFile(exportData, filename);
break;
}
case 'html': {
const exportData = annotationsExporter.buildHTMLExportContent(
return annotationsExporter.buildHTMLExportContent(
annotationsToExport,
{
groupName: group?.name,
......@@ -188,6 +176,45 @@ function ExportAnnotations({
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);
break;
}
......@@ -195,7 +222,35 @@ function ExportAnnotations({
} catch (e) {
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
const pluralize = (count: number, singular: string, plural: string) => {
......@@ -310,6 +365,16 @@ function ExportAnnotations({
</p>
)}
<CardActions>
{exportFormatsEnabled && (
<Button
data-testid="copy-button"
icon={CopyIcon}
onClick={copyAnnotationsExport}
disabled={exportableAnnotations.length === 0}
>
Copy to clipboard
</Button>
)}
<Button
data-testid="export-button"
variant="primary"
......
......@@ -19,6 +19,8 @@ describe('ExportAnnotations', () => {
let fakeDownloadCSVFile;
let fakeDownloadHTMLFile;
let fakeSuggestedFilename;
let fakeCopyPlainText;
let fakeCopyHTML;
const fakePrivateGroup = {
type: 'private',
......@@ -44,6 +46,7 @@ describe('ExportAnnotations', () => {
};
fakeToastMessenger = {
error: sinon.stub(),
success: sinon.stub(),
};
fakeDownloadJSONFile = sinon.stub();
fakeDownloadTextFile = sinon.stub();
......@@ -64,6 +67,8 @@ describe('ExportAnnotations', () => {
.returns([fixtures.oldAnnotation(), fixtures.oldAnnotation()]),
};
fakeSuggestedFilename = sinon.stub().returns('suggested-filename');
fakeCopyPlainText = sinon.stub().resolves(undefined);
fakeCopyHTML = sinon.stub().resolves(undefined);
$imports.$mock(mockImportedComponents());
......@@ -78,6 +83,10 @@ describe('ExportAnnotations', () => {
suggestedFilename: fakeSuggestedFilename,
},
'../../store': { useSidebarStore: () => fakeStore },
'../../util/copy-to-clipboard': {
copyPlainText: fakeCopyPlainText,
copyHTML: fakeCopyHTML,
},
});
$imports.$restore({
......@@ -96,6 +105,15 @@ describe('ExportAnnotations', () => {
const waitForTestId = async (wrapper, 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)', () => {
it('renders a loading spinner if there is no focused group', () => {
fakeStore.focusedGroup.returns(null);
......@@ -288,14 +306,6 @@ describe('ExportAnnotations', () => {
describe('export form submitted', () => {
const submitExportForm = wrapper =>
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', () => {
});
});
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', () => {
beforeEach(() => {
fakeStore.savedAnnotations.returns([]);
......
......@@ -7,6 +7,8 @@
* @throws {Error}
* This function may throw an exception if the browser rejects the attempt
* to copy text.
*
* @deprecated Use copyPlainText instead
*/
export function copyText(text: string) {
const temp = document.createElement('textarea'); // use textarea instead of input to preserve line breaks
......@@ -30,3 +32,35 @@ export function copyText(text: string) {
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', () => {
const createFakeNavigator = ({ supportsWrite = true } = {}) => ({
clipboard: {
writeText: sinon.stub(),
write: supportsWrite ? sinon.stub() : undefined,
},
});
describe('copyText', () => {
beforeEach(() => {
sinon.stub(document, 'execCommand');
});
......@@ -9,7 +17,6 @@ describe('copy-to-clipboard', () => {
document.execCommand.restore();
});
describe('copyText', () => {
/**
* Returns the temporary element used to hold text being copied.
*/
......@@ -51,4 +58,37 @@ describe('copy-to-clipboard', () => {
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