Commit 3802a4c0 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Add `AnnotationShareControl` preact component

Add preact component for sharing a single annotation. Replace use of
`share-annotation-dialog` within the `annotation` component with this
new component.
parent ec1bb63a
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="8" viewBox="0 0 15 8" aria-hidden="true" focusable="false"><path d="M0 8 L7 0 L15 8" stroke="currentColor" strokeWidth="2" /></svg>
'use strict';
const propTypes = require('prop-types');
const { createElement } = require('preact');
const { useEffect, useRef, useState } = require('preact/hooks');
const useElementShouldClose = require('./hooks/use-element-should-close');
const { copyText } = require('../util/copy-to-clipboard');
const { withServices } = require('../util/service-context');
const AnnotationActionButton = require('./annotation-action-button');
const ShareLinks = require('./share-links');
const SvgIcon = require('./svg-icon');
/**
* "Popup"-style component for sharing a single annotation.
*/
function AnnotationShareControl({
analytics,
flash,
group,
isPrivate,
shareUri,
}) {
const shareRef = useRef();
const inputRef = useRef();
const [isOpen, setOpen] = useState(false);
const wasOpen = useRef(isOpen);
const toggleSharePanel = () => setOpen(!isOpen);
const closePanel = () => setOpen(false);
// Interactions outside of the component when it is open should close it
useElementShouldClose(shareRef, isOpen, closePanel);
useEffect(() => {
if (wasOpen.current !== isOpen) {
wasOpen.current = isOpen;
if (isOpen) {
// Panel was just opened: select and focus the share URI for convenience
inputRef.current.focus();
inputRef.current.select();
}
}
}, [isOpen]);
const copyShareLink = () => {
try {
copyText(shareUri);
flash.info('Copied share link to clipboard');
} catch (err) {
flash.error('Unable to copy link');
}
};
// Generate some descriptive text about who may see the annotation if they
// follow the share link.
// First: Based on the type of the group the annotation is in, who would
// be able to view it?
const groupSharingInfo =
group.type === 'private' ? (
<span>
Only members of the group <em>{group.name}</em> may view this
annotation.
</span>
) : (
<span>Anyone using this link may view this annotation.</span>
);
// However, if the annotation is marked as "only me" (`isPrivate` is `true`),
// then group sharing settings are irrelevant—only the author may view the
// annotation.
const annotationSharingInfo = isPrivate ? (
<span>Only you may view this annotation.</span>
) : (
groupSharingInfo
);
return (
<div className="annotation-share-control" ref={shareRef}>
<AnnotationActionButton
icon="h-icon-annotation-share"
isDisabled={false}
label="Share"
onClick={toggleSharePanel}
/>
{isOpen && (
<div className="annotation-share-panel">
<div className="annotation-share-panel__header">
<div className="annotation-share-panel__title">
Share this annotation
</div>
</div>
<div className="annotation-share-panel__content">
<div className="annotation-share-panel__input">
<input
aria-label="Use this URL to share this annotation"
className="form-input"
type="text"
value={shareUri}
readOnly
ref={inputRef}
/>
<button
className="annotation-share-panel__copy-btn"
aria-label="Copy share link to clipboard"
onClick={copyShareLink}
>
<SvgIcon name="copy" />
</button>
</div>
<div className="annotation-share-panel__permissions">
{annotationSharingInfo}
</div>
<ShareLinks
shareURI={shareUri}
analyticsEventName={analytics.events.ANNOTATION_SHARED}
className="annotation-share-control__links"
/>
</div>
<SvgIcon name="pointer" className="annotation-share-panel__arrow" />
</div>
)}
</div>
);
}
AnnotationShareControl.propTypes = {
/** group that the annotation is in */
group: propTypes.object.isRequired,
/** Is this annotation set to "only me"/private? */
isPrivate: propTypes.bool.isRequired,
/** The URI to view the annotation on its own */
shareUri: propTypes.string.isRequired,
/* services */
analytics: propTypes.object.isRequired,
flash: propTypes.object.isRequired,
};
AnnotationShareControl.injectedProps = ['analytics', 'flash'];
module.exports = withServices(AnnotationShareControl);
......@@ -131,9 +131,6 @@ function AnnotationController(
/** True if the annotation is currently being saved. */
self.isSaving = false;
/** True if the 'Share' dialog for this annotation is currently open. */
self.showShareDialog = false;
/**
* `true` if this AnnotationController instance was created as a result of
* the highlight button being clicked.
......
......@@ -33,6 +33,7 @@ const icons = {
link: require('../../images/icons/link.svg'),
lock: require('../../images/icons/lock.svg'),
logo: require('../../images/icons/logo.svg'),
pointer: require('../../images/icons/pointer.svg'),
public: require('../../images/icons/public.svg'),
refresh: require('../../images/icons/refresh.svg'),
reply: require('../../images/icons/reply.svg'),
......
'use strict';
const { createElement } = require('preact');
const { mount } = require('enzyme');
const { act } = require('preact/test-utils');
const AnnotationShareControl = require('../annotation-share-control');
const mockImportedComponents = require('./mock-imported-components');
describe('AnnotationShareControl', () => {
let fakeAnalytics;
let fakeCopyToClipboard;
let fakeFlash;
let fakeGroup;
let fakeShareUri;
let container;
function createComponent(props = {}) {
return mount(
<AnnotationShareControl
analytics={fakeAnalytics}
flash={fakeFlash}
group={fakeGroup}
isPrivate={false}
shareUri={fakeShareUri}
{...props}
/>,
{ attachTo: container }
);
}
function openElement(wrapper) {
act(() => {
wrapper
.find('AnnotationActionButton')
.props()
.onClick();
});
wrapper.update();
}
beforeEach(() => {
// This extra element is necessary to test automatic `focus`-ing
// of the component's `input` element
container = document.createElement('div');
document.body.appendChild(container);
fakeAnalytics = {
events: {
ANNOTATION_SHARED: 'whatever',
},
};
fakeCopyToClipboard = {
copyText: sinon.stub(),
};
fakeFlash = {
info: sinon.stub(),
error: sinon.stub(),
};
fakeGroup = {
name: 'My Group',
type: 'private',
};
fakeShareUri = 'https://www.example.com';
AnnotationShareControl.$imports.$mock(mockImportedComponents());
AnnotationShareControl.$imports.$mock({
'../util/copy-to-clipboard': fakeCopyToClipboard,
'./hooks/use-element-should-close': sinon.stub(),
});
});
afterEach(() => {
AnnotationShareControl.$imports.$restore();
});
it('does not render content when not open', () => {
const wrapper = createComponent();
// Component is not `open` initially
assert.isFalse(wrapper.find('.annotation-share-panel').exists());
});
it('toggles the share control element when the button is clicked', () => {
const wrapper = createComponent();
act(() => {
wrapper
.find('AnnotationActionButton')
.props()
.onClick();
});
wrapper.update();
assert.isTrue(wrapper.find('.annotation-share-panel').exists());
});
it('renders the share URI in a readonly input field', () => {
const wrapper = createComponent();
openElement(wrapper);
const inputEl = wrapper.find('input');
assert.equal(inputEl.prop('value'), fakeShareUri);
assert.isTrue(inputEl.prop('readOnly'));
});
describe('copying the share URI to the clipboard', () => {
it('copies the share link to the clipboard when the copy button is clicked', () => {
const wrapper = createComponent();
openElement(wrapper);
wrapper.find('.annotation-share-panel__copy-btn').simulate('click');
assert.calledWith(
fakeCopyToClipboard.copyText,
'https://www.example.com'
);
});
it('confirms link copy when successful', () => {
const wrapper = createComponent();
openElement(wrapper);
wrapper.find('.annotation-share-panel__copy-btn').simulate('click');
assert.calledWith(fakeFlash.info, 'Copied share link to clipboard');
});
it('flashes an error if link copying unsuccessful', () => {
fakeCopyToClipboard.copyText.throws();
const wrapper = createComponent();
openElement(wrapper);
wrapper.find('.annotation-share-panel__copy-btn').simulate('click');
assert.calledWith(fakeFlash.error, 'Unable to copy link');
});
});
[
{
groupType: 'private',
isPrivate: false,
expected: 'Only members of the group My Group may view this annotation.',
},
{
groupType: 'open',
isPrivate: false,
expected: 'Anyone using this link may view this annotation.',
},
{
groupType: 'private',
isPrivate: true,
expected: 'Only you may view this annotation.',
},
{
groupType: 'open',
isPrivate: true,
expected: 'Only you may view this annotation.',
},
].forEach(testcase => {
it(`renders the correct sharing information for a ${testcase.groupType} group when annotation privacy is ${testcase.isPrivate}`, () => {
fakeGroup.type = testcase.groupType;
const wrapper = createComponent({ isPrivate: testcase.isPrivate });
openElement(wrapper);
const permissionsEl = wrapper.find(
'.annotation-share-panel__permissions'
);
assert.equal(permissionsEl.text(), testcase.expected);
});
});
it('focuses the share-URI input when opened', () => {
document.body.focus();
const wrapper = createComponent();
openElement(wrapper);
wrapper.update();
assert.equal(
document.activeElement.getAttribute('aria-label'),
'Use this URL to share this annotation'
);
});
});
......@@ -153,6 +153,8 @@ function startAngularApp(config) {
.component(
'annotationShareDialog',
require('./components/annotation-share-dialog')
'annotationShareControl',
wrapReactComponent(require('./components/annotation-share-control'))
)
.component('annotationThread', require('./components/annotation-thread'))
.component(
......
......@@ -108,19 +108,7 @@
on-click="vm.reply()"
></annotation-action-button>
<span class="annotation-share-dialog-wrapper" ng-if="vm.incontextLink()">
<annotation-action-button
icon="'h-icon-annotation-share'"
is-disabled="vm.isDeleted()"
label="'Share'"
on-click="vm.showShareDialog = true"
></annotation-action-button>
<annotation-share-dialog
group="vm.group()"
uri="vm.incontextLink()"
is-private="vm.state().isPrivate"
is-open="vm.showShareDialog"
on-close="vm.showShareDialog = false">
</annotation-share-dialog>
<annotation-share-control group="vm.group()" is-private="vm.state().isPrivate" share-uri="vm.incontextLink()"></annotation-share-control>
</span>
<span ng-if="vm.canFlag()">
<annotation-action-button
......
......@@ -45,3 +45,21 @@
margin-top: 0;
}
}
/**
* `panel` with tighter margins and padding, for use in more confined spaces
*/
@mixin panel--compact {
@include panel;
&__header {
padding: 0.75em 0;
margin: 0 0.75em;
border-bottom: none;
}
&__content {
margin: 0.75em;
margin-top: 0;
}
}
@use '../../mixins/buttons';
@use '../../mixins/links';
@use '../../mixins/panel';
@use "../../variables" as var;
.annotation-share-control {
position: relative;
}
.annotation-share-panel {
@include panel.panel--compact;
position: absolute;
right: -5px;
bottom: 30px;
width: 275px;
box-shadow: 0px 0px 5px 0px rgba(0, 0, 0, 0.15);
cursor: default;
&__input {
display: flex;
}
& .form-input {
padding: 0.5em;
border-radius: 0;
font-size: var.$small-font-size;
}
&__copy-btn {
@include buttons.input-icon-button;
padding: 5px;
}
&__permissions {
margin: 0.5em 0;
font-size: var.$small-font-size;
}
&__arrow {
color: var.$grey-3;
fill: white;
transform: rotateX(180deg);
position: absolute;
z-index: 100;
right: 0px;
}
.share-links__icon {
width: 18px;
height: 18px;
}
}
......@@ -29,6 +29,7 @@
@use './components/annotation-document-info';
@use './components/annotation-header';
@use './components/annotation-share-dialog';
@use './components/annotation-share-control';
@use './components/annotation-share-info';
@use './components/annotation-publish-control';
@use './components/annotation-thread';
......
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