feature (notification): proper notification system

This commit is contained in:
Mickael KERJEAN
2018-04-06 13:09:22 +10:00
parent 22d2cd7b00
commit 4b06b8a802
14 changed files with 185 additions and 145 deletions

View File

@ -1,15 +0,0 @@
import React from 'react';
import { NgIf } from './';
import "./error.scss";
export const Error = (props) => {
return (
<div className="component_error">
{props.err.message || "Oups something went wrong :/"}
<NgIf cond={props.err.trace !== undefined} className="trace">
{JSON.stringify(props.err.trace)}
</NgIf>
</div>
);
}

View File

@ -1,13 +0,0 @@
.component_error{
text-align: center;
margin-top: 50px;
font-size: 25px;
font-style: italic;
font-weight: 100;
.trace{
fontSize: 12px;
maxWidth: 500px;
margin: 10px auto 0 auto;
}
}

View File

@ -7,7 +7,6 @@ export { Container } from './container';
export { NgIf } from './ngif';
export { Card } from './card';
export { Loader } from './loader';
export { Error } from './error';
export { Fab } from './fab';
export { Icon } from './icon';
export { Uploader } from './uploader';

View File

@ -1,68 +1,107 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import { NgIf } from './';
import { notify } from '../helpers/';
import './notification.scss';
export class Notification extends React.Component {
constructor(props){
super(props);
this.state = {
visible: null,
error: null,
timeout: null
appear: false,
message_text: null,
message_type: null
};
}
componentWillMount(){
this.componentWillReceiveProps(this.props);
}
function TaskManager(){
let jobs = [];
let is_running = false;
componentWillUnmount(){
window.clearTimeout(this.timeout);
}
componentWillReceiveProps(props){
if(props.error !== null){
this.componentWillUnmount();
this.setState({visible: true, error: props.error});
this.timeout = window.setTimeout(() => {
this.setState({visible: null});
}, 5000);
const ret = {
addJob: (job) => {
jobs.push(job);
if(is_running === false){
is_running = true;
ret._executor();
}
},
_executor: () => {
let job = jobs.shift();
if(!job){
is_running = false;
return Promise.resolve();
}
return job().then(ret._executor);
}
};
return ret;
}
this.runner = new TaskManager();
}
toggleVisibility(){
this.setState({visible: !this.state.visible});
}
formatError(err){
if(typeof err === 'object'){
if(err && err.message){
return err.message;
}else{
return JSON.stringify(err);
componentDidMount(){
notify.subscribe((_message, type) => {
let job = playMessage.bind(this, {
text: stringify(_message),
type: type
});
this.runner.addJob(job);
});
function stringify(data){
if(typeof data === 'object' && data.message){
return data.message;
}else if(typeof data === 'string'){
return data;
}
}else if(typeof err === 'string'){
return err;
}else{
throw('unrecognized notification');
return JSON.stringify(data);
}
function playMessage(message){
const displayMessage = (message) => {
this.setState({
appear: true,
message_text: message.text,
message_type: message.type
});
return Promise.resolve(message);
};
const waitForABit = (timeout, message) => {
return new Promise((done, err) => {
window.setTimeout(() => {
done(message);
}, timeout);
});
};
const hideMessage = (message) => {
this.setState({
appear: false
});
return Promise.resolve(message);
};
return displayMessage(message)
.then(waitForABit.bind(this, 5000))
.then(hideMessage)
.then(waitForABit.bind(this, 1000));
}
}
close(){
this.setState({ appear: false });
}
render(){
return (
<NgIf cond={this.state.visible === true}>
<div className="component_notification">
<div onClick={this.toggleVisibility.bind(this)}>
{this.formatError(this.state.error)}
<NgIf cond={this.state.appear === true} className="component_notification no-select">
<ReactCSSTransitionGroup transitionName="notification" transitionLeave={true} transitionLeaveTimeout={200} transitionEnter={true} transitionEnterTimeout={500}>
<div className={"component_notification--container "+(this.state.message_type || 'info')}>
<div className="message">
{ this.state.message_text }
</div>
<div className="close" onClick={this.close.bind(this)}>X</div>
</div>
</div>
</ReactCSSTransitionGroup>
</NgIf>
);
}
}
Notification.propTypes = {
error: PropTypes.any
}

View File

@ -1,22 +1,42 @@
.component_notification{
position: fixed;
bottom: 0;
left: 0;
bottom: 25px;
left: 25px;
right: 0;
text-align: center;
font-size: 0.95em;
.component_notification--container{
width: 400px;
> div{
display: inline-block;
background: #637d8b;
min-width: 200px;
max-width: 400px;
margin: 0 auto;
padding: 10px 15px;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
color: white;
text-align: left;
cursor: pointer;
box-shadow: rgba(0, 0, 0, 0.14) 0px 4px 5px 0px, rgba(0, 0, 0, 0.12) 0px 1px 10px 0px, rgba(0, 0, 0, 0.2) 0px 2px 4px -1px;
display: inline-block;
padding: 15px 25px 15px 15px;
border-radius: 2px;
box-shadow: rgba(0, 0, 0, 0.14) 0px 4px 5px 0px;
display: flex;
align-items: center;
&.info{
background: rgba(0,0,0,0.6);
color: white;
}
&.error{
background: var(--error);
color: var(--secondary);
}
&.success{
background: var(--success);
color: var(--secondary);
}
.message{
flex: 1 1 auto;
}
.close{
color: rgba(0,0,0,0.3);
cursor: pointer;
padding: 0 2px;
font-size: 0.95em;
}
}
}

View File

@ -10,3 +10,4 @@ export { prepare } from './navigate';
export { invalidate, http_get, http_post, http_delete } from './ajax';
export { screenHeight } from './dom';
export { prompt } from './prompt';
export { notify } from './notify';

17
client/helpers/notify.js Normal file
View File

@ -0,0 +1,17 @@
const Message = function (){
let fn = null;
return {
send: function(text, type){
if(['info', 'success', 'error'].indexOf(type) === -1){ type = 'info'; }
if(!fn){ return window.setTimeout(() => this.send(text,type), 50); }
fn(text, type);
return Promise.resolve();
},
subscribe: function(_fn){
fn = _fn;
}
};
};
export const notify = new Message();

View File

@ -84,7 +84,7 @@ class FileSystem{
rm(path){
const url = '/api/files/rm?path='+prepare(path);
this._replace(path, 'loading')
return this._replace(path, 'loading')
.then(() => http_get(url))
.then((res) => {
if(res.status === 'ok'){
@ -127,14 +127,14 @@ class FileSystem{
mkdir(path){
const url = '/api/files/mkdir?path='+prepare(path);
this._add(path, 'loading')
return this._add(path, 'loading')
.then(() => this._add(path, 'loading'))
.then(() => http_get(url))
.then((res) => res.status === 'ok'? this._replace(path) : this._replace(path, 'error'));
}
touch(path, file){
this._add(path, 'loading')
return this._add(path, 'loading')
.then(() => {
if(file){
const url = '/api/files/cat?path='+prepare(path);
@ -152,7 +152,7 @@ class FileSystem{
mv(from, to){
const url = '/api/files/mv?from='+prepare(from)+"&to="+prepare(to);
ui_before_request(from, to)
return ui_before_request(from, to)
.then(() => this._ls_from_cache(dirname(from)))
.then(() => this._ls_from_cache(dirname(to)))
.then(() => http_get(url)

View File

@ -5,7 +5,7 @@ import './connectpage.scss';
import { Session } from '../model/';
import { Container, NgIf, Loader, Notification } from '../components/';
import { ForkMe, RememberMe, Credentials, Form } from './connectpage/';
import { cache } from '../helpers/';
import { cache, notify } from '../helpers/';
import config from '../../config_client';
import { Alert } from '../components/';
@ -17,7 +17,6 @@ export class ConnectPage extends React.Component {
credentials: {},
remember_me: window.localStorage.hasOwnProperty('credentials') ? true : false,
loading: false,
error: null,
doing_a_third_party_login: false
};
}
@ -52,9 +51,9 @@ export class ConnectPage extends React.Component {
const path = params.path && /^\//.test(params.path)? /\/$/.test(params.path) ? params.path : params.path+'/' : '/';
this.props.history.push('/files'+path);
})
.catch(err => {
this.setState({loading: false, error: err});
window.setTimeout(() => this.setState({error: null}), 1000);
.catch((err) => {
this.setState({loading: false});
notify.send(err, 'error');
});
}
@ -64,16 +63,16 @@ export class ConnectPage extends React.Component {
Session.url('dropbox').then((url) => {
window.location.href = url;
}).catch((err) => {
this.setState({loading: false, error: err});
window.setTimeout(() => this.setState({error: null}), 1000);
this.setState({loading: false});
notify.send(err, 'error');
});
}else if(source === 'google'){
this.setState({loading: true});
Session.url('gdrive').then((url) => {
window.location.href = url;
}).catch((err) => {
this.setState({loading: false, error: err});
window.setTimeout(() => this.setState({error: null}), 1000);
this.setState({loading: false});
notify.send(err, 'error');
});
}
}
@ -118,7 +117,6 @@ export class ConnectPage extends React.Component {
onCredentialsFound={this.setCredentials.bind(this)}
credentials={this.state.credentials} />
</NgIf>
<Notification error={this.state.error && this.state.error.message} />
</Container>
</div>
);

View File

@ -6,8 +6,8 @@ import Path from 'path';
import './filespage.scss';
import { Files } from '../model/';
import { NgIf, Loader, Error, Uploader, EventReceiver } from '../components/';
import { debounce, goToFiles, goToViewer, event, screenHeight } from '../helpers/';
import { NgIf, Loader, Uploader, EventReceiver } from '../components/';
import { notify, debounce, goToFiles, goToViewer, event, screenHeight } from '../helpers/';
import { BreadCrumb, FileSystem } from './filespage/';
@EventReceiver
@ -81,6 +81,7 @@ export class FilesPage extends React.Component {
});
this.setState({files: files, loading: false});
}, (error) => {
notify.send(error, 'error');
this.setState({error: error});
});
this.setState({error: false});
@ -88,18 +89,26 @@ export class FilesPage extends React.Component {
onCreate(path, type, file){
if(type === 'file'){
return Files.touch(path, file);
return Files.touch(path, file)
.then(() => notify.send('A file named "'+Path.basename(path)+'" was created', 'success'))
.catch((err) => notify.send(err, 'error'));
}else if(type === 'directory'){
return Files.mkdir(path);
return Files.mkdir(path)
.then(() => notify.send('A folder named "'+Path.basename(path)+'" was created', 'success'))
.catch((err) => notify.send(err, 'error'));
}else{
return Promise.reject({message: 'internal error: can\'t create a '+type.toString(), code: 'UNKNOWN_TYPE'});
}
}
onRename(from, to, type){
return Files.mv(from, to, type);
return Files.mv(from, to, type)
.then(() => notify.send('The file "'+Path.basename(from)+'" was renamed', 'success'))
.catch((err) => notify.send(err, 'error'));
}
onDelete(file, type){
return Files.rm(file, type);
onDelete(path, type){
return Files.rm(path, type)
.then(() => notify.send('The file "'+Path.basename(path)+'" was deleted', 'success'))
.catch((err) => notify.send(err, 'error'));
}
onUpload(path, files){
@ -205,10 +214,7 @@ export class FilesPage extends React.Component {
<FileSystem path={this.state.path} files={this.state.files} />
<Uploader path={this.state.path} />
</NgIf>
<NgIf cond={!!this.state.error} className="error" onClick={this.componentDidMount.bind(this)}>
<Error err={this.state.error}/>
</NgIf>
<NgIf cond={this.state.loading && !this.state.error}>
<NgIf cond={this.state.loading}>
<Loader/>
</NgIf>
</div>

View File

@ -85,20 +85,14 @@ export class ExistingThing extends React.Component {
};
}
onSelect(){
if(this.state.icon !== 'loading' && this.state.icon !== 'error'){
}
}
onRename(newFilename){
if(this.state.icon !== 'loading' && this.state.icon !== 'error'){
this.props.emit(
'file.rename',
pathBuilder(this.props.path, this.props.file.name),
pathBuilder(this.props.path, newFilename),
this.props.file.type
);
}
this.props.emit(
'file.rename',
pathBuilder(this.props.path, this.props.file.name),
pathBuilder(this.props.path, newFilename),
this.props.file.type
);
this.setState({is_renaming: false});
}
onRenameRequest(){

View File

@ -2,8 +2,8 @@ import React from 'react';
import Path from 'path';
import { Files } from '../model/';
import { BreadCrumb, Bundle, NgIf, Loader, Error, Container, EventReceiver, EventEmitter } from '../components/';
import { debounce, opener, screenHeight } from '../helpers/';
import { BreadCrumb, Bundle, NgIf, Loader, Container, EventReceiver, EventEmitter } from '../components/';
import { debounce, opener, screenHeight, notify } from '../helpers/';
import { AudioPlayer, FileDownloader, ImageViewer, PDFViewer } from './viewerpage/';
const VideoPlayer = (props) => (
@ -29,7 +29,6 @@ export class ViewerPage extends React.Component {
needSaving: false,
isSaving: false,
loading: true,
error: false,
height: 0
};
this.props.subscribe('file.select', this.onPathUpdate.bind(this));
@ -37,7 +36,7 @@ export class ViewerPage extends React.Component {
}
componentWillMount(){
this.setState({loading: null, error: false}, () => {
this.setState({loading: null}, () => {
window.setTimeout(() => {
if(this.state.loading === null) this.setState({loading: true});
}, 500);
@ -50,13 +49,12 @@ export class ViewerPage extends React.Component {
if(err && err.code === 'CANCELLED'){ return; }
if(err.code === 'BINARY_FILE'){
Files.url(this.state.path).then((url) => {
console.log(this.state.path);
this.setState({data: url, loading: false, opener: 'download'});
}).catch(err => {
this.setState({error: err});
notify.send(err, 'error');
});
}else{
this.setState({error: err});
notify.send(err, 'error');
}
});
}else{
@ -64,7 +62,7 @@ export class ViewerPage extends React.Component {
this.setState({data: url, loading: false, opener: app});
}).catch(err => {
if(err && err.code === 'CANCELLED'){ return; }
this.setState({error: err});
notify.send(err, 'error');
});
}
}
@ -87,13 +85,10 @@ export class ViewerPage extends React.Component {
this.setState({needSaving: false});
})
.catch((err) => {
if(err && err.code === 'CANCELLED'){ return; }
this.setState({isSaving: false});
let message = "Oups, something went wrong";
if(err.message){
message += ':\n'+err.message;
if(err && err.code === 'CANCELLED'){
notify.send(err, 'error');
}
alert(message);
this.setState({isSaving: false});
});
}
@ -142,12 +137,7 @@ export class ViewerPage extends React.Component {
</NgIf>
</NgIf>
<NgIf cond={this.state.loading === true}>
<NgIf cond={this.state.error === false}>
<Loader/>
</NgIf>
<NgIf cond={this.state.error !== false} onClick={this.componentWillMount.bind(this)} style={{cursor: 'pointer'}}>
<Error err={this.state.error}/>
</NgIf>
<Loader/>
</NgIf>
</div>
</div>

View File

@ -21,6 +21,7 @@ export default class AppRouter extends React.Component {
</div>
</BrowserRouter>
<ModalPrompt />
<Notification />
</div>
);
}

View File

@ -80,7 +80,10 @@ app.post('/cat', function(req, res){
app.get('/mv', function(req, res){
let from = decodeURIComponent(req.query.from),
to = decodeURIComponent(req.query.to);
if(from && to){
if(from === to){
res.send({status: 'ok'});
}else if(from && to){
Files.mv(from, to, req.cookies.auth)
.then((message) => {
res.send({status: 'ok'});