Feature: permalink, closes #120

This commit is contained in:
Bartek Fabiszewski 2020-06-10 12:40:28 +02:00
parent 21746f2f2d
commit 9a6c1c97e8
21 changed files with 822 additions and 41 deletions

View File

@ -716,8 +716,8 @@ class InternalAPITest extends UloggerAPITestCase {
$this->assertEquals(200, $response->getStatusCode(), "Unexpected status code"); $this->assertEquals(200, $response->getStatusCode(), "Unexpected status code");
$json = json_decode($response->getBody()); $json = json_decode($response->getBody());
$this->assertNotNull($json, "JSON object is null"); $this->assertNotNull($json, "JSON object is null");
$this->assertEquals((int) $json->error, 1, "Wrong error status"); $this->assertEquals(1, (int) $json->error, "Wrong error status");
$this->assertEquals((string) $json->message, $lang["servererror"], "Wrong error message"); $this->assertEquals($lang["notauthorized"], (string) $json->message, "Wrong error message");
} }
public function testHandleTrackUpdate() { public function testHandleTrackUpdate() {

View File

@ -63,11 +63,11 @@
<a data-bind="onShowUserMenu"><img class="icon" alt="<?= $lang['user'] ?>" src="images/user.svg"> <?= htmlspecialchars($auth->user->login) ?></a> <a data-bind="onShowUserMenu"><img class="icon" alt="<?= $lang['user'] ?>" src="images/user.svg"> <?= htmlspecialchars($auth->user->login) ?></a>
<div id="user-menu" class="menu-hidden"> <div id="user-menu" class="menu-hidden">
<a id="user-pass" data-bind="onPasswordChange"><img class="icon" alt="<?= $lang['changepass'] ?>" src="images/lock.svg"> <?= $lang['changepass'] ?></a> <a id="user-pass" data-bind="onPasswordChange"><img class="icon" alt="<?= $lang['changepass'] ?>" src="images/lock.svg"> <?= $lang['changepass'] ?></a>
<a href="utils/logout.php"><img class="icon" alt="<?= $lang['logout'] ?>" src="images/poweroff.svg"> <?= $lang['logout'] ?></a> <a class="menu-link" data-bind="onLogout"><img class="icon" alt="<?= $lang['logout'] ?>" src="images/poweroff.svg"> <?= $lang['logout'] ?></a>
</div> </div>
</div> </div>
<?php else: ?> <?php else: ?>
<a href="login.php"><img class="icon" alt="<?= $lang['login'] ?>" src="images/key.svg"> <?= $lang['login'] ?></a> <a class="menu-link" data-bind="onLogin"><img class="icon" alt="<?= $lang['login'] ?>" src="images/key.svg"> <?= $lang['login'] ?></a>
<?php endif; ?> <?php endif; ?>
<div class="section"> <div class="section">

View File

@ -18,6 +18,8 @@
*/ */
import ViewModel from './viewmodel.js'; import ViewModel from './viewmodel.js';
import { config } from './initializer.js';
import uUtils from './utils.js';
const hiddenClass = 'menu-hidden'; const hiddenClass = 'menu-hidden';
@ -29,11 +31,15 @@ export default class MainViewModel extends ViewModel {
constructor(state) { constructor(state) {
super({ super({
onMenuToggle: null, onMenuToggle: null,
onShowUserMenu: null onShowUserMenu: null,
onLogin: null,
onLogout: null
}); });
this.state = state; this.state = state;
this.model.onMenuToggle = () => this.toggleSideMenu(); this.model.onMenuToggle = () => this.toggleSideMenu();
this.model.onShowUserMenu = () => this.toggleUserMenu(); this.model.onShowUserMenu = () => this.toggleUserMenu();
this.model.onLogin = () => MainViewModel.login();
this.model.onLogout = () => MainViewModel.logout();
this.hideUserMenuCallback = (e) => this.hideUserMenu(e); this.hideUserMenuCallback = (e) => this.hideUserMenu(e);
this.menuEl = document.querySelector('#menu'); this.menuEl = document.querySelector('#menu');
this.userMenuEl = document.querySelector('#user-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);
}
} }

View File

@ -110,6 +110,9 @@ export default class GoogleMapsApi {
this.popup.addListener('closeclick', () => { this.popup.addListener('closeclick', () => {
this.popupClose(); this.popupClose();
}); });
this.saveState = () => {
this.viewModel.state.mapParams = this.getState();
};
} }
/** /**
@ -135,9 +138,12 @@ export default class GoogleMapsApi {
if (!track || !track.hasPositions) { if (!track || !track.hasPositions) {
return Promise.resolve(); return Promise.resolve();
} }
google.maps.event.clearListeners(this.map, 'idle');
const promise = new Promise((resolve) => { const promise = new Promise((resolve) => {
google.maps.event.addListenerOnce(this.map, 'tilesloaded', () => { google.maps.event.addListenerOnce(this.map, 'tilesloaded', () => {
console.log('tilesloaded'); console.log('tilesloaded');
this.saveState();
this.map.addListener('idle', this.saveState);
resolve(); resolve();
}) })
}); });
@ -368,6 +374,32 @@ export default class GoogleMapsApi {
return 10000; 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} */ /** @type {boolean} */

View File

@ -142,6 +142,10 @@ export default class OpenLayersApi {
this.viewModel.model.markerOver = null; 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) { if (!track || !track.hasPositions) {
return Promise.resolve(); return Promise.resolve();
} }
this.map.un('moveend', this.saveState);
const promise = new Promise((resolve) => { const promise = new Promise((resolve) => {
this.map.once('rendercomplete', () => { this.map.once('rendercomplete', () => {
console.log('rendercomplete'); console.log('rendercomplete');
this.saveState();
this.map.on('moveend', this.saveState);
resolve(); resolve();
}); });
}); });
@ -484,15 +491,13 @@ export default class OpenLayersApi {
} }
/** /**
* Fit to extent, zoom out if needed * Fit to extent, respect max zoom
* @param {Array.<number>} extent * @param {Array.<number>} extent
* @return {Array.<number>} * @return {Array.<number>}
*/ */
fitToExtent(extent) { fitToExtent(extent) {
this.map.getView().fit(extent, { padding: [ 40, 10, 10, 10 ] }); this.map.getView().fit(extent, { padding: [ 40, 10, 10, 10 ], maxZoom: OpenLayersApi.ZOOM_MAX });
const zoom = this.map.getView().getZoom(); if (this.map.getView().getZoom() === OpenLayersApi.ZOOM_MAX) {
if (zoom > OpenLayersApi.ZOOM_MAX) {
this.map.getView().setZoom(OpenLayersApi.ZOOM_MAX);
extent = this.map.getView().calculateExtent(this.map.getSize()); extent = this.map.getView().calculateExtent(this.map.getSize());
} }
return extent; return extent;
@ -597,10 +602,10 @@ export default class OpenLayersApi {
} }
/** /**
* Zoom to track extent * Zoom to track extent, respect max zoom
*/ */
zoomToExtent() { 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%'; extentImg.style.width = '60%';
return extentImg; 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; OpenLayersApi.ZOOM_MAX = 20;

View File

@ -41,6 +41,15 @@ import uUtils from './utils.js';
* @property {function} zoomToExtent * @property {function} zoomToExtent
* @property {function} zoomToBounds * @property {function} zoomToBounds
* @property {function} updateSize * @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() { onReady() {
if (this.savedBounds) {
this.api.zoomToBounds(this.savedBounds);
}
if (this.state.currentTrack) { 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.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) { displayTrack(track, update) {
this.state.jobStart(); 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) this.api.displayTrack(track, update)
.finally(() => this.state.jobStop()); .finally(() => this.state.jobStop());
} }

165
js/src/permalink.js Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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<?PermalinkState>}
*/
static parseHash() {
return uPermalink.parse(window.location.hash);
}
/**
* Parse URL hash string
* @param {string} hash
* @return {Promise<?PermalinkState>} 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;
}
}

View File

@ -26,6 +26,8 @@ import uObserve from './observe.js';
* @property {boolean} showLatest * @property {boolean} showLatest
* @property {boolean} showAllUsers * @property {boolean} showAllUsers
* @property {number} activeJobs * @property {number} activeJobs
* @property {?MapParams} mapParams
* @property {?PermalinkState} history
*/ */
export default class uState { export default class uState {
@ -35,6 +37,8 @@ export default class uState {
this.showLatest = false; this.showLatest = false;
this.showAllUsers = false; this.showAllUsers = false;
this.activeJobs = 0; this.activeJobs = 0;
this.mapParams = null;
this.history = null;
} }
jobStart() { jobStart() {

View File

@ -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 * Save track data
* @param {Object} data * @param {Object} data

View File

@ -124,6 +124,11 @@ export default class TrackViewModel extends ViewModel {
this.startAutoReload(); this.startAutoReload();
} }
}); });
this.state.onChanged('history', (history) => {
if (history && !history.userId && history.trackId) {
this.model.currentTrackId = history.trackId.toString();
}
});
} }
setClickHandlers() { setClickHandlers() {
@ -268,8 +273,9 @@ export default class TrackViewModel extends ViewModel {
if (_tracks.length) { if (_tracks.length) {
if (this.state.showLatest) { if (this.state.showLatest) {
this.onUserLastPosition(); this.onUserLastPosition();
} else if (this.state.history) {
this.model.currentTrackId = this.state.history.trackId.toString();
} else { } else {
// autoload first track in list
this.model.currentTrackId = _tracks[0].listValue; this.model.currentTrackId = _tracks[0].listValue;
} }
} else { } else {

View File

@ -25,21 +25,26 @@ import MapViewModel from './mapviewmodel.js';
import TrackViewModel from './trackviewmodel.js'; import TrackViewModel from './trackviewmodel.js';
import UserViewModel from './userviewmodel.js'; import UserViewModel from './userviewmodel.js';
import uAlert from './alert.js'; import uAlert from './alert.js';
import uPermalink from './permalink.js';
import uSpinner from './spinner.js'; import uSpinner from './spinner.js';
import uState from './state.js'; import uState from './state.js';
const domReady = uInitializer.waitForDom(); const domReady = uInitializer.waitForDom();
const initReady = initializer.initialize(); const initReady = initializer.initialize();
const initLink = uPermalink.parseHash();
Promise.all([ domReady, initReady ]) Promise.all([ domReady, initReady, initLink ])
.then(() => { .then((result) => {
start(); start(result[2]);
}) })
.catch((msg) => uAlert.error(`${$._('actionfailure')}\n${msg}`)); .catch((msg) => uAlert.error(`${$._('actionfailure')}\n${msg}`));
/**
function start() { * @param {?Object} linkState
*/
function start(linkState) {
const state = new uState(); const state = new uState();
const permalink = new uPermalink(state);
const spinner = new uSpinner(state); const spinner = new uSpinner(state);
const mainVM = new MainViewModel(state); const mainVM = new MainViewModel(state);
const userVM = new UserViewModel(state); const userVM = new UserViewModel(state);
@ -47,6 +52,7 @@ function start() {
const mapVM = new MapViewModel(state); const mapVM = new MapViewModel(state);
const chartVM = new ChartViewModel(state); const chartVM = new ChartViewModel(state);
const configVM = new ConfigViewModel(state); const configVM = new ConfigViewModel(state);
permalink.init().onPop(linkState);
spinner.init(); spinner.init();
mainVM.init(); mainVM.init();
userVM.init(); userVM.init();

View File

@ -71,7 +71,9 @@ export default class UserViewModel extends ViewModel {
this.model.userList = _users; this.model.userList = _users;
if (_users.length) { if (_users.length) {
let userId = _users[0].listValue; 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); const user = this.model.userList.find((_user) => _user.listValue === auth.user.listValue);
if (user) { if (user) {
userId = user.listValue; userId = user.listValue;
@ -103,6 +105,11 @@ export default class UserViewModel extends ViewModel {
this.select.hideAllOption(); this.select.hideAllOption();
} }
}); });
state.onChanged('history', (history) => {
if (history && history.userId) {
this.model.currentUserId = history.userId.toString();
}
});
} }
showDialog(action) { showDialog(action) {

View File

@ -288,4 +288,20 @@ export default class uUtils {
static deg2rad(degrees) { static deg2rad(degrees) {
return degrees * Math.PI / 180; 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];
})
}
} }

View File

@ -366,14 +366,13 @@ describe('Openlayers map API tests', () => {
const zoomedExtent = [ 3, 2, 1, 0 ]; const zoomedExtent = [ 3, 2, 1, 0 ];
api.map = mockMap; api.map = mockMap;
spyOn(ol.View.prototype, 'fit'); 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, 'setZoom');
spyOn(ol.View.prototype, 'calculateExtent').and.returnValue(zoomedExtent); spyOn(ol.View.prototype, 'calculateExtent').and.returnValue(zoomedExtent);
// when // when
const result = api.fitToExtent(extent); const result = api.fitToExtent(extent);
// then // then
expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent, jasmine.any(Object)); expect(ol.View.prototype.fit).toHaveBeenCalledWith(extent, jasmine.any(Object));
expect(ol.View.prototype.setZoom).toHaveBeenCalledWith(OpenlayersApi.ZOOM_MAX);
expect(result).toEqual(zoomedExtent); expect(result).toEqual(zoomedExtent);
}); });
@ -498,7 +497,7 @@ describe('Openlayers map API tests', () => {
// when // when
api.zoomToExtent(); api.zoomToExtent();
// then // 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)', () => { it('should get map bounds and convert to WGS84 (EPSG:4326)', () => {

View File

@ -23,7 +23,7 @@ const stubFn = function() {/* ignore */};
const stubFnObj = function() { return stubObj; }; const stubFnObj = function() { return stubObj; };
export const setupGmapsStub = () => { export const setupGmapsStub = () => {
// noinspection JSUnresolvedVariable // noinspection JSUnresolvedVariable,JSConstantReassignment
window.google = { window.google = {
maps: { maps: {
Animation: { Animation: {
@ -33,7 +33,8 @@ export const setupGmapsStub = () => {
event: { event: {
addListener: stubFn, addListener: stubFn,
addListenerOnce: stubFn, addListenerOnce: stubFn,
removeListener: stubFn removeListener: stubFn,
clearListeners: stubFn
}, },
Icon: stubFn, Icon: stubFn,
InfoWindow: stubFn, InfoWindow: stubFn,
@ -94,6 +95,6 @@ export const applyPrototypes = () => {
}; };
export const clear = () => { export const clear = () => {
// noinspection JSAnnotator,JSUnresolvedVariable // noinspection JSAnnotator,JSUnresolvedVariable,JSConstantReassignment
delete window.google; delete window.google;
}; };

View File

@ -144,6 +144,7 @@ describe('MapViewModel tests', () => {
// given // given
vm.api = mockApi; vm.api = mockApi;
vm.savedBounds = bounds; vm.savedBounds = bounds;
state.currentTrack = track;
// when // when
vm.onReady(); vm.onReady();
// then // then

324
js/test/permalink.test.js Normal file
View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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);
});
});

View File

@ -267,4 +267,103 @@ describe('Utils tests', () => {
expect(uUtils.deg2rad(1)).toBeCloseTo(0.0174533, 7); 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();
});
}); });

View File

@ -33,12 +33,19 @@
<title><?= $lang["title"] ?></title> <title><?= $lang["title"] ?></title>
<?php include("meta.php"); ?> <?php include("meta.php"); ?>
<script type="text/javascript"> <script type="text/javascript">
function focus() { function init() {
document.forms[0].elements[0].focus(); const form = document.forms[0];
const action = form.getAttribute('action');
form.setAttribute('action', action + window.location.hash);
const cancelEl = document.getElementById('cancel');
if (cancelEl) {
cancelEl.firstElementChild.href += window.location.hash;
}
form.elements[0].focus();
} }
</script> </script>
</head> </head>
<body onload="focus()"> <body onload="init()">
<div id="login"> <div id="login">
<div id="title"><?= $lang["title"] ?></div> <div id="title"><?= $lang["title"] ?></div>
<div id="subtitle"><?= $lang["private"] ?></div> <div id="subtitle"><?= $lang["private"] ?></div>

View File

@ -25,9 +25,9 @@ require_once(ROOT_DIR . "/helpers/config.php");
$auth = new uAuth(); $auth = new uAuth();
$action = uUtils::postString('action'); $action = uUtils::postString("action");
$trackId = uUtils::postInt('trackid'); $trackId = uUtils::postInt("trackid");
$trackName = uUtils::postString('trackname'); $trackName = uUtils::postString("trackname");
$config = uConfig::getInstance(); $config = uConfig::getInstance();
$lang = (new uLang($config))->getStrings(); $lang = (new uLang($config))->getStrings();
@ -36,30 +36,44 @@ if (empty($action) || empty($trackId)) {
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
} }
$track = new uTrack($trackId); $track = new uTrack($trackId);
if (!$track->isValid || if (!$track->isValid) {
(!$auth->isAuthenticated() || (!$auth->isAdmin() && $auth->user->id !== $track->userId))) {
uUtils::exitWithError($lang["servererror"]); 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) { switch ($action) {
case 'update': case "update":
if (empty($trackName) || $track->update($trackName) === false) { if (empty($trackName) || $track->update($trackName) === false) {
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
} }
break; break;
case 'delete': case "delete":
if ($track->delete() === false) { if ($track->delete() === false) {
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
} }
break; break;
case "getmeta":
$result = [
"id" => $track->id,
"name" => $track->name,
"userId" => $track->userId,
"comment" => $track->comment
];
break;
default: default:
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
break; break;
} }
uUtils::exitWithSuccess(); uUtils::exitWithSuccess($result);
?> ?>

View File

@ -19,7 +19,11 @@
include_once(dirname(__DIR__) . "/helpers/auth.php"); include_once(dirname(__DIR__) . "/helpers/auth.php");
$hash = uUtils::getString("hash", "");
if (!empty($hash)) {
$hash = "#{$hash}";
}
$auth = new uAuth(); $auth = new uAuth();
$auth->logOutWithRedirect(); $auth->logOutWithRedirect($hash);
?> ?>