Unverified Commit 31daf657 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #2110 from hypothesis/hypothesis-app-migration

Migrate HypothesisApp component and remove AngularJS
parents bbba566e 4c66b9cb
...@@ -155,7 +155,6 @@ const cssBundles = [ ...@@ -155,7 +155,6 @@ const cssBundles = [
'./src/styles/sidebar/sidebar.scss', './src/styles/sidebar/sidebar.scss',
// Vendor // Vendor
'./src/styles/vendor/angular-csp.css',
'./src/styles/vendor/icomoon.css', './src/styles/vendor/icomoon.css',
'./node_modules/katex/dist/katex.min.css', './node_modules/katex/dist/katex.min.css',
]; ];
......
...@@ -12,8 +12,6 @@ ...@@ -12,8 +12,6 @@
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@octokit/rest": "^17.0.0", "@octokit/rest": "^17.0.0",
"@sentry/browser": "^5.6.2", "@sentry/browser": "^5.6.2",
"angular": "^1.7.5",
"angular-mocks": "^1.7.5",
"autoprefixer": "^9.4.7", "autoprefixer": "^9.4.7",
"aws-sdk": "^2.345.0", "aws-sdk": "^2.345.0",
"axe-core": "^3.4.1", "axe-core": "^3.4.1",
......
...@@ -9,7 +9,6 @@ ...@@ -9,7 +9,6 @@
module.exports = { module.exports = {
bundles: { bundles: {
jquery: ['jquery'], jquery: ['jquery'],
angular: ['angular'],
katex: ['katex'], katex: ['katex'],
sentry: ['@sentry/browser'], sentry: ['@sentry/browser'],
showdown: ['showdown'], showdown: ['showdown'],
......
...@@ -112,14 +112,12 @@ function bootSidebarApp(doc, config) { ...@@ -112,14 +112,12 @@ function bootSidebarApp(doc, config) {
// Vendor code required by sidebar.bundle.js // Vendor code required by sidebar.bundle.js
'scripts/sentry.bundle.js', 'scripts/sentry.bundle.js',
'scripts/angular.bundle.js',
'scripts/katex.bundle.js', 'scripts/katex.bundle.js',
'scripts/showdown.bundle.js', 'scripts/showdown.bundle.js',
// The sidebar app // The sidebar app
'scripts/sidebar.bundle.js', 'scripts/sidebar.bundle.js',
'styles/angular-csp.css',
'styles/katex.min.css', 'styles/katex.min.css',
'styles/sidebar.css', 'styles/sidebar.css',
]); ]);
......
...@@ -41,12 +41,10 @@ describe('bootstrap', function () { ...@@ -41,12 +41,10 @@ describe('bootstrap', function () {
// Sidebar app // Sidebar app
'scripts/sentry.bundle.js', 'scripts/sentry.bundle.js',
'scripts/angular.bundle.js',
'scripts/katex.bundle.js', 'scripts/katex.bundle.js',
'scripts/showdown.bundle.js', 'scripts/showdown.bundle.js',
'scripts/sidebar.bundle.js', 'scripts/sidebar.bundle.js',
'styles/angular-csp.css',
'styles/katex.min.css', 'styles/katex.min.css',
'styles/sidebar.css', 'styles/sidebar.css',
]; ];
...@@ -146,12 +144,10 @@ describe('bootstrap', function () { ...@@ -146,12 +144,10 @@ describe('bootstrap', function () {
it('loads assets for the sidebar application', function () { it('loads assets for the sidebar application', function () {
runBoot(); runBoot();
const expectedAssets = [ const expectedAssets = [
'scripts/angular.bundle.1234.js',
'scripts/katex.bundle.1234.js', 'scripts/katex.bundle.1234.js',
'scripts/sentry.bundle.1234.js', 'scripts/sentry.bundle.1234.js',
'scripts/showdown.bundle.1234.js', 'scripts/showdown.bundle.1234.js',
'scripts/sidebar.bundle.1234.js', 'scripts/sidebar.bundle.1234.js',
'styles/angular-csp.1234.css',
'styles/katex.min.1234.css', 'styles/katex.min.1234.css',
'styles/sidebar.1234.css', 'styles/sidebar.1234.css',
].map(assetUrl); ].map(assetUrl);
......
import { createElement } from 'preact';
import { useEffect, useMemo } from 'preact/hooks';
import propTypes from 'prop-types';
import bridgeEvents from '../../shared/bridge-events'; import bridgeEvents from '../../shared/bridge-events';
import events from '../events';
import serviceConfig from '../service-config'; import serviceConfig from '../service-config';
import useStore from '../store/use-store';
import uiConstants from '../ui-constants'; import uiConstants from '../ui-constants';
import { parseAccountID } from '../util/account-id'; import { parseAccountID } from '../util/account-id';
import isSidebar from '../util/is-sidebar';
import { shouldAutoDisplayTutorial } from '../util/session'; import { shouldAutoDisplayTutorial } from '../util/session';
import { applyTheme } from '../util/theme'; import { applyTheme } from '../util/theme';
import { withServices } from '../util/service-context';
import AnnotationViewerContent from './annotation-viewer-content';
import HelpPanel from './help-panel';
import ShareAnnotationsPanel from './share-annotations-panel';
import SidebarContent from './sidebar-content';
import StreamContent from './stream-content';
import ToastMessages from './toast-messages';
import TopBar from './top-bar';
/** /**
* Return the user's authentication status from their profile. * Return the user's authentication status from their profile.
...@@ -31,102 +43,82 @@ function authStateFromProfile(profile) { ...@@ -31,102 +43,82 @@ function authStateFromProfile(profile) {
} }
} }
// @ngInject /**
function HypothesisAppController( * The root component for the Hypothesis client.
$document, *
$rootScope, * This handles login/logout actions and renders the top navigation bar
$scope, * and content appropriate for the current route.
$window, */
analytics, function HypothesisApp({
store,
auth, auth,
bridge, bridge,
features,
frameSync,
groups,
serviceUrl, serviceUrl,
session,
settings, settings,
toastMessenger session,
) { toastMessenger,
const self = this; }) {
const clearGroups = useStore(store => store.clearGroups);
// This stores information about the current user's authentication status. const closeSidebarPanel = useStore(store => store.closeSidebarPanel);
// When the controller instantiates we do not yet know if the user is const countDrafts = useStore(store => store.countDrafts);
// logged-in or not, so it has an initial status of 'unknown'. This can be const discardAllDrafts = useStore(store => store.discardAllDrafts);
// used by templates to show an intermediate or loading state. const hasFetchedProfile = useStore(store => store.hasFetchedProfile());
this.auth = { status: 'unknown' }; const openSidebarPanel = useStore(store => store.openSidebarPanel);
const profile = useStore(store => store.profile());
this.backgroundStyle = applyTheme(['appBackgroundColor'], settings); const removeAnnotations = useStore(store => store.removeAnnotations);
const route = useStore(store => store.route());
// Check to see if we're in the sidebar, or on a standalone page such as const unsavedAnnotations = useStore(store => store.unsavedAnnotations);
// the stream page or an individual annotation page.
this.isSidebar = isSidebar(); const authState = useMemo(() => {
if (this.isSidebar) { if (!hasFetchedProfile) {
frameSync.connect(); return { status: 'unknown' };
}
// Reload the view when the user switches accounts
this.onUserChange = profile => {
self.auth = authStateFromProfile(profile);
if (shouldAutoDisplayTutorial(this.isSidebar, store.profile(), settings)) {
// Auto-open the tutorial (help) panel
store.openSidebarPanel(uiConstants.PANEL_HELP);
} }
}; return authStateFromProfile(profile);
}, [hasFetchedProfile, profile]);
this.route = () => store.route(); const backgroundStyle = useMemo(
() => applyTheme(['backgroundColor'], settings),
[settings]
);
$scope.$on(events.USER_CHANGED, function (event, data) { const isSidebar = route === 'sidebar';
self.onUserChange(data.profile);
});
session.load().then(profile => { useEffect(() => {
self.onUserChange(profile); if (shouldAutoDisplayTutorial(isSidebar, profile, settings)) {
}); openSidebarPanel(uiConstants.PANEL_HELP);
}
}, [isSidebar, profile, openSidebarPanel, settings]);
/** const login = async () => {
* Start the login flow. This will present the user with the login dialog.
*
* @return {Promise<void>} - A Promise that resolves when the login flow
* completes. For non-OAuth logins, always resolves immediately.
*/
this.login = function () {
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
// Let the host page handle the login request // Let the host page handle the login request
bridge.call(bridgeEvents.LOGIN_REQUESTED); bridge.call(bridgeEvents.LOGIN_REQUESTED);
return Promise.resolve(); return;
} }
return auth try {
.login() await auth.login();
.then(() => {
// If the prompt-to-log-in sidebar panel is open, close it
store.closeSidebarPanel(uiConstants.PANEL_LOGIN_PROMPT);
store.clearGroups();
session.reload();
})
.catch(err => {
toastMessenger.error(err.message);
});
};
this.signUp = function () { closeSidebarPanel(uiConstants.PANEL_LOGIN_PROMPT);
analytics.track(analytics.events.SIGN_UP_REQUESTED); clearGroups();
session.reload();
} catch (err) {
toastMessenger.error(err.message);
}
};
const signUp = () => {
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
// Let the host page handle the signup request // Let the host page handle the signup request
bridge.call(bridgeEvents.SIGNUP_REQUESTED); bridge.call(bridgeEvents.SIGNUP_REQUESTED);
return; return;
} }
$window.open(serviceUrl('signup')); window.open(serviceUrl('signup'));
}; };
// Prompt to discard any unsaved drafts. const promptToLogout = () => {
const promptToLogout = function () {
// TODO - Replace this with a UI which doesn't look terrible. // TODO - Replace this with a UI which doesn't look terrible.
let text = ''; let text = '';
const drafts = store.countDrafts(); const drafts = countDrafts();
if (drafts === 1) { if (drafts === 1) {
text = text =
'You have an unsaved annotation.\n' + 'You have an unsaved annotation.\n' +
...@@ -138,31 +130,73 @@ function HypothesisAppController( ...@@ -138,31 +130,73 @@ function HypothesisAppController(
' unsaved annotations.\n' + ' unsaved annotations.\n' +
'Do you really want to discard these drafts?'; 'Do you really want to discard these drafts?';
} }
return drafts === 0 || $window.confirm(text); return drafts === 0 || window.confirm(text);
}; };
// Log the user out. const logout = () => {
this.logout = function () {
if (!promptToLogout()) { if (!promptToLogout()) {
return; return;
} }
clearGroups();
store.clearGroups(); removeAnnotations(unsavedAnnotations());
store.removeAnnotations(store.unsavedAnnotations()); discardAllDrafts();
store.discardAllDrafts();
if (serviceConfig(settings)) { if (serviceConfig(settings)) {
// Let the host page handle the signup request
bridge.call(bridgeEvents.LOGOUT_REQUESTED); bridge.call(bridgeEvents.LOGOUT_REQUESTED);
return; return;
} }
session.logout(); session.logout();
}; };
return (
<div
className="app-content-wrapper js-thread-list-scroll-root"
style={backgroundStyle}
>
<TopBar
auth={authState}
onLogin={login}
onSignUp={signUp}
onLogout={logout}
isSidebar={isSidebar}
/>
<div className="content">
<ToastMessages />
<HelpPanel auth={authState} />
<ShareAnnotationsPanel />
{route && (
<main>
{route === 'annotation' && <AnnotationViewerContent />}
{route === 'stream' && <StreamContent />}
{route === 'sidebar' && (
<SidebarContent onLogin={login} onSignUp={signUp} />
)}
</main>
)}
</div>
</div>
);
} }
export default { HypothesisApp.propTypes = {
controller: HypothesisAppController, // Injected.
controllerAs: 'vm', auth: propTypes.object,
template: require('../templates/hypothesis-app.html'), bridge: propTypes.object,
serviceUrl: propTypes.func,
settings: propTypes.object,
session: propTypes.object,
toastMessenger: propTypes.object,
}; };
HypothesisApp.injectedProps = [
'auth',
'bridge',
'serviceUrl',
'session',
'settings',
'toastMessenger',
];
export default withServices(HypothesisApp);
/* global angular */
/**
* Converts a camelCase name into hyphenated ('camel-case') form.
*
* This matches how Angular maps directive names to HTML tag names.
*/
function hyphenate(name) {
const uppercasePattern = /([A-Z])/g;
return name.replace(uppercasePattern, '-$1').toLowerCase();
}
/**
* A helper for instantiating an AngularJS directive in a unit test.
*
* Usage:
* var domElement = createDirective(document, 'myComponent', {
* attrA: 'initial-value'
* }, {
* scopeProperty: scopeValue
* },
* 'Hello, world!');
*
* Will generate '<my-component attr-a="attrA">Hello, world!</my-component>' and
* compile and link it with the scope:
*
* { attrA: 'initial-value', scopeProperty: scopeValue }
*
* The initial value may be a callback function to invoke. eg:
*
* var domElement = createDirective(document, 'myComponent', {
* onEvent: function () {
* console.log('event triggered');
* }
* });
*
* If the callback accepts named arguments, these need to be specified
* via an object with 'args' and 'callback' properties:
*
* var domElement = createDirective(document, 'myComponent', {
* onEvent: {
* args: ['arg1'],
* callback: function (arg1) {
* console.log('callback called with arg', arg1);
* }
* }
* });
*
* @param {Document} document - The DOM Document to create the element in
* @param {string} name - The name of the directive to instantiate
* @param {Object} [attrs] - A map of attribute names (in camelCase) to initial
* values.
* @param {Object} [initialScope] - A dictionary of properties to set on the
* scope when the element is linked
* @param {string} [initialHtml] - Initial inner HTML content for the directive
* element.
* @param {Object} [opts] - Object specifying options for creating the
* directive:
* 'parentElement' - The parent element for the new
* directive. Defaults to document.body
*
* @return {DOMElement} The Angular jqLite-wrapped DOM element for the component.
* The returned object has a link(scope) method which will
* re-link the component with new properties.
*/
export function createDirective(
document,
name,
attrs,
initialScope,
initialHtml,
opts
) {
attrs = attrs || {};
initialScope = initialScope || {};
initialHtml = initialHtml || '';
opts = opts || {};
opts.parentElement = opts.parentElement || document.body;
// Create a template consisting of a single element, the directive
// we want to create and compile it.
let $compile;
let $scope;
angular.mock.inject(function (_$compile_, _$rootScope_) {
$compile = _$compile_;
$scope = _$rootScope_.$new();
});
const templateElement = document.createElement(hyphenate(name));
Object.keys(attrs).forEach(function (key) {
const attrName = hyphenate(key);
let attrKey = key;
if (typeof attrs[key] === 'function') {
// If the input property is a function, generate a function expression,
// eg. `<my-component on-event="onEvent()">`
attrKey += '()';
} else if (attrs[key].callback) {
// If the input property is a function which accepts arguments,
// generate the argument list.
// eg. `<my-component on-change="onChange(newValue)">`
attrKey += '(' + attrs[key].args.join(',') + ')';
}
templateElement.setAttribute(attrName, attrKey);
});
templateElement.innerHTML = initialHtml;
// Add the element to the document's body so that
// it responds to events, becomes visible, reports correct
// values for its dimensions etc.
opts.parentElement.appendChild(templateElement);
// setup initial scope
Object.keys(attrs).forEach(function (key) {
if (attrs[key].callback) {
$scope[key] = attrs[key].callback;
} else {
$scope[key] = attrs[key];
}
});
// compile the template
const linkFn = $compile(templateElement);
// link the component, passing in the initial
// scope values. The caller can then re-render/link
// the template passing in different properties
// and verify the output
const linkDirective = function (props) {
const childScope = $scope.$new();
angular.extend(childScope, props);
const element = linkFn(childScope);
element.scope = childScope;
childScope.$digest();
element.ctrl = element.controller(name);
if (!element.ctrl) {
throw new Error(
'Failed to create "' +
name +
'" directive in test.' +
'Did you forget to register it with angular.module(...).directive() ?'
);
}
return element;
};
return linkDirective(initialScope);
}
import angular from 'angular'; import { mount } from 'enzyme';
import { createElement } from 'preact';
import bridgeEvents from '../../../shared/bridge-events'; import bridgeEvents from '../../../shared/bridge-events';
import events from '../../events'; import mockImportedComponents from '../../../test-util/mock-imported-components';
import { events as analyticsEvents } from '../../services/analytics';
import hypothesisApp from '../hypothesis-app'; import HypothesisApp, { $imports } from '../hypothesis-app';
import { $imports } from '../hypothesis-app';
describe('HypothesisApp', () => {
describe('sidebar.components.hypothesis-app', function () {
let $componentController = null;
let $scope = null;
let $rootScope = null;
let fakeStore = null; let fakeStore = null;
let fakeAnalytics = null;
let fakeAuth = null; let fakeAuth = null;
let fakeBridge = null; let fakeBridge = null;
let fakeFeatures = null;
let fakeFrameSync = null;
let fakeIsSidebar = null;
let fakeServiceConfig = null; let fakeServiceConfig = null;
let fakeSession = null; let fakeSession = null;
let fakeShouldAutoDisplayTutorial = null; let fakeShouldAutoDisplayTutorial = null;
let fakeGroups = null;
let fakeServiceUrl = null; let fakeServiceUrl = null;
let fakeSettings = null; let fakeSettings = null;
let fakeToastMessenger = null; let fakeToastMessenger = null;
let fakeWindow = null;
let sandbox = null;
const createController = function (locals) { const createComponent = (props = {}) => {
locals = locals || {}; return mount(
locals.$scope = $scope; <HypothesisApp
return $componentController('hypothesisApp', locals); auth={fakeAuth}
bridge={fakeBridge}
serviceUrl={fakeServiceUrl}
settings={fakeSettings}
session={fakeSession}
toastMessenger={fakeToastMessenger}
{...props}
/>
);
}; };
beforeEach(function () { beforeEach(() => {
sandbox = sinon.createSandbox(); fakeServiceConfig = sinon.stub();
});
beforeEach(function () {
fakeIsSidebar = sandbox.stub().returns(true);
fakeServiceConfig = sandbox.stub();
fakeShouldAutoDisplayTutorial = sinon.stub().returns(false); fakeShouldAutoDisplayTutorial = sinon.stub().returns(false);
fakeStore = {
clearSelectedAnnotations: sinon.spy(),
clearGroups: sinon.stub(),
closeSidebarPanel: sinon.stub(),
openSidebarPanel: sinon.stub(),
// draft store
countDrafts: sinon.stub().returns(0),
discardAllDrafts: sinon.stub(),
unsavedAnnotations: sinon.stub().returns([]),
removeAnnotations: sinon.stub(),
hasFetchedProfile: sinon.stub().returns(true),
profile: sinon.stub().returns({
userid: null,
preferences: {
show_sidebar_tutorial: false,
},
}),
route: sinon.stub().returns('sidebar'),
};
fakeAuth = {};
fakeSession = {
load: sinon.stub().returns(Promise.resolve({ userid: null })),
logout: sinon.stub(),
reload: sinon.stub().returns(Promise.resolve({ userid: null })),
};
fakeServiceUrl = sinon.stub();
fakeSettings = {};
fakeBridge = {
call: sinon.stub(),
};
fakeToastMessenger = {
error: sinon.stub(),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({ $imports.$mock({
'../util/is-sidebar': fakeIsSidebar,
'../service-config': fakeServiceConfig, '../service-config': fakeServiceConfig,
'../store/use-store': callback => callback(fakeStore),
'../util/session': { '../util/session': {
shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial, shouldAutoDisplayTutorial: fakeShouldAutoDisplayTutorial,
}, },
}); });
angular.module('h', []).component('hypothesisApp', hypothesisApp);
}); });
afterEach(() => { afterEach(() => {
$imports.$restore(); $imports.$restore();
}); });
beforeEach(angular.mock.module('h')); it('does not render content if route is not yet determined', () => {
fakeStore.route.returns(null);
beforeEach( const wrapper = createComponent();
angular.mock.module(function ($provide) { [
fakeStore = { 'main',
tool: 'comment', 'AnnotationViewerContent',
clearSelectedAnnotations: sandbox.spy(), 'StreamContent',
clearGroups: sinon.stub(), 'SidebarContent',
closeSidebarPanel: sinon.stub(), ].forEach(contentComponent => {
openSidebarPanel: sinon.stub(), assert.isFalse(wrapper.exists(contentComponent));
// draft store });
countDrafts: sandbox.stub().returns(0),
discardAllDrafts: sandbox.stub(),
unsavedAnnotations: sandbox.stub().returns([]),
removeAnnotations: sandbox.stub(),
profile: sinon.stub().returns({
preferences: {
show_sidebar_tutorial: false,
},
}),
};
fakeAnalytics = {
track: sandbox.stub(),
events: analyticsEvents,
};
fakeAuth = {};
fakeFeatures = {
fetch: sandbox.spy(),
flagEnabled: sandbox.stub().returns(false),
};
fakeFrameSync = {
connect: sandbox.spy(),
};
fakeSession = {
load: sandbox.stub().returns(Promise.resolve({ userid: null })),
logout: sandbox.stub(),
reload: sandbox.stub().returns(Promise.resolve({ userid: null })),
};
fakeGroups = {
focus: sandbox.spy(),
};
fakeWindow = {
top: {},
confirm: sandbox.stub(),
open: sandbox.stub(),
};
fakeServiceUrl = sinon.stub();
fakeSettings = {};
fakeBridge = {
call: sandbox.stub(),
};
fakeToastMessenger = {
error: sandbox.stub(),
};
$provide.value('store', fakeStore);
$provide.value('auth', fakeAuth);
$provide.value('analytics', fakeAnalytics);
$provide.value('features', fakeFeatures);
$provide.value('frameSync', fakeFrameSync);
$provide.value('serviceUrl', fakeServiceUrl);
$provide.value('session', fakeSession);
$provide.value('settings', fakeSettings);
$provide.value('toastMessenger', fakeToastMessenger);
$provide.value('bridge', fakeBridge);
$provide.value('groups', fakeGroups);
$provide.value('$window', fakeWindow);
})
);
beforeEach(
angular.mock.inject(function (_$componentController_, _$rootScope_) {
$componentController = _$componentController_;
$rootScope = _$rootScope_;
$scope = $rootScope.$new();
})
);
afterEach(function () {
sandbox.restore();
});
it('connects to host frame in the sidebar app', function () {
fakeIsSidebar.returns(true);
createController();
assert.called(fakeFrameSync.connect);
}); });
it('does not connect to the host frame in the stream', function () { [
fakeIsSidebar.returns(false); {
createController(); route: 'annotation',
assert.notCalled(fakeFrameSync.connect); contentComponent: 'AnnotationViewerContent',
},
{
route: 'sidebar',
contentComponent: 'SidebarContent',
},
{
route: 'stream',
contentComponent: 'StreamContent',
},
].forEach(({ route, contentComponent }) => {
it('renders app content for route', () => {
fakeStore.route.returns(route);
const wrapper = createComponent();
assert.isTrue(wrapper.find(contentComponent).exists());
});
}); });
describe('auto-opening tutorial', () => { describe('auto-opening tutorial', () => {
it('should open tutorial on profile load when criteria are met', () => { it('should open tutorial on profile load when criteria are met', () => {
fakeShouldAutoDisplayTutorial.returns(true); fakeShouldAutoDisplayTutorial.returns(true);
createController(); createComponent();
return fakeSession.load().then(() => { assert.calledOnce(fakeStore.openSidebarPanel);
assert.calledOnce(fakeStore.openSidebarPanel);
});
}); });
it('should not open tutorial on profile load when criteria are not met', () => { it('should not open tutorial on profile load when criteria are not met', () => {
fakeShouldAutoDisplayTutorial.returns(false); fakeShouldAutoDisplayTutorial.returns(false);
createController(); createComponent();
return fakeSession.load().then(() => { assert.notCalled(fakeStore.openSidebarPanel);
assert.equal(fakeStore.openSidebarPanel.callCount, 0);
});
}); });
}); });
it('auth.status is "unknown" on startup', function () { describe('"status" field of "auth" prop passed to children', () => {
const ctrl = createController(); const getStatus = wrapper => wrapper.find('TopBar').prop('auth').status;
assert.equal(ctrl.auth.status, 'unknown');
});
it('sets auth.status to "logged-out" if userid is null', function () { it('is "unknown" if profile has not yet been fetched', () => {
const ctrl = createController(); fakeStore.hasFetchedProfile.returns(false);
return fakeSession.load().then(function () { const wrapper = createComponent();
assert.equal(ctrl.auth.status, 'logged-out'); assert.equal(getStatus(wrapper), 'unknown');
}); });
});
it('sets auth.status to "logged-in" if userid is non-null', function () { it('is "logged-out" if userid is null', () => {
fakeSession.load = function () { fakeStore.profile.returns({ userid: null });
return Promise.resolve({ userid: 'acct:jim@hypothes.is' }); const wrapper = createComponent();
}; assert.equal(getStatus(wrapper), 'logged-out');
const ctrl = createController(); });
return fakeSession.load().then(function () {
assert.equal(ctrl.auth.status, 'logged-in'); it('is "logged-in" if userid is non-null', () => {
fakeStore.profile.returns({ userid: 'acct:jimsmith@hypothes.is' });
const wrapper = createComponent();
assert.equal(getStatus(wrapper), 'logged-in');
}); });
}); });
...@@ -236,123 +194,101 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -236,123 +194,101 @@ describe('sidebar.components.hypothesis-app', function () {
}, },
}, },
].forEach(({ profile, expectedAuth }) => { ].forEach(({ profile, expectedAuth }) => {
it('sets `auth` properties when profile has loaded', () => { it('passes expected "auth" prop to children', () => {
fakeSession.load = () => Promise.resolve(profile); fakeStore.profile.returns(profile);
const ctrl = createController(); const wrapper = createComponent();
return fakeSession.load().then(() => { const auth = wrapper.find('TopBar').prop('auth');
assert.deepEqual(ctrl.auth, expectedAuth); assert.deepEqual(auth, expectedAuth);
});
}); });
}); });
it('updates auth when the logged-in user changes', function () { describe('"Sign up" action', () => {
const ctrl = createController(); const clickSignUp = wrapper => wrapper.find('TopBar').props().onSignUp();
return fakeSession.load().then(function () {
$scope.$broadcast(events.USER_CHANGED, { beforeEach(() => {
profile: { sinon.stub(window, 'open');
userid: 'acct:john@hypothes.is',
},
});
assert.deepEqual(ctrl.auth, {
status: 'logged-in',
displayName: 'john',
userid: 'acct:john@hypothes.is',
username: 'john',
provider: 'hypothes.is',
});
}); });
});
describe('#signUp', function () { afterEach(() => {
it('tracks sign up requests in analytics', function () { window.open.restore();
const ctrl = createController();
ctrl.signUp();
assert.calledWith(
fakeAnalytics.track,
fakeAnalytics.events.SIGN_UP_REQUESTED
);
}); });
context('when using a third-party service', function () { context('when using a third-party service', () => {
beforeEach(function () { beforeEach(() => {
fakeServiceConfig.returns({}); fakeServiceConfig.returns({});
}); });
it('sends SIGNUP_REQUESTED event', function () { it('sends SIGNUP_REQUESTED event', () => {
const ctrl = createController(); const wrapper = createComponent();
ctrl.signUp(); clickSignUp(wrapper);
assert.calledWith(fakeBridge.call, bridgeEvents.SIGNUP_REQUESTED); assert.calledWith(fakeBridge.call, bridgeEvents.SIGNUP_REQUESTED);
}); });
it('does not open a URL directly', function () { it('does not open a URL directly', () => {
const ctrl = createController(); const wrapper = createComponent();
ctrl.signUp(); clickSignUp(wrapper);
assert.notCalled(fakeWindow.open); assert.notCalled(window.open);
}); });
}); });
context('when not using a third-party service', function () { context('when not using a third-party service', () => {
it('opens the signup URL in a new tab', function () { it('opens the signup URL in a new tab', () => {
fakeServiceUrl.withArgs('signup').returns('https://ann.service/signup'); fakeServiceUrl.withArgs('signup').returns('https://ann.service/signup');
const ctrl = createController(); const wrapper = createComponent();
ctrl.signUp(); clickSignUp(wrapper);
assert.calledWith(fakeWindow.open, 'https://ann.service/signup'); assert.calledWith(window.open, 'https://ann.service/signup');
}); });
}); });
}); });
describe('#login()', function () { describe('"Log in" action', () => {
const clickLogIn = wrapper => wrapper.find('TopBar').props().onLogin();
beforeEach(() => { beforeEach(() => {
fakeAuth.login = sinon.stub().returns(Promise.resolve()); fakeAuth.login = sinon.stub().returns(Promise.resolve());
}); });
it('clears groups', () => { it('clears groups', async () => {
const ctrl = createController(); const wrapper = createComponent();
await clickLogIn(wrapper);
return ctrl.login().then(() => { assert.called(fakeStore.clearGroups);
assert.called(fakeStore.clearGroups);
});
}); });
it('initiates the OAuth login flow', () => { it('initiates the OAuth login flow', async () => {
const ctrl = createController(); const wrapper = createComponent();
ctrl.login(); await clickLogIn(wrapper);
assert.called(fakeAuth.login); assert.called(fakeAuth.login);
}); });
it('reloads the session when login completes', () => { it('reloads the session when login completes', async () => {
const ctrl = createController(); const wrapper = createComponent();
return ctrl.login().then(() => { await clickLogIn(wrapper);
assert.called(fakeSession.reload); assert.called(fakeSession.reload);
});
}); });
it('closes the login prompt panel', () => { it('closes the login prompt panel', async () => {
const ctrl = createController(); const wrapper = createComponent();
return ctrl.login().then(() => { await clickLogIn(wrapper);
assert.called(fakeStore.closeSidebarPanel); assert.called(fakeStore.closeSidebarPanel);
});
}); });
it('reports an error if login fails', () => { it('reports an error if login fails', async () => {
fakeAuth.login.returns(Promise.reject(new Error('Login failed'))); fakeAuth.login.returns(Promise.reject(new Error('Login failed')));
const ctrl = createController(); const wrapper = createComponent();
await clickLogIn(wrapper);
return ctrl.login().then(null, () => { assert.called(fakeToastMessenger.error);
assert.called(fakeToastMessenger.error);
});
}); });
it('sends LOGIN_REQUESTED if a third-party service is in use', function () { it('sends LOGIN_REQUESTED event to host page if using a third-party service', async () => {
// If the client is using a third-party annotation service then clicking // If the client is using a third-party annotation service then clicking
// on a login button should send the LOGIN_REQUESTED event over the bridge // on a login button should send the LOGIN_REQUESTED event over the bridge
// (so that the partner site we're embedded in can do its own login // (so that the partner site we're embedded in can do its own login
// thing). // thing).
fakeServiceConfig.returns({}); fakeServiceConfig.returns({});
const ctrl = createController();
ctrl.login(); const wrapper = createComponent();
await clickLogIn(wrapper);
assert.equal(fakeBridge.call.callCount, 1); assert.equal(fakeBridge.call.callCount, 1);
assert.isTrue( assert.isTrue(
...@@ -361,33 +297,44 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -361,33 +297,44 @@ describe('sidebar.components.hypothesis-app', function () {
}); });
}); });
describe('#logout()', function () { describe('"Log out" action', () => {
// Tests shared by both of the contexts below. const clickLogOut = wrapper => wrapper.find('TopBar').props().onLogout();
function doSharedTests() {
it('prompts the user if there are drafts', function () { beforeEach(() => {
fakeStore.countDrafts.returns(1); sinon.stub(window, 'confirm');
const ctrl = createController(); });
afterEach(() => {
window.confirm.restore();
});
// Tests used by both the first and third-party account scenarios.
function addCommonLogoutTests() {
// nb. Slightly different messages are shown depending on the draft count.
[1, 2].forEach(draftCount => {
it('prompts the user if there are drafts', () => {
fakeStore.countDrafts.returns(draftCount);
ctrl.logout(); const wrapper = createComponent();
clickLogOut(wrapper);
assert.equal(fakeWindow.confirm.callCount, 1); assert.equal(window.confirm.callCount, 1);
});
}); });
it('clears groups', () => { it('clears groups', () => {
const ctrl = createController(); const wrapper = createComponent();
clickLogOut(wrapper);
ctrl.logout();
assert.called(fakeStore.clearGroups); assert.called(fakeStore.clearGroups);
}); });
it('removes unsaved annotations', function () { it('removes unsaved annotations', () => {
fakeStore.unsavedAnnotations = sandbox fakeStore.unsavedAnnotations = sinon
.stub() .stub()
.returns(['draftOne', 'draftTwo', 'draftThree']); .returns(['draftOne', 'draftTwo', 'draftThree']);
const ctrl = createController(); const wrapper = createComponent();
clickLogOut(wrapper);
ctrl.logout();
assert.calledWith(fakeStore.removeAnnotations, [ assert.calledWith(fakeStore.removeAnnotations, [
'draftOne', 'draftOne',
...@@ -396,64 +343,63 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -396,64 +343,63 @@ describe('sidebar.components.hypothesis-app', function () {
]); ]);
}); });
it('discards drafts', function () { it('discards drafts', () => {
const ctrl = createController(); const wrapper = createComponent();
clickLogOut(wrapper);
ctrl.logout();
assert(fakeStore.discardAllDrafts.calledOnce); assert(fakeStore.discardAllDrafts.calledOnce);
}); });
it('does not remove unsaved annotations if the user cancels the prompt', function () { it('does not remove unsaved annotations if the user cancels the prompt', () => {
const ctrl = createController(); const wrapper = createComponent();
fakeStore.countDrafts.returns(1); fakeStore.countDrafts.returns(1);
$rootScope.$emit = sandbox.stub(); window.confirm.returns(false);
fakeWindow.confirm.returns(false);
ctrl.logout(); clickLogOut(wrapper);
assert.notCalled(fakeStore.removeAnnotations); assert.notCalled(fakeStore.removeAnnotations);
}); });
it('does not discard drafts if the user cancels the prompt', function () { it('does not discard drafts if the user cancels the prompt', () => {
const ctrl = createController(); const wrapper = createComponent();
fakeStore.countDrafts.returns(1); fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false); window.confirm.returns(false);
ctrl.logout(); clickLogOut(wrapper);
assert(fakeStore.discardAllDrafts.notCalled); assert(fakeStore.discardAllDrafts.notCalled);
}); });
it('does not prompt if there are no drafts', function () { it('does not prompt if there are no drafts', () => {
const ctrl = createController(); const wrapper = createComponent();
fakeStore.countDrafts.returns(0); fakeStore.countDrafts.returns(0);
ctrl.logout(); clickLogOut(wrapper);
assert.equal(fakeWindow.confirm.callCount, 0); assert.notCalled(window.confirm);
}); });
} }
context('when no third-party service is in use', function () { context('when no third-party service is in use', () => {
doSharedTests(); addCommonLogoutTests();
it('calls session.logout()', function () { it('calls session.logout()', () => {
const ctrl = createController(); const wrapper = createComponent();
ctrl.logout(); clickLogOut(wrapper);
assert.called(fakeSession.logout); assert.called(fakeSession.logout);
}); });
}); });
context('when a third-party service is in use', function () { context('when a third-party service is in use', () => {
beforeEach('configure a third-party service to be in use', function () { beforeEach('configure a third-party service to be in use', () => {
fakeServiceConfig.returns({}); fakeServiceConfig.returns({});
}); });
doSharedTests(); addCommonLogoutTests();
it('sends LOGOUT_REQUESTED', function () { it('sends LOGOUT_REQUESTED', () => {
createController().logout(); const wrapper = createComponent();
clickLogOut(wrapper);
assert.calledOnce(fakeBridge.call); assert.calledOnce(fakeBridge.call);
assert.calledWithExactly( assert.calledWithExactly(
...@@ -462,18 +408,19 @@ describe('sidebar.components.hypothesis-app', function () { ...@@ -462,18 +408,19 @@ describe('sidebar.components.hypothesis-app', function () {
); );
}); });
it('does not send LOGOUT_REQUESTED if the user cancels the prompt', function () { it('does not send LOGOUT_REQUESTED if the user cancels the prompt', () => {
fakeStore.countDrafts.returns(1); fakeStore.countDrafts.returns(1);
fakeWindow.confirm.returns(false); window.confirm.returns(false);
createController().logout(); const wrapper = createComponent();
clickLogOut(wrapper);
assert.notCalled(fakeBridge.call); assert.notCalled(fakeBridge.call);
}); });
it('does not call session.logout()', function () { it('does not call session.logout()', () => {
createController().logout(); const wrapper = createComponent();
clickLogOut(wrapper);
assert.notCalled(fakeSession.logout); assert.notCalled(fakeSession.logout);
}); });
}); });
......
...@@ -21,19 +21,9 @@ if (appConfig.sentry) { ...@@ -21,19 +21,9 @@ if (appConfig.sentry) {
sentry.init(appConfig.sentry); sentry.init(appConfig.sentry);
} }
// Disable Angular features that are not compatible with CSP.
//
// See https://docs.angularjs.org/api/ng/directive/ngCsp
//
// The `ng-csp` attribute must be set on some HTML element in the document
// _before_ Angular is require'd for the first time.
document.body.setAttribute('ng-csp', '');
// Prevent tab-jacking. // Prevent tab-jacking.
disableOpenerForExternalLinks(document.body); disableOpenerForExternalLinks(document.body);
import angular from 'angular';
// Load polyfill for :focus-visible pseudo-class. // Load polyfill for :focus-visible pseudo-class.
import 'focus-visible'; import 'focus-visible';
...@@ -42,8 +32,6 @@ if (process.env.NODE_ENV !== 'production') { ...@@ -42,8 +32,6 @@ if (process.env.NODE_ENV !== 'production') {
require('preact/debug'); require('preact/debug');
} }
import wrapReactComponent from './util/wrap-react-component';
if (appConfig.googleAnalytics) { if (appConfig.googleAnalytics) {
addAnalytics(appConfig.googleAnalytics); addAnalytics(appConfig.googleAnalytics);
} }
...@@ -100,29 +88,24 @@ function autosave(autosaveService) { ...@@ -100,29 +88,24 @@ function autosave(autosaveService) {
autosaveService.init(); autosaveService.init();
} }
// @ngInject
function setupFrameSync(frameSync) {
if (isSidebar) {
frameSync.connect();
}
}
// Register icons used by the sidebar app (and maybe other assets in future). // Register icons used by the sidebar app (and maybe other assets in future).
import { registerIcons } from '../shared/components/svg-icon'; import { registerIcons } from '../shared/components/svg-icon';
import iconSet from './icons'; import iconSet from './icons';
registerIcons(iconSet); registerIcons(iconSet);
// Preact UI components that are wrapped for use within Angular templates. // The entry point component for the app.
import { createElement, render } from 'preact';
import AnnotationViewerContent from './components/annotation-viewer-content'; import HypothesisApp from './components/hypothesis-app';
import HelpPanel from './components/help-panel'; import { ServiceContext } from './util/service-context';
import LoginPromptPanel from './components/login-prompt-panel';
import ShareAnnotationsPanel from './components/share-annotations-panel';
import SidebarContent from './components/sidebar-content';
import StreamContent from './components/stream-content';
import ThreadList from './components/thread-list';
import ToastMessages from './components/toast-messages';
import TopBar from './components/top-bar';
// Remaining UI components that are still built with Angular.
import hypothesisApp from './components/hypothesis-app';
// Services. // Services.
import bridgeService from '../shared/bridge'; import bridgeService from '../shared/bridge';
import analyticsService from './services/analytics'; import analyticsService from './services/analytics';
...@@ -151,18 +134,13 @@ import unicodeService from './services/unicode'; ...@@ -151,18 +134,13 @@ import unicodeService from './services/unicode';
import viewFilterService from './services/view-filter'; import viewFilterService from './services/view-filter';
// Redux store. // Redux store.
import store from './store'; import store from './store';
// Utilities. // Utilities.
import { Injector } from '../shared/injector'; import { Injector } from '../shared/injector';
import EventEmitter from 'tiny-emitter';
function startAngularApp(config) { function startApp(config) {
// Create dependency injection container for services.
//
// This is a replacement for the use of Angular's dependency injection
// (including its `$injector` service) to construct services with dependencies.
const container = new Injector(); const container = new Injector();
// Register services. // Register services.
...@@ -194,6 +172,15 @@ function startAngularApp(config) { ...@@ -194,6 +172,15 @@ function startAngularApp(config) {
.register('viewFilter', viewFilterService) .register('viewFilter', viewFilterService)
.register('store', store); .register('store', store);
// Register a dummy `$rootScope` pub-sub service for services that still
// use it.
const emitter = new EventEmitter();
const dummyRootScope = {
$on: (event, callback) => emitter.on(event, data => callback({}, data)),
$broadcast: (event, data) => emitter.emit(event, data),
};
container.register('$rootScope', { value: dummyRootScope });
// Register utility values/classes. // Register utility values/classes.
// //
// nb. In many cases these can be replaced by direct imports in the services // nb. In many cases these can be replaced by direct imports in the services
...@@ -203,92 +190,23 @@ function startAngularApp(config) { ...@@ -203,92 +190,23 @@ function startAngularApp(config) {
.register('isSidebar', { value: isSidebar }) .register('isSidebar', { value: isSidebar })
.register('settings', { value: config }); .register('settings', { value: config });
// Register services which only Angular can construct, once Angular has // Initialize services.
// constructed them. container.run(persistDefaults);
// container.run(autosave);
// @ngInject container.run(sendPageView);
function registerAngularServices($rootScope) { container.run(setupApi);
container.register('$rootScope', { value: $rootScope }); container.run(setupRoute);
} container.run(startRPCServer);
container.run(setupFrameSync);
// Run initialization logic that uses constructed services.
//
// @ngInject
function initServices() {
container.run(persistDefaults);
container.run(autosave);
container.run(sendPageView);
container.run(setupApi);
container.run(setupRoute);
container.run(startRPCServer);
}
const wrapComponent = component => wrapReactComponent(component, container);
angular
.module('h', [])
// The root component for the application
.component('hypothesisApp', hypothesisApp)
// UI components
.component(
'annotationViewerContent',
wrapComponent(AnnotationViewerContent)
)
.component('helpPanel', wrapComponent(HelpPanel))
.component('loginPromptPanel', wrapComponent(LoginPromptPanel))
.component('sidebarContent', wrapComponent(SidebarContent))
.component('shareAnnotationsPanel', wrapComponent(ShareAnnotationsPanel))
.component('streamContent', wrapComponent(StreamContent))
.component('threadList', wrapComponent(ThreadList))
.component('toastMessages', wrapComponent(ToastMessages))
.component('topBar', wrapComponent(TopBar))
// Register services, the store and utilities with Angular, so that
// Angular components can use them.
.service('analytics', () => container.get('analytics'))
.service('api', () => container.get('api'))
.service('auth', () => container.get('auth'))
.service('bridge', () => container.get('bridge'))
.service('features', () => container.get('features'))
.service('frameSync', () => container.get('frameSync'))
.service('groups', () => container.get('groups'))
.service('loadAnnotationsService', () =>
container.get('loadAnnotationsService')
)
.service('rootThread', () => container.get('rootThread'))
.service('searchFilter', () => container.get('searchFilter'))
.service('serviceUrl', () => container.get('serviceUrl'))
.service('session', () => container.get('session'))
.service('streamer', () => container.get('streamer'))
.service('streamFilter', () => container.get('streamFilter'))
.service('toastMessenger', () => container.get('toastMessenger'))
// Redux store
.service('store', () => container.get('store'))
// Utilities
.value('isSidebar', container.get('isSidebar'))
.value('settings', container.get('settings'))
// Make Angular built-ins available to services constructed by `container`.
.run(registerAngularServices)
.run(initServices);
// Work around a check in Angular's $sniffer service that causes it to
// incorrectly determine that Firefox extensions are Chrome Packaged Apps which
// do not support the HTML 5 History API. This results Angular redirecting the
// browser on startup and thus the app fails to load.
// See https://github.com/angular/angular.js/blob/a03b75c6a812fcc2f616fc05c0f1710e03fca8e9/src/ng/sniffer.js#L30
if (window.chrome && !window.chrome.app) {
window.chrome.app = {
dummyAddedByHypothesisClient: true,
};
}
// Render the UI.
const appEl = document.querySelector('hypothesis-app'); const appEl = document.querySelector('hypothesis-app');
angular.bootstrap(appEl, ['h'], { strictDi: true }); render(
<ServiceContext.Provider value={container}>
<HypothesisApp />
</ServiceContext.Provider>,
appEl
);
} }
// Start capturing RPC requests before we start the RPC server (startRPCServer) // Start capturing RPC requests before we start the RPC server (startRPCServer)
...@@ -296,11 +214,10 @@ preStartRPCServer(); ...@@ -296,11 +214,10 @@ preStartRPCServer();
fetchConfig(appConfig) fetchConfig(appConfig)
.then(config => { .then(config => {
startAngularApp(config); startApp(config);
}) })
.catch(err => { .catch(err => {
// Report error. This will be the only notice that the user gets because the // Report error. This will be the only notice that the user gets because the
// sidebar does not currently appear at all if the Angular app fails to // sidebar does not currently appear at all if the app fails to start.
// start.
console.error('Failed to start Hypothesis client: ', err); console.error('Failed to start Hypothesis client: ', err);
}); });
...@@ -47,31 +47,6 @@ import sidebarPanels from './modules/sidebar-panels'; ...@@ -47,31 +47,6 @@ import sidebarPanels from './modules/sidebar-panels';
import toastMessages from './modules/toast-messages'; import toastMessages from './modules/toast-messages';
import viewer from './modules/viewer'; import viewer from './modules/viewer';
/**
* Redux middleware which triggers an Angular change-detection cycle
* if no cycle is currently in progress.
*
* This ensures that Angular UI components are updated after the UI
* state changes in response to external inputs (eg. WebSocket messages,
* messages arriving from other frames in the page, async network responses).
*
* See http://redux.js.org/docs/advanced/Middleware.html
*/
function angularDigestMiddleware($rootScope) {
return function (next) {
return function (action) {
next(action);
// '$$phase' is set if Angular is in the middle of a digest cycle already
if (!$rootScope.$$phase) {
// $applyAsync() is similar to $apply() but provides debouncing.
// See http://stackoverflow.com/questions/30789177
$rootScope.$applyAsync(function () {});
}
};
};
}
/** /**
* Factory which creates the sidebar app's state store. * Factory which creates the sidebar app's state store.
* *
...@@ -81,11 +56,8 @@ function angularDigestMiddleware($rootScope) { ...@@ -81,11 +56,8 @@ function angularDigestMiddleware($rootScope) {
* passing the current state of the store. * passing the current state of the store.
*/ */
// @ngInject // @ngInject
export default function store($rootScope, settings) { export default function store(settings) {
const middleware = [ const middleware = [debugMiddleware];
debugMiddleware,
angularDigestMiddleware.bind(null, $rootScope),
];
const modules = [ const modules = [
activity, activity,
......
...@@ -19,7 +19,6 @@ const fixtures = immutable({ ...@@ -19,7 +19,6 @@ const fixtures = immutable({
describe('store', function () { describe('store', function () {
let store; let store;
let fakeRootScope;
function tagForID(id) { function tagForID(id) {
const storeAnn = store.findAnnotationByID(id); const storeAnn = store.findAnnotationByID(id);
...@@ -30,8 +29,7 @@ describe('store', function () { ...@@ -30,8 +29,7 @@ describe('store', function () {
} }
beforeEach(function () { beforeEach(function () {
fakeRootScope = { $applyAsync: sinon.stub() }; store = storeFactory({});
store = storeFactory(fakeRootScope, {});
}); });
describe('initialization', function () { describe('initialization', function () {
...@@ -41,14 +39,14 @@ describe('store', function () { ...@@ -41,14 +39,14 @@ describe('store', function () {
}); });
it('sets the selection when settings.annotations is set', function () { it('sets the selection when settings.annotations is set', function () {
store = storeFactory(fakeRootScope, { annotations: 'testid' }); store = storeFactory({ annotations: 'testid' });
assert.deepEqual(store.getSelectedAnnotationMap(), { assert.deepEqual(store.getSelectedAnnotationMap(), {
testid: true, testid: true,
}); });
}); });
it('expands the selected annotations when settings.annotations is set', function () { it('expands the selected annotations when settings.annotations is set', function () {
store = storeFactory(fakeRootScope, { annotations: 'testid' }); store = storeFactory({ annotations: 'testid' });
assert.deepEqual(store.expandedThreads(), { assert.deepEqual(store.expandedThreads(), {
testid: true, testid: true,
}); });
......
<div class="app-content-wrapper js-thread-list-scroll-root" ng-style="vm.backgroundStyle">
<top-bar
auth="vm.auth"
on-login="vm.login()"
on-sign-up="vm.signUp()"
on-logout="vm.logout()"
is-sidebar="::vm.isSidebar">
</top-bar>
<div class="content">
<toast-messages></toast-messages>
<help-panel auth="vm.auth"></help-panel>
<share-annotations-panel></share-annotations-panel>
<main ng-if="vm.route()">
<annotation-viewer-content ng-if="vm.route() == 'annotation'"></annotation-viewer-content>
<stream-content ng-if="vm.route() == 'stream'"></stream-content>
<sidebar-content ng-if="vm.route() == 'sidebar'" on-login="vm.login()" on-signUp="vm.signUp()"></sidebar-content>
</main>
</div>
</div>
...@@ -5,9 +5,6 @@ sinon.assert.expose(assert, { prefix: null }); ...@@ -5,9 +5,6 @@ sinon.assert.expose(assert, { prefix: null });
import { patch } from '../../test-util/assert-methods'; import { patch } from '../../test-util/assert-methods';
patch(assert); patch(assert);
import 'angular';
import 'angular-mocks';
// Configure Enzyme for UI tests. // Configure Enzyme for UI tests.
import 'preact/debug'; import 'preact/debug';
......
import angular from 'angular';
import { Component, createElement } from 'preact';
import { useContext } from 'preact/hooks';
import propTypes from 'prop-types';
import { Injector } from '../../../shared/injector';
import { createDirective } from '../../components/test/angular-util';
import { ServiceContext } from '../service-context';
import wrapReactComponent from '../wrap-react-component';
// Saved `onDblClick` prop from last render of `Button`.
// This makes it easy to call it with different arguments.
let lastOnDblClickCallback;
// Saved service context from last render of `Button`.
// Components in the tree can use this to get at Angular services.
let lastServiceContext;
function Button({ label, isDisabled, onClick, onDblClick }) {
// We don't actually use the `onDblClick` handler in this component.
// It exists just to test callbacks that take arguments.
lastOnDblClickCallback = onDblClick;
lastServiceContext = useContext(ServiceContext);
return (
<button disabled={isDisabled} onClick={onClick}>
{label}
</button>
);
}
Button.propTypes = {
// Simple input properties passed by parent component.
label: propTypes.string.isRequired,
isDisabled: propTypes.bool,
// A required callback with no arguments.
onClick: propTypes.func.isRequired,
// An optional callback with a `{ click }` argument.
onDblClick: propTypes.func,
};
describe('wrapReactComponent', () => {
function renderButton() {
const onClick = sinon.stub();
const onDblClick = sinon.stub();
const element = createDirective(document, 'btn', {
label: 'Edit',
isDisabled: false,
onClick,
onDblClick: {
args: ['count'],
callback: onDblClick,
},
});
return { element, onClick, onDblClick };
}
let servicesInjector;
beforeEach(() => {
const servicesInjector = new Injector();
servicesInjector.register('theme', { value: 'dark' });
angular
.module('app', [])
.component('btn', wrapReactComponent(Button, servicesInjector));
angular.mock.module('app');
});
afterEach(() => {
if (console.error.restore) {
console.error.restore();
}
});
it('derives Angular component "bindings" from React "propTypes"', () => {
const ngComponent = wrapReactComponent(Button, servicesInjector);
assert.deepEqual(ngComponent.bindings, {
label: '<',
isDisabled: '<',
onClick: '&',
onDblClick: '&',
// nb. Props passed via dependency injection should not appear here.
});
});
it('renders the React component when the Angular component is created', () => {
const { element, onClick } = renderButton();
const btnEl = element[0].querySelector('button');
assert.ok(btnEl);
// Check that properties are passed correctly.
assert.equal(btnEl.textContent, 'Edit');
assert.equal(btnEl.disabled, false);
// Verify that events result in callbacks being invoked.
btnEl.click();
assert.called(onClick);
});
it('exposes Angular services to the React component and descendants', () => {
lastServiceContext = null;
renderButton();
assert.ok(lastServiceContext);
assert.equal(lastServiceContext.get('theme'), 'dark');
});
it('updates the React component when the Angular component is updated', () => {
const { element } = renderButton();
const btnEl = element[0].querySelector('button');
assert.equal(btnEl.textContent, 'Edit');
// Change the inputs and re-render.
element.scope.label = 'Saving...';
element.scope.$digest();
// Check that the text _of the original DOM element_, was updated.
assert.equal(btnEl.textContent, 'Saving...');
});
it('removes the React component when the Angular component is destroyed', () => {
// Create parent Angular component which renders a React child.
const parentComponent = {
controllerAs: 'vm',
bindings: {
showChild: '<',
},
template: '<child ng-if="vm.showChild"></child>',
};
// Create a React child which needs to do some cleanup when destroyed.
const childUnmounted = sinon.stub();
class ChildComponent extends Component {
componentWillUnmount() {
childUnmounted();
}
}
ChildComponent.propTypes = {};
angular
.module('app', [])
.component('parent', parentComponent)
.component('child', wrapReactComponent(ChildComponent, servicesInjector));
angular.mock.module('app');
// Render the component with the child initially visible.
const element = createDirective(document, 'parent', { showChild: true });
// Re-render with the child removed and check that the React component got
// destroyed properly.
element.scope.showChild = false;
element.scope.$digest();
assert.called(childUnmounted);
});
it('throws an error if the developer forgets to set propTypes', () => {
function TestComponent() {
return <div>Hello world</div>;
}
assert.throws(
() => wrapReactComponent(TestComponent, servicesInjector),
'React component TestComponent does not specify its inputs using "propTypes"'
);
});
// Input property checking is handled by React when debug checks are enabled.
// This test just makes sure that these checks are working as expected.
it('throws an error if property types do not match when component is rendered', () => {
const { element } = renderButton();
const btnEl = element[0].querySelector('button');
assert.equal(btnEl.textContent, 'Edit');
// Incorrectly set label to a number, instead of a string.
element.scope.label = 123;
const consoleError = sinon.stub(console, 'error');
element.scope.$digest();
assert.calledWithMatch(
consoleError,
/Invalid prop `label` of type `number`/
);
});
it('throws an error if a callback is passed a non-object argument', () => {
renderButton();
assert.throws(() => {
lastOnDblClickCallback('not an object');
}, 'onDblClick callback must be invoked with an object. Was passed "not an object"');
});
it('supports invoking callback properties', () => {
const { onDblClick } = renderButton();
lastOnDblClickCallback({ count: 1 });
// The React component calls `onDblClick({ count: 1 })`. The template which
// renders the Angular wrapper contains an expression which references
// those variables (`<btn on-dbl-click="doSomething(count)">`) and the end
// result is that the callback gets passed the value of `count`.
assert.calledWith(onDblClick, 1);
});
it('supports invoking callback properties if a digest cycle is already in progress', () => {
const { element, onDblClick } = renderButton();
element.scope.$apply(() => {
lastOnDblClickCallback({ count: 1 });
});
assert.calledWith(onDblClick, 1);
});
it('triggers a digest cycle when invoking callback properties', () => {
// Create an Angular component which passes an `on-{event}` callback down
// to a child React component.
const parentComponent = {
controller() {
this.clicked = false;
},
controllerAs: 'vm',
template: `
<child on-click="vm.clicked = true"></child>
<div class="click-indicator" ng-if="vm.clicked">Clicked</div>
`,
};
function Child({ onClick }) {
return <button onClick={onClick}>Click me</button>;
}
Child.propTypes = { onClick: propTypes.func };
angular
.module('app', [])
.component('parent', parentComponent)
.component('child', wrapReactComponent(Child, servicesInjector));
angular.mock.module('app');
const element = createDirective(document, 'parent');
assert.isNull(element[0].querySelector('.click-indicator'));
const btn = element.find('button')[0];
btn.click();
// Check that parent component DOM has been updated to reflect new state of
// `vm.clicked`. This requires the `btn.click()` call to trigger a digest
// cycle.
assert.ok(element[0].querySelector('.click-indicator'));
});
});
import { createElement, render } from 'preact';
import { ServiceContext } from './service-context';
function useExpressionBinding(propName) {
return propName.match(/^on[A-Z]/);
}
/**
* @typedef {import('../../shared/injector').Injector} Injector
*/
/**
* Controller for an Angular component that wraps a React component.
*
* This is responsible for taking the inputs to the Angular component and
* rendering the React component in the DOM node where the Angular component
* has been created.
*/
class ReactController {
constructor($element, services, $scope, type) {
/** The DOM element where the React component should be rendered. */
this.domElement = $element[0];
/**
* The services injector, used by this component and its descendants.
*
* @type {Injector}
*/
this.services = services;
/** The React component function or class. */
this.type = type;
/** The input props to the React component. */
this.props = {};
// Wrap callback properties (eg. `onClick`) with `$scope.$apply` to trigger
// a digest cycle after the function is called. This ensures that the
// parent Angular component will update properly afterwards.
Object.keys(this.type.propTypes).forEach(propName => {
if (!useExpressionBinding(propName)) {
return;
}
this.props[propName] = arg => {
if (arg !== Object(arg)) {
throw new Error(
`${propName} callback must be invoked with an object. ` +
`Was passed "${arg}"`
);
}
// Test whether a digest cycle is already in progress using `$$phase`,
// in which case there is no need to trigger one with `$apply`.
//
// Most of the time there will be no digest cycle in progress, but this
// can happen if a change made by Angular code indirectly causes a
// component to call a function prop.
if ($scope.$root.$$phase) {
this[propName](arg);
} else {
$scope.$apply(() => {
this[propName](arg);
});
}
};
});
}
$onChanges(changes) {
// Copy updated property values from parent Angular component to React
// props. This callback is run when the component is initially created as
// well as subsequent updates.
Object.keys(changes).forEach(propName => {
if (!useExpressionBinding(propName)) {
this.props[propName] = changes[propName].currentValue;
}
});
this.updateReactComponent();
}
$onDestroy() {
// Unmount the rendered React component. Although Angular will remove the
// element itself, this is necessary to run any cleanup/unmount lifecycle
// hooks in the React component tree.
render(createElement(null), this.domElement);
}
updateReactComponent() {
// Render component, with a `ServiceContext.Provider` wrapper which
// provides access to services via `withServices` or `useContext`
// in child components.
render(
<ServiceContext.Provider value={this.services}>
<this.type {...this.props} />
</ServiceContext.Provider>,
this.domElement
);
}
}
/**
* Create an AngularJS component which wraps a React component.
*
* The React component must specify its expected inputs using the `propTypes`
* property on the function or class (see
* https://reactjs.org/docs/typechecking-with-proptypes.html). Props use
* one-way ('<') bindings except for those with names matching /^on[A-Z]/ which
* are assumed to be callbacks that use expression ('&') bindings.
*
* If the React component needs access to a service, it can get at
* them using the `withServices` wrapper from service-context.js.
*
* @param {Function} type - The React component class or function
* @param {Injector} services
* Dependency injection container providing services that components use.
* @return {Object} -
* An AngularJS component spec for use with `angular.component(...)`
*/
export default function wrapReactComponent(type, services) {
if (!type.propTypes) {
throw new Error(
`React component ${type.name} does not specify its inputs using "propTypes"`
);
}
/**
* Create an AngularJS component controller that renders the specific React
* component being wrapped.
*/
// @ngInject
function createController($element, $scope) {
return new ReactController($element, services, $scope, type);
}
const bindings = {};
Object.keys(type.propTypes).forEach(propName => {
bindings[propName] = useExpressionBinding(propName) ? '&' : '<';
});
return {
bindings,
controller: createController,
};
}
/* Include this file in your html if you are using the CSP mode. */
@charset "UTF-8";
[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak],
.ng-cloak, .x-ng-cloak,
.ng-hide {
display: none !important;
}
ng\:form {
display: block;
}
.ng-animate-block-transitions {
transition:0s all!important;
-webkit-transition:0s all!important;
}
/* show the element during a show/hide animation when the
* animation is ongoing, but the .ng-hide class is active */
.ng-hide-add-active, .ng-hide-remove {
display: block!important;
}
...@@ -1149,16 +1149,6 @@ ancestors@0.0.3: ...@@ -1149,16 +1149,6 @@ ancestors@0.0.3:
resolved "https://registry.yarnpkg.com/ancestors/-/ancestors-0.0.3.tgz#124eb944447d68b302057047d15d077a9da5179d" resolved "https://registry.yarnpkg.com/ancestors/-/ancestors-0.0.3.tgz#124eb944447d68b302057047d15d077a9da5179d"
integrity sha1-Ek65RER9aLMCBXBH0V0Hep2lF50= integrity sha1-Ek65RER9aLMCBXBH0V0Hep2lF50=
angular-mocks@^1.7.5:
version "1.7.9"
resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.7.9.tgz#0a3b7e28b9a493b4e3010ed2b0f69a68e9b4f79b"
integrity sha512-LQRqqiV3sZ7NTHBnNmLT0bXtE5e81t97+hkJ56oU0k3dqKv1s6F+nBWRlOVzqHWPGFOiPS8ZJVdrS8DFzHyNIA==
angular@^1.7.5:
version "1.7.9"
resolved "https://registry.yarnpkg.com/angular/-/angular-1.7.9.tgz#e52616e8701c17724c3c238cfe4f9446fd570bc4"
integrity sha512-5se7ZpcOtu0MBFlzGv5dsM1quQDoDeUTwZrWjGtTNA7O88cD8TEk5IEKCTDa3uECV9XnvKREVUr7du1ACiWGFQ==
ansi-colors@3.2.3: ansi-colors@3.2.3:
version "3.2.3" version "3.2.3"
resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813"
......
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