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");
$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() {

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>
<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 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>
<?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; ?>
<div class="section">

View File

@ -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);
}
}

View File

@ -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} */

View File

@ -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.<number>} extent
* @return {Array.<number>}
*/
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;

View File

@ -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());
}

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} 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() {

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

View File

@ -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 {

View File

@ -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();

View File

@ -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) {

View File

@ -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];
})
}
}

View File

@ -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)', () => {

View File

@ -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;
};

View File

@ -144,6 +144,7 @@ describe('MapViewModel tests', () => {
// given
vm.api = mockApi;
vm.savedBounds = bounds;
state.currentTrack = track;
// when
vm.onReady();
// 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);
});
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>
<?php include("meta.php"); ?>
<script type="text/javascript">
function focus() {
document.forms[0].elements[0].focus();
function init() {
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>
</head>
<body onload="focus()">
<body onload="init()">
<div id="login">
<div id="title"><?= $lang["title"] ?></div>
<div id="subtitle"><?= $lang["private"] ?></div>

View File

@ -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);
?>

View File

@ -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);
?>