Наша цель, написать offline-first приложение — SPA которое загружается и сохраняет полную функциональность в отсутствии интернет-соединения. В первой части повествования мы научились пользоваться браузерной базой данных. Сегодня мы настроим синхронизацию с серверной бд и подключим авторизацию. В результате мы получим возможность редактировать наши данные на разных устройствах даже в оффлайне с последующей синхронизацией при появлении соединения.
CouchDB
Да, на сервере нам потребуется именно эта база данных. В настоящий момент активно разрабатывается Pouchdb-Server, который на базе LevelDB имитирует API CouchDB. Hoodie по умолчанию работает с ним, это сделано с целью упрощения установки для новичков. Но он сыр даже для целей разработки. Возможно, мне просто повезло, но я потратил 3 дня впервые пытаясь завести Hoodie и натыкаясь на странные ошибки, 3 дня issues и pull-request-ов. И на грани разочарования решил-таки установить нормальную CouchDB и все мои проблемы кончились. Поэтому я предлагаю вам сразу поставить последнюю, разве что вы тоже хотите предварительно внести посильный вклад в opensource.
В большинстве дистрибутивов CouchDB ставится штатными средствами.
Вот инструкция которой пользовался я. Однако, база постоянно падала пока я не удалил /etc/init.d/coucdb
и не отдал её под надзор supervisord-а, вот конфиг последнего:
[program:couchdb]
user=couchdb
environment=HOME=/usr/local/var/lib/couchdb
command=/usr/local/bin/couchdb
autorestart=true
stdout_logfile=NONE
stderr_logfile=NONE
Поставив базу создаём админа:
curl -X PUT $HOST/_config/admins/username -d '"password"'
И включаем CORS:
npm install -g add-cors-to-couchdb
add-cors-to-couchdb -u username -p password
Теперь остаётся лишь немного поправить команду для запуска сервера в package.json
:
"server": "hoodie --port 8000 --dbUrl 'http://username:password@127.0.0.1:5984'"
Надеюсь, у вас всё получилось :)
Авторизация
В AppBar-е у нас будет иконка авторизации со контекстным меню. Поэтому мы вынесем его в отдельную компоненту и будем использовать её в App.js
вместо AppBar
:
import NavBar from './NavBar'
<NavBar account={hoodie.account} />
Туда мы передаём hoodie.account
который предоставляет нам API для авторизации:
hoodie.account.SignUp({username, password)}
hoodie.account.SignIn({username, password)}
hoodie.account.SingOut()
И события на которые можно подписаться:
hoodie.account.on('signin', callback)
hoodie.account.on('signout', callback)
А вот и сама компонента:
import React from 'react'
import AppBar from 'material-ui/AppBar'
import FlatButton from 'material-ui/FlatButton'
import IconButton from 'material-ui/IconButton'
import IconMenu from 'material-ui/IconMenu'
import MenuItem from 'material-ui/MenuItem'
import KeyIcon from 'material-ui/svg-icons/communication/vpn-key'
import AccountIcon from 'material-ui/svg-icons/action/account-circle'
import AuthDialog from './AuthDialog'
export default class NavBar extends React.Component {
constructor(props) {
super(props)
this.state = {
isSignedIn: this.props.account.isSignedIn(),
openedDialog: null
}
}
signOutCallback = () => this.setState({isSignedIn: false})
signInCallback = () => this.setState({isSignedIn: true})
componentDidMount() {
this.props.account.on('signout', this.signOutCallback)
this.props.account.on('signin', this.signInCallback)
}
componentWillUnmount() {
this.props.account.off('signout', this.signOutCallback)
this.props.account.off('signin', this.signInCallback)
}
render () {
let authMenu;
if (this.state.isSignedIn) {
authMenu = (
<IconMenu
iconButtonElement={<IconButton><AccountIcon /></IconButton>}
targetOrigin={{horizontal: 'right', vertical: 'top'}}
anchorOrigin={{horizontal: 'right', vertical: 'top'}}
>
<MenuItem primaryText="Sign Out" onTouchTap={() => this.props.account.signOut()} />
</IconMenu>
)
} else {
authMenu = (
<IconMenu
iconButtonElement={<IconButton><KeyIcon /></IconButton>}
targetOrigin={{horizontal: 'right', vertical: 'top'}}
anchorOrigin={{horizontal: 'right', vertical: 'top'}}
>
<MenuItem primaryText="Sign Up" onTouchTap={() => this.setState({openedDialog: 'signup'})} />
<MenuItem primaryText="Sign In" onTouchTap={() => this.setState({openedDialog: 'signin'})} />
</IconMenu>
)
}
return (
<div>
<AppBar
title="Action Loop"
showMenuIconButton={false}
iconElementRight={authMenu}
/>
<AuthDialog
account={this.props.account}
action={this.state.openedDialog}
handleClose={() => this.setState({openedDialog: null})}
/>
</div>
)
}
}
В state
у нас лежит текущее состояние авторизации для отрисовки иконки и меню. И открытый в данный момент диалог (регистрация, вход или null
— если всё закрыто). В componentDidMount
мы подписываемся на события входа и выхода. И в render
отображаем нужную иконку в соответствии с состоянием авторизации. Осталось нарисовать диалог авторизации:
import React from 'react';
import Dialog from 'material-ui/Dialog';
import FlatButton from 'material-ui/FlatButton';
import TextField from 'material-ui/TextField';
export default class AuthDialog extends React.Component {
constructor(props) {
super(props);
this.state = {
username: '',
password: '',
};
}
handleConfirm = () => {
const account = this.props.account;
const username = this.state.username.trim();
const password = this.state.password.trim();
if (!username || !password) {
return;
}
if (this.props.action == 'signup') {
account.signUp({username, password})
.then(() => {
return account.signIn({username, password})
})
.catch(console.error)
} else {
account.signIn({username, password})
.catch(console.error)
}
this.props.handleClose();
this.clearState();
}
handleCancel = () => {
this.props.handleClose();
this.clearState();
}
handleSubmit = (e) => {
e.preventDefault();
this.handleConfirm();
}
clearState = () => {
this.setState({
username: '',
password: ''
})
}
render () {
const buttons = [
<FlatButton
label="Cancel"
primary={true}
onTouchTap={this.handleCancel}
/>,
<FlatButton
label="Submit"
type="submit"
primary={true}
keyboardFocused={true}
onTouchTap={this.handleConfirm}
/>
];
return (
<div>
<Dialog
title={this.props.action == 'signup' ? 'Sign Up' : 'Sign In'}
actions={buttons}
modal={false}
open={this.props.action !== null}
onRequestClose={this.handleCancel}
contentStyle={{maxWidth: 400}}
>
<form onSubmit={this.handleSubmit}>
<TextField
name="username"
floatingLabelText="Username"
onChange={(e) => this.setState({username: e.target.value})}
/>
<TextField
name="password"
floatingLabelText="Password"
onChange={(e) => this.setState({password: e.target.value})}
/>
</form>
</Dialog>
</div>
);
}
}
Диалоги регистрации и входа у нас имеют идентичные поля формы, поэтому мы объединим их в один. Логика компоненты элементарна: в handleConfirm
мы либо входим либо сначала регистрируемся, а затем входим.
Осталось перезагрузить сами loop-ы при авторизации. Добавим реакцию на события в App.js
:
componentDidMount() {
hoodie.store.on('change', this.loadLoops);
hoodie.account.on('signin', this.loadLoops)
hoodie.account.on('signout', this.loadLoops)
}
componentWillUnmount() {
hoodie.store.off('change', this.loadLoops);
hoodie.account.off('signin', this.loadLoops)
hoodie.account.off('signout', this.loadLoops)
}
Конец
Итак, авторизация готова. Наибольшим вызовом этой части была, вероятно, установка CouchDB. Теперь наше приложение сохранит свою функциональность при разрыве соединения, а при появлении синхронизируется. Однако, если совсем закрыть сайт, открыть его без интернета не получится. Мы исправим это в следующей, финальной части.
» Код этой части доступен тут: https://github.com/imbolc/action-loop под тегом part2.