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 { NgIf } from './ngif';
export { Card } from './card'; export { Card } from './card';
export { Loader } from './loader'; export { Loader } from './loader';
export { Error } from './error';
export { Fab } from './fab'; export { Fab } from './fab';
export { Icon } from './icon'; export { Icon } from './icon';
export { Uploader } from './uploader'; export { Uploader } from './uploader';

View File

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

View File

@ -1,22 +1,42 @@
.component_notification{ .component_notification{
position: fixed; position: fixed;
bottom: 0; bottom: 25px;
left: 0; left: 25px;
right: 0; 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; text-align: left;
cursor: pointer; display: inline-block;
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; 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 { invalidate, http_get, http_post, http_delete } from './ajax';
export { screenHeight } from './dom'; export { screenHeight } from './dom';
export { prompt } from './prompt'; 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){ rm(path){
const url = '/api/files/rm?path='+prepare(path); const url = '/api/files/rm?path='+prepare(path);
this._replace(path, 'loading') return this._replace(path, 'loading')
.then(() => http_get(url)) .then(() => http_get(url))
.then((res) => { .then((res) => {
if(res.status === 'ok'){ if(res.status === 'ok'){
@ -127,14 +127,14 @@ class FileSystem{
mkdir(path){ mkdir(path){
const url = '/api/files/mkdir?path='+prepare(path); const url = '/api/files/mkdir?path='+prepare(path);
this._add(path, 'loading') return this._add(path, 'loading')
.then(() => this._add(path, 'loading')) .then(() => this._add(path, 'loading'))
.then(() => http_get(url)) .then(() => http_get(url))
.then((res) => res.status === 'ok'? this._replace(path) : this._replace(path, 'error')); .then((res) => res.status === 'ok'? this._replace(path) : this._replace(path, 'error'));
} }
touch(path, file){ touch(path, file){
this._add(path, 'loading') return this._add(path, 'loading')
.then(() => { .then(() => {
if(file){ if(file){
const url = '/api/files/cat?path='+prepare(path); const url = '/api/files/cat?path='+prepare(path);
@ -152,7 +152,7 @@ class FileSystem{
mv(from, to){ mv(from, to){
const url = '/api/files/mv?from='+prepare(from)+"&to="+prepare(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(from)))
.then(() => this._ls_from_cache(dirname(to))) .then(() => this._ls_from_cache(dirname(to)))
.then(() => http_get(url) .then(() => http_get(url)

View File

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

View File

@ -6,8 +6,8 @@ import Path from 'path';
import './filespage.scss'; import './filespage.scss';
import { Files } from '../model/'; import { Files } from '../model/';
import { NgIf, Loader, Error, Uploader, EventReceiver } from '../components/'; import { NgIf, Loader, Uploader, EventReceiver } from '../components/';
import { debounce, goToFiles, goToViewer, event, screenHeight } from '../helpers/'; import { notify, debounce, goToFiles, goToViewer, event, screenHeight } from '../helpers/';
import { BreadCrumb, FileSystem } from './filespage/'; import { BreadCrumb, FileSystem } from './filespage/';
@EventReceiver @EventReceiver
@ -81,6 +81,7 @@ export class FilesPage extends React.Component {
}); });
this.setState({files: files, loading: false}); this.setState({files: files, loading: false});
}, (error) => { }, (error) => {
notify.send(error, 'error');
this.setState({error: error}); this.setState({error: error});
}); });
this.setState({error: false}); this.setState({error: false});
@ -88,18 +89,26 @@ export class FilesPage extends React.Component {
onCreate(path, type, file){ onCreate(path, type, file){
if(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'){ }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{ }else{
return Promise.reject({message: 'internal error: can\'t create a '+type.toString(), code: 'UNKNOWN_TYPE'}); return Promise.reject({message: 'internal error: can\'t create a '+type.toString(), code: 'UNKNOWN_TYPE'});
} }
} }
onRename(from, to, 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){ onDelete(path, type){
return Files.rm(file, 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){ onUpload(path, files){
@ -205,10 +214,7 @@ export class FilesPage extends React.Component {
<FileSystem path={this.state.path} files={this.state.files} /> <FileSystem path={this.state.path} files={this.state.files} />
<Uploader path={this.state.path} /> <Uploader path={this.state.path} />
</NgIf> </NgIf>
<NgIf cond={!!this.state.error} className="error" onClick={this.componentDidMount.bind(this)}> <NgIf cond={this.state.loading}>
<Error err={this.state.error}/>
</NgIf>
<NgIf cond={this.state.loading && !this.state.error}>
<Loader/> <Loader/>
</NgIf> </NgIf>
</div> </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){ onRename(newFilename){
if(this.state.icon !== 'loading' && this.state.icon !== 'error'){ this.props.emit(
this.props.emit( 'file.rename',
'file.rename', pathBuilder(this.props.path, this.props.file.name),
pathBuilder(this.props.path, this.props.file.name), pathBuilder(this.props.path, newFilename),
pathBuilder(this.props.path, newFilename), this.props.file.type
this.props.file.type );
); this.setState({is_renaming: false});
}
} }
onRenameRequest(){ onRenameRequest(){

View File

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

View File

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

View File

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