Add spinner

This commit is contained in:
Bartek Fabiszewski 2020-05-15 21:25:45 +02:00
parent e3e524f406
commit b8d5a92fc6
12 changed files with 207 additions and 33 deletions

View File

@ -472,16 +472,6 @@ button > * {
text-decoration: underline;
}
.loader {
animation: blink 1s linear infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
#configForm label {
display: block;
}
@ -502,8 +492,6 @@ button > * {
width: 150px;
margin: 3px 0;
padding: 2px 4px;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
@ -526,6 +514,8 @@ button > * {
margin: 0 5px;
}
/* alert */
.alert {
position: fixed;
top: 0;
@ -545,6 +535,8 @@ button > * {
border-top: 1px solid #555;
box-shadow: 10px 10px 10px -8px rgba(0, 0, 0, 0.3);
z-index: 100000;
opacity: 0;
transition: all 1s;
}
.alert.error {
@ -552,6 +544,14 @@ button > * {
border-top: 1px solid #d05858;
}
.alert.in {
opacity: 1;
}
.alert.out {
opacity: 0;
}
.alert button {
position: absolute;
top: -1px;
@ -564,6 +564,67 @@ button > * {
font-size: 15px;
}
.alert.spinner {
background-color: transparent;
border: none;
box-shadow: none;
}
.alert.spinner > span {
position: relative;
display: block;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #9880ff;
color: #9880ff;
animation: spinner-dot 1s infinite linear alternate;
animation-delay: 0.5s;
transform: translateZ(0);
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
will-change: transform, opacity;
}
.alert.spinner > span::after {
left: 15px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #9880ff;
color: #9880ff;
animation: spinner-dot 1s infinite alternate;
animation-delay: 1s;
}
.alert.spinner > span::before, .alert.spinner > span::after {
content: '';
display: inline-block;
position: absolute;
top: 0;
}
.alert.spinner > span::before {
left: -15px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #9880ff;
color: #9880ff;
animation: spinner-dot 1s infinite alternate;
animation-delay: 0s;
}
@keyframes spinner-dot {
0% {
background-color: #9880ff;
}
50%, 100% {
background-color: #ebe6ff;
}
}
/* chart */
.ct-point {
transition: 0.3s;
@ -592,7 +653,7 @@ button > * {
font-size: 0.8em;
}
/* openlayers 3 popup */
/* openlayers popup */
.ol-popup {
position: absolute;
bottom: 12px;

View File

@ -24,8 +24,10 @@ export default class uAlert {
/**
* @typedef {Object} AlertOptions
* @property {number} [autoClose=0] Optional autoclose delay time in ms, default 0 no autoclose
* @property {boolean} [hasButton] Optional can be closed by button click, default true when autoClose not set
* @property {string} [id] Optional box id
* @property {string} [class] Optional box class
* @property {boolean} [fixed=false] Optional set fixed position, default false
*/
/**
@ -35,6 +37,8 @@ export default class uAlert {
*/
constructor(message, options = {}) {
this.autoClose = options.autoClose || 0;
this.hasButton = typeof options.hasButton !== 'undefined' ? options.hasButton : this.autoClose === 0
this.fixedPosition = options.fixed || false;
const html = `<div class="alert"><span>${message}</span></div>`;
this.box = uUtils.nodeFromHtml(html);
if (options.id) {
@ -43,7 +47,7 @@ export default class uAlert {
if (options.class) {
this.box.classList.add(options.class);
}
if (this.autoClose === 0) {
if (this.hasButton) {
const button = document.createElement('button');
button.setAttribute('type', 'button');
button.textContent = '×';
@ -72,11 +76,18 @@ export default class uAlert {
}
render() {
const top = uAlert.getPosition();
if (top) {
this.box.style.top = `${top}px`;
if (!this.fixedPosition) {
const top = uAlert.getPosition();
if (top) {
this.box.style.top = `${top}px`;
}
}
document.body.appendChild(this.box);
setTimeout(() => {
if (this.box) {
this.box.classList.add('in');
}
}, 50);
}
destroy() {
@ -84,10 +95,14 @@ export default class uAlert {
clearTimeout(this.closeHandle);
this.closeHandle = null;
}
if (this.box) {
if (document.body.contains(this.box)) {
document.body.removeChild(this.box);
}
if (this.box && document.body.contains(this.box)) {
const element = this.box;
requestAnimationFrame(() => {
element.classList.add('out');
setTimeout(() => {
element.remove();
}, 1000);
});
this.box = null;
}
}
@ -129,4 +144,8 @@ export default class uAlert {
return this.show(message, { class: 'toast', autoClose: 10000 });
}
static spinner() {
return this.show('', { class: 'spinner', hasButton: false, fixed: true });
}
}

View File

@ -129,11 +129,18 @@ export default class GoogleMapsApi {
* Display track
* @param {uPositionSet} track
* @param {boolean} update Should fit bounds if true
* @return {Promise.<void>}
*/
displayTrack(track, update) {
if (!track || !track.hasPositions) {
return;
return Promise.resolve();
}
const promise = new Promise((resolve) => {
google.maps.event.addListenerOnce(this.map, 'tilesloaded', () => {
console.log('tilesloaded');
resolve();
})
});
// init polyline
const polyOptions = {
strokeColor: config.strokeColor,
@ -184,6 +191,7 @@ export default class GoogleMapsApi {
}, 2000);
}
}
return promise;
}
/**

View File

@ -424,11 +424,18 @@ export default class OpenLayersApi {
* Display track
* @param {uPositionSet} track Track
* @param {boolean} update Should fit bounds if true
* @return {Promise.<void>}
*/
displayTrack(track, update) {
if (!track || !track.hasPositions) {
return;
return Promise.resolve();
}
const promise = new Promise((resolve) => {
this.map.once('rendercomplete', () => {
console.log('rendercomplete');
resolve();
});
});
let start = this.layerMarkers ? this.layerMarkers.getSource().getFeatures().length : 0;
if (start > 0) {
this.removePoint(--start);
@ -457,6 +464,7 @@ export default class OpenLayersApi {
extent = this.fitToExtent(extent);
}
this.setZoomToExtent(extent);
return promise;
}
/**

View File

@ -120,7 +120,7 @@ export default class MapViewModel extends ViewModel {
this.api.zoomToBounds(this.savedBounds);
}
if (this.state.currentTrack) {
this.api.displayTrack(this.state.currentTrack, this.savedBounds === null);
this.displayTrack(this.state.currentTrack, this.savedBounds === null);
}
}
@ -135,14 +135,20 @@ export default class MapViewModel extends ViewModel {
this.api.clearMap();
if (track) {
uObserve.observe(track, 'positions', () => {
this.api.displayTrack(track, false);
this.displayTrack(track, false);
this.api.zoomToExtent();
});
this.api.displayTrack(track, true);
this.displayTrack(track, true);
}
});
}
displayTrack(track, update) {
this.state.jobStart();
this.api.displayTrack(track, update)
.finally(() => this.state.jobStop());
}
/**
* Get popup html
* @param {number} id Position index

42
js/src/spinner.js Normal file
View File

@ -0,0 +1,42 @@
/*
* μ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 uAlert from './alert.js';
export default class uSpinner {
constructor(state) {
this.spinner = null;
this.state = state;
}
init() {
this.state.onChanged('activeJobs', (jobs) => {
if (jobs > 0) {
if (!this.spinner) {
this.spinner = uAlert.spinner();
}
} else if (this.spinner) {
this.spinner.destroy();
this.spinner = null;
}
});
}
}

View File

@ -25,6 +25,7 @@ import uObserve from './observe.js';
* @property {?uUser} currentUser
* @property {boolean} showLatest
* @property {boolean} showAllUsers
* @property {number} activeJobs
*/
export default class uState {
@ -33,6 +34,15 @@ export default class uState {
this.currentUser = null;
this.showLatest = false;
this.showAllUsers = false;
this.activeJobs = 0;
}
jobStart() {
this.activeJobs++;
}
jobStop() {
this.activeJobs--;
}
/**

View File

@ -173,6 +173,7 @@ export default class TrackViewModel extends ViewModel {
uAlert.error($._('notauthorized'));
return;
}
this.state.jobStart();
uTrack.import(form, auth.user)
.then((trackList) => {
if (trackList.length) {
@ -186,6 +187,7 @@ export default class TrackViewModel extends ViewModel {
.catch((e) => uAlert.error(`${$._('actionfailure')}\n${e.message}`, e))
.finally(() => {
this.model.inputFile = '';
this.state.jobStop();
});
}
@ -199,6 +201,7 @@ export default class TrackViewModel extends ViewModel {
if (!track) {
this.state.currentTrack = null;
} else if (!track.isEqualTo(this.state.currentTrack)) {
this.state.jobStart();
track.fetchPositions().then(() => {
console.log(`currentTrack id: ${track.id}, loaded ${track.length} positions`);
this.state.currentTrack = track;
@ -206,7 +209,8 @@ export default class TrackViewModel extends ViewModel {
this.model.showLatest = false;
}
})
.catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); });
.catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); })
.finally(() => this.state.jobStop());
}
}
@ -243,6 +247,7 @@ export default class TrackViewModel extends ViewModel {
* Handle last position of all users request
*/
loadAllUsersPosition() {
this.state.jobStart();
uPositionSet.fetchLatest()
.then((_track) => {
if (_track) {
@ -251,10 +256,12 @@ export default class TrackViewModel extends ViewModel {
this.state.currentTrack = _track;
}
})
.catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); });
.catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); })
.finally(() => this.state.jobStop());
}
loadTrackList() {
this.state.jobStart();
uTrack.fetchList(this.state.currentUser)
.then((_tracks) => {
this.model.trackList = _tracks;
@ -269,7 +276,8 @@ export default class TrackViewModel extends ViewModel {
this.model.currentTrackId = '';
}
})
.catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); });
.catch((e) => { uAlert.error(`${$._('actionfailure')}\n${e.message}`, e); })
.finally(() => this.state.jobStop());
}
showDialog() {

View File

@ -25,6 +25,7 @@ import MapViewModel from './mapviewmodel.js';
import TrackViewModel from './trackviewmodel.js';
import UserViewModel from './userviewmodel.js';
import uAlert from './alert.js';
import uSpinner from './spinner.js';
import uState from './state.js';
const domReady = uInitializer.waitForDom();
@ -39,13 +40,14 @@ Promise.all([ domReady, initReady ])
function start() {
const state = new uState();
const spinner = new uSpinner(state);
const mainVM = new MainViewModel(state);
const userVM = new UserViewModel(state);
const trackVM = new TrackViewModel(state);
const mapVM = new MapViewModel(state);
const chartVM = new ChartViewModel(state);
const configVM = new ConfigViewModel(state);
spinner.init();
mainVM.init();
userVM.init();
trackVM.init();

View File

@ -24,6 +24,10 @@ describe('Alert tests', () => {
const message = 'test message';
let alert;
beforeEach(() => {
spyOn(window, 'requestAnimationFrame').and.callFake((callback) => callback());
})
afterEach(() => {
if (alert) {
alert.destroy();
@ -79,6 +83,7 @@ describe('Alert tests', () => {
it('should render and destroy alert box', () => {
// given
spyOn(window, 'setTimeout').and.callFake((callback) => callback());
const id = 'testId';
const options = { id }
alert = new uAlert(message, options);
@ -96,6 +101,7 @@ describe('Alert tests', () => {
it('should show and autoclose alert box', (done) => {
// given
jasmine.clock().install();
const id = 'testId';
const options = { id: id, autoClose: 50 }
@ -103,7 +109,8 @@ describe('Alert tests', () => {
alert = uAlert.show(message, options);
// then
expect(document.querySelector(`#${id}`)).not.toBeNull();
jasmine.clock().tick(5000);
jasmine.clock().uninstall();
setTimeout(() => {
expect(document.querySelector(`#${id}`)).toBeNull();
done();
@ -112,12 +119,15 @@ describe('Alert tests', () => {
it('should close alert box on close button click', (done) => {
// given
jasmine.clock().install();
const id = 'testId';
const options = { id }
alert = uAlert.show(message, options);
const closeButton = alert.box.querySelector('button');
// when
closeButton.click();
jasmine.clock().tick(5000);
jasmine.clock().uninstall();
// then
setTimeout(() => {
expect(document.querySelector(`#${id}`)).toBeNull();

View File

@ -281,7 +281,7 @@ describe('Google Maps map API tests', () => {
expect(api.polies[0].path.length).toBe(track.length);
expect(api.setMarker).toHaveBeenCalledTimes(track.length);
expect(google.maps.Map.prototype.fitBounds).toHaveBeenCalledTimes(1);
expect(google.maps.event.addListenerOnce).not.toHaveBeenCalled();
expect(google.maps.event.addListenerOnce).toHaveBeenCalledTimes(1);
expect(setTimeout).not.toHaveBeenCalled();
});

View File

@ -56,7 +56,7 @@ describe('MapViewModel tests', () => {
'cleanup': { /* ignored */ },
'zoomToBounds': { /* ignored */ },
'zoomToExtent': { /* ignored */ },
'displayTrack': { /* ignored */ },
'displayTrack': Promise.resolve(),
'clearMap': { /* ignored */ },
'updateSize': { /* ignored */ }
});