Unverified Commit bbaab28e authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1906 from hypothesis/replace-angular-router

Replace Angular router
parents 40b027d6 64a4a104
......@@ -14,7 +14,6 @@
"@sentry/browser": "^5.6.2",
"angular": "^1.7.5",
"angular-mocks": "^1.7.5",
"angular-route": "^1.7.5",
"angular-toastr": "^2.1.1",
"autoprefixer": "^9.4.7",
"aws-sdk": "^2.345.0",
......
......@@ -9,7 +9,7 @@
module.exports = {
bundles: {
jquery: ['jquery'],
angular: ['angular', 'angular-route', 'angular-toastr'],
angular: ['angular', 'angular-toastr'],
katex: ['katex'],
sentry: ['@sentry/browser'],
showdown: ['showdown'],
......
......@@ -26,7 +26,6 @@ function fetchThread(api, id) {
// @ngInject
function AnnotationViewerContentController(
$routeParams,
store,
api,
rootThread,
......@@ -34,9 +33,9 @@ function AnnotationViewerContentController(
streamFilter,
annotationMapper
) {
store.setAppIsSidebar(false);
store.clearAnnotations();
const id = $routeParams.id;
const annotationId = store.routeParams().id;
this.rootThread = () => rootThread.thread(store.getState());
......@@ -44,7 +43,7 @@ function AnnotationViewerContentController(
store.setCollapsed(id, collapsed);
};
this.ready = fetchThread(api, id).then(function(annots) {
this.ready = fetchThread(api, annotationId).then(function(annots) {
annotationMapper.loadAnnotations(annots);
const topLevelAnnot = annots.filter(function(annot) {
......@@ -65,8 +64,8 @@ function AnnotationViewerContentController(
store.setCollapsed(annot.id, false);
});
if (topLevelAnnot.id !== id) {
store.highlightAnnotations([id]);
if (topLevelAnnot.id !== annotationId) {
store.highlightAnnotations([annotationId]);
}
});
}
......
......@@ -35,7 +35,6 @@ function authStateFromProfile(profile) {
function HypothesisAppController(
$document,
$rootScope,
$route,
$scope,
$window,
analytics,
......@@ -82,6 +81,8 @@ function HypothesisAppController(
}
};
this.route = () => store.route();
$scope.$on(events.USER_CHANGED, function(event, data) {
self.onUserChange(data.profile);
});
......
// @ngInject
function StreamContentController(
$scope,
$route,
$routeParams,
annotationMapper,
store,
api,
rootThread,
searchFilter
) {
store.setAppIsSidebar(false);
/** `offset` parameter for the next search API call. */
let offset = 0;
......@@ -20,6 +16,8 @@ function StreamContentController(
annotationMapper.loadAnnotations(result.rows, result.replies);
};
const currentQuery = () => store.routeParams().q;
/**
* Fetch the next `limit` annotations starting from `offset` from the API.
*/
......@@ -30,7 +28,7 @@ function StreamContentController(
offset: offset,
limit: limit,
},
searchFilter.toObject($routeParams.q)
searchFilter.toObject(currentQuery())
);
api
......@@ -41,21 +39,27 @@ function StreamContentController(
});
};
// Re-do search when query changes
const lastQuery = $routeParams.q;
$scope.$on('$routeUpdate', function() {
if ($routeParams.q !== lastQuery) {
store.clearAnnotations();
$route.reload();
function clearAndFetch() {
// In case this route loaded after a client-side route change (eg. from
// '/a/:id'), clear any existing annotations.
store.clearAnnotations();
// Fetch initial batch of annotations.
offset = 0;
fetch(20);
}
let lastQuery = currentQuery();
const unsubscribe = store.subscribe(() => {
const query = currentQuery();
if (query !== lastQuery) {
lastQuery = query;
clearAndFetch();
}
});
$scope.$on('$destroy', unsubscribe);
// In case this route loaded after a client-side route change (eg. from
// '/a/:id'), clear any existing annotations.
store.clearAnnotations();
// Perform the initial search
fetch(20);
clearAndFetch();
this.setCollapsed = store.setCollapsed;
this.rootThread = () => rootThread.thread(store.getState());
......
import { createElement } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import propTypes from 'prop-types';
import useStore from '../store/use-store';
import { withServices } from '../util/service-context';
import SearchInput from './search-input';
......@@ -11,30 +11,23 @@ import SearchInput from './search-input';
*
* This displays and updates the "q" query param in the URL.
*/
function StreamSearchInput({ $location, $rootScope }) {
const [query, setQuery] = useState($location.search().q);
const search = query => {
$rootScope.$apply(() => {
// Re-route the user to `/stream` if they are on `/a/:id` and then set
// the search query.
$location.path('/stream').search({ q: query });
});
function StreamSearchInput({ router }) {
const query = useStore(store => store.routeParams().q);
const setQuery = query => {
// Re-route the user to `/stream` if they are on `/a/:id` and then set
// the search query.
router.navigate('stream', { q: query });
};
useEffect(() => {
$rootScope.$on('$locationChangeSuccess', () => {
setQuery($location.search().q);
});
}, [$location, $rootScope]);
return <SearchInput query={query} onSearch={search} alwaysExpanded={true} />;
return (
<SearchInput query={query} onSearch={setQuery} alwaysExpanded={true} />
);
}
StreamSearchInput.propTypes = {
$location: propTypes.object,
$rootScope: propTypes.object,
router: propTypes.object,
};
StreamSearchInput.injectedProps = ['$location', '$rootScope'];
StreamSearchInput.injectedProps = ['router'];
export default withServices(StreamSearchInput);
......@@ -41,12 +41,11 @@ describe('annotationViewerContent', function() {
function createController(opts) {
const locals = {
$location: {},
$routeParams: { id: 'test_annotation_id' },
store: {
setAppIsSidebar: sinon.stub(),
clearAnnotations: sinon.stub(),
setCollapsed: sinon.stub(),
highlightAnnotations: sinon.stub(),
routeParams: sinon.stub().returns({ id: 'test_annotation_id' }),
subscribe: sinon.stub(),
},
api: opts.api,
......
......@@ -18,12 +18,10 @@ describe('sidebar.components.hypothesis-app', function() {
let fakeFlash = null;
let fakeFrameSync = null;
let fakeIsSidebar = null;
let fakeParams = null;
let fakeServiceConfig = null;
let fakeSession = null;
let fakeShouldAutoDisplayTutorial = null;
let fakeGroups = null;
let fakeRoute = null;
let fakeServiceUrl = null;
let fakeSettings = null;
let fakeWindow = null;
......@@ -103,8 +101,6 @@ describe('sidebar.components.hypothesis-app', function() {
connect: sandbox.spy(),
};
fakeParams = { id: 'test' };
fakeSession = {
load: sandbox.stub().returns(Promise.resolve({ userid: null })),
logout: sandbox.stub(),
......@@ -115,8 +111,6 @@ describe('sidebar.components.hypothesis-app', function() {
focus: sandbox.spy(),
};
fakeRoute = { reload: sandbox.spy() };
fakeWindow = {
top: {},
confirm: sandbox.stub(),
......@@ -140,8 +134,6 @@ describe('sidebar.components.hypothesis-app', function() {
$provide.value('settings', fakeSettings);
$provide.value('bridge', fakeBridge);
$provide.value('groups', fakeGroups);
$provide.value('$route', fakeRoute);
$provide.value('$routeParams', fakeParams);
$provide.value('$window', fakeWindow);
})
);
......
......@@ -12,9 +12,6 @@ class FakeRootThread extends EventEmitter {
describe('StreamContentController', function() {
let $componentController;
let $rootScope;
let fakeRoute;
let fakeRouteParams;
let fakeAnnotationMapper;
let fakeStore;
let fakeRootThread;
......@@ -34,19 +31,13 @@ describe('StreamContentController', function() {
fakeStore = {
clearAnnotations: sinon.spy(),
setAppIsSidebar: sinon.spy(),
routeParams: sinon.stub().returns({ id: 'test' }),
setCollapsed: sinon.spy(),
setForceVisible: sinon.spy(),
setSortKey: sinon.spy(),
subscribe: sinon.spy(),
};
fakeRouteParams = { id: 'test' };
fakeRoute = {
reload: sinon.spy(),
};
fakeSearchFilter = {
generateFacetedFilter: sinon.stub(),
toObject: sinon.stub().returns({}),
......@@ -74,8 +65,6 @@ describe('StreamContentController', function() {
fakeRootThread = new FakeRootThread();
angular.mock.module('h', {
$route: fakeRoute,
$routeParams: fakeRouteParams,
annotationMapper: fakeAnnotationMapper,
store: fakeStore,
api: fakeApi,
......@@ -85,9 +74,8 @@ describe('StreamContentController', function() {
streamer: fakeStreamer,
});
angular.mock.inject(function(_$componentController_, _$rootScope_) {
angular.mock.inject(function(_$componentController_) {
$componentController = _$componentController_;
$rootScope = _$rootScope_;
});
});
......@@ -125,25 +113,30 @@ describe('StreamContentController', function() {
});
});
context('when a $routeUpdate event occurs', function() {
it('reloads the route if the query changed', function() {
fakeRouteParams.q = 'test query';
context('when route parameters change', function() {
it('updates annotations if the query changed', function() {
fakeStore.routeParams.returns({ q: 'test query' });
createController();
fakeRouteParams.q = 'new query';
$rootScope.$broadcast('$routeUpdate');
fakeStore.clearAnnotations.resetHistory();
fakeApi.search.resetHistory();
fakeStore.routeParams.returns({ q: 'new query' });
fakeStore.subscribe.lastCall.callback();
assert.called(fakeStore.clearAnnotations);
assert.calledOnce(fakeRoute.reload);
assert.called(fakeApi.search);
});
it('does not reload the route if the query did not change', function() {
fakeRouteParams.q = 'test query';
it('does not clear annotations if the query did not change', function() {
fakeStore.routeParams.returns({ q: 'test query' });
createController();
fakeApi.search.resetHistory();
fakeStore.clearAnnotations.resetHistory();
$rootScope.$broadcast('$routeUpdate');
fakeStore.subscribe.lastCall.callback();
assert.notCalled(fakeStore.clearAnnotations);
assert.notCalled(fakeRoute.reload);
assert.notCalled(fakeApi.search);
});
});
});
......@@ -8,20 +8,20 @@ import { $imports } from '../stream-search-input';
import mockImportedComponents from '../../../test-util/mock-imported-components';
describe('StreamSearchInput', () => {
let fakeLocation;
let fakeRootScope;
let fakeRouter;
let fakeStore;
beforeEach(() => {
fakeLocation = {
path: sinon.stub().returnsThis(),
search: sinon.stub().returns({ q: 'the-query' }),
fakeRouter = {
navigate: sinon.stub(),
};
fakeRootScope = {
$apply: callback => callback(),
$on: sinon.stub(),
fakeStore = {
routeParams: sinon.stub().returns({}),
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'../store/use-store': callback => callback(fakeStore),
});
});
afterEach(() => {
......@@ -29,16 +29,11 @@ describe('StreamSearchInput', () => {
});
function createSearchInput(props = {}) {
return mount(
<StreamSearchInput
$location={fakeLocation}
$rootScope={fakeRootScope}
{...props}
/>
);
return mount(<StreamSearchInput router={fakeRouter} {...props} />);
}
it('displays current "q" search param', () => {
fakeStore.routeParams.returns({ q: 'the-query' });
const wrapper = createSearchInput();
assert.equal(wrapper.find('SearchInput').prop('query'), 'the-query');
});
......@@ -51,23 +46,6 @@ describe('StreamSearchInput', () => {
.props()
.onSearch('new-query');
});
assert.calledWith(fakeLocation.path, '/stream');
assert.calledWith(fakeLocation.search, { q: 'new-query' });
});
it('updates query when changed in URL', () => {
fakeLocation.search.returns({ q: 'query-b' });
const wrapper = createSearchInput();
assert.calledOnce(fakeRootScope.$on);
assert.calledWith(fakeRootScope.$on, '$locationChangeSuccess');
act(() => {
fakeRootScope.$on.lastCall.callback();
});
// Check that new query is displayed.
wrapper.update();
assert.equal(wrapper.find('SearchInput').prop('query'), 'query-b');
assert.calledWith(fakeRouter.navigate, 'stream', { q: 'new-query' });
});
});
......@@ -32,7 +32,6 @@ import angular from 'angular';
// Angular addons which export the Angular module name via `module.exports`.
import angularRoute from 'angular-route';
import angularToastr from 'angular-toastr';
// Load polyfill for :focus-visible pseudo-class.
......@@ -49,15 +48,6 @@ if (appConfig.googleAnalytics) {
addAnalytics(appConfig.googleAnalytics);
}
// Fetch external state that the app needs before it can run. This includes the
// user's profile and list of groups.
const resolve = {
// @ngInject
state: function(groups, session) {
return Promise.all([groups.load(), session.load()]);
},
};
const isSidebar = !(
window.location.pathname.startsWith('/stream') ||
window.location.pathname.startsWith('/a/')
......@@ -69,28 +59,6 @@ function configureLocation($locationProvider) {
return $locationProvider.html5Mode(true);
}
// @ngInject
function configureRoutes($routeProvider) {
// The `vm.{auth,search}` properties used in these templates come from the
// `<hypothesis-app>` component which hosts the router's container element.
$routeProvider.when('/a/:id', {
template: '<annotation-viewer-content></annotation-viewer-content>',
reloadOnSearch: false,
resolve: resolve,
});
$routeProvider.when('/stream', {
template: '<stream-content></stream-content>',
reloadOnSearch: false,
resolve: resolve,
});
$routeProvider.otherwise({
template:
'<sidebar-content auth="vm.auth" on-login="vm.login()" on-sign-up="vm.signUp()"></sidebar-content>',
reloadOnSearch: false,
resolve: resolve,
});
}
// @ngInject
function configureToastr(toastrConfig) {
angular.extend(toastrConfig, {
......@@ -103,6 +71,17 @@ function setupApi(api, streamer) {
api.setClientId(streamer.clientId);
}
/**
* Perform the initial fetch of groups and user profile and then set the initial
* route to match the current URL.
*/
// @ngInject
function setupRoute(groups, session, router) {
Promise.all([groups.load(), session.load()]).finally(() => {
router.sync();
});
}
/**
* Send a page view event when the app starts up.
*
......@@ -183,6 +162,7 @@ import localStorageService from './services/local-storage';
import permissionsService from './services/permissions';
import persistedDefaultsService from './services/persisted-defaults';
import rootThreadService from './services/root-thread';
import routerService from './services/router';
import searchFilterService from './services/search-filter';
import serviceUrlService from './services/service-url';
import sessionService from './services/session';
......@@ -226,6 +206,7 @@ function startAngularApp(config) {
.register('permissions', permissionsService)
.register('persistedDefaults', persistedDefaultsService)
.register('rootThread', rootThreadService)
.register('router', routerService)
.register('searchFilter', searchFilterService)
.register('serviceUrl', serviceUrlService)
.register('session', sessionService)
......@@ -259,7 +240,7 @@ function startAngularApp(config) {
const wrapComponent = component => wrapReactComponent(component, container);
angular
.module('h', [angularRoute, angularToastr])
.module('h', [angularToastr])
// The root component for the application
.component('hypothesisApp', hypothesisApp)
......@@ -313,6 +294,7 @@ function startAngularApp(config) {
.service('permissions', () => container.get('permissions'))
.service('persistedDefaults', () => container.get('persistedDefaults'))
.service('rootThread', () => container.get('rootThread'))
.service('router', () => container.get('router'))
.service('searchFilter', () => container.get('searchFilter'))
.service('serviceUrl', () => container.get('serviceUrl'))
.service('session', () => container.get('session'))
......@@ -327,7 +309,6 @@ function startAngularApp(config) {
.value('settings', container.get('settings'))
.config(configureLocation)
.config(configureRoutes)
.config(configureToastr)
// Make Angular built-ins available to services constructed by `container`.
......@@ -337,6 +318,7 @@ function startAngularApp(config) {
.run(autosave)
.run(sendPageView)
.run(setupApi)
.run(setupRoute)
.run(crossOriginRPC.server.start);
// Work around a check in Angular's $sniffer service that causes it to
......
......@@ -72,7 +72,7 @@ export default function RootThread(
}
let threadFilterFn;
if (state.viewer.isSidebar && !shouldFilterThread()) {
if (state.route.name === 'sidebar' && !shouldFilterThread()) {
threadFilterFn = function(thread) {
if (!thread.annotation) {
return false;
......
import * as queryString from 'query-string';
/**
* A service that manages the association between the route and route parameters
* implied by the URL and the corresponding route state in the store.
*/
// @ngInject
export default function router($window, store) {
/**
* Return the name and parameters of the current route.
*/
function currentRoute() {
const path = $window.location.pathname;
const pathSegments = path.slice(1).split('/');
const params = queryString.parse($window.location.search);
let route;
switch (pathSegments[0]) {
case 'a':
route = 'annotation';
params.id = pathSegments[1] || '';
break;
case 'stream':
route = 'stream';
break;
default:
route = 'sidebar';
break;
}
return { route, params };
}
/**
* Generate a URL for a given route.
*/
function routeUrl(name, params = {}) {
let url;
const queryParams = { ...params };
switch (name) {
case 'annotation':
{
const id = params.id;
delete queryParams.id;
url = `/a/${id}`;
}
break;
case 'stream':
url = '/stream';
break;
default:
throw new Error(`Cannot generate URL for route "${name}"`);
}
const query = queryString.stringify(queryParams);
if (query.length > 0) {
url += '?' + query;
}
return url;
}
let didRegisterPopstateListener = false;
/**
* Synchronize the route name and parameters in the store with the current
* URL.
*
* The first call to this method also registers a listener for future back/forwards
* navigation in the browser.
*/
function sync() {
const { route, params } = currentRoute();
store.changeRoute(route, params);
// Set up listener for back/forward navigation. We do this in `sync()` to
// avoid the route being changed by a "popstate" emitted by the browser on
// document load (which Safari and Chrome do).
if (!didRegisterPopstateListener) {
$window.addEventListener('popstate', () => {
// All the state we need to update the route is contained in the URL, which
// has already been updated at this point, so just sync the store route
// to match the URL.
sync();
});
didRegisterPopstateListener = true;
}
}
/**
* Navigate to a given route.
*
* @param {string} name
* @param {Object} params
*/
function navigate(name, params) {
$window.history.pushState({}, '', routeUrl(name, params));
sync();
}
return { sync, navigate };
}
......@@ -52,7 +52,7 @@ export default function Streamer(
break;
}
if (!store.isSidebar()) {
if (store.route() !== 'sidebar') {
applyPendingUpdates();
}
}
......
......@@ -41,7 +41,6 @@ describe('rootThread', function() {
annotations: [],
},
viewer: {
isSidebar: true,
visibleHighlights: false,
},
drafts: [],
......@@ -55,6 +54,10 @@ describe('rootThread', function() {
sortKey: 'Location',
sortKeysAvailable: ['Location'],
},
route: {
name: 'sidebar',
params: {},
},
},
getState: function() {
return this.state;
......@@ -294,7 +297,7 @@ describe('rootThread', function() {
it('does not filter annotations when not in the sidebar', function() {
fakeBuildThread.reset();
fakeStore.state.viewer.isSidebar = false;
fakeStore.state.route.name = 'stream';
rootThread.thread(fakeStore.state);
const threadFilterFn = fakeBuildThread.args[0][1].threadFilterFn;
......
import EventEmitter from 'tiny-emitter';
import router from '../router';
const fixtures = [
{
path: '/app.html',
route: 'sidebar',
params: {},
},
{
path: '/a/foo',
route: 'annotation',
params: { id: 'foo' },
},
{
path: '/stream',
search: 'q=foobar',
route: 'stream',
params: { q: 'foobar' },
},
];
describe('router', () => {
let fakeWindow;
let fakeStore;
function createService() {
return router(fakeWindow, fakeStore);
}
function updateUrl(path, search) {
fakeWindow.location.pathname = path;
fakeWindow.location.search = search;
}
beforeEach(() => {
const emitter = new EventEmitter();
fakeWindow = {
location: {
pathname: '',
search: '',
},
history: {
pushState: sinon.stub(),
},
addEventListener: emitter.on.bind(emitter),
emit: emitter.emit.bind(emitter),
};
fakeStore = {
changeRoute: sinon.stub(),
};
});
describe('#sync', () => {
fixtures.forEach(({ path, search, route, params }) => {
it('updates the active route in the store', () => {
updateUrl(path, search);
const svc = createService();
svc.sync();
assert.calledWith(fakeStore.changeRoute, route, params);
});
});
});
describe('#navigate', () => {
fixtures.forEach(({ path, search, route, params }) => {
if (route === 'sidebar') {
// You can't navigate _to_ the sidebar from another route.
return;
}
it('updates the URL', () => {
const svc = createService();
svc.navigate(route, params);
const expectedUrl = path + (search ? `?${search}` : '');
assert.calledWith(fakeWindow.history.pushState, {}, '', expectedUrl);
});
});
it('throws an error if route does not have a fixed URL', () => {
const svc = createService();
assert.throws(() => {
svc.navigate('sidebar');
}, 'Cannot generate URL for route "sidebar"');
});
it('updates the active route in the store', () => {
const svc = createService();
updateUrl('/stream', 'q=foobar');
svc.navigate('stream', { q: 'foobar' });
assert.calledWith(fakeStore.changeRoute, 'stream', { q: 'foobar' });
});
});
context('when a browser history navigation happens', () => {
fixtures.forEach(({ path, search, route, params }) => {
it('updates the active route in the store', () => {
const svc = createService();
svc.sync();
fakeStore.changeRoute.resetHistory();
updateUrl(path, search);
fakeWindow.emit('popstate');
assert.calledWith(fakeStore.changeRoute, route, params);
});
});
it('does nothing if the initial call to `sync()` has not happened', () => {
createService();
// Simulate a "popstate" event being triggered by the browser before
// the app is ready to initialize the router by calling `sync()`.
//
// Safari and Chrome do this when the page loads. Firefox does not.
fakeWindow.emit('popstate');
assert.notCalled(fakeStore.changeRoute);
});
});
});
......@@ -123,11 +123,11 @@ describe('Streamer', function() {
userid: 'jim@hypothes.is',
},
}),
isSidebar: sinon.stub().returns(true),
pendingUpdates: sinon.stub().returns({}),
pendingDeletions: sinon.stub().returns({}),
receiveRealTimeUpdates: sinon.stub(),
removeAnnotations: sinon.stub(),
route: sinon.stub().returns('sidebar'),
};
fakeGroups = {
......@@ -275,7 +275,7 @@ describe('Streamer', function() {
context('when the app is the stream', function() {
beforeEach(function() {
fakeStore.isSidebar.returns(false);
fakeStore.route.returns('stream');
});
it('applies updates immediately', function() {
......
......@@ -40,6 +40,7 @@ import frames from './modules/frames';
import groups from './modules/groups';
import links from './modules/links';
import realTimeUpdates from './modules/real-time-updates';
import route from './modules/route';
import selection from './modules/selection';
import session from './modules/session';
import sidebarPanels from './modules/sidebar-panels';
......@@ -95,6 +96,7 @@ export default function store($rootScope, settings) {
links,
groups,
realTimeUpdates,
route,
selection,
session,
sidebarPanels,
......
......@@ -9,6 +9,8 @@ import * as metadata from '../../util/annotation-metadata';
import * as arrayUtil from '../../util/array';
import * as util from '../util';
import route from './route';
/**
* Return a copy of `current` with all matching annotations in `annotations`
* removed.
......@@ -241,7 +243,7 @@ function addAnnotations(annotations) {
// If we're not in the sidebar, we're done here.
// FIXME Split the annotation-adding from the anchoring code; possibly
// move into service
if (!getState().viewer.isSidebar) {
if (route.selectors.route(getState()) !== 'sidebar') {
return;
}
......
......@@ -9,7 +9,7 @@ import { actionTypes } from '../util';
import annotations from './annotations';
import groups from './groups';
import viewer from './viewer';
import route from './route';
function init() {
return {
......@@ -96,7 +96,7 @@ function receiveRealTimeUpdates({
// when switching groups.
if (
ann.group === groups.selectors.focusedGroupId(getState()) ||
!viewer.selectors.isSidebar(getState())
route.selectors.route(getState()) !== 'sidebar'
) {
pendingUpdates[ann.id] = ann;
}
......
import { actionTypes } from '../util';
function init() {
return {
/**
* The current route.
* One of null (if no route active yet), "sidebar", "annotation" or "stream".
*/
name: null,
/**
* Parameters of the current route.
*
* - The "annotation" route has an "id" (annotation ID) parameter.
* - The "stream" route has a "q" (query) parameter.
* - The "sidebar" route has no parameters.
*/
params: {},
};
}
const update = {
CHANGE_ROUTE(state, { name, params }) {
return { name, params };
},
};
const actions = actionTypes(update);
/**
* Change the active route.
*
* @param {string} name - Name of the route to activate. See `init` for possible values
* @param {Object} params - Parameters associated with the route
*/
function changeRoute(name, params = {}) {
return {
type: actions.CHANGE_ROUTE,
name,
params,
};
}
/**
* Return the name of the current route.
*/
function route(state) {
return state.route.name;
}
/**
* Return any parameters for the current route, extracted from the path and
* query string.
*/
function routeParams(state) {
return state.route.params;
}
export default {
init,
namespace: 'route',
update,
actions: {
changeRoute,
},
selectors: {
route,
routeParams,
},
};
......@@ -2,12 +2,12 @@ import * as fixtures from '../../../test/annotation-fixtures';
import * as metadata from '../../../util/annotation-metadata';
import createStore from '../../create-store';
import annotations from '../annotations';
import viewer from '../viewer';
import route from '../route';
const { actions, selectors } = annotations;
function createTestStore() {
return createStore([annotations, viewer], [{}]);
return createStore([annotations, route], [{}]);
}
// Tests for most of the functionality in reducers/annotations.js are currently
......@@ -30,6 +30,7 @@ describe('sidebar/store/modules/annotations', function() {
beforeEach(function() {
clock = sinon.useFakeTimers();
store = createTestStore();
store.changeRoute('sidebar', {});
});
afterEach(function() {
......@@ -134,7 +135,7 @@ describe('sidebar/store/modules/annotations', function() {
};
const annot = fixtures.defaultAnnotation();
store.setAppIsSidebar(false);
store.changeRoute('stream', { q: 'a-query' });
store.addAnnotations([annot]);
clock.tick(ANCHOR_TIME_LIMIT);
......
......@@ -11,14 +11,14 @@ const { focusGroup } = groups.actions;
describe('sidebar/store/modules/real-time-updates', () => {
let fakeAnnotationExists;
let fakeFocusedGroupId;
let fakeIsSidebar;
let fakeRoute;
let fakeSettings = {};
let store;
beforeEach(() => {
fakeAnnotationExists = sinon.stub().returns(true);
fakeFocusedGroupId = sinon.stub().returns('group-1');
fakeIsSidebar = sinon.stub().returns(true);
fakeRoute = sinon.stub().returns('sidebar');
store = createStore(
[realTimeUpdates, annotations, selection],
......@@ -36,9 +36,9 @@ describe('sidebar/store/modules/real-time-updates', () => {
selectors: { focusedGroupId: fakeFocusedGroupId },
},
},
'./viewer': {
'./route': {
default: {
selectors: { isSidebar: fakeIsSidebar },
selectors: { route: fakeRoute },
},
},
});
......@@ -84,7 +84,7 @@ describe('sidebar/store/modules/real-time-updates', () => {
it('always adds pending updates in the stream where there is no focused group', () => {
fakeFocusedGroupId.returns(null);
fakeIsSidebar.returns(false);
fakeRoute.returns('stream');
addPendingUpdates(store);
......
......@@ -7,17 +7,8 @@ describe('store/modules/viewer', function() {
beforeEach(() => {
store = createStore([viewer]);
});
describe('#setAppIsSidebar', function() {
it('sets a flag indicating that the app is not the sidebar', function() {
store.setAppIsSidebar(false);
assert.isFalse(store.isSidebar());
});
it('sets a flag indicating that the app is the sidebar', function() {
store.setAppIsSidebar(true);
assert.isTrue(store.isSidebar());
});
describe('#setShowHighlights', function() {
it('sets a flag indicating that highlights are visible', function() {
store.setShowHighlights(true);
assert.isTrue(store.getState().viewer.visibleHighlights);
......
......@@ -7,21 +7,11 @@ import * as util from '../util';
function init() {
return {
// Flag that indicates whether the app is the sidebar and connected to
// a page where annotations are being shown in context.
//
// Note that this flag is not available early in the lifecycle of the
// application.
isSidebar: true,
visibleHighlights: false,
};
}
const update = {
SET_SIDEBAR: function(state, action) {
return { isSidebar: action.isSidebar };
},
SET_HIGHLIGHTS_VISIBLE: function(state, action) {
return { visibleHighlights: action.visible };
},
......@@ -29,11 +19,6 @@ const update = {
const actions = util.actionTypes(update);
/** Set whether the app is the sidebar */
function setAppIsSidebar(isSidebar) {
return { type: actions.SET_SIDEBAR, isSidebar: isSidebar };
}
/**
* Sets whether annotation highlights in connected documents are shown
* or not.
......@@ -42,24 +27,12 @@ function setShowHighlights(show) {
return { type: actions.SET_HIGHLIGHTS_VISIBLE, visible: show };
}
/**
* Returns true if the app is being used as the sidebar in the annotation
* client, as opposed to the standalone annotation page or stream views.
*/
function isSidebar(state) {
return state.viewer.isSidebar;
}
export default {
init: init,
namespace: 'viewer',
update: update,
actions: {
setAppIsSidebar: setAppIsSidebar,
setShowHighlights: setShowHighlights,
},
selectors: {
isSidebar,
},
selectors: {},
};
......@@ -10,6 +10,15 @@
<div class="content">
<help-panel auth="vm.auth"></help-panel>
<share-annotations-panel></share-annotations-panel>
<main ng-view=""></main>
<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'"
auth="vm.auth"
on-login="vm.login()"
on-sign-up="vm.signUp()"></sidebar-content>
</main>
</div>
</div>
......@@ -1140,11 +1140,6 @@ angular-mocks@^1.7.5:
resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.7.9.tgz#0a3b7e28b9a493b4e3010ed2b0f69a68e9b4f79b"
integrity sha512-LQRqqiV3sZ7NTHBnNmLT0bXtE5e81t97+hkJ56oU0k3dqKv1s6F+nBWRlOVzqHWPGFOiPS8ZJVdrS8DFzHyNIA==
angular-route@^1.7.5:
version "1.7.9"
resolved "https://registry.yarnpkg.com/angular-route/-/angular-route-1.7.9.tgz#f9910a2af0ba3ad7a969c5dd369b8360d0d5e4ef"
integrity sha512-vRoj5hzdQtWbODhWJqDzD1iNOEfCKshO6GFBuPVV7RHlPjzIc4R2dHCc7Qiv/8F3LDxJDohc6vSnTDMLHuaqeA==
angular-toastr@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/angular-toastr/-/angular-toastr-2.1.1.tgz#9f8350ca482145a44d011a755b8fb3623d60544c"
......
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