Feature: permalink, closes #120
This commit is contained in:
parent
21746f2f2d
commit
9a6c1c97e8
@ -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() {
|
||||
|
@ -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">
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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} */
|
||||
|
@ -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;
|
||||
|
@ -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
165
js/src/permalink.js
Normal 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;
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
|
@ -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) {
|
||||
|
@ -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];
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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)', () => {
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
324
js/test/permalink.test.js
Normal 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);
|
||||
});
|
||||
|
||||
});
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
13
login.php
13
login.php
@ -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>
|
||||
|
@ -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);
|
||||
|
||||
?>
|
@ -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);
|
||||
|
||||
?>
|
Loading…
x
Reference in New Issue
Block a user