diff --git a/.tests/tests/InternalAPITest.php b/.tests/tests/InternalAPITest.php index b56c497..322884e 100644 --- a/.tests/tests/InternalAPITest.php +++ b/.tests/tests/InternalAPITest.php @@ -716,8 +716,8 @@ class InternalAPITest extends UloggerAPITestCase { $this->assertEquals(200, $response->getStatusCode(), "Unexpected status code"); $json = json_decode($response->getBody()); $this->assertNotNull($json, "JSON object is null"); - $this->assertEquals((int) $json->error, 1, "Wrong error status"); - $this->assertEquals((string) $json->message, $lang["servererror"], "Wrong error message"); + $this->assertEquals(1, (int) $json->error, "Wrong error status"); + $this->assertEquals($lang["notauthorized"], (string) $json->message, "Wrong error message"); } public function testHandleTrackUpdate() { diff --git a/index.php b/index.php index 6812991..cbf0743 100644 --- a/index.php +++ b/index.php @@ -63,11 +63,11 @@ <?= $lang['user'] ?> user->login) ?> - <?= $lang['login'] ?> + <?= $lang['login'] ?>
diff --git a/js/src/mainviewmodel.js b/js/src/mainviewmodel.js index 270151c..73689c7 100644 --- a/js/src/mainviewmodel.js +++ b/js/src/mainviewmodel.js @@ -18,6 +18,8 @@ */ import ViewModel from './viewmodel.js'; +import { config } from './initializer.js'; +import uUtils from './utils.js'; const hiddenClass = 'menu-hidden'; @@ -29,11 +31,15 @@ export default class MainViewModel extends ViewModel { constructor(state) { super({ onMenuToggle: null, - onShowUserMenu: null + onShowUserMenu: null, + onLogin: null, + onLogout: null }); this.state = state; this.model.onMenuToggle = () => this.toggleSideMenu(); this.model.onShowUserMenu = () => this.toggleUserMenu(); + this.model.onLogin = () => MainViewModel.login(); + this.model.onLogout = () => MainViewModel.logout(); this.hideUserMenuCallback = (e) => this.hideUserMenu(e); this.menuEl = document.querySelector('#menu'); this.userMenuEl = document.querySelector('#user-menu'); @@ -80,4 +86,16 @@ export default class MainViewModel extends ViewModel { } } + static login() { + uUtils.openUrl(`login.php${window.location.hash}`); + } + + static logout() { + let url = 'utils/logout.php'; + if (!config.requireAuth) { + url += `?hash=${window.location.hash.replace('#', '')}`; + } + uUtils.openUrl(url); + } + } diff --git a/js/src/mapapi/api_gmaps.js b/js/src/mapapi/api_gmaps.js index 100a1c5..c4dd038 100644 --- a/js/src/mapapi/api_gmaps.js +++ b/js/src/mapapi/api_gmaps.js @@ -110,6 +110,9 @@ export default class GoogleMapsApi { this.popup.addListener('closeclick', () => { this.popupClose(); }); + this.saveState = () => { + this.viewModel.state.mapParams = this.getState(); + }; } /** @@ -135,9 +138,12 @@ export default class GoogleMapsApi { if (!track || !track.hasPositions) { return Promise.resolve(); } + google.maps.event.clearListeners(this.map, 'idle'); const promise = new Promise((resolve) => { google.maps.event.addListenerOnce(this.map, 'tilesloaded', () => { console.log('tilesloaded'); + this.saveState(); + this.map.addListener('idle', this.saveState); resolve(); }) }); @@ -368,6 +374,32 @@ export default class GoogleMapsApi { return 10000; } + /** + * Set map state + * Note: ignores rotation + * @param {MapParams} state + */ + updateState(state) { + this.map.setCenter({ lat: state.center[0], lng: state.center[1] }); + this.map.setZoom(state.zoom); + } + + /** + * Get map state + * Note: ignores rotation + * @return {MapParams|null} + */ + getState() { + if (this.map) { + const center = this.map.getCenter(); + return { + center: [ center.lat(), center.lng() ], + zoom: this.map.getZoom(), + rotation: 0 + }; + } + return null; + } } /** @type {boolean} */ diff --git a/js/src/mapapi/api_openlayers.js b/js/src/mapapi/api_openlayers.js index 10eb31a..5793c29 100644 --- a/js/src/mapapi/api_openlayers.js +++ b/js/src/mapapi/api_openlayers.js @@ -142,6 +142,10 @@ export default class OpenLayersApi { this.viewModel.model.markerOver = null; } }); + + this.saveState = () => { + this.viewModel.state.mapParams = this.getState(); + }; } /** @@ -430,9 +434,12 @@ export default class OpenLayersApi { if (!track || !track.hasPositions) { return Promise.resolve(); } + this.map.un('moveend', this.saveState); const promise = new Promise((resolve) => { this.map.once('rendercomplete', () => { console.log('rendercomplete'); + this.saveState(); + this.map.on('moveend', this.saveState); resolve(); }); }); @@ -484,15 +491,13 @@ export default class OpenLayersApi { } /** - * Fit to extent, zoom out if needed + * Fit to extent, respect max zoom * @param {Array.} extent * @return {Array.} */ fitToExtent(extent) { - this.map.getView().fit(extent, { padding: [ 40, 10, 10, 10 ] }); - const zoom = this.map.getView().getZoom(); - if (zoom > OpenLayersApi.ZOOM_MAX) { - this.map.getView().setZoom(OpenLayersApi.ZOOM_MAX); + this.map.getView().fit(extent, { padding: [ 40, 10, 10, 10 ], maxZoom: OpenLayersApi.ZOOM_MAX }); + if (this.map.getView().getZoom() === OpenLayersApi.ZOOM_MAX) { extent = this.map.getView().calculateExtent(this.map.getSize()); } return extent; @@ -597,10 +602,10 @@ export default class OpenLayersApi { } /** - * Zoom to track extent + * Zoom to track extent, respect max zoom */ zoomToExtent() { - this.map.getView().fit(this.layerMarkers.getSource().getExtent()); + this.map.getView().fit(this.layerMarkers.getSource().getExtent(), { maxZoom: OpenLayersApi.ZOOM_MAX }); } /** @@ -630,5 +635,32 @@ export default class OpenLayersApi { extentImg.style.width = '60%'; return extentImg; } + + /** + * Set map state + * @param {MapParams} state + */ + updateState(state) { + this.map.getView().setCenter(state.center); + this.map.getView().setZoom(state.zoom); + this.map.getView().setRotation(state.rotation); + } + + /** + * Get map state + * @return {MapParams|null} + */ + getState() { + const view = this.map ? this.map.getView() : null; + if (view) { + return { + center: view.getCenter(), + zoom: view.getZoom(), + rotation: view.getRotation() + }; + } + return null; + } } +/** @type {number} */ OpenLayersApi.ZOOM_MAX = 20; diff --git a/js/src/mapviewmodel.js b/js/src/mapviewmodel.js index fe33509..a8e3e22 100644 --- a/js/src/mapviewmodel.js +++ b/js/src/mapviewmodel.js @@ -41,6 +41,15 @@ import uUtils from './utils.js'; * @property {function} zoomToExtent * @property {function} zoomToBounds * @property {function} updateSize + * @property {function} updateState + */ + + +/** + * @typedef {Object} MapParams + * @property {number[]} center + * @property {number} zoom + * @property {number} rotation */ /** @@ -116,11 +125,13 @@ export default class MapViewModel extends ViewModel { } onReady() { - if (this.savedBounds) { - this.api.zoomToBounds(this.savedBounds); - } if (this.state.currentTrack) { - this.displayTrack(this.state.currentTrack, this.savedBounds === null); + let update = true; + if (this.savedBounds) { + this.api.zoomToBounds(this.savedBounds); + update = false; + } + this.displayTrack(this.state.currentTrack, update); } } @@ -141,10 +152,34 @@ export default class MapViewModel extends ViewModel { this.displayTrack(track, true); } }); + this.state.onChanged('history', () => { + const history = this.state.history; + if (this.api && history && !history.trackId) { + if (history.mapApi) { + config.mapApi = history.mapApi; + } else { + if (history.mapParams) { + this.api.updateState(history.mapParams); + } else { + this.api.zoomToExtent(); + } + this.state.history = null; + } + } + }); } + /** + * @param {uTrack} track Track to display + * @param {boolean} update Should update map view + */ displayTrack(track, update) { this.state.jobStart(); + if (update && this.state.history && this.state.history.mapParams) { + this.api.updateState(this.state.history.mapParams); + update = false; + } + this.state.history = null; this.api.displayTrack(track, update) .finally(() => this.state.jobStop()); } diff --git a/js/src/permalink.js b/js/src/permalink.js new file mode 100644 index 0000000..2a3d932 --- /dev/null +++ b/js/src/permalink.js @@ -0,0 +1,165 @@ +/* + * μlogger + * + * Copyright(C) 2020 Bartek Fabiszewski (www.fabiszewski.net) + * + * This is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +import { lang as $, config } from './initializer.js'; +import uTrack from './track.js'; +import uUtils from './utils.js'; + +/** + * @typedef {Object} PermalinkState + * @property {string} title + * @property {number|null} userId + * @property {number|null} trackId + * @property {string|null} mapApi + * @property {MapParams|null} mapParams + */ + +export default class uPermalink { + + /** + * @param {uState} state + */ + constructor(state) { + this.state = state; + this.skipPush = false; + } + + /** + * @return {uPermalink} + */ + init() { + this.state.onChanged('mapParams', () => this.pushState()); + window.addEventListener('popstate', (event) => { + if (event.state === null) { + return; + } + const track = this.state.currentTrack; + const user = this.state.currentUser; + // remove elements that won't be updated + const state = { + title: event.state.title, + userId: (user && user.id === event.state.userId) ? null : event.state.userId, + trackId: (track && track.id === event.state.trackId) ? null : event.state.trackId, + mapApi: config.mapApi === event.state.mapApi ? null : event.state.mapApi, + mapParams: event.state.mapParams + } + this.onPop(state); + this.skipPush = true; + }); + return this; + } + + /** + * @return {Promise} + */ + static parseHash() { + return uPermalink.parse(window.location.hash); + } + + /** + * Parse URL hash string + * @param {string} hash + * @return {Promise} Permalink state or null if not parsable + */ + static parse(hash) { + const parts = hash.replace('#', '').split('/'); + parts.reverse(); + const trackId = parseInt(parts.pop()); + if (!isNaN(trackId)) { + let mapApi = 'openlayers'; + if (parts.pop() === 'g') { + mapApi = 'gmaps'; + } + let mapParams = null; + if (parts.length >= 4) { + mapParams = {}; + mapParams.center = [ parseFloat(parts.pop()), parseFloat(parts.pop()) ]; + mapParams.zoom = parseFloat(parts.pop()); + mapParams.rotation = parseFloat(parts.pop()); + } + return uTrack.getMeta(trackId) + .then((meta) => { + const userId = meta.userId; + const title = meta.name; + return { title, userId, trackId, mapApi, mapParams }; + }) + .catch((e) => { + console.log(`Ignoring unknown track ${trackId} ${e}`); + return null; + }); + } + return Promise.resolve(null); + } + + /** + * @param {?PermalinkState} state + */ + onPop(state) { + console.log('popState: #' + (state ? `${state.trackId}/${state.mapApi}/${state.mapParams}` : '')); + this.state.history = state; + if (state) { + document.title = `${$._('title')} ${state.title}`; + } + } + + /** + * Push state into browser history + */ + pushState() { + if (this.skipPush) { + this.skipPush = false; + return; + } + if (this.state.currentUser === null || this.state.currentTrack === null) { + return; + } + const state = this.getState(); + const prevState = window.history.state; + if (!prevState || !uUtils.isDeepEqual(prevState, state)) { + const hash = uPermalink.getHash(state); + console.log(`pushState: ${hash} => ${state}`); + window.history.pushState(state, state.title, hash); + document.title = `${$._('title')} ${state.title}`; + } + } + + getState() { + return { + title: this.state.currentTrack.name, + userId: this.state.currentUser.id, + trackId: this.state.currentTrack.id, + mapApi: config.mapApi, + mapParams: this.state.mapParams + }; + } + + /** + * Get link hash + * @param {PermalinkState} state + * @return {string} + */ + static getHash(state) { + let hash = `#${state.trackId}/${state.mapApi.charAt(0)}`; + if (state.mapParams) { + hash += `/${state.mapParams.center[0]}/${state.mapParams.center[1]}`; + hash += `/${state.mapParams.zoom}/${state.mapParams.rotation}`; + } + return hash; + } +} diff --git a/js/src/state.js b/js/src/state.js index ca7fe79..5574012 100644 --- a/js/src/state.js +++ b/js/src/state.js @@ -26,6 +26,8 @@ import uObserve from './observe.js'; * @property {boolean} showLatest * @property {boolean} showAllUsers * @property {number} activeJobs + * @property {?MapParams} mapParams + * @property {?PermalinkState} history */ export default class uState { @@ -35,6 +37,8 @@ export default class uState { this.showLatest = false; this.showAllUsers = false; this.activeJobs = 0; + this.mapParams = null; + this.history = null; } jobStart() { diff --git a/js/src/track.js b/js/src/track.js index 1b20830..d8ea507 100644 --- a/js/src/track.js +++ b/js/src/track.js @@ -228,6 +228,17 @@ export default class uTrack extends uPositionSet { }); } + /** + * @param {number} id + * @return {Promise<{id: number, name: string, userId: number, comment: string|null}, Error>} + */ + static getMeta(id) { + return uTrack.update({ + action: 'getmeta', + trackid: id + }); + } + /** * Save track data * @param {Object} data diff --git a/js/src/trackviewmodel.js b/js/src/trackviewmodel.js index e05d310..7318546 100644 --- a/js/src/trackviewmodel.js +++ b/js/src/trackviewmodel.js @@ -124,6 +124,11 @@ export default class TrackViewModel extends ViewModel { this.startAutoReload(); } }); + this.state.onChanged('history', (history) => { + if (history && !history.userId && history.trackId) { + this.model.currentTrackId = history.trackId.toString(); + } + }); } setClickHandlers() { @@ -268,8 +273,9 @@ export default class TrackViewModel extends ViewModel { if (_tracks.length) { if (this.state.showLatest) { this.onUserLastPosition(); + } else if (this.state.history) { + this.model.currentTrackId = this.state.history.trackId.toString(); } else { - // autoload first track in list this.model.currentTrackId = _tracks[0].listValue; } } else { diff --git a/js/src/ulogger.js b/js/src/ulogger.js index d7cb797..cecf714 100644 --- a/js/src/ulogger.js +++ b/js/src/ulogger.js @@ -25,21 +25,26 @@ import MapViewModel from './mapviewmodel.js'; import TrackViewModel from './trackviewmodel.js'; import UserViewModel from './userviewmodel.js'; import uAlert from './alert.js'; +import uPermalink from './permalink.js'; import uSpinner from './spinner.js'; import uState from './state.js'; const domReady = uInitializer.waitForDom(); const initReady = initializer.initialize(); +const initLink = uPermalink.parseHash(); -Promise.all([ domReady, initReady ]) - .then(() => { - start(); +Promise.all([ domReady, initReady, initLink ]) + .then((result) => { + start(result[2]); }) .catch((msg) => uAlert.error(`${$._('actionfailure')}\n${msg}`)); - -function start() { +/** + * @param {?Object} linkState + */ +function start(linkState) { const state = new uState(); + const permalink = new uPermalink(state); const spinner = new uSpinner(state); const mainVM = new MainViewModel(state); const userVM = new UserViewModel(state); @@ -47,6 +52,7 @@ function start() { const mapVM = new MapViewModel(state); const chartVM = new ChartViewModel(state); const configVM = new ConfigViewModel(state); + permalink.init().onPop(linkState); spinner.init(); mainVM.init(); userVM.init(); diff --git a/js/src/userviewmodel.js b/js/src/userviewmodel.js index 9c7e710..f9c87bc 100644 --- a/js/src/userviewmodel.js +++ b/js/src/userviewmodel.js @@ -71,7 +71,9 @@ export default class UserViewModel extends ViewModel { this.model.userList = _users; if (_users.length) { let userId = _users[0].listValue; - if (auth.isAuthenticated) { + if (this.state.history) { + userId = this.state.history.userId.toString(); + } else if (auth.isAuthenticated) { const user = this.model.userList.find((_user) => _user.listValue === auth.user.listValue); if (user) { userId = user.listValue; @@ -103,6 +105,11 @@ export default class UserViewModel extends ViewModel { this.select.hideAllOption(); } }); + state.onChanged('history', (history) => { + if (history && history.userId) { + this.model.currentUserId = history.userId.toString(); + } + }); } showDialog(action) { diff --git a/js/src/utils.js b/js/src/utils.js index 591d3e6..694469a 100644 --- a/js/src/utils.js +++ b/js/src/utils.js @@ -288,4 +288,20 @@ export default class uUtils { static deg2rad(degrees) { return degrees * Math.PI / 180; } + + /** + * Recursively compare properties of two objects with same keys + * @param {Object} obj1 + * @param {Object} obj2 + * @return {boolean} True if properties are equal + */ + static isDeepEqual(obj1, obj2) { + return Object.keys(obj1).every((key) => { + if (typeof obj1[key] === 'object' && obj1[key] !== null && + typeof obj2[key] === 'object' && obj2[key] !== null) { + return this.isDeepEqual(obj1[key], obj2[key]); + } + return obj1[key] === obj2[key]; + }) + } } diff --git a/js/test/api_openlayers.test.js b/js/test/api_openlayers.test.js index 549d95f..ef2ac0a 100644 --- a/js/test/api_openlayers.test.js +++ b/js/test/api_openlayers.test.js @@ -366,14 +366,13 @@ describe('Openlayers map API tests', () => { const zoomedExtent = [ 3, 2, 1, 0 ]; api.map = mockMap; spyOn(ol.View.prototype, 'fit'); - spyOn(ol.View.prototype, 'getZoom').and.returnValue(OpenlayersApi.ZOOM_MAX + 1); + spyOn(ol.View.prototype, 'getZoom').and.returnValue(OpenlayersApi.ZOOM_MAX); spyOn(ol.View.prototype, 'setZoom'); spyOn(ol.View.prototype, 'calculateExtent').and.returnValue(zoomedExtent); // when const result = api.fitToExtent(extent); // then expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent, jasmine.any(Object)); - expect(ol.View.prototype.setZoom).toHaveBeenCalledWith(OpenlayersApi.ZOOM_MAX); expect(result).toEqual(zoomedExtent); }); @@ -498,7 +497,7 @@ describe('Openlayers map API tests', () => { // when api.zoomToExtent(); // then - expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent); + expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent, { maxZoom: OpenlayersApi.ZOOM_MAX }); }); it('should get map bounds and convert to WGS84 (EPSG:4326)', () => { diff --git a/js/test/helpers/googlemaps.stub.js b/js/test/helpers/googlemaps.stub.js index 80a3679..2086e2b 100644 --- a/js/test/helpers/googlemaps.stub.js +++ b/js/test/helpers/googlemaps.stub.js @@ -23,7 +23,7 @@ const stubFn = function() {/* ignore */}; const stubFnObj = function() { return stubObj; }; export const setupGmapsStub = () => { - // noinspection JSUnresolvedVariable + // noinspection JSUnresolvedVariable,JSConstantReassignment window.google = { maps: { Animation: { @@ -33,7 +33,8 @@ export const setupGmapsStub = () => { event: { addListener: stubFn, addListenerOnce: stubFn, - removeListener: stubFn + removeListener: stubFn, + clearListeners: stubFn }, Icon: stubFn, InfoWindow: stubFn, @@ -94,6 +95,6 @@ export const applyPrototypes = () => { }; export const clear = () => { - // noinspection JSAnnotator,JSUnresolvedVariable + // noinspection JSAnnotator,JSUnresolvedVariable,JSConstantReassignment delete window.google; }; diff --git a/js/test/mapviewmodel.test.js b/js/test/mapviewmodel.test.js index 108778e..bba25fa 100644 --- a/js/test/mapviewmodel.test.js +++ b/js/test/mapviewmodel.test.js @@ -144,6 +144,7 @@ describe('MapViewModel tests', () => { // given vm.api = mockApi; vm.savedBounds = bounds; + state.currentTrack = track; // when vm.onReady(); // then diff --git a/js/test/permalink.test.js b/js/test/permalink.test.js new file mode 100644 index 0000000..198be56 --- /dev/null +++ b/js/test/permalink.test.js @@ -0,0 +1,324 @@ +/* + * μlogger + * + * Copyright(C) 2020 Bartek Fabiszewski (www.fabiszewski.net) + * + * This is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, see . + */ + +import { config, lang } from '../src/initializer.js'; +import Fixture from './helpers/fixture.js'; +import MapViewModel from '../src/mapviewmodel.js'; +import TrackViewModel from '../src/trackviewmodel.js'; +import UserViewModel from '../src/userviewmodel.js'; +import uObserve from '../src/observe.js'; +import uPermalink from '../src/permalink.js'; +import uState from '../src/state.js'; +import uTrack from '../src/track.js'; +import uUser from '../src/user.js'; + +describe('Permalink tests', () => { + + let permalink; + let state; + const trackId = 123; + const trackName = 'test track'; + const userId = 456; + const mapApi = 'testApi'; + const lat = -267220.5357759836; + const lng = 4514512.219090612; + const zoom = 7.7081991502812075; + const rotation = 20.21; + const mapParams = { + center: [ lat, lng ], + zoom: zoom, + rotation: rotation + }; + let spy; + let tm, um, mm; + let mockApi; + + beforeEach((done) => { + Fixture.load('main.html') + .then(() => done()) + .catch((e) => done.fail(e)); + }); + + beforeEach(() => { + config.reinitialize(); + lang.init(config); + spyOn(lang, '_').and.callFake((arg) => arg); + mockApi = jasmine.createSpyObj('mockApi', { + 'init': Promise.resolve(), + 'getBounds': { /* ignored */ }, + 'cleanup': { /* ignored */ }, + 'zoomToBounds': { /* ignored */ }, + 'zoomToExtent': { /* ignored */ }, + 'displayTrack': Promise.resolve(), + 'clearMap': { /* ignored */ }, + 'updateSize': { /* ignored */ }, + 'updateState': { /* ignored */ } + }); + state = new uState(); + tm = new TrackViewModel(state); + um = new UserViewModel(state); + mm = new MapViewModel(state); + mm.api = mockApi; + spyOn(tm, 'onTrackSelect'); + spyOn(tm, 'loadTrackList'); + permalink = new uPermalink(state); + spy = spyOn(uTrack, 'getMeta').and.callFake((_trackId) => Promise.resolve({ + id: _trackId, + name: trackName, + userId: userId, + comment: null + })); + }); + + afterEach(() => { + Fixture.clear(); + uObserve.unobserveAll(lang); + }); + + it('should create instance', () => { + expect(permalink).toBeInstanceOf(uPermalink); + expect(permalink.state).toBe(state); + expect(permalink.skipPush).toBeFalse(); + }); + + let testHashes = [ + { hash: `#${trackId}`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } }, + { hash: `#${trackId}/`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } }, + { hash: `#${trackId}/o`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } }, + { hash: `#${trackId}/x`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } }, + { hash: `#${trackId}/g`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'gmaps', mapParams: null } }, + { hash: `#${trackId}/o/`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } }, + { hash: `#${trackId}/o/${lat}/${lng}`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: null } }, + { hash: `#${trackId}/o/${lat}/${lng}/${zoom}/${rotation}`, + state: { title: trackName, userId: userId, trackId: trackId, mapApi: 'openlayers', mapParams: mapParams } } + ]; + testHashes.forEach((test) => { + it(`should parse link hash "${test.hash}"`, (done) => { + uPermalink.parse(test.hash).then((result) => { + expect(result).toEqual(test.state); + done(); + }).catch((e) => done.fail(e)); + }); + }); + testHashes = [ '#', '', '#hash' ]; + testHashes.forEach((test) => { + it(`should parse link and return null for corrupt hash "${test}"`, (done) => { + uPermalink.parse(test).then((result) => { + expect(result).toBeNull(); + done(); + }).catch((e) => done.fail(e)); + }); + }); + + it('should parse link and return null for unknown track id', (done) => { + spy.and.returnValue(Promise.reject(new Error('error'))); + uPermalink.parse('#21').then((result) => { + expect(result).toBeNull(); + done(); + }).catch((e) => done.fail(e)); + }); + + it('should create hash from app state', () => { + // given + const permalinkState = { + userId: userId, + trackId: trackId, + mapApi: mapApi, + mapParams: null + }; + // when + const hash = uPermalink.getHash(permalinkState); + // then + expect(hash).toBe(`#${trackId}/${mapApi.charAt(0)}`); + }); + + it('should create hash from app state and map parameters', () => { + // given + const permalinkState = { userId, trackId, mapApi, mapParams }; + // when + const hash = uPermalink.getHash(permalinkState); + // then + expect(hash).toBe(`#${trackId}/${mapApi.charAt(0)}/${mapParams.center[0]}/${mapParams.center[1]}/${mapParams.zoom}/${mapParams.rotation}`); + }); + + it('should return permalink state from application state', () => { + // given + state.currentUser = new uUser(userId, 'test'); + state.currentTrack = new uTrack(trackId, trackName, state.currentUser); + state.mapParams = mapParams; + config.mapApi = mapApi; + const title = trackName; + // when + const permalinkState = permalink.getState(); + // then + expect(permalinkState).toEqual({ title, userId, trackId, mapApi, mapParams }); + }); + + it('should restore state and switch user', (done) => { + // given + const newUserId = userId + 1; + const newUser = new uUser(newUserId, 'new'); + const newTrackId = trackId + 1; + spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ newUser ])); + state.currentUser = new uUser(userId, 'test'); + state.currentTrack = new uTrack(trackId, trackName, state.currentUser); + tm.init(); + um.init(); + mm.init(); + permalink.init(); + const historyState = { + title: 'title', + userId: newUserId, + trackId: newTrackId, + mapApi: mapApi, + mapParams: mapParams + }; + const event = new PopStateEvent('popstate', { state: historyState }); + setTimeout(() => { + // when + dispatchEvent(event); + // then + setTimeout(() => { + expect(tm.loadTrackList).toHaveBeenCalledTimes(1); + expect(tm.onTrackSelect).toHaveBeenCalledTimes(1); + expect(mockApi.updateState).not.toHaveBeenCalled(); + expect(state.currentUser).toBe(newUser); + expect(tm.model.currentTrackId).toBe(newTrackId.toString()); + done(); + }, 100); + }, 100); + }); + + it('should restore state and load track for same user', (done) => { + // given + const user = new uUser(userId, 'test'); + const newTrackId = trackId + 1; + const track = new uTrack(trackId, trackName, user); + state.currentUser = user; + state.currentTrack = track; + spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ user ])); + tm.init(); + um.init(); + mm.init(); + permalink.init(); + const historyState = { + title: 'title', + userId: userId, + trackId: newTrackId, + mapApi: mapApi, + mapParams: mapParams + }; + const event = new PopStateEvent('popstate', { state: historyState }); + setTimeout(() => { + // when + dispatchEvent(event); + // then + setTimeout(() => { + expect(tm.loadTrackList).not.toHaveBeenCalled(); + expect(tm.onTrackSelect).toHaveBeenCalledTimes(1); + expect(mockApi.updateState).not.toHaveBeenCalled(); + expect(state.currentUser).toBe(user); + expect(tm.model.currentTrackId).toBe(newTrackId.toString()); + done(); + }, 100); + }, 100); + }); + + it('should restore state without user and track update, with map api change', (done) => { + // given + config.mapApi = 'oldApi'; + const user = new uUser(userId, 'test'); + const track = new uTrack(trackId, trackName, user); + state.currentUser = user; + state.currentTrack = track; + spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ user ])); + tm.model.currentTrackId = trackId.toString(); + tm.init(); + um.init(); + mm.init(); + permalink.init(); + const historyState = { + title: 'title', + userId: userId, + trackId: trackId, + mapApi: mapApi, + mapParams: mapParams + }; + const event = new PopStateEvent('popstate', { state: historyState }); + setTimeout(() => { + // when + dispatchEvent(event); + // then + setTimeout(() => { + expect(tm.loadTrackList).not.toHaveBeenCalled(); + expect(tm.onTrackSelect).not.toHaveBeenCalled(); + expect(mockApi.updateState).not.toHaveBeenCalled(); + expect(config.mapApi).toBe(mapApi); + expect(state.currentUser).toBe(user); + expect(tm.model.currentTrackId).toBe(trackId.toString()); + done(); + }, 100); + }, 100); + }); + + it('should restore state without user, track and map api update', (done) => { + // given + config.mapApi = mapApi; + const user = new uUser(userId, 'test'); + const track = new uTrack(trackId, trackName, user); + state.currentUser = user; + state.currentTrack = track; + spyOn(uUser, 'fetchList').and.returnValue(Promise.resolve([ user ])); + tm.model.currentTrackId = trackId.toString(); + tm.init(); + um.init(); + mm.init(); + permalink.init(); + const historyState = { + title: 'title', + userId: userId, + trackId: trackId, + mapApi: mapApi, + mapParams: mapParams + }; + const event = new PopStateEvent('popstate', { state: historyState }); + setTimeout(() => { + // when + dispatchEvent(event); + // then + setTimeout(() => { + expect(tm.loadTrackList).not.toHaveBeenCalled(); + expect(tm.onTrackSelect).not.toHaveBeenCalled(); + expect(mockApi.updateState).toHaveBeenCalledTimes(1); + expect(config.mapApi).toBe(mapApi); + expect(state.currentUser).toBe(user); + expect(tm.model.currentTrackId).toBe(trackId.toString()); + done(); + }, 100); + }, 100); + }); + +}); diff --git a/js/test/utils.test.js b/js/test/utils.test.js index e10f8f4..1fea76c 100644 --- a/js/test/utils.test.js +++ b/js/test/utils.test.js @@ -267,4 +267,103 @@ describe('Utils tests', () => { expect(uUtils.deg2rad(1)).toBeCloseTo(0.0174533, 7); }); + it('should confirm two objects are equal', () => { + // given + const obj1 = { + property1: true, + property2: null, + property3: 'string', + property4: 4, + property5: { + sub1: 4, + sub2: 'sub' + } + } + const obj2 = JSON.parse(JSON.stringify(obj1)); + // when + const result = uUtils.isDeepEqual(obj1, obj2); + // then + expect(result).toBeTrue(); + }); + + it('should confirm two objects are not equal', () => { + // given + const obj1 = { + property1: true, + property2: null, + property3: 'string', + property4: 4, + property5: { + sub1: 4, + sub2: 'sub' + } + } + const obj2 = JSON.parse(JSON.stringify(obj1)); + obj2.property1 = false; + // when + const result = uUtils.isDeepEqual(obj1, obj2); + // then + expect(result).toBeFalse(); + }); + + it('should confirm two objects are not equal on deeper level', () => { + // given + const obj1 = { + property1: true, + property2: null, + property3: 'string', + property4: 4, + property5: { + sub1: 4, + sub2: 'sub' + } + } + const obj2 = JSON.parse(JSON.stringify(obj1)); + obj2.property5.sub1 = 5; + // when + const result = uUtils.isDeepEqual(obj1, obj2); + // then + expect(result).toBeFalse(); + }); + + it('should confirm two objects are not equal when object property is null on obj2', () => { + // given + const obj1 = { + property1: true, + property2: null, + property3: 'string', + property4: 4, + property5: { + sub1: 4, + sub2: 'sub' + } + } + const obj2 = JSON.parse(JSON.stringify(obj1)); + obj2.property5 = null; + // when + const result = uUtils.isDeepEqual(obj1, obj2); + // then + expect(result).toBeFalse(); + }); + + it('should confirm two objects are not equal when object property is null on obj1', () => { + // given + const obj1 = { + property1: true, + property2: null, + property3: 'string', + property4: 4, + property5: { + sub1: 4, + sub2: 'sub' + } + } + const obj2 = JSON.parse(JSON.stringify(obj1)); + obj2.property2 = { sub1: 1 }; + // when + const result = uUtils.isDeepEqual(obj1, obj2); + // then + expect(result).toBeFalse(); + }); + }); diff --git a/login.php b/login.php index 92072e5..31a3dd4 100644 --- a/login.php +++ b/login.php @@ -33,12 +33,19 @@ <?= $lang["title"] ?> - +
diff --git a/utils/handletrack.php b/utils/handletrack.php index b92c764..41eaf5c 100644 --- a/utils/handletrack.php +++ b/utils/handletrack.php @@ -25,9 +25,9 @@ require_once(ROOT_DIR . "/helpers/config.php"); $auth = new uAuth(); -$action = uUtils::postString('action'); -$trackId = uUtils::postInt('trackid'); -$trackName = uUtils::postString('trackname'); +$action = uUtils::postString("action"); +$trackId = uUtils::postInt("trackid"); +$trackName = uUtils::postString("trackname"); $config = uConfig::getInstance(); $lang = (new uLang($config))->getStrings(); @@ -36,30 +36,44 @@ if (empty($action) || empty($trackId)) { uUtils::exitWithError($lang["servererror"]); } $track = new uTrack($trackId); -if (!$track->isValid || - (!$auth->isAuthenticated() || (!$auth->isAdmin() && $auth->user->id !== $track->userId))) { +if (!$track->isValid) { uUtils::exitWithError($lang["servererror"]); } +if (($action === "getmeta" && !$auth->hasReadAccess($track->userId)) || + ($action !== "getmeta" && !$auth->hasReadWriteAccess($track->userId))) { + uUtils::exitWithError($lang["notauthorized"]); +} + +$result = null; switch ($action) { - case 'update': + case "update": if (empty($trackName) || $track->update($trackName) === false) { uUtils::exitWithError($lang["servererror"]); } break; - case 'delete': + case "delete": if ($track->delete() === false) { uUtils::exitWithError($lang["servererror"]); } break; + case "getmeta": + $result = [ + "id" => $track->id, + "name" => $track->name, + "userId" => $track->userId, + "comment" => $track->comment + ]; + break; + default: uUtils::exitWithError($lang["servererror"]); break; } -uUtils::exitWithSuccess(); +uUtils::exitWithSuccess($result); ?> \ No newline at end of file diff --git a/utils/logout.php b/utils/logout.php index c758fc3..4093cc5 100644 --- a/utils/logout.php +++ b/utils/logout.php @@ -19,7 +19,11 @@ include_once(dirname(__DIR__) . "/helpers/auth.php"); +$hash = uUtils::getString("hash", ""); +if (!empty($hash)) { + $hash = "#{$hash}"; +} $auth = new uAuth(); -$auth->logOutWithRedirect(); +$auth->logOutWithRedirect($hash); ?> \ No newline at end of file