Commit b52f00e0 authored by Robert Knight's avatar Robert Knight

Make Sidebar collaborate with rather than inherit from Guest

Change the relationship between the `Guest` and `Sidebar` classes from
one of inheritance to one where `Sidebar` receives a reference to the
`Guest` instance and calls methods on it or subscribes to events from
it. The `Guest` also no longer "owns" references to the bucket bar /
toolbar objects that logically belong to the sidebar. Instead it emits
events which the sidebar responds to.

This change makes the interface of the `Guest` used by the `Sidebar`
more explicit and ensures a better boundary between the two. This also
makes it easier for the `Sidebar` tests to be concerned only with the
interface of the `Guest` and not its implementation details. In future
this change will also make it possible to have a frame which does not
contain a sidebar but is not annotateable. We had a need for this
historically when integrating with epub viewers, although it was never
implemented.

Updating the tests for `PdfSidebar` was complicated by the fact that the
`PdfSidebar` tests are not pure unit tests. They instantiate the
`Sidebar` base class and so depend on many implementation details of it.
To make this change and others easier, the `PdfSidebar` tests have been
changed to mock the `Sidebar` base class. The steps involved in this are
non-obvious so I extracted the logic into a utility function.

 - Change `Sidebar` to no longer inherit `Guest` but accept it as a
   constructor argument
 - Remove direct references to the `BucketBar` and `ToolbarController`
   instances from the `Guest` class and instead emit events from the
   `Guest` which the `Sidebar` responds to.
 - Add a `mockBaseClass` testing helper in `src/test-util/mock-base.js`
   and change the `PdfSidebar` tests to use it, so that they are less
   coupled to `Sidebar` implementation details.
parent 7b95403c
...@@ -21,7 +21,6 @@ import { normalizeURI } from './util/url'; ...@@ -21,7 +21,6 @@ import { normalizeURI } from './util/url';
* @typedef {import('../types/annotator').AnnotationData} AnnotationData * @typedef {import('../types/annotator').AnnotationData} AnnotationData
* @typedef {import('../types/annotator').Anchor} Anchor * @typedef {import('../types/annotator').Anchor} Anchor
* @typedef {import('../types/api').Target} Target * @typedef {import('../types/api').Target} Target
* @typedef {import('./toolbar').ToolbarController} ToolbarController
*/ */
/** /**
...@@ -120,9 +119,6 @@ export default class Guest extends Delegator { ...@@ -120,9 +119,6 @@ export default class Guest extends Delegator {
this.visibleHighlights = false; this.visibleHighlights = false;
/** @type {ToolbarController|null} */
this.toolbar = null;
this.adderToolbar = document.createElement('hypothesis-adder'); this.adderToolbar = document.createElement('hypothesis-adder');
this.adderToolbar.style.display = 'none'; this.adderToolbar.style.display = 'none';
this.element.appendChild(this.adderToolbar); this.element.appendChild(this.adderToolbar);
...@@ -152,9 +148,6 @@ export default class Guest extends Delegator { ...@@ -152,9 +148,6 @@ export default class Guest extends Delegator {
this.plugins = {}; this.plugins = {};
/** @typedef {import('./bucket-bar').default|null} */
this.bucketBar = null;
/** @type {Anchor[]} */ /** @type {Anchor[]} */
this.anchors = []; this.anchors = [];
...@@ -483,10 +476,9 @@ export default class Guest extends Delegator { ...@@ -483,10 +476,9 @@ export default class Guest extends Delegator {
annotation.$orphan = hasAnchorableTargets && !hasAnchoredTargets; annotation.$orphan = hasAnchorableTargets && !hasAnchoredTargets;
// Add the anchors for this annotation to instance storage. // Add the anchors for this annotation to instance storage.
this.anchors = this.anchors.concat(anchors); this._updateAnchors(this.anchors.concat(anchors));
// Let plugins know about the new information. // Let plugins know about the new information.
this.bucketBar?.update();
this.plugins.CrossFrame?.sync([annotation]); this.plugins.CrossFrame?.sync([annotation]);
return anchors; return anchors;
...@@ -534,7 +526,6 @@ export default class Guest extends Delegator { ...@@ -534,7 +526,6 @@ export default class Guest extends Delegator {
detach(annotation) { detach(annotation) {
const anchors = []; const anchors = [];
let unhighlight = []; let unhighlight = [];
for (let anchor of this.anchors) { for (let anchor of this.anchors) {
if (anchor.annotation === annotation) { if (anchor.annotation === annotation) {
unhighlight.push(...(anchor.highlights ?? [])); unhighlight.push(...(anchor.highlights ?? []));
...@@ -542,11 +533,14 @@ export default class Guest extends Delegator { ...@@ -542,11 +533,14 @@ export default class Guest extends Delegator {
anchors.push(anchor); anchors.push(anchor);
} }
} }
removeHighlights(unhighlight);
this.anchors = anchors; this._updateAnchors(anchors);
}
removeHighlights(unhighlight); _updateAnchors(anchors) {
this.bucketBar?.update(); this.anchors = anchors;
this.publish('anchorsChanged', [this.anchors]);
} }
/** /**
...@@ -658,9 +652,7 @@ export default class Guest extends Delegator { ...@@ -658,9 +652,7 @@ export default class Guest extends Delegator {
} }
this.selectedRanges = [range]; this.selectedRanges = [range];
if (this.toolbar) { this.publish('hasSelectionChanged', [true]);
this.toolbar.newAnnotationType = 'annotation';
}
this.adderCtrl.annotationsForSelection = annotationsForSelection(); this.adderCtrl.annotationsForSelection = annotationsForSelection();
this.adderCtrl.show(focusRect, isBackwards); this.adderCtrl.show(focusRect, isBackwards);
...@@ -669,9 +661,7 @@ export default class Guest extends Delegator { ...@@ -669,9 +661,7 @@ export default class Guest extends Delegator {
_onClearSelection() { _onClearSelection() {
this.adderCtrl.hide(); this.adderCtrl.hide();
this.selectedRanges = []; this.selectedRanges = [];
if (this.toolbar) { this.publish('hasSelectionChanged', [false]);
this.toolbar.newAnnotationType = 'note';
}
} }
/** /**
...@@ -707,10 +697,7 @@ export default class Guest extends Delegator { ...@@ -707,10 +697,7 @@ export default class Guest extends Delegator {
*/ */
setVisibleHighlights(shouldShowHighlights) { setVisibleHighlights(shouldShowHighlights) {
setHighlightsVisible(this.element, shouldShowHighlights); setHighlightsVisible(this.element, shouldShowHighlights);
this.visibleHighlights = shouldShowHighlights; this.visibleHighlights = shouldShowHighlights;
if (this.toolbar) { this.publish('highlightsVisibleChanged', [shouldShowHighlights]);
this.toolbar.highlightsVisible = shouldShowHighlights;
}
} }
} }
...@@ -48,15 +48,15 @@ const config = configFrom(window); ...@@ -48,15 +48,15 @@ const config = configFrom(window);
function init() { function init() {
const isPDF = typeof window_.PDFViewerApplication !== 'undefined'; const isPDF = typeof window_.PDFViewerApplication !== 'undefined';
/** @type {new (e: HTMLElement, config: any) => Guest} */ /** @type {(new (e: HTMLElement, config: any, guest: Guest) => Sidebar)|null} */
let Klass = isPDF ? PdfSidebar : Sidebar; let SidebarClass = isPDF ? PdfSidebar : Sidebar;
if (config.subFrameIdentifier) { if (config.subFrameIdentifier) {
// Make sure the PDF plugin is loaded if the subframe contains the PDF.js viewer. // Make sure the PDF plugin is loaded if the subframe contains the PDF.js viewer.
if (isPDF) { if (isPDF) {
config.PDF = {}; config.PDF = {};
} }
Klass = Guest; SidebarClass = null;
// Other modules use this to detect if this // Other modules use this to detect if this
// frame context belongs to hypothesis. // frame context belongs to hypothesis.
...@@ -66,11 +66,16 @@ function init() { ...@@ -66,11 +66,16 @@ function init() {
config.pluginClasses = pluginClasses; config.pluginClasses = pluginClasses;
const annotator = new Klass(document.body, config); const guest = new Guest(document.body, config);
const sidebar = SidebarClass
? new SidebarClass(document.body, config, guest)
: null;
const notebook = new Notebook(document.body, config); const notebook = new Notebook(document.body, config);
appLinkEl.addEventListener('destroy', function () {
annotator.destroy(); appLinkEl.addEventListener('destroy', () => {
sidebar?.destroy();
notebook.destroy(); notebook.destroy();
guest.destroy();
// Remove all the `<link>`, `<script>` and `<style>` elements added to the // Remove all the `<link>`, `<script>` and `<style>` elements added to the
// page by the boot script. // page by the boot script.
......
import Sidebar from './sidebar'; import Sidebar from './sidebar';
/** /**
* @typedef {import('./guest').default} Guest
* @typedef {import('../types/annotator').HypothesisWindow} HypothesisWindow * @typedef {import('../types/annotator').HypothesisWindow} HypothesisWindow
* @typedef {import('./sidebar').LayoutState} LayoutState * @typedef {import('./sidebar').LayoutState} LayoutState
*/ */
...@@ -21,9 +22,10 @@ export default class PdfSidebar extends Sidebar { ...@@ -21,9 +22,10 @@ export default class PdfSidebar extends Sidebar {
/** /**
* @param {HTMLElement} element * @param {HTMLElement} element
* @param {Record<string, any>} config * @param {Record<string, any>} config
* @param {Guest} guest
*/ */
constructor(element, config) { constructor(element, config, guest) {
super(element, { ...defaultConfig, ...config }); super(element, { ...defaultConfig, ...config }, guest);
this._lastSidebarLayoutState = { this._lastSidebarLayoutState = {
expanded: false, expanded: false,
......
...@@ -6,12 +6,14 @@ import { createSidebarConfig } from './config/sidebar'; ...@@ -6,12 +6,14 @@ import { createSidebarConfig } from './config/sidebar';
import events from '../shared/bridge-events'; import events from '../shared/bridge-events';
import features from './features'; import features from './features';
import Guest from './guest'; import Delegator from './delegator';
import { ToolbarController } from './toolbar'; import { ToolbarController } from './toolbar';
import { createShadowRoot } from './util/shadow-root'; import { createShadowRoot } from './util/shadow-root';
import BucketBar from './bucket-bar'; import BucketBar from './bucket-bar';
/** /**
* @typedef {import('./guest').default} Guest
*
* @typedef LayoutState * @typedef LayoutState
* @prop {boolean} expanded * @prop {boolean} expanded
* @prop {number} width * @prop {number} width
...@@ -55,16 +57,25 @@ function createSidebarIframe(config) { ...@@ -55,16 +57,25 @@ function createSidebarIframe(config) {
* The `Sidebar` class creates the sidebar application iframe and its container, * The `Sidebar` class creates the sidebar application iframe and its container,
* as well as the adjacent controls. * as well as the adjacent controls.
*/ */
export default class Sidebar extends Guest { export default class Sidebar extends Delegator {
/** /**
* Create the sidebar iframe, its container and adjacent controls.
*
* @param {HTMLElement} element * @param {HTMLElement} element
* @param {Record<string, any>} config * @param {Record<string, any>} config
* @param {Guest} guest -
* The `Guest` instance for the current frame. It is currently assumed that
* it is always possible to annotate in the frame where the sidebar is
* displayed.
*/ */
constructor(element, config) { constructor(element, config, guest) {
super(element, config); super(element, config);
this.iframe = createSidebarIframe(config); this.iframe = createSidebarIframe(config);
/** @type {BucketBar|null} */
this.bucketBar = null;
if (config.externalContainerSelector) { if (config.externalContainerSelector) {
this.externalFrame = this.externalFrame =
/** @type {HTMLElement} */ /** @type {HTMLElement} */
...@@ -78,11 +89,13 @@ export default class Sidebar extends Guest { ...@@ -78,11 +89,13 @@ export default class Sidebar extends Guest {
if (config.theme === 'clean') { if (config.theme === 'clean') {
this.iframeContainer.classList.add('annotator-frame--theme-clean'); this.iframeContainer.classList.add('annotator-frame--theme-clean');
} else { } else {
this.bucketBar = new BucketBar( const bucketBar = new BucketBar(
this.iframeContainer, this.iframeContainer,
this, guest,
config.BucketBar config.BucketBar
); );
guest.subscribe('anchorsChanged', () => bucketBar.update());
this.bucketBar = bucketBar;
} }
this.iframeContainer.appendChild(this.iframe); this.iframeContainer.appendChild(this.iframe);
...@@ -95,6 +108,8 @@ export default class Sidebar extends Guest { ...@@ -95,6 +108,8 @@ export default class Sidebar extends Guest {
element.appendChild(this.hypothesisSidebar); element.appendChild(this.hypothesisSidebar);
} }
this.guest = guest;
/** @type {RegisteredListener[]} */ /** @type {RegisteredListener[]} */
this.registeredListeners = []; this.registeredListeners = [];
...@@ -126,7 +141,7 @@ export default class Sidebar extends Guest { ...@@ -126,7 +141,7 @@ export default class Sidebar extends Guest {
// Set up the toolbar on the left edge of the sidebar. // Set up the toolbar on the left edge of the sidebar.
const toolbarContainer = document.createElement('div'); const toolbarContainer = document.createElement('div');
this.toolbar = new ToolbarController(toolbarContainer, { this.toolbar = new ToolbarController(toolbarContainer, {
createAnnotation: () => this.createAnnotation(), createAnnotation: () => guest.createAnnotation(),
setSidebarOpen: open => (open ? this.show() : this.hide()), setSidebarOpen: open => (open ? this.show() : this.hide()),
setHighlightsVisible: show => this.setAllVisibleHighlights(show), setHighlightsVisible: show => this.setAllVisibleHighlights(show),
}); });
...@@ -137,6 +152,13 @@ export default class Sidebar extends Guest { ...@@ -137,6 +152,13 @@ export default class Sidebar extends Guest {
this.toolbar.useMinimalControls = false; this.toolbar.useMinimalControls = false;
} }
this.subscribe('highlightsVisibleChanged', visible => {
this.toolbar.highlightsVisible = visible;
});
this.subscribe('hasSelectionChanged', hasSelection => {
this.toolbar.newAnnotationType = hasSelection ? 'annotation' : 'note';
});
if (this.iframeContainer) { if (this.iframeContainer) {
// If using our own container frame for the sidebar, add the toolbar to it. // If using our own container frame for the sidebar, add the toolbar to it.
this.iframeContainer.prepend(toolbarContainer); this.iframeContainer.prepend(toolbarContainer);
...@@ -177,6 +199,7 @@ export default class Sidebar extends Guest { ...@@ -177,6 +199,7 @@ export default class Sidebar extends Guest {
} }
destroy() { destroy() {
this.bucketBar?.destroy();
this._unregisterEvents(); this._unregisterEvents();
this._hammerManager?.destroy(); this._hammerManager?.destroy();
if (this.hypothesisSidebar) { if (this.hypothesisSidebar) {
...@@ -205,20 +228,20 @@ export default class Sidebar extends Guest { ...@@ -205,20 +228,20 @@ export default class Sidebar extends Guest {
} }
_setupSidebarEvents() { _setupSidebarEvents() {
annotationCounts(document.body, this.crossframe); annotationCounts(document.body, this.guest.crossframe);
sidebarTrigger(document.body, () => this.show()); sidebarTrigger(document.body, () => this.show());
features.init(this.crossframe); features.init(this.guest.crossframe);
this.crossframe.on('showSidebar', () => this.show()); this.guest.crossframe.on('showSidebar', () => this.show());
this.crossframe.on('hideSidebar', () => this.hide()); this.guest.crossframe.on('hideSidebar', () => this.hide());
// Re-publish the crossframe event so that anything extending Delegator // Re-publish the crossframe event so that anything extending Delegator
// can subscribe to it (without need for crossframe) // can subscribe to it (without need for crossframe)
this.crossframe.on('showNotebook', groupId => { this.guest.crossframe.on('showNotebook', groupId => {
this.hide(); this.hide();
this.publish('showNotebook', [groupId]); this.publish('showNotebook', [groupId]);
}); });
this.crossframe.on('hideNotebook', () => { this.guest.crossframe.on('hideNotebook', () => {
this.show(); this.show();
this.publish('hideNotebook'); this.publish('hideNotebook');
}); });
...@@ -232,7 +255,7 @@ export default class Sidebar extends Guest { ...@@ -232,7 +255,7 @@ export default class Sidebar extends Guest {
]; ];
eventHandlers.forEach(([event, handler]) => { eventHandlers.forEach(([event, handler]) => {
if (handler) { if (handler) {
this.crossframe.on(event, () => handler()); this.guest.crossframe.on(event, () => handler());
} }
}); });
} }
...@@ -408,7 +431,7 @@ export default class Sidebar extends Guest { ...@@ -408,7 +431,7 @@ export default class Sidebar extends Guest {
} }
show() { show() {
this.crossframe.call('sidebarOpened'); this.guest.crossframe.call('sidebarOpened');
this.publish('sidebarOpened'); this.publish('sidebarOpened');
if (this.iframeContainer) { if (this.iframeContainer) {
...@@ -420,7 +443,7 @@ export default class Sidebar extends Guest { ...@@ -420,7 +443,7 @@ export default class Sidebar extends Guest {
this.toolbar.sidebarOpen = true; this.toolbar.sidebarOpen = true;
if (this.options.showHighlights === 'whenSidebarOpen') { if (this.options.showHighlights === 'whenSidebarOpen') {
this.setVisibleHighlights(true); this.guest.setVisibleHighlights(true);
} }
this._notifyOfLayoutChange(true); this._notifyOfLayoutChange(true);
...@@ -435,7 +458,7 @@ export default class Sidebar extends Guest { ...@@ -435,7 +458,7 @@ export default class Sidebar extends Guest {
this.toolbar.sidebarOpen = false; this.toolbar.sidebarOpen = false;
if (this.options.showHighlights === 'whenSidebarOpen') { if (this.options.showHighlights === 'whenSidebarOpen') {
this.setVisibleHighlights(false); this.guest.setVisibleHighlights(false);
} }
this._notifyOfLayoutChange(false); this._notifyOfLayoutChange(false);
...@@ -447,6 +470,6 @@ export default class Sidebar extends Guest { ...@@ -447,6 +470,6 @@ export default class Sidebar extends Guest {
* @param {boolean} shouldShowHighlights * @param {boolean} shouldShowHighlights
*/ */
setAllVisibleHighlights(shouldShowHighlights) { setAllVisibleHighlights(shouldShowHighlights) {
this.crossframe.call('setVisibleHighlights', shouldShowHighlights); this.guest.crossframe.call('setVisibleHighlights', shouldShowHighlights);
} }
} }
...@@ -536,22 +536,24 @@ describe('Guest', () => { ...@@ -536,22 +536,24 @@ describe('Guest', () => {
assert.called(FakeAdder.instance.hide); assert.called(FakeAdder.instance.hide);
}); });
it("sets the toolbar's `newAnnotationType` to 'annotation' if there is a selection", () => { it('emits `hasSelectionChanged` event with argument `true` if selection is non-empty', () => {
const guest = createGuest(); const guest = createGuest();
guest.toolbar = {}; const callback = sinon.stub();
guest.subscribe('hasSelectionChanged', callback);
simulateSelectionWithText(); simulateSelectionWithText();
assert.equal(guest.toolbar.newAnnotationType, 'annotation'); assert.calledWith(callback, true);
}); });
it("sets the toolbar's `newAnnotationType` to 'note' if the selection is empty", () => { it('emits `hasSelectionChanged` event with argument `false` if selection is empty', () => {
const guest = createGuest(); const guest = createGuest();
guest.toolbar = {}; const callback = sinon.stub();
guest.subscribe('hasSelectionChanged', callback);
simulateSelectionWithoutText(); simulateSelectionWithoutText();
assert.equal(guest.toolbar.newAnnotationType, 'note'); assert.calledWith(callback, false);
}); });
}); });
...@@ -767,17 +769,26 @@ describe('Guest', () => { ...@@ -767,17 +769,26 @@ describe('Guest', () => {
.then(() => assert.notCalled(htmlAnchoring.anchor)); .then(() => assert.notCalled(htmlAnchoring.anchor));
}); });
it('updates the cross frame and bucket bar plugins', () => { it('updates the cross frame plugin', () => {
const guest = createGuest(); const guest = createGuest();
guest.plugins.CrossFrame = { sync: sinon.stub() }; guest.plugins.CrossFrame = { sync: sinon.stub() };
guest.bucketBar = { update: sinon.stub() };
const annotation = {}; const annotation = {};
return guest.anchor(annotation).then(() => { return guest.anchor(annotation).then(() => {
assert.called(guest.bucketBar.update);
assert.called(guest.plugins.CrossFrame.sync); assert.called(guest.plugins.CrossFrame.sync);
}); });
}); });
it('emits an `anchorsChanged` event', async () => {
const guest = createGuest();
const annotation = {};
const anchorsChanged = sinon.stub();
guest.subscribe('anchorsChanged', anchorsChanged);
await guest.anchor(annotation);
assert.calledWith(anchorsChanged, guest.anchors);
});
it('returns a promise of the anchors for the annotation', () => { it('returns a promise of the anchors for the annotation', () => {
const guest = createGuest(); const guest = createGuest();
const highlights = [document.createElement('span')]; const highlights = [document.createElement('span')];
...@@ -850,15 +861,16 @@ describe('Guest', () => { ...@@ -850,15 +861,16 @@ describe('Guest', () => {
assert.equal(guest.anchors.length, 0); assert.equal(guest.anchors.length, 0);
}); });
it('updates the bucket bar plugin', () => { it('emits an `anchorsChanged` event', () => {
const guest = createGuest(); const guest = createGuest();
guest.bucketBar = { update: sinon.stub() };
const annotation = {}; const annotation = {};
guest.anchors.push({ annotation }); guest.anchors.push({ annotation });
const anchorsChanged = sinon.stub();
guest.subscribe('anchorsChanged', anchorsChanged);
guest.detach(annotation); guest.detach(annotation);
assert.calledOnce(guest.bucketBar.update); assert.calledWith(anchorsChanged, guest.anchors);
}); });
it('removes any highlights associated with the annotation', () => { it('removes any highlights associated with the annotation', () => {
......
import PdfSidebar from '../pdf-sidebar'; import PdfSidebar from '../pdf-sidebar';
import { $imports } from '../pdf-sidebar'; import Delegator from '../delegator';
import { mockBaseClass } from '../../test-util/mock-base';
class FakeSidebar extends Delegator {
constructor(element, config, guest) {
super(element, config);
this.guest = guest;
}
_registerEvent(target, event, callback) {
target.addEventListener(event, callback);
}
}
describe('PdfSidebar', () => { describe('PdfSidebar', () => {
const sandbox = sinon.createSandbox(); const sandbox = sinon.createSandbox();
let CrossFrame;
let fakeCrossFrame;
let fakePDFViewerApplication; let fakePDFViewerApplication;
let fakePDFContainer; let fakePDFContainer;
let fakePDFViewerUpdate; let fakePDFViewerUpdate;
const sidebarConfig = { pluginClasses: {} };
const createPdfSidebar = config => { const createPdfSidebar = config => {
config = { ...sidebarConfig, ...config }; const fakeGuest = {};
const element = document.createElement('div'); const element = document.createElement('div');
return new PdfSidebar(element, config); return new PdfSidebar(element, config, fakeGuest);
}; };
let unmockSidebar;
beforeEach(() => { beforeEach(() => {
sandbox.stub(PdfSidebar.prototype, '_setupGestures');
fakePDFContainer = document.createElement('div'); fakePDFContainer = document.createElement('div');
fakePDFViewerUpdate = sinon.stub(); fakePDFViewerUpdate = sinon.stub();
...@@ -36,31 +46,13 @@ describe('PdfSidebar', () => { ...@@ -36,31 +46,13 @@ describe('PdfSidebar', () => {
// Can't stub an undefined property in a sandbox // Can't stub an undefined property in a sandbox
window.PDFViewerApplication = fakePDFViewerApplication; window.PDFViewerApplication = fakePDFViewerApplication;
// From `Sidebar.js` tests unmockSidebar = mockBaseClass(PdfSidebar, FakeSidebar);
fakeCrossFrame = {};
fakeCrossFrame.onConnect = sandbox.stub().returns(fakeCrossFrame);
fakeCrossFrame.on = sandbox.stub().returns(fakeCrossFrame);
fakeCrossFrame.call = sandbox.spy();
fakeCrossFrame.destroy = sandbox.stub();
const fakeBucketBar = {};
fakeBucketBar.element = document.createElement('div');
fakeBucketBar.destroy = sandbox.stub();
CrossFrame = sandbox.stub();
CrossFrame.returns(fakeCrossFrame);
const BucketBar = sandbox.stub();
BucketBar.returns(fakeBucketBar);
sidebarConfig.pluginClasses.CrossFrame = CrossFrame;
sidebarConfig.pluginClasses.BucketBar = BucketBar;
}); });
afterEach(() => { afterEach(() => {
delete window.PDFViewerApplication; delete window.PDFViewerApplication;
sandbox.restore(); sandbox.restore();
$imports.$restore(); unmockSidebar();
}); });
context('side-by-side mode configured', () => { context('side-by-side mode configured', () => {
......
import events from '../../shared/bridge-events'; import events from '../../shared/bridge-events';
import Delegator from '../delegator';
import Sidebar, { MIN_RESIZE } from '../sidebar'; import Sidebar, { MIN_RESIZE } from '../sidebar';
import { $imports } from '../sidebar'; import { $imports } from '../sidebar';
...@@ -9,15 +10,15 @@ const EXTERNAL_CONTAINER_SELECTOR = 'test-external-container'; ...@@ -9,15 +10,15 @@ const EXTERNAL_CONTAINER_SELECTOR = 'test-external-container';
describe('Sidebar', () => { describe('Sidebar', () => {
const sandbox = sinon.createSandbox(); const sandbox = sinon.createSandbox();
let CrossFrame;
let fakeCrossFrame; let fakeCrossFrame;
const sidebarConfig = { pluginClasses: {} }; let fakeGuest;
// Containers and Sidebar instances created by current test. // Containers and Sidebar instances created by current test.
let containers; let containers;
let sidebars; let sidebars;
let FakeToolbarController; let FakeToolbarController;
let fakeBucketBar;
let fakeToolbar; let fakeToolbar;
before(() => { before(() => {
...@@ -34,14 +35,13 @@ describe('Sidebar', () => { ...@@ -34,14 +35,13 @@ describe('Sidebar', () => {
// Dummy sidebar app. // Dummy sidebar app.
sidebarAppUrl: '/base/annotator/test/empty.html', sidebarAppUrl: '/base/annotator/test/empty.html',
}, },
sidebarConfig,
config config
); );
const container = document.createElement('div'); const container = document.createElement('div');
document.body.appendChild(container); document.body.appendChild(container);
containers.push(container); containers.push(container);
const sidebar = new Sidebar(container, config); const sidebar = new Sidebar(container, config, fakeGuest);
sidebars.push(sidebar); sidebars.push(sidebar);
return sidebar; return sidebar;
...@@ -62,11 +62,22 @@ describe('Sidebar', () => { ...@@ -62,11 +62,22 @@ describe('Sidebar', () => {
beforeEach(() => { beforeEach(() => {
sidebars = []; sidebars = [];
containers = []; containers = [];
fakeCrossFrame = {}; fakeCrossFrame = {
fakeCrossFrame.onConnect = sandbox.stub().returns(fakeCrossFrame); on: sandbox.stub(),
fakeCrossFrame.on = sandbox.stub().returns(fakeCrossFrame); call: sandbox.stub(),
fakeCrossFrame.call = sandbox.spy(); };
fakeCrossFrame.destroy = sandbox.stub();
class FakeGuest extends Delegator {
constructor() {
const element = document.createElement('div');
super(element, {});
this.createAnnotation = sinon.stub();
this.crossframe = fakeCrossFrame;
this.setVisibleHighlights = sinon.stub();
}
}
fakeGuest = new FakeGuest();
fakeToolbar = { fakeToolbar = {
getWidth: sinon.stub().returns(100), getWidth: sinon.stub().returns(100),
...@@ -78,16 +89,11 @@ describe('Sidebar', () => { ...@@ -78,16 +89,11 @@ describe('Sidebar', () => {
}; };
FakeToolbarController = sinon.stub().returns(fakeToolbar); FakeToolbarController = sinon.stub().returns(fakeToolbar);
const fakeBucketBar = {}; fakeBucketBar = {
fakeBucketBar.element = document.createElement('div'); destroy: sinon.stub(),
fakeBucketBar.destroy = sandbox.stub(); update: sinon.stub(),
const BucketBar = sandbox.stub(); };
BucketBar.returns(fakeBucketBar); const BucketBar = sandbox.stub().returns(fakeBucketBar);
CrossFrame = sandbox.stub();
CrossFrame.returns(fakeCrossFrame);
sidebarConfig.pluginClasses.CrossFrame = CrossFrame;
sidebars = []; sidebars = [];
...@@ -218,11 +224,30 @@ describe('Sidebar', () => { ...@@ -218,11 +224,30 @@ describe('Sidebar', () => {
it('creates an annotation when toolbar button is clicked', () => { it('creates an annotation when toolbar button is clicked', () => {
const sidebar = createSidebar(); const sidebar = createSidebar();
sinon.stub(sidebar, 'createAnnotation');
FakeToolbarController.args[0][1].createAnnotation(); FakeToolbarController.args[0][1].createAnnotation();
assert.called(sidebar.createAnnotation); assert.called(sidebar.guest.createAnnotation);
});
it('sets create annotation button to "Annotation" when selection becomes non-empty', () => {
const sidebar = createSidebar();
// nb. This event is normally published by the Guest, but the sidebar
// doesn't care about that.
sidebar.publish('hasSelectionChanged', [true]);
assert.equal(sidebar.toolbar.newAnnotationType, 'annotation');
});
it('sets create annotation button to "Page Note" when selection becomes empty', () => {
const sidebar = createSidebar();
// nb. This event is normally published by the Guest, but the sidebar
// doesn't care about that.
sidebar.publish('hasSelectionChanged', [false]);
assert.equal(sidebar.toolbar.newAnnotationType, 'note');
}); });
}); });
...@@ -495,30 +520,35 @@ describe('Sidebar', () => { ...@@ -495,30 +520,35 @@ describe('Sidebar', () => {
}); });
}); });
describe('destruction', () => { describe('#destroy', () => {
it('the sidebar is destroyed and the frame is detached', () => { it('removes sidebar DOM elements', () => {
const sidebar = createSidebar(); const sidebar = createSidebar();
const sidebarContainer = containers[0]; const sidebarContainer = containers[0];
sidebar.destroy(); sidebar.destroy();
assert.called(fakeCrossFrame.destroy);
assert.notExists(sidebarContainer.querySelector('hypothesis-sidebar')); assert.notExists(sidebarContainer.querySelector('hypothesis-sidebar'));
assert.equal(sidebar.iframeContainer.parentElement, null); assert.equal(sidebar.iframeContainer.parentElement, null);
}); });
it('cleans up bucket bar', () => {
const sidebar = createSidebar();
sidebar.destroy();
assert.called(sidebar.bucketBar.destroy);
});
}); });
describe('#show', () => { describe('#show', () => {
it('shows highlights if "showHighlights" is set to "whenSidebarOpen"', () => { it('shows highlights if "showHighlights" is set to "whenSidebarOpen"', () => {
const sidebar = createSidebar({ showHighlights: 'whenSidebarOpen' }); const sidebar = createSidebar({ showHighlights: 'whenSidebarOpen' });
assert.isFalse(sidebar.visibleHighlights);
sidebar.show(); sidebar.show();
assert.isTrue(sidebar.visibleHighlights); assert.calledWith(sidebar.guest.setVisibleHighlights, true);
}); });
it('does not show highlights otherwise', () => { it('does not show highlights otherwise', () => {
const sidebar = createSidebar({ showHighlights: 'never' }); const sidebar = createSidebar({ showHighlights: 'never' });
assert.isFalse(sidebar.visibleHighlights);
sidebar.show(); sidebar.show();
assert.isFalse(sidebar.visibleHighlights); assert.notCalled(sidebar.guest.setVisibleHighlights);
}); });
it('updates the `sidebarOpen` property of the toolbar', () => { it('updates the `sidebarOpen` property of the toolbar', () => {
...@@ -535,7 +565,7 @@ describe('Sidebar', () => { ...@@ -535,7 +565,7 @@ describe('Sidebar', () => {
sidebar.show(); sidebar.show();
sidebar.hide(); sidebar.hide();
assert.isFalse(sidebar.visibleHighlights); assert.calledWith(sidebar.guest.setVisibleHighlights, false);
}); });
it('updates the `sidebarOpen` property of the toolbar', () => { it('updates the `sidebarOpen` property of the toolbar', () => {
...@@ -794,22 +824,30 @@ describe('Sidebar', () => { ...@@ -794,22 +824,30 @@ describe('Sidebar', () => {
}); });
}); });
describe('config', () => { describe('bucket bar state', () => {
it('does have the BucketBar', () => { it('displays the bucket bar by default', () => {
const sidebar = createSidebar(); const sidebar = createSidebar();
assert.isNotNull(sidebar.bucketBar); assert.isNotNull(sidebar.bucketBar);
}); });
it('does not have the BucketBar if the clean theme is enabled', () => { it('does not display the bucket bar if using the "clean" theme', () => {
const sidebar = createSidebar({ theme: 'clean' }); const sidebar = createSidebar({ theme: 'clean' });
assert.isNull(sidebar.bucketBar); assert.isNull(sidebar.bucketBar);
}); });
it('does not have the BucketBar if an external container is provided', () => { it('does not display the bucket bar if using an external container for the sidebar', () => {
const sidebar = createSidebar({ const sidebar = createSidebar({
externalContainerSelector: `.${EXTERNAL_CONTAINER_SELECTOR}`, externalContainerSelector: `.${EXTERNAL_CONTAINER_SELECTOR}`,
}); });
assert.isNull(sidebar.bucketBar); assert.isNull(sidebar.bucketBar);
}); });
it('updates the bucket bar when an `anchorsChanged` event is received', () => {
const sidebar = createSidebar();
fakeGuest.publish('anchorsChanged');
assert.calledOnce(sidebar.bucketBar.update);
});
}); });
}); });
/**
* Mock the base class of a derived class.
*
* In unit tests for a derived class it may be useful to mock the base class
* so that the tests only depend on the interface of the base class and not
* its implementation.
*
* This cannot be done using `$imports.$mock` because the links between the
* derived and base class are set once when the derived class is initially
* evaluated.
*
* Although it is possible to mock a base class using this function, using
* composition over inheritance generally makes mocking easier.
*
* @param {object} derivedClass - The derived class constructor to mock
* @param {object} mockBase - The new mock class
* @return {() => void} A function that un-mocks the base class
*/
export function mockBaseClass(derivedClass, mockBase) {
const originalBase = derivedClass.__proto__;
// Modify the base class reference used by `super` expressions.
derivedClass.__proto__ = mockBase;
// Modify the prototype chain used by instances of the derived class to find
// methods or properties defined on the base class.
derivedClass.prototype.__proto__ = mockBase.prototype;
return () => {
derivedClass.__proto__ = originalBase;
derivedClass.prototype.__proto__ = originalBase.prototype;
};
}
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