Клон Trello на Phoenix и React

Эффективное управление задачами с помощью Trello

Самая ценная вещь в нашей жизни — время. Парадокс в том, что его всегда мало, но при этом должно хватать на все… Правильное управление своими задачами и временем — цель любого современного человека. У западных теоретиков даже есть направление специальное — time managment (по-русски — управление временем). Однако изучать всю эту теорию нам, разумеется, некогда. Поэтому воспользуемся уже готовыми средствами, удобно и доступно реализованными в сервисе под названием Trello.

Одним из разработчиков Trello является Джоэль Спольски – достаточно известная личность в мире программирования.

Для кого предназначен этот сервис? Да для любого человека, который хочет эффективно управлять своими задачами и задачами членов своей команды.

Основной принцип — наличие на рабочем столе (Board) нескольких списков (List) с табличками/карточками дел/заданий (Card). Вот простой и доступный пример такого рабочего стола.

В левом списке находятся задачи, которые необходимо выполнить. Потом мы мышкой переносим их в раздел «В работе». А когда задача выполнена, то помещаем ее в список выполненных.

Более эффективно работа пойдет при наличии не одного, а нескольких участников проекта (Members). Если один уже занялся какой-либо проблемой, то остальные это видят, и бессмысленное увеличение энтропии вселенной не происходит. Администратор проекта может назначать исполнителей на каждую задачу и наглядно видеть процесс выполнения проекта.

Как и всякая система управления, Trello поддерживает возможность напоминаний, чтобы вы не пропустили что-нибудь важное. Также поддерживается повышенная секретность по протоколу SSL (это чтобы ваши враги не завладели ценными тайнами). В аккаунте можно создавать свою организацию и регулировать ее видимость, можно сделать ее видимой только участникам проекта, а можно сделать его публичным.

Для того, чтобы работать с сервисом Trello не нужно ничего устанавливать и настраивать на своем компьютере, достаточно просто иметь браузер и подключение к сети. Вся работа идет исключительно через браузер. В качестве дополнения можно установить версию для iPhone/iPad или Android, с помощью которых также можно работать с системой.

Типичными применениями сервиса могут стать следующие задачи.

  • Организация праздников и прочих событий вроде отпуска (списки «Ищем», «Заказали», «Заказ подтвержден»).
  • Публикация: можно распределить материалы между авторами, отслеживать степень готовности материала к публикации и пр.
  • Продажи.
  • Поддержка пользователей.
  • Обсуждение планов по дальнейшей разработке программы (в нашем распоряжении возможность создавать голосования и опросы для каждого элемента списка дел).
  • Разработка программных продуктов.
  • И масса других вариантов использования, число которых ограничено только нашей фантазией.

Вот картинка, где использованы почти все реализованные для списков дел возможности: чеклисты, голосования, цветные метки, опросы, гиперссылки и прочее.

Заключение

Trello — сервис для эффективного управления как персональными задачами, так и для управления проектами и командой.

Главным плюсом сервиса является «быстрый старт» и простота использования. Даже регистрироваться необязательно — можно использовать существующую учетную запись Google. Чтобы решить простую задачу, надо сделать простые действия: просто создать новую доску и работать.

Второй большой плюс проекта — возможность решать довольно сложные задачи, разбивая их на небольшие подзадачи и назначая на каждую одного или нескольких исполнителей. Вы можете посмотреть, как сами разработчики Trello используют свой сервис для его же разработки.

Введение и выбор стека технологий

Trello — одно из самых моих любимых приложений. Я пользуюсь им с момента появления, и мне очень нравится то, как оно работает, его простота и гибкость. Каждый раз, начиная изучать новую технологию, я предпочитаю создать полноценное приложение, в котором смогу применить на практике всё, что изучил, для решения реальных проблем, и проверить эти решения. Так что, начав изучать Elixir и его Phoenix Framework, я понял: что должен на практике использовать весь этот потрясающий материал, с которым познакомился, и поделиться им в виде руководства о том, как реализовать простое, но функциональное посвящение Trello.

Что мы собираемся сделать

По сути, мы создадим одностраничное приложение, в котором существующие пользователи смогут авторизоваться, создать несколько досок, поделиться ими с другими пользователями и добавить на них списки и карточки. Подключенные пользователи будут показаны при просмотре доски, а любые изменения автоматически немедленно — в стиле Trello — будут отражаться в браузере каждого такого пользователя.

Текущий стек технологий

Phoenix управляет статическими ресурсами с помощью npm и собирает их, прямо «из коробки» используя Brunch или Webpack, так что довольно просто по-настоящему разделить front-end и back-end, при этом сохраняя единую кодовую базу. Так, для back-end мы воспользуемся:

  • Elixir
  • Phoenix Framework
  • Ecto
  • PostgreSQL

А чтобы создать одностраничное приложение для front-end:

  • Webpack
  • Sass для таблиц стилей
  • React
  • React router
  • Redux
  • ES6/ES7 JavaScript

Мы воспользуемся несколько большим количеством зависимостей Elixir и пакетов npm, но я расскажу о них позднее, в процессе использования.

Почему этот стек?

Elixir — очень быстрый и мощный функциональный язык, базирующийся на Erlang и имеющий дружелюбный синтаксис, весьма похожий на Ruby. Он очень надёжен и специализируется на параллельности, и благодаря виртуальной машине Erlang (Erlang VM, BEAM — прим. переводчика) может справиться с тысячами параллельных процессов. Я новичок в Elixir, так что мне всё ещё предстоит изучить немало, но исходя из уже изученного могу сказать, что это очень впечатляюще.

Мы будем использовать Phoenix — на текущий момент наиболее популярный веб-фреймворк для Elixir, который не только реализует некоторые моменты и стандарты, привнесённые в веб-разработку Rails, но и предлагает много других клёвых возможностей вроде способа управления статическими ресурсами, который я упомянул выше, и, самое важное для меня, встроенной realtime функциональности с помощью websockets без каких-либо сложностей и дополнительных внешних зависимостей (и поверьте мне — это работает как часы).

В то же время мы воспользуемся React, react-router и Redux, потому что я просто обожаю использовать это сочетание для создания одностраничных приложений и управления их состоянием. Вместо того, чтобы как обычно использовать CoffieScript, в новом году (статья была написана в начале января 2016 года — прим. переводчика) я хочу поработать с ES6 и ES7, так что это отличная возможность начать и втянуться.

Конечный результат

Приложение будет состоять из четырёх различных представлений. Первые два — экраны регистрации и входа в систему.

Главный экран будет содержать список собственных досок пользователя и досок, к которым он был подключён другими пользователями.

И, наконец, представление доски, где все пользователи смогут видеть, кто к ней подключён, а так же управлять списками и карточками.

Но довольно разговоров. Остановимся здесь, чтобы я мог начать подготовку второй части, в которой мы увидим, как создать новый проект Phoenix, что необходимо изменить, чтобы воспользоваться Webpack вместо Brunch и как настроить основу для front-end.

Начальная настройка проекта Phoenix Framework

Итак, после того, как мы выбрали текущий стек технологий, давайте начнём с создания нового проекта Phoenix. Перед этим необходимо иметь уже установленными Elixir и Phoenix, так что воспользуйтесь официальными сайтами для получения инструкций по установке.

Статические ресурсы с помощью Webpack

В отличие от Ruby on Rails Phoenix не имеет собственного конвейера обработки ресурсов (asset pipeline, некоторые русскоязычные Rails-ресурсы переводят термин как «файлопровод» — прим. переводчика), вместо этого используется Brunch как средство для сборки ресурсов, что лично я считаю более современным и гибким. Прикольно, что нет необходимости использовать и Brunch, если вы этого не хотите, можно воспользоваться Webpack. Я никогда не имел дела с Brunch, поэтому вместо него мы применим Webpack.

Phoenix включает node.js как опциональную зависимость, поскольку она требуется для Brunch, но так как Webpack тоже нуждается в node.js, удостоверьтесь, что последняя у вас установлена.

Создадим новый проект Phoenix без Brunch:

 

$ mix phoenix.new —no-brunch phoenix_trello

$ cd phoenix_trello

Хорошо, теперь у нас есть новый проект без средств сборки ресурсов. Создадим новый файл package.json и установим Webpack как зависимость для разработки (dev dependency — прим. переводчика):

$ npm init

(Можно просто нажать Enter в ответ на вопрос об установке значений по умолчанию)

$ npm i webpack —save-dev

Теперь наш package.json должен выглядеть примерно так:

{

«name»: «phoenix_trello»,

«devDependencies»: {

«webpack»: «^1.12.9»

},

«dependencies»: {

 

},

}

Для проекта нам понадобится куча зависимостей, так что вместо того, чтобы листать их все тут, пожалуйста, загляните в исходный файл в репозитории проекта и скопируйте их оттуда в свой package.json. Теперь необходимо запустить следующую команду, чтобы установить все пакеты:

$ npm install

Нам так же нужно добавить конфигурационный файл webpack.config.js, чтобы подсказать Webpack, как собирать ресурсы:

‘use strict’;

var path = require(‘path’);

var ExtractTextPlugin = require(‘extract-text-webpack-plugin’);

var webpack = require(‘webpack’);

function join(dest) { return path.resolve(__dirname, dest); }

function web(dest) { return join(‘web/static/’ + dest); }

var config = module.exports = {

entry: {

application: [

web(‘css/application.sass’),

web(‘js/application.js’),

],

},

output: {

path: join(‘priv/static’),

filename: ‘js/application.js’,

},

resolve: {

extesions: [», ‘.js’, ‘.sass’],

modulesDirectories: [‘node_modules’],

},

module: {

noParse: /vendor\/phoenix/,

loaders: [

{

test: /\.js$/,

exclude: /node_modules/,

loader: ‘babel’,

query: {

cacheDirectory: true,

plugins: [‘transform-decorators-legacy’],

presets: [‘react’, ‘es2015’, ‘stage-2’, ‘stage-0’],

},

},

{

test: /\.sass$/,

loader: ExtractTextPlugin.extract(‘style’, ‘css!sass?indentedSyntax&includePaths[]=’ + __dirname +  ‘/node_modules’),

},

],

},

plugins: [

new ExtractTextPlugin(‘css/application.css’),

],

};

 

if (process.env.NODE_ENV === ‘production’) {

config.plugins.push(

new webpack.optimize.DedupePlugin(),

new webpack.optimize.UglifyJsPlugin({ minimize: true })

);

}

Здесь мы указываем, что потребуется две точки входа webpack, одна для JavaScript и вторая — для таблиц стилей, обе расположены в директории web/static. Выходные файлы будут созданы в priv/static. Так как мы собираемся воспользоваться некоторыми возможностями ES6/7 и JSX, то будем использовать Babel с некоторыми предустановками, созданными для этих целей.

Последний шаг — указать Phoenix стартовать Webpack каждый раз при запуске сервера разработки, чтобы Webpack отслеживал изменения в процессе разработки и генерировал соответствующие файлы ресурсов, на которые ссылается представление front-end’а. Для этого необходимо добавить описание ‘наблюдателя’ в файл config/dev.exs:

config :phoenix_trello, PhoenixTrello.Endpoint,

http: [port: 4000],

debug_errors: true,

code_reloader: true,

cache_static_lookup: false,

check_origin: false,

watchers: [

node: [«node_modules/webpack/bin/webpack.js», «—watch», «—color»]

]

 

Если мы теперь запустим сервер разработки, то сможем увидеть, что Webpack тоже работает и отслеживает изменения:

$ mix phoenix.server

[info] Running PhoenixTrello.Endpoint with Cowboy using http on port 4000

Hash: 93bc1d4743159d9afc35

Version: webpack 1.12.10

Time: 6488ms

Asset     Size  Chunks             Chunk Names

js/application.js  1.28 MB       0  [emitted]  application

css/application.css  49.3 kB       0  [emitted]  application

[0] multi application 40 bytes {0} [built]

+ 397 hidden modules

Child extract-text-webpack-plugin:

+ 2 hidden modules

Ещё одна вещь, которую нужно сделать. Если мы заглянем в директорию priv/static/js, то обнаружим файл phoenix.js. Этот файл содержит всё, что нам понадобится для использования websocket и channels, так что давайте переместим его в нашу базовую директорию с исходниками web/static/js, чтобы можно было подключить его в момент, когда это понадобится.

Основная структура front-end

Теперь у нас есть всё, чтобы начать программировать; начнём с создания структуры приложения front-end, которому, среди прочих, понадобятся следующие пакеты:

  • bourbon и bourbon-neat, моя самая любимая библиотека включений (mixin) для Sass
  • history для управления историей из JavaScript
  • react и react-dom
  • redux и react-redux для управления состоянием (state)
  • react-router в качестве библиотеки для маршрутизации (роутинга)
  • redux-simple-router для сохранения изменений маршрутов в состоянии (state)

Я не собираюсь терять время на обсуждении таблиц стилей, поскольку всё ещё правлю их, но хотел бы отметить, что для создания подходящей структуры Sass-файлов обычно использую css-buritto, который, по моему личному мнению, весьма полезен.

Нам нужно настроить хранилище Redux (redux store), так что создадим следующий файл:

 

//web/static/js/store/index.js

import { createStore, applyMiddleware } from ‘redux’;

import createLogger                     from ‘redux-logger’;

import thunkMiddleware                  from ‘redux-thunk’;

import { syncHistory }                  from ‘react-router-redux’;

import reducers                         from ‘../reducers’;

const loggerMiddleware = createLogger({

level: ‘info’,

collapsed: true,

});

export default function configureStore(browserHistory) {

const reduxRouterMiddleware = syncHistory(browserHistory);

const createStoreWithMiddleware = applyMiddleware(reduxRouterMiddleware, thunkMiddleware, loggerMiddleware)(createStore);

return createStoreWithMiddleware(reducers);

}

Фактически, мы настраиваем хранилище (Store) с тремя промежуточными слоями (middleware):

 

  • reduxRouterMiddleware для передачи действий (action) маршрутизатора к хранилищу
  • redux-thunk для передачи асинхронный действий
  • redux-logger для логирования любых действий и изменений состояния в консоль браузера

Нам также нужно передать комбинацию преобразователей состояния (state reducers), так что создадим базовую версию этого файла:

 

//web/static/js/reducers/index.js

import { combineReducers }  from ‘redux’;

import { routeReducer }     from ‘redux-simple-router’;

import session              from ‘./session’;

export default combineReducers({

routing: routeReducer,

session: session,

});

В качестве отправной точки нам понадобится только два преобразователя (редьюсера): routerReducer, который будет автоматически передавать изменения маршрутизации в состояние, и session, выглядящий как-то так:

 

//web/static/js/reducers/session.js

const initialState = {

currentUser: null,

socket: null,

error: null,

};

export default function reducer(state = initialState, action = {}) {

return state;

}

Изначальное состояние последнего будет содержать объекты currentUser, который мы передадим после аутентификации посетителей, socket, которым мы воспользуемся для подключения к каналам (channels), и error для отслеживания любых проблем во время аутентификации пользователя.

Закончив с этим, мы можем перейти к нашему основному файлу application.js и отрисовать компонент Root:

 

//web/static/js/application.js

import React                    from ‘react’;

import ReactDOM                 from ‘react-dom’;

import { browserHistory }       from ‘react-router’;

import configureStore           from ‘./store’;

import Root                     from ‘./containers/root’;

const store  = configureStore(browserHistory);

const target = document.getElementById(‘main_container’);

const node = <Root routerHistory={browserHistory} store={store}/>;

ReactDOM.render(node, target);

Мы создаём объект, содержащий историю браузера, настраиваем хранилища, и, наконец, отрисовываем в основном шаблоне приложения компонент Root, который будет Redux-адаптером (wrapper) Provider для routes:

 

//web/static/js/containers/root.js

import React              from ‘react’;

import { Provider }       from ‘react-redux’;

import { Router }         from ‘react-router’;

import invariant          from ‘invariant’;

import routes             from ‘../routes’;

export default class Root extends React.Component {

_renderRouter() {

invariant(

this.props.routerHistory,

‘<Root /> needs either a routingContext or routerHistory to render.’

);

return (

<Router history={this.props.routerHistory}>

{routes}

</Router>

);

}

render() {

return (

<Provider store={this.props.store}>

{this._renderRouter()}

</Provider>

);

}

}

Теперь давайте опишем очень простой файл маршрутов:

 

//web/static/js/routes/index.js

import { IndexRoute, Route }  from ‘react-router’;

import React                  from ‘react’;

import MainLayout             from ‘../layouts/main’;

import RegistrationsNew       from ‘../views/registrations/new’;

export default (

<Route component={MainLayout}>

<Route path=»/» component={RegistrationsNew} />

</Route>

);

Наше приложение будет заключено внутрь компонента MainLayout, и корневой путь будет отрисовывать экран регистрации. Конечная версия этого файла станет несколько сложнее из-за механизма аутентификации, который мы реализуем далее, но поговорим об этом позже.

В завершении необходимо добавить html-контейнер, в котором мы будем отрисовывать компонент Root в основном шаблоне приложения Phoenix:

 

<!— web/templates/layout/app.html.eex —>

<!DOCTYPE html>

<html lang=»en»>

<head>

<meta charset=»utf-8″>

<meta http-equiv=»X-UA-Compatible» content=»IE=edge»>

<meta name=»viewport» content=»width=device-width, initial-scale=1″>

<meta name=»description» content=»»>

<meta name=»author» content=»ricardo@codeloveandboards.com»>

<title>Phoenix Trello</title>

<link rel=»stylesheet» href=»<%= static_path(@conn, «/css/application.css») %>»>

</head>

<body>

<main id=»main_container» role=»main»></main>

<script src=»<%= static_path(@conn, «/js/application.js») %>»></script>

</body>

</html>

Обратите внимание, что теги link и script ссылаются на статические ресурсы, сгенерированные Webpack.

Так как мы собираемся управлять маршрутизацией на front-end, необходимо сказать Phoenix отправлять любые http-запросы на обработчик события (action) index контроллера PageController, который будет только отрисовывать основной шаблон и компонент Root:

 

# master/web/router.ex

defmodule PhoenixTrello.Router do

use PhoenixTrello.Web, :router

pipeline :browser do

plug :accepts, [«html»]

plug :fetch_session

plug :fetch_flash

plug :protect_from_forgery

plug :put_secure_browser_headers

end

scope «/», PhoenixTrello do

pipe_through :browser # Use the default browser stack

get «*path», PageController, :index

end

end

На данный момент это всё. В следующей публикации мы рассмотрим, как создать первую миграцию для базы данных, модель User и функциональность для создания нового пользовательского аккаунта.

Модель User и JWT-аутентификация

Регистрация пользователя

Теперь, когда наш проект полностью настроен, мы готовы к созданию модели User и инструкций для миграции базы данных. В этой части мы увидим, как это сделать, а так же как позволить посетителю создать новый аккаунт пользователя.

Модель и миграция User

Phoenix использует Ecto как посредник при любом взаимодействии с базой данных. В случае с Rails можно сказать, что Ecto был бы чем-то, похожим на ActiveRecords, хотя он и делит похожую функциональность по разным модулям.

Прежде, чем продолжить, необходимо создать базу данных (но перед этим необходимо настроить параметры подключения к базе данных в config/dev.exs — прим. переводчика):

 

$ mix ecto.create

Теперь создадим новую миграцию и модель Ecto. Генератор модели получает в качестве параметров название модуля, его множественную форму для именования схемы и требуемые поля в виде имя:тип, так что давайте выполним:

 

$ mix phoenix.gen.model User users first_name:string last_name:string email:string encrypted_password:string

Если мы взглянем на получившийся файл миграции, то немедленно отметим его похожесть на файл миграции Rails:

 

# priv/repo/migrations/20151224075404_create_user.exs

defmodule PhoenixTrello.Repo.Migrations.CreateUser do

use Ecto.Migration

def change do

create table(:users) do

add :first_name, :string, null: false

add :last_name, :string, null: false

add :email, :string, null: false

add :crypted_password, :string, null: false

timestamps

end

create unique_index(:users, [:email])

end

end

Я добавил запрет на null в содержимом полей и даже уникальный индекс для поля email. Делаю это потому, что предпочитаю переложить ответственность за целостность данных на базу данных вместо того, чтобы полагаться на приложение, как делают многие другие разработчики. Думаю, это просто вопрос персональных предпочтений.

Теперь давайте создадим в базе данных таблицу users:

 

$ mix ecto.migrate

Настало время посмотреть на модель User поближе:

 

# web/models/user.ex

defmodule PhoenixTrello.User do

use Ecto.Schema

import Ecto.Changeset

schema «users» do

field :first_name, :string

field :last_name, :string

field :email, :string

field :encrypted_password, :string

timestamps

end

@required_fields ~w(first_name last_name email)

@optional_fields ~w(encrypted_password)

def changeset(model, params \\ :empty) do

model

|> cast(params, @required_fields, @optional_fields)

end

end

В ней можно увидеть два основных раздела:

 

  • Блок схемы (schema), в котором расположены все метаданные, относящиеся к полям таблицы
  • Функцию changeset, в которой можно определить все проверки и трансформации, применяемые к данным до того, как они будут готовы к использованию в нашем приложении.

Прим. переводчика:
В последние версии Ecto были внесены некоторые изменения. Например, атом :empty помечен как нерекомендуемый (deprecated), вместо него необходимо использовать пустой ассоциативный массив (map)
%{}, а функцию cast/4 рекомендуется заменить на связку cast/3 и validate_required/3. Естественно, генератор последних версий Phoenix этим рекомендациям следует.

Проверки и трансформации набора изменений (changeset)

Итак, когда пользователь регистрируется, мы хотели бы дополнительно ввести некоторые проверки, поскольку ранее добавили запрет на использование null в качестве значения полей и ввели требование уникальности email. Мы обязаны отразить это в модели User, чтобы обработать возможные ошибки, вызванные некорректными данными. Так же хотелось бы зашифровать поле encrypted_field так, чтобы даже несмотря на использование незашифрованной строки в качестве пароля записан он был в защищённом виде.

Давайте обновим модель и для начала добавим некоторые проверки:

 

# web/models/user.ex

defmodule PhoenixTrello.User do

# …

schema «users» do

# …

field :password, :string, virtual: true

# …

end

@required_fields ~w(first_name last_name email password)

@optional_fields ~w(encrypted_password)

def changeset(model, params \\ :empty) do

model

|> cast(params, @required_fields, @optional_fields)

|> validate_format(:email, ~r/@/)

|> validate_length(:password, min: 5)

|> validate_confirmation(:password, message: «Password does not match»)

|> unique_constraint(:email, message: «Email already taken»)

end

end

В основном, мы сделали следующие модификации:

 

  • добавили новое виртуальное поле password, которое не будет записано в базу данных, но может использоваться как любое другое поле для любых иных целей. В нашем случае мы будем его заполнять из формы регистрации
  • сделали поле password обязательным
  • добавили проверку формата поля email
  • добавили проверку пароля, требуя его длины минимум в 5 символов; также будет проверяться массив параметров на предмет идентичности пароля с полем password_confirmation
  • добавили ограничение уникальности для проверки на наличие уже существующего email

Этими изменениями мы покрыли все требуемые проверки. Однако до записи данных также необходимо заполнить поле encrypted_password. Для этого воспользуемся библиотекой хэширования паролей comeonin, добавив её в mix.exs как приложение и зависимость:

 

# mix.exs

defmodule PhoenixTrello.Mixfile do

use Mix.Project

# …

def application do

[mod: {PhoenixTrello, []},

applications: [

# …

:comeonin

]

]

end

#…

defp deps do

[

# …

{:comeonin, «~> 2.0»},

# …

]

end

end

Не забудьте установить библиотеку командой:

$ mix deps.get

После установки comeonin давайте вернёмся к модели User и для генерации encrypted_passwordдобавим новый шаг к цепочке changeset:

# web/models/user.ex

defmodule PhoenixTrello.User do

# …

def changeset(model, params \\ :empty) do

model

# … другие проверки и ограничения

|> generate_encrypted_password

end

defp generate_encrypted_password(current_changeset) do

case current_changeset do

%Ecto.Changeset{valid?: true, changes: %{password: password}} ->

put_change(current_changeset, :encrypted_password, Comeonin.Bcrypt.hashpwsalt(password))

_ ->

current_changeset

end

end

end

В этом новом методе мы сначала проверяем, корректны ли изменения в наборе и изменился ли пароль. Если да, мы шифруем пароль с помощью comeonin и помещаем результат в поле encrypted_password нашего набора, в противном случае возвращаем набор как есть.

Маршрутизатор

Теперь, когда модель User готова, продолжим реализацию процесса регистрации, добавив в файл router.ex цепочку :api и наш первый маршрут:

 

# web/router.ex

defmodule PhoenixTrello.Router do

use PhoenixTrello.Web, :router

#…

pipeline :api do

plug :accepts, [«json»]

end

scope «/api», PhoenixTrello do

pipe_through :api

scope «/v1» do

post «/registrations», RegistrationController, :create

end

end

#…

end

Так, любой запрос POST к /api/v1/registrations будет обработан обработчиком (action) :createконтроллера RegistrationController, принимающего данные в формате json… в целом, всё довольно очевидно 🙂

Контроллер

До начала реализации контроллера давайте подумаем, что же нам нужно. Посетитель зайдёт на страницу регистрации, заполнит форму и отправит её. Если данные, полученные контроллером, корректны, нам потребуется добавить нового пользователя в базу данных, ввести его в систему и вернуть во front-end в формате json данные о пользователе вместе с токеном аутентификации jwt в качестве результата входа в систему. Этот токен — то, что потребуется не только для отправки с каждым запросом для аутентификации пользователя, но и для доступа пользователя к защищённым экранам приложения.

Чтобы реализовать аутентификацию и генерацию jwt, воспользуемся библиотекой Guardian, которая очень неплохо справляется с этой задачей. Просто добавьте следующее в mix.exs:

 

# mix.exs

defmodule PhoenixTrello.Mixfile do

use Mix.Project

#…

defp deps do

[

# …

{:guardian, «~> 0.9.0»},

# …

]

end

end

После запуска mix deps.get потребуется внести настройки библиотеки в config.exs:

 

# config/confg.exs

#…

config :guardian, Guardian,

issuer: «PhoenixTrello»,

ttl: { 3, :days },

verify_issuer: true,

secret_key: <your guardian secret key>,

serializer: PhoenixTrello.GuardianSerializer

Так же понадобится создать GuardianSerializer, который подскажет Guardian, как кодировать и декодировать информацию о пользователе в токен и из токена:

 

# lib/phoenix_trello/guardian_serializer.ex

defmodule PhoenixTrello.GuardianSerializer do

@behaviour Guardian.Serializer

alias PhoenixTrello.{Repo, User}

def for_token(user = %User{}), do: { :ok, «User:#{user.id}» }

def for_token(_), do: { :error, «Unknown resource type» }

def from_token(«User:» <> id), do: { :ok, Repo.get(User, String.to_integer(id)) }

def from_token(_), do: { :error, «Unknown resource type» }

end

Теперь готово всё, чтобы реализовать RegistrationController:

 

# web/controllers/api/v1/registration_controller.ex

defmodule PhoenixTrello.RegistrationController  do

use PhoenixTrello.Web, :controller

alias PhoenixTrello.{Repo, User}

plug :scrub_params, «user» when action in [:create]

def create(conn, %{«user» => user_params}) do

changeset = User.changeset(%User{}, user_params)

case Repo.insert(changeset) do

{:ok, user} ->

{:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :token)

conn

|> put_status(:created)

|> render(PhoenixTrello.SessionView, «show.json», jwt: jwt, user: user)

{:error, changeset} ->

conn

|> put_status(:unprocessable_entity)

|> render(PhoenixTrello.RegistrationView, «error.json», changeset: changeset)

end

end

end

Благодаря механизму сопоставления с шаблоном (pattern matching), обработчик create ожидает в параметрах ключ «user». С этими параметрами мы создадим набор User и добавим его в базу данных. Если всё будет хорошо, мы воспользуемся Guardian для кодирования и подписи (метод encode_and_sign) данных нового пользователя, получив токен jwt и преобразуя его вместе с данными о пользователе в json. В противном случае, если набор данных некорректен, мы отобразим ошибки в виде json так, что сможем показать их пользователю в форме регистрации.

Сериализация JSON

Phoenix в качестве библиотеки JSON по-умолчанию использует Poison. Так как это одна из зависимостей Phoenix, для её установки нам не потребуется делать что-то особенное. Что же действительно нужно сделать — так это обновить модель User и указать, какие поля необходимо сериализовать:

 

# web/models/user.ex

defmodule PhoenixTrello.User do

use PhoenixTrello.Web, :model

# …

@derive {Poison.Encoder, only: [:id, :first_name, :last_name, :email]}

# …

end

С этого момента при конвертации данных о пользователе или списка пользователей в формат json в ответ на событие в контроллере или канале (channel), библиотека просто вернёт указанные поля.

Получив back-end, готовый к регистрации новых пользователей, в следующей публикации мы переместимся к front-end, и, чтобы завершить процесс регистрации, запрограммируем несколько прикольных штук на React и Redux.

Front-end для регистрации на React и Redux

Предыдущую публикацию мы закончили созданием модели User с проверкой корректности и необходимыми для генерации зашифрованного пароля трансформациями набора изменений (changeset); так же мы обновили файл маршрутизатора и создали контроллер RegistrationController, который обрабатывает запрос на создание нового пользователя и возвращает данные пользователя и его jwt-токен для аутентификации будущих запросов в формате JSON. Теперь двинемся дальше — к front-end.

Подготовка маршрутизатора React

Основная цель — иметь два публичных маршрута, /sign_in и /sign_up, по которым сможет пройти любой посетитель, чтобы, соответственно, войти в приложение или зарегистрировать новый аккаунт.

Помимо этого нам понадобится / как корневой маршрут, чтобы показать все доски, относящиеся к пользователю, и, наконец, маршрут /board/:id для вывода содержимого выбранной пользователем доски. Для доступа к последним двум маршрутам пользователь должен быть аутентифицирован, в противном случае мы перенаправим его на экран регистрации.

Обновим файл routes для react-router, чтобы отразить то, что мы хотим сделать:

 

// web/static/js/routes/index.js

import { IndexRoute, Route }        from ‘react-router’;

import React                        from ‘react’;

import MainLayout                   from ‘../layouts/main’;

import AuthenticatedContainer       from ‘../containers/authenticated’;

import HomeIndexView                from ‘../views/home’;

import RegistrationsNew             from ‘../views/registrations/new’;

import SessionsNew                  from ‘../views/sessions/new’;

import BoardsShowView               from ‘../views/boards/show’;

export default (

<Route component={MainLayout}>

<Route path=»/sign_up» component={RegistrationsNew} />

<Route path=»/sign_in» component={SessionsNew} />

<Route path=»/» component={AuthenticatedContainer}>

<IndexRoute component={HomeIndexView} />

<Route path=»/boards/:id» component={BoardsShowView} />

</Route>

</Route>

);

Хитрый момент — AuthenticatedContainer, давайте взглянем на него:

 

// web/static/js/containers/authenticated.js

import React        from ‘react’;

import { connect }  from ‘react-redux’;

import { routeActions } from ‘redux-simple-router’;

class AuthenticatedContainer extends React.Component {

componentDidMount() {

const { dispatch, currentUser } = this.props;

if (localStorage.getItem(‘phoenixAuthToken’)) {

dispatch(Actions.currentUser());

} else {

dispatch(routeActions.push(‘/sign_up’));

}

}

render() {

// …

}

}

const mapStateToProps = (state) => ({

currentUser: state.session.currentUser,

});

export default connect(mapStateToProps)(AuthenticatedContainer);

Вкратце, что мы тут делаем: проверяем при подключении компонента, присутствует ли jwt-токен в локальном хранилище браузера. Позже мы разберёмся, как этот токен сохранить, но пока давайте представим, что токен не существует; в результате благодаря библиотеке redux-simple-route перенаправим пользователя на страницу регистрации.

Компонент представления (view component) для регистрации

Это то, что мы будем показывать пользователю, если обнаружим, что он не аутентифицирован:

 

// web/static/js/views/registrations/new.js

import React, {PropTypes}   from ‘react’;

import { connect }          from ‘react-redux’;

import { Link }             from ‘react-router’;

import { setDocumentTitle, renderErrorsFor } from ‘../../utils’;

import Actions              from ‘../../actions/registrations’;

class RegistrationsNew extends React.Component {

componentDidMount() {

setDocumentTitle(‘Sign up’);

}

_handleSubmit(e) {

e.preventDefault();

const { dispatch } = this.props;

const data = {

first_name: this.refs.firstName.value,

last_name: this.refs.lastName.value,

email: this.refs.email.value,

password: this.refs.password.value,

password_confirmation: this.refs.passwordConfirmation.value,

};

dispatch(Actions.signUp(data));

}

render() {

const { errors } = this.props;

return (

<div className=»view-container registrations new»>

<main>

<header>

<div className=»logo» />

</header>

<form onSubmit={::this._handleSubmit}>

<div className=»field»>

<input ref=»firstName» type=»text» placeholder=»First name» required={true} />

{renderErrorsFor(errors, ‘first_name’)}

</div>

<div className=»field»>

<input ref=»lastName» type=»text» placeholder=»Last name» required={true} />

{renderErrorsFor(errors, ‘last_name’)}

</div>

<div className=»field»>

<input ref=»email» type=»email» placeholder=»Email» required={true} />

{renderErrorsFor(errors, ’email’)}

</div>

<div className=»field»>

<input ref=»password» type=»password» placeholder=»Password» required={true} />

{renderErrorsFor(errors, ‘password’)}

</div>

<div className=»field»>

<input ref=»passwordConfirmation» type=»password» placeholder=»Confirm password» required={true} />

{renderErrorsFor(errors, ‘password_confirmation’)}

</div>

<button type=»submit»>Sign up</button>

</form>

<Link to=»/sign_in»>Sign in</Link>

</main>

</div>

);

}

}

const mapStateToProps = (state) => ({

errors: state.registration.errors,

});

export default connect(mapStateToProps)(RegistrationsNew);

Не особо много можно рассказать об этом компоненте… он изменяет заголовок документа при подключении, выводит форму регистрации и перенаправляет результат конструктора действия (action creator) регистрации singUp.

Конструктор действия (action creator)

Когда предыдущая форма отправлена, нам нужно переслать данные на сервер, где они будут обработаны:

 

// web/static/js/actions/registrations.js

import { pushPath }  from ‘redux-simple-router’;

import Constants     from ‘../constants’;

import { httpPost }  from ‘../utils’;

const Actions = {};

Actions.signUp = (data) => {

return dispatch => {

httpPost(‘/api/v1/registrations’, {user: data})

.then((data) => {

localStorage.setItem(‘phoenixAuthToken’, data.jwt);

dispatch({

type: Constants.CURRENT_USER,

currentUser: data.user,

});

dispatch(pushPath(‘/’));

})

.catch((error) => {

error.response.json()

.then((errorJSON) => {

dispatch({

type: Constants.REGISTRATIONS_ERROR,

errors: errorJSON.errors,

});

});

});

};

};

export default Actions;

Когда компонент RegistrationsNew вызывает конструктор действия, передавая ему данные формы, на сервер отправляется новый POST-запрос. Запрос фильтруется маршрутизатором Phoenix и обрабатывается контроллером RegistrationController, который мы создали в предыдущей публикации. В случае успеха полученный с сервера jwt-токен сохраняется в localStorage, данные созданного пользователя передаются действию CURRENT_USER и, наконец, пользователь переадресуется на корневой путь. Наоборот, если присутствуют любые ошибки, связанные с регистрационными данными, будет вызвано действие REGISTRATIONS_ERROR с ошибками в параметрах, так что мы сможем показать их пользователю в форме.

Для работы с http-запросами мы собираемся положиться на пакет isomorphic-fetch, вызываемый из вспомогательного файла, который для этих целей включает несколько методов:

 

// web/static/js/utils/index.js

import React        from ‘react’;

import fetch        from ‘isomorphic-fetch’;

import { polyfill } from ‘es6-promise’;

export function checkStatus(response) {

if (response.status >= 200 && response.status < 300) {

return response;

} else {

var error = new Error(response.statusText);

error.response = response;

throw error;

}

}

export function parseJSON(response) {

return response.json();

}

export function httpPost(url, data) {

const headers = {

Authorization: localStorage.getItem(‘phoenixAuthToken’),

Accept: ‘application/json’,

‘Content-Type’: ‘application/json’,

}

const body = JSON.stringify(data);

return fetch(url, {

method: ‘post’,

headers: headers,

body: body,

})

.then(checkStatus)

.then(parseJSON);

}

// …

Преобразователи (reducers)

Последний шаг — обработка этих результатов действий с помощью преобразователей, в результате чего мы сможем создать новое дерево состояния, требуемое нашему приложению. Во-первых, взглянем на преобразователь session, в котором будет сохраняться currentUser:

 

// web/static/js/reducers/session.js

import Constants from ‘../constants’;

const initialState = {

currentUser: null,

};

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

case Constants.CURRENT_USER:

return { …state, currentUser: action.currentUser };

default:

return state;

}

}

В случае наличия ошибок регистрации любого типа необходимо добавить их к новому состоянию, чтобы мы могли показать их пользователю. Добавим их к преобразователю registration:

// web/static/js/reducers/registration.js

import Constants from ‘../constants’;

const initialState = {

errors: null,

};

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

case Constants.REGISTRATIONS_ERROR:

return {…state, errors: action.errors};

default:

return state;

}

}

Обратите внимание, что для вывода ошибок мы обращаемся к фунцкии renderErrorsFor из этого вспомогательного файла:

 

// web/static/js/utils/index.js

// …

export function renderErrorsFor(errors, ref) {

if (!errors) return false;

return errors.map((error, i) => {

if (error[ref]) {

return (

<div key={i} className=»error»>

{error[ref]}

</div>

);

}

});

}

В целом это всё, что нужно для процесса регистрации. Далее мы увидим, как существующий пользователь может аутентифицироваться в приложении и получить доступ к собственному содержимому.

Начальное заполнение базы данных и контроллер для входа в приложение

Вход пользователя в приложение

Ранее мы подготовили всё для того, чтобы посетители могли регистрироваться и создавать новые пользовательские аккаунты. В этой части мы собираемся реализовать функциональность, необходимую, чтобы позволить посетителям аутентифицироваться в приложение, используя e-mail и пароль. В конце мы создадим механизм для получения пользовательских данных с помощью их токенов аутентификации.

Начальное заполнение базы данных

Если у вас есть опыт работы с Rails, вы увидите, что первоначальное заполнение базы данных в Phoenix выглядит очень похоже. Всё, что нам нужно для этого — наличие файла seeds.exs:

 

# priv/repo/seeds.exs

alias PhoenixTrello.{Repo, User}

[

%{

first_name: «John»,

last_name: «Doe»,

email: «john@phoenix-trello.com»,

password: «12345678»

},

]

|> Enum.map(&User.changeset(%User{}, &1))

|> Enum.each(&Repo.insert!(&1))

По сути, в этом файле мы просто добавляем в базу данных все данные, которые хотели бы предоставить нашему приложению в качестве начальных. Если вы хотите зарегистрировать любого другого пользователя — просто добавьте его в список и запустите заполнение базы:

 

$ mix run priv/repo/seeds.exs

Контроллер для входа в приложение

До того, как создать контроллер, необходимо внести некоторые изменения в файл router.ex:

 

# web/router.ex

defmodule PhoenixTrello.Router do

use PhoenixTrello.Web, :router

#…

pipeline :api do

# …

plug Guardian.Plug.VerifyHeader

plug Guardian.Plug.LoadResource

end

scope «/api», PhoenixTrello do

pipe_through :api

scope «/v1» do

# …

post «/sessions», SessionController, :create

delete «/sessions», SessionController, :delete

# …

end

end

#…

end

Первая добавка, которую нужно произвести — добавить в цепочку :api две вставки (plugs, далее будет оригинальный термин использоваться — plug, — поскольку слово «вставка» хоть и отражает букву сути, но не передаёт, как мне кажется, полного смысла; но если я не прав, буду рад нормальному русскому термину. Также имеет смысл для понимания почитать переводной материал о plug и plug pipeline — прим. переводчика):

  • VerifyHeader: этот plug просто проверяет наличие токена в заголовке Authorization (на самом деле, он помимо этого пытается расшифровать его, попутно проверяя на корректность, и создаёт структуру с содержимым токена — прим. переводчика)
  • LoadResource: если токен присутствует, то делает текущий ресурс (в данном случае — конретную запись из модели User — прим. переводчика) доступным как результат вызова Plug.current_resource(conn)

Также нужно добавить в область /api/v1 ещё два маршрута для создания и удаления сессии пользователя, оба обрабатываемые контроллером SessionController. Начнём с обработчика :create:

 

# web/controllers/api/v1/session_controller.ex

defmodule PhoenixTrello.SessionController do

use PhoenixTrello.Web, :controller

plug :scrub_params, «session» when action in [:create]

def create(conn, %{«session» => session_params}) do

case PhoenixTrello.Session.authenticate(session_params) do

{:ok, user} ->

{:ok, jwt, _full_claims} = user |> Guardian.encode_and_sign(:token)

conn

|> put_status(:created)

|> render(«show.json», jwt: jwt, user: user)

:error ->

conn

|> put_status(:unprocessable_entity)

|> render(«error.json»)

end

end

# …

end

Чтобы аутентифицировать пользователя с полученными параметрами, мы воспользуемся вспомогательным модулем PhoenixTrello.Session. Если всё :ok, то мы зашифруем идентификатор пользователя и впустим его (encode and sign in — несколько вольный, но более понятный перевод — прим. переводчика). Это даст нам jwt-токен, который мы сможем вернуть вместе с записью user в виде JSON. Прежде, чем продолжить, давайте взглянем на вспомогательный модуль Session:

 

# web/helpers/session.ex

defmodule PhoenixTrello.Session do

alias PhoenixTrello.{Repo, User}

def authenticate(%{«email» => email, «password» => password}) do

user = Repo.get_by(User, email: String.downcase(email))

case check_password(user, password) do

true -> {:ok, user}

_ -> :error

end

end

defp check_password(user, password) do

case user do

nil -> false

_ -> Comeonin.Bcrypt.checkpw(password, user.encrypted_password)

end

end

end

Он пытается найти пользователя по e-mail и проверяет, соответствует ли пришедший пароль зашифрованному паролю пользователя. Если пользователь существует и пароль правильный, возвращается кортеж, содержащий {:ok, user}. В противном случае, если пользователь не найден или пароль неверен, возвращается атом :error.

Возвращаясь к контроллеру SessionController обратите внимание, что он интерпретирует шаблон error.json, если результат аутентификации пользователя — упомянутый ранее атом :error. Наконец, необходимо создать модуль SessionView для отображения обоих результатов:

 

# web/views/session_view.ex

defmodule PhoenixTrello.SessionView do

use PhoenixTrello.Web, :view

def render(«show.json», %{jwt: jwt, user: user}) do

%{

jwt: jwt,

user: user

}

end

def render(«error.json», _) do

%{error: «Invalid email or password»}

end

end

Пользователи, уже авторизовавшиеся в приложении

Другая причина возвращать представление пользователя в JSON при аутентификации в приложении заключается в том, что эти данные могут нам понадобиться для разных целей; к примеру, чтобы показать имя пользователя в шапке приложения. Это соответствует тому, что мы уже сделали. Но что, если пользователь обновит страницу браузера, находясь на первом экране? Всё просто: состояние приложение, управляемое Redux, будет обнулено, а полученная ранее информация исчезнет, что может привести к нежелательным ошибкам. А это не то, чего мы хотим, так что для предотвращения такой ситуации мы можем создать новый контроллер, отвечающий за возврат при необходимости данных аутентифицированного пользователя.

Добавим в файл router.ex новый маршрут:

 

# web/router.ex

defmodule PhoenixTrello.Router do

use PhoenixTrello.Web, :router

#…

scope «/api», PhoenixTrello do

pipe_through :api

scope «/v1» do

# …

get «/current_user», CurrentUserController, :show

# …

end

end

#…

end

Теперь нам нужен контроллер CurrentUserController, который выглядит так:

 

# web/controllers/api/v1/current_user_controller.ex

defmodule PhoenixTrello.CurrentUserController do

use PhoenixTrello.Web, :controller

plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController

def show(conn, _) do

user = Guardian.Plug.current_resource(conn)

conn

|> put_status(:ok)

|> render(«show.json», user: user)

end

end

Guardian.Plug.EnsureAuthenticated проверяет наличие ранее проверенного токена, и при его отсутствии перенаправляет запрос на функцию :unauthenticated контроллера SessionController. Таким способом мы защитим приватные контроллеры, так что если появится желание определённые маршруты сделать доступными только аутентифицированным пользователям, всё, что понадобится — добавить этот plug в соответствующие контроллеры. Прочая функциональность довольно проста: после подтверждения наличия аутентифицированного токена будет транслирован current_resource, которым в нашем случае являются данные пользователя.

Наконец, нужно в контроллер SessionController добавить обработчик unauthenticated:

 

# web/controllers/api/v1/session_controller.ex

defmodule PhoenixTrello.SessionController do

use PhoenixTrello.Web, :controller

# …

def unauthenticated(conn, _params) do

conn

|> put_status(:forbidden)

|> render(PhoenixTrello.SessionView, «forbidden.json», error: «Not Authenticated»)

end

end

Он вернёт код 403 — Forbidden вместе с простым текстовым описанием ошибки в JSON. На этом мы закончили с функциональность back-end, относящейся ко входу в приложение и последующей аутентификации.

Теперь, когда back-end готов обслуживать запросы на аутентификацию, давайте перейдём к front-end и посмотрим, как создать и отправить эти запросы и как использовать возвращённые данные для того, чтобы разрешить пользователю доступ к личным разделам.

Файлы маршрутов

Прежде, чем продолжить, посмотрим снова на файл маршрутов React:

 

// web/static/js/routes/index.js

import { IndexRoute, Route }        from ‘react-router’;

import React                        from ‘react’;

import MainLayout                   from ‘../layouts/main’;

import AuthenticatedContainer       from ‘../containers/authenticated’;

import HomeIndexView                from ‘../views/home’;

import RegistrationsNew             from ‘../views/registrations/new’;

import SessionsNew                  from ‘../views/sessions/new’;

import BoardsShowView               from ‘../views/boards/show’;

import CardsShowView               from ‘../views/cards/show’;

export default (

<Route component={MainLayout}>

<Route path=»/sign_up» component={RegistrationsNew} />

<Route path=»/sign_in» component={SessionsNew} />

<Route path=»/» component={AuthenticatedContainer}>

<IndexRoute component={HomeIndexView} />

<Route path=»/boards/:id» component={BoardsShowView}>

<Route path=»cards/:id» component={CardsShowView}/>

</Route>

</Route>

</Route>

);

Как мы видели в четвертой части, AuthenticatedContainer запретит пользователям доступ к экранам досок, кроме случаев, когда jwt-токен, полученный в результате процесса аутентификации, присутствует и корректен.

Компонент представления (view component)

Сейчас необходимо создать компонент SessionNew, который будет отрисовывать форму входа в приложение:

 

import React, {PropTypes}   from ‘react’;

import { connect }          from ‘react-redux’;

import { Link }             from ‘react-router’;

import { setDocumentTitle } from ‘../../utils’;

import Actions              from ‘../../actions/sessions’;

class SessionsNew extends React.Component {

componentDidMount() {

setDocumentTitle(‘Sign in’);

}

_handleSubmit(e) {

e.preventDefault();

const { email, password } = this.refs;

const { dispatch } = this.props;

dispatch(Actions.signIn(email.value, password.value));

}

_renderError() {

const { error } = this.props;

if (!error) return false;

return (

<div className=»error»>

{error}

</div>

);

}

render() {

return (

<div className=’view-container sessions new‘>

<main>

<header>

<div className=»logo» />

</header>

<form onSubmit={::this._handleSubmit}>

{::this._renderError()}

<div className=»field»>

<input ref=»email» type=»Email» placeholder=»Email» required=»true» defaultValue=»john@phoenix-trello.com»/>

</div>

<div className=»field»>

<input ref=»password» type=»password» placeholder=»Password» required=»true» defaultValue=»12345678″/>

</div>

<button type=»submit»>Sign in</button>

</form>

<Link to=»/sign_up»>Create new account</Link>

</main>

</div>

);

}

}

const mapStateToProps = (state) => (

state.session

);

export default connect(mapStateToProps)(SessionsNew);

В целом этот компонент отрисовывает форму и вызывает конструктор действия signIn при отправке последней. Он также будет подключён к хранилищу, чтобы иметь доступ к своим свойствам, каковые будут обновляться с помощью преобразователя сессии; в результате мы сможем показать пользователю ошибки проверки данных.

Конструктор действия (action creator)

Следуя по направлению действий пользователя, создадим конструктор действия сессий:

 

// web/static/js/actions/sessions.js

import { routeActions }                   from ‘redux-simple-router’;

import Constants                          from ‘../constants’;

import { Socket }                         from ‘phoenix’;

import { httpGet, httpPost, httpDelete }  from ‘../utils’;

function setCurrentUser(dispatch, user) {

dispatch({

type: Constants.CURRENT_USER,

currentUser: user,

});

// …

};

const Actions = {

signIn: (email, password) => {

return dispatch => {

const data = {

session: {

email: email,

password: password,

},

};

httpPost(‘/api/v1/sessions’, data)

.then((data) => {

localStorage.setItem(‘phoenixAuthToken’, data.jwt);

setCurrentUser(dispatch, data.user);

dispatch(routeActions.push(‘/’));

})

.catch((error) => {

error.response.json()

.then((errorJSON) => {

dispatch({

type: Constants.SESSIONS_ERROR,

error: errorJSON.error,

});

});

});

};

},

// …

};

export default Actions;

Функция signIn создаст POST-запрос, передающий email и пароль, указанные пользователем. Если аутентификация на back-end прошла успешно, функция сохранит полученный jwt-токен в localStorage и направит JSON-структуру currentUser в хранилище. Если по какой-то причине результатом аутентификации будут ошибки, вместо этого функция перенаправит именно их, а мы сможем показать их в форме входа в приложение.

Преобразователь (reducer)

Создадим преобразователь session:

 

// web/static/js/reducers/session.js

import Constants from ‘../constants’;

const initialState = {

currentUser: null,

error: null,

};

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

case Constants.CURRENT_USER:

return { …state, currentUser: action.currentUser, error: null };

case Constants.SESSIONS_ERROR:

return { …state, error: action.error };

default:

return state;

}

}

Тут мало что можно добавить, поскольку всё очевидно из кода, поэтому изменим контейнер authenticated, чтобы он сумел обработать новое состояние:

 

Контейнер authenticated

 

// web/static/js/containers/authenticated.js

import React            from ‘react’;

import { connect }      from ‘react-redux’;

import Actions          from ‘../actions/sessions’;

import { routeActions } from ‘redux-simple-router’;

import Header           from ‘../layouts/header’;

class AuthenticatedContainer extends React.Component {

componentDidMount() {

const { dispatch, currentUser } = this.props;

const phoenixAuthToken = localStorage.getItem(‘phoenixAuthToken’);

if (phoenixAuthToken && !currentUser) {

dispatch(Actions.currentUser());

} else if (!phoenixAuthToken) {

dispatch(routeActions.push(‘/sign_in’));

}

}

render() {

const { currentUser, dispatch } = this.props;

if (!currentUser) return false;

return (

<div className=»application-container»>

<Header

currentUser={currentUser}

dispatch={dispatch}/>

<div className=»main-container»>

{this.props.children}

</div>

</div>

);

}

}

const mapStateToProps = (state) => ({

currentUser: state.session.currentUser,

});

export default connect(mapStateToProps)(AuthenticatedContainer);

Если при подключении этого компонента токен аутентификации уже существует, но в хранилище отсутствует currentUser, компонент вызовет конструктор действия currentUser, чтобы получить от back-end данные пользователя. Добавим его:

// web/static/js/actions/sessions.js

// …

const Actions = {

// …

currentUser: () => {

return dispatch => {

httpGet(‘/api/v1/current_user’)

.then(function(data) {

setCurrentUser(dispatch, data);

})

.catch(function(error) {

console.log(error);

dispatch(routeActions.push(‘/sign_in’));

});

};

},

// …

}

// …

Это прикроет нас, когда пользователь обновляет страницу браузера или снова переходит на корневой URL, не завершив предварительно свой сеанс. Следуя за уже сказанным, после аутентификации пользователя и передачи currentUser в состояние (state), данный компонент запустит обычную отрисовку, показывая компонент заголовка и собственные вложенные дочерние маршруты.

Компонент заголовка

Данный компонент отрисует граватар и имя пользователя вместе со ссылкой на доски и кнопкой выхода.

 

// web/static/js/layouts/header.js

import React          from ‘react’;

import { Link }       from ‘react-router’;

import Actions        from ‘../actions/sessions’;

import ReactGravatar  from ‘react-gravatar’;

export default class Header extends React.Component {

constructor() {

super();

}

_renderCurrentUser() {

const { currentUser } = this.props;

if (!currentUser) {

return false;

}

const fullName = [currentUser.first_name, currentUser.last_name].join(‘ ‘);

return (

<a className=»current-user»>

<ReactGravatar email={currentUser.email} https /> {fullName}

</a>

);

}

_renderSignOutLink() {

if (!this.props.currentUser) {

return false;

}

return (

<a href=»#» onClick={::this._handleSignOutClick}><i className=»fa fa-sign-out»/> Sign out</a>

);

}

_handleSignOutClick(e) {

e.preventDefault();

this.props.dispatch(Actions.signOut());

}

render() {

return (

<header className=»main-header»>

<nav>

<ul>

<li>

<Link to=»/»><i className=»fa fa-columns»/> Boards</Link>

</li>

</ul>

</nav>

<Link to=’/’>

<span className=’logo’/>

</Link>

<nav className=»right»>

<ul>

<li>

{this._renderCurrentUser()}

</li>

<li>

{this._renderSignOutLink()}

</li>

</ul>

</nav>

</header>

);

}

}

При нажатии пользователем кнопки выхода происходит вызов метода singOut конструктора действия session. Добавим этот метод:

 

// web/static/js/actions/sessions.js

// …

const Actions = {

// …

 

signOut: () => {

return dispatch => {

httpDelete(‘/api/v1/sessions’)

.then((data) => {

localStorage.removeItem(‘phoenixAuthToken’);

dispatch({

type: Constants.USER_SIGNED_OUT,

});

dispatch(routeActions.push(‘/sign_in’));

})

.catch(function(error) {

console.log(error);

});

};

},

// …

}

// …

Он отправит на back-end запрос DELETE и, в случае успеха, удалит phoenixAuthToken из localStorage, а так же отправит действие USER_SIGNED_OUT, обнуляющее currentUser в состоянии (state), используя ранее описанный преобразователь сессии:

 

// web/static/js/reducers/session.js

import Constants from ‘../constants’;

const initialState = {

currentUser: null,

error: null,

};

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

// …

case Constants.USER_SIGNED_OUT:

return initialState;

// …

}

}

Ещё кое-что

Хотя мы закончили с процессом аутентификации и входа пользователя в приложение, мы ещё не реализовали ключевую функциональность, которая станет основой всех будущих возможностей, которые мы запрограммируем: пользовательские сокеты и каналы (the user sockets and channels). Этот момент настолько важен, что я скорее предпочёл бы оставить его для следующей части, где мы увидим, как выглядит userSocket, и как к нему подключиться, чтобы у нас появились двунаправленные каналы между front-end и back-end, показывающие изменения в реальном времени.

Сокеты и каналы

В предыдущей части мы завершили процесс аутентификации и теперь готовы начать веселье. С этого момента для соединения front-end и back-end мы будем во многом полагаться на возможности Phoenix по работе в реальном времени. Пользователи получат уведомления о любых событиях, затрагивающих их доски, а изменения будут автоматически показаны на экране.

Мы можем представить каналы (channels) в целом как контроллеры. Но в отличие от обработки запроса и возврата результата в одном соединении, они обрабатывают двунаправленные события на заданную тему, которые могут передаваться нескольким подключённым получателям. Для их настройки Phoenix использует обработчики сокетов (socket handlers), которые аутентифицируют и идентифицируют соединение с сокетом, а также описывают маршруты каналов, определяющие, какой канал обрабатывает соответствующий запрос.

Пользовательский сокет (user socket)

При создании нового приложения Phoenix оно автоматически создаёт для нас начальную конфигурацию сокета:

 

# lib/phoenix_trello/endpoint.ex

defmodule PhoenixTrello.Endpoint do

use Phoenix.Endpoint, otp_app: :phoenix_trello

socket «/socket», PhoenixTrello.UserSocket

# …

end

Создаётся и UserSocket, но нам понадобится внести некоторые изменения в нём, чтобы обрабатывать нужные сообщения:

 

# web/channels/user_socket.ex

defmodule PhoenixTrello.UserSocket do

use Phoenix.Socket

alias PhoenixTrello.{Repo, User}

# Channels

channel «users:*», PhoenixTrello.UserChannel

channel «boards:*», PhoenixTrello.BoardChannel

# Transports

transport :websocket, Phoenix.Transports.WebSocket

transport :longpoll, Phoenix.Transports.LongPoll

# …

end

По сути, у нас будет два разных канала:

 

  • UserChannel будет обрабатывать сообщения на любую тему, начинающуюся с `»users:», и мы воспользуемся им, чтобы информировать пользователей о событиях, относящихся к ним самим, например, если они были приглашены присоединиться к доске.
  • BoardChannel будет обладать основной функциональностью, обрабатывая сообщения для управления досками, списками и карточками, информируя любого пользователя, просматривающего доску непосредственно в данный момент о любых изменениях.

Нам так же нужно реализовать функции connect и id, которые будут выглядеть так:

 

# web/channels/user_socket.ex

defmodule PhoenixTrello.UserSocket do

# …

def connect(%{«token» => token}, socket) do

case Guardian.decode_and_verify(token) do

{:ok, claims} ->

case GuardianSerializer.from_token(claims[«sub»]) do

{:ok, user} ->

{:ok, assign(socket, :current_user, user)}

{:error, _reason} ->

:error

end

{:error, _reason} ->

:error

end

end

def connect(_params, _socket), do: :error

def id(socket), do: «users_socket:#{socket.assigns.current_user.id}»

end

При вызове функции connect (что происходит автоматически при подключении к сокету — прим. переводчика) с token в качестве параметра, она проверит токен, получит из токена данные пользователя с помощью GuardianSerializer, созданного нами в части 3, и сохранит эти данные в сокете, так, что они в случае необходимости будут доступны в канале. Более того, она так же запретит подключение к сокету неаутентифицированных пользователей.

 

Канал user

После того, как мы настроили сокет, давайте переместимся к UserChannel, который очень прост:

 

# web/channels/user_channel.ex

defmodule PhoenixTrello.UserChannel do

use PhoenixTrello.Web, :channel

def join(«users:» <> user_id, _params, socket) do

{:ok, socket}

end

end

Этот канал позволит нам передавать любое сообщение, связанное с пользователем, откуда угодно, обрабатывая его на front-end. В нашем конкретном случае мы воспользуемся им для передачи данных о доске, на которую пользователь был добавлен в качестве участника, чтобы мы могли поместить эту новую доску в список данного пользователя. Мы также можем использовать канал для показа уведомлений о других досках, которыми владеет пользователь и для чего угодно другого, что взбредёт вам в голову.

Подключение к сокету и каналу

Прежде, чем продолжить, вспомним, что мы сделали в предыдущей части… после аутентификации пользователя вне зависимости от того, использовалась ли форма для входа или ранее сохранённый phoenixAuthToken, нам необходимо получить данные currentUser, чтобы переправить их в хранилище (store) Redux и иметь возможность показать в заголовке аватар и имя пользователя. Это выглядит неплохим местом, чтобы подключиться также к сокету и каналу, поэтому давайте проведём некоторый рефакторинг:

 

// web/static/js/actions/sessions.js

import Constants                          from ‘../constants’;

import { Socket }                         from ‘phoenix’;

// …

export function setCurrentUser(dispatch, user) {

dispatch({

type: Constants.CURRENT_USER,

currentUser: user,

});

const socket = new Socket(‘/socket’, {

params: { token: localStorage.getItem(‘phoenixAuthToken’) },

});

socket.connect();

const channel = socket.channel(`users:${user.id}`);

channel.join().receive(‘ok’, () => {

dispatch({

type: Constants.SOCKET_CONNECTED,

socket: socket,

channel: channel,

});

});

};

// …

После переадресации данных пользователя мы создаём новый объект Socket из JavaScript-библиотеки Phoenix, передав параметром phoenixAuthToken, требуемый для установки соединения, а затем вызываем функцию connect. Мы продолжаем созданием нового канала пользователя (user channel) и присоединяемся к нему. Получив сообщение ok в ответ на join, мы направляем действие SOCKET_CONNECTED, чтобы сохранить и сокет, и канал в хранилище:

 

// web/static/js/reducers/session.js

import Constants from ‘../constants’;

const initialState = {

currentUser: null,

socket: null,

channel: null,

error: null,

};

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

case Constants.CURRENT_USER:

return { …state, currentUser: action.currentUser, error: null };

case Constants.USER_SIGNED_OUT:

return initialState;

case Constants.SOCKET_CONNECTED:

return { …state, socket: action.socket, channel: action.channel };

case Constants.SESSIONS_ERROR:

return { …state, error: action.error };

default:

return state;

}

}

Основная причина хранить эти объекты заключается в том, что они понадобятся нам во многих местах, так что хранение в состоянии (state) делает их доступными компонентам через свойства (props).

После аутентификации пользователя, подключения к сокету и присоединения к каналу, AuthenticatedContainer отрисует представление HomeIndexView, где мы покажем все доски, принадлежащие пользователю, равно как и те, куда он был приглашён в качестве участника. В следующей части мы раскроем, как создать новую доску и пригласить существующих пользователей, используя каналы для передачи результирующих данных вовлечёнными пользователям.

Выводим список и создаём новые доски

В настоящий момент мы реализовали все важные аспекты регистрации пользователя и управления аутентификацией, равно как и подключение к сокету и вход на каналы, так что готовы перейти на следующий уровень, и дать пользователю возможность выводить список и создавать собственные доски.

Миграция для модели досок

Для начала нам нужно создать миграцию и модель. Для этого просто запустите:

 

$ mix phoenix.gen.model Board boards user_id:references:users name:string

Это создаст новый файл миграции, который будет выглядеть похоже на:

 

# priv/repo/migrations/20151224093233_create_board.exs

defmodule PhoenixTrello.Repo.Migrations.CreateBoard do

use Ecto.Migration

def change do

create table(:boards) do

add :name, :string, null: false

add :user_id, references(:users, on_delete: :delete_all), null: false

timestamps

end

create index(:boards, [:user_id])

end

end

Новая таблица под именем boards получит, помимо полей id и timestamps (на самом деле, последнее — это макрос для создания пары полей inserted_at и created_at с типом, аналогичным в соответствующей базе типу datetime — прим. переводчика), поле name и внешний ключ к таблице users. Обратите внимание, что для очистки списка досок, относящихся к пользователю в случае его удаления, мы полагаемся на базу данных. В файл миграции для ускорения также добавлен индекс к полю user_id и ограничение на null для поля name.

Завершив модификацию файла миграции, необходимо запустить:

 

$ mix ecto.migrate

Модель Board

Взгляните на модель board:

 

# web/models/board.ex

defmodule PhoenixTrello.Board do

use PhoenixTrello.Web, :model

alias __MODULE__

@derive {Poison.Encoder, only: [:id, :name, :user]}

schema «boards» do

field :name, :string

belongs_to :user, User

timestamps

end

@required_fields ~w(name user_id)

@optional_fields ~w()

@doc «»»

Creates a changeset based on the `model` and `params`.

If no params are provided, an invalid changeset is returned

with no validation performed.

«»»

def changeset(model, params \\ :empty) do

model

|> cast(params, @required_fields, @optional_fields))

end

end

 

Пока тут отсутствует что-то, стоящее упоминания, однако нужно обновить модель User, чтобы добавить связь с собственными досками:

 

# web/models/user.ex

defmodule PhoenixTrello.User do

use PhoenixTrello.Web, :model

# …

schema «users» do

# …

has_many :owned_boards, PhoenixTrello.Board

# …

end

# …

end

Почему именно owned_boards (собственные доски)? Чтобы отличать доски, созданные пользователем, от досок, на которые он был добавлен другими пользователями; но давайте пока не будем волноваться по этому поводу, мы глубже погрузимся в данный вопрос позднее.

Контроллер BoardController

Итак, для создания новых досок потребуется обновить файл маршрутов, чтобы добавить соответствующую запись для обработки запросов:

 

# web/router.ex

defmodule PhoenixTrello.Router do

use PhoenixTrello.Web, :router

# …

scope «/api», PhoenixTrello do

# …

scope «/v1» do

# …

resources «boards», BoardController, only: [:index, :create]

end

end

# …

end

Мы добавили ресурс boards, ограничив обработчики (action) списком из :index и :create, так что BoardController будет обслуживать следующие запросы:

 

$ mix phoenix.routes

board_path  GET     /api/v1/boards    PhoenixTrello.BoardController :index

board_path  POST    /api/v1/boards    PhoenixTrello.BoardController :create

Создадим новый контроллер:

web/controllers/board_controller.ex

Отметьте, что мы добавляем plug EnsureAuthenticated из Guardian, так что в этом контроллере будут разрешены только аутентифицированные соединения. В обработчике index мы получаем из соединения данные текущего пользователя и запрашиваем у базы данных список принадлежащих ему досок, чтобы иметь возможность отобразить их с помощью BoardView. В обработчике create происходит почти то же самое: мы создаём набор изменений (changeset) owned_board, используя данные текущего пользователя, и добавляем его в базу данных, отображая board в качестве ответа, если всё прошло так, как ожидается.

Создадим BoardsView:

 

# web/views/board_view.ex

defmodule PhoenixTrello.BoardView do

use PhoenixTrello.Web, :view

def render(«index.json», %{owned_boards: owned_boards}) do

%{owned_boards: owned_boards}

end

def render(«show.json», %{board: board}) do

board

end

def render(«error.json», %{changeset: changeset}) do

errors = Enum.map(changeset.errors, fn {field, detail} ->

%{} |> Map.put(field, detail)

end)

%{

errors: errors

}

end

end

Компонент представления (view) React

Теперь, когда back-end готов обрабатывать запросы на получение списка досок, а так же на их создание, пора сфокусироваться на front-end. После аутентификации пользователя и входа в приложение первое, чего мы хотим — показать список его досок и форму для добавления новой, так что давайте создадим HomeIndexView:

 

HomeIndexView

Тут много чего происходит, так давайте рассмотрим по порядку:

 

  • Для начала помните, что этот компонент соединён с хранилищем (store) и в случае изменений будет получать свои параметры (props) с помощью преобразователя boards, который мы вскоре создадим.
  • При подключении компонент поменяет заголовок документа на Boards и запросит конструктор действия получить с back-end список досок.
  • Пока что произойдёт только отображение массива owned_boards, как и компонента BoardForm.
  • Прежде, чем отобразить эти два элемента, будет проверено, установлено ли свойство fetching в true. Если да, это будет означать, что список ещё скачивается, так что отобразится индикатор загрузки. В противном случае будет показан список досок и кнопка для добавления новой.
  • При нажатии на кнопку Add new будет запрошен новый конструктор действия для сокрытия этой кнопки и вывода формы.

Теперь добавим компонент BoardForm:

 

BoardForm

Этот компонент крайне прост. Он отображает форму и при отправке запрашивает конструктор действия создать новую доску с предоставленным именем. PageClick — найденный мной внешний компонент, который отслеживает клики по странице за пределами элемента-контейнера. В нашем случае мы воспользуемся им для того, чтобы скрыть форму и снова показать кнопку Add new.

Конструкторы действия

action creators

  • fetchBoards: для начала он выдаст действие типа BOARDS_FETCHING, которое отобразит упомянутый ранее индикатор загрузки. Я также отправлю к back-end http-запрос, чтобы получить список досок, принадлежащих пользователю, который будет обработан с помощью BoardController:index. При получении ответа доски будут перенаправлены в хранилище.
  • showForm: этот конструктор весьма прост и будет устанавливать действие BOARDS_SHOW_FORM, чтобы показать, хотим мы отображать форму или нет.
  • create: отправит POST-запрос на создание новой доски. Если результат положителен, он направит действие BOARDS_NEW_BOARD_CREATED вместе с данными о созданной доске, так что она будет добавлена к доскам в хранилище, а затем отображения содержимого доски переадресует пользователя по соответствующему маршруту. В случае любых ошибок будет направлено действие BOARDS_CREATE_ERROR.

Преобразователь

Последним кусочком паззла будет очень простой преобразователь:

 

web/static/js/reducers/boards.js

Отметьте, что при завершении загрузки досок мы устанавливаем атрибут fetching в false, а также как мы объединяем (concat) созданную новую доску с уже существующими.

Довольно работы на сегодня! В следующей части мы построим представление для показа содержимого доски и добавим функциональность для добавления на доску новых участников, для отправки данных доски связанным с ней пользователям, чтобы она появилась в списке досок, приглашение присоединиться к которым было получено; этот список так же предстоит создать.

Добавляем новых пользователей досок

В предыдущей части мы создали таблицу для хранения досок, модель Board и сгенерировали контроллер, отвечающий за перечисление и создание новых досок для аутентифицированных пользователей. Мы также запрограммировали front-end, так что могут быть показаны имеющиеся доски и форма для добавления новой доски. Напомню, не чём мы остановились: после получения от контроллера подтверждения после создания новой доски мы хотим перенаправить пользователя на её представление, чтобы он мог видеть все подробности и добавить существующих пользователей как участников. Сделаем это!

Компонент представления React

Прежде, чем продолжить, взглянем на маршруты React:

 

// web/static/js/routes/index.js

import { IndexRoute, Route }        from ‘react-router’;

import React                        from ‘react’;

import MainLayout                   from ‘../layouts/main’;

import AuthenticatedContainer       from ‘../containers/authenticated’;;

import BoardsShowView               from ‘../views/boards/show’;

// …

export default (

<Route component={MainLayout}>

<Route path=»/» component={AuthenticatedContainer}>

<IndexRoute component={HomeIndexView} />

<Route path=»/boards/:id» component={BoardsShowView}/>

</Route>

</Route>

);

Маршрут /boards/:id будет обработан компонентом BoardsShowView, который нужно создать:

 

BoardsShowView

При подключении компонент будет подключаться к каналу доски, используя пользовательский сокет, созданный нами в части 7. При отображении он вначале проверит, установлен ли атрибут fetching в true, и если данные ещё скачиваются, будет показан индикатор загрузки. Как мы можем увидеть, он получает свои параметры от элемента currentBoard, хранящегося в состоянии (state), который создаётся нижеследующим преобразователем.

Преобразователь и конструкторы действий

В качестве отправной точки состояния текущей доски нам понадобится хранить только данные board, канал (channel) и флаг fetching:

 

// web/static/js/reducers/current_board.js

import Constants  from ‘../constants’;

const initialState = {

channel: null,

fetching: true,

};

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

case Constants.CURRENT_BOARD_FETHING:

return { …state, fetching: true };

case Constants.BOARDS_SET_CURRENT_BOARD:

return { …state, fetching: false, …action.board };

case Constants.CURRENT_BOARD_CONNECTED_TO_CHANNEL:

return { …state, channel: action.channel };

default:

return state;

}

}

Давайте посмотрим на конструктор действия current_board, чтобы проверить, как подключиться к каналу и обработать все требуемые данные:

 

// web/static/js/actions/current_board.js

import Constants  from ‘../constants’;

const Actions = {

connectToChannel: (socket, boardId) => {

return dispatch => {

const channel = socket.channel(`boards:${boardId}`);

dispatch({ type: Constants.CURRENT_BOARD_FETHING });

channel.join().receive(‘ok’, (response) => {

dispatch({

type: Constants.BOARDS_SET_CURRENT_BOARD,

board: response.board,

});

dispatch({

type: Constants.CURRENT_BOARD_CONNECTED_TO_CHANNEL,

channel: channel,

});

});

};

},

// …

};

export default Actions;

Так же, как и с UserChannel, мы используем сокет для создания и подключения к новому каналу, определяемого как boards:${boardId}, и получения в качестве ответа представление доски в виде JSON, которое будет направлено в хранилище вместе с действием BOARDS_SET_CURRENT_BOARD. С этого момента конструктор будет подключён к каналу, получая все изменения, производимые на доске любым участником, автоматически отображая эти изменения на экране благодаря React и Redux. Но сначала необходимо создать BoardChannel.

BoardChannel

Хотя почти вся оставшаяся функциональность будет реализована в этом модуле, на данный момент мы реализуем очень простую его версию:

 

# web/channels/board_channel.ex

defmodule PhoenixTrello.BoardChannel do

use PhoenixTrello.Web, :channel

alias PhoenixTrello.Board

def join(«boards:» <> board_id, _params, socket) do

board = get_current_board(socket, board_id)

{:ok, %{board: board}, assign(socket, :board, board)}

end

defp get_current_board(socket, board_id) do

socket.assigns.current_user

|> assoc(:boards)

|> Repo.get(board_id)

end

end

Метод join получает текущую доску, ассоциированную с пользователем, закреплённом за сокетом, возвращает её и закрепляет за сокетом, в результате чего она будет доступна для дальнейших сообщений (без дополнительных запросов к базе данных — прим. переводчика).

Участники доски

Как только доска показана пользователю, следующий шаг — позволить ему добавлять существующих пользователей в качестве участников, чтобы они могли работать над ней совместно. Для связи доски с другими пользователями мы должны создать новую таблицу для хранения этой взаимосвязи. Переключимся в консоль и запустим:

 

$ mix phoenix.gen.model UserBoard user_boards user_id:references:users board_id:references:boards

Необходимо слегка обновить получившийся файл миграции:

 

# priv/repo/migrations/20151230081546_create_user_board.exs

defmodule PhoenixTrello.Repo.Migrations.CreateUserBoard do

use Ecto.Migration

def change do

create table(:user_boards) do

add :user_id, references(:users, on_delete: :delete_all), null: false

add :board_id, references(:boards, on_delete: :delete_all), null: false

timestamps

end

create index(:user_boards, [:user_id])

create index(:user_boards, [:board_id])

create unique_index(:user_boards, [:user_id, :board_id])

end

end

Помимо ограничений на null мы добавим уникальный индекс для user_id и board_id, так что User не сможет быть добавленным на ту же Board дважды. После запуска mix ecto.migrate перейдём к модели UserBoard:

 

# web/models/user_board.ex

defmodule PhoenixTrello.UserBoard do

use PhoenixTrello.Web, :model

alias PhoenixTrello.{User, Board}

schema «user_boards» do

belongs_to :user, User

belongs_to :board, Board

timestamps

end

@required_fields ~w(user_id board_id)

@optional_fields ~w()

def changeset(model, params \\ :empty) do

model

|> cast(params, @required_fields, @optional_fields)

|> unique_constraint(:user_id, name: :user_boards_user_id_board_id_index)

end

end

Тут ничего необычного, но нужно также добавить новую взаимосвязь к модели User:

 

# web/models/user.ex

defmodule PhoenixTrello.User do

use PhoenixTrello.Web, :model

# …

schema «users» do

# …

has_many :user_boards, UserBoard

has_many :boards, through: [:user_boards, :board]

# …

end

# …

end

У нас есть ещё две взаимосвязи, но наиболее важная — :boards, которую мы будем использовать для контроля доступа. Также добавим к модели Board:

 

# web/models/board.ex

defmodule PhoenixTrello.Board do

# …

schema «boards» do

# …

has_many :user_boards, UserBoard

has_many :members, through: [:user_boards, :user]

timestamps

end

end

Теперь, благодаря этим изменениям, мы можем различать доски, созданные пользователем, и доски, на которые он был приглашён. Это очень важно, потому что в представлении доски мы хотим показывать форму для добавления участников только её создателю. Помимо этого мы хотим автоматически добавлять создателя как участника, чтобы показывать его по-умолчанию, так что внесём небольшие изменения в BoardController:

 

BoardController

Отметьте, как мы создаём объединение UserBoard и добавляем его после проверки на корректность.

Компонент участников доски

Этот компонент будет показывать аватары всех участников и форму для добавления нового участника. Как вы видите, благодаря предыдущим изменениям в BoardController, сейчас владелец показан единственным участником. Посмотрим, как этот компонент будет выглядеть.

Компонент участников доски

По сути, мы будем перебирать параметр members, показывая их аватары. Компонент также покажет кнопку Add new, если текущий пользователь окажется владельцем доски. При нажатии этой кнопки будет показана форма, запрашивающая e-mail участника и при отправке формы вызывающая конструктор действия addNewMember.

Конструктор действия addNewMember

С этого момента вместо использования контроллера для создания и получения необходимых для нашего React front-end данных мы переложим ответственность за это на BoardChannel, так, что любые изменения будут отправлены каждому подключенному пользователю. Не забывая об этом, добавим требуемые конструкторы действия:

 

// web/static/js/actions/current_board.js

import Constants  from ‘../constants’;

const Actions = {

// …

showMembersForm: (show) => {

return dispatch => {

dispatch({

type: Constants.CURRENT_BOARD_SHOW_MEMBERS_FORM,

show: show,

});

};

},

addNewMember: (channel, email) => {

return dispatch => {

channel.push(‘members:add’, { email: email })

.receive(‘error’, (data) => {

dispatch({

type: Constants.CURRENT_BOARD_ADD_MEMBER_ERROR,

error: data.error,

});

});

};

},

// …

}

export default Actions;

showMembersForm позволит форме быть отображённой или скрытой, проще паренной репы. Сложнее становится, если мы хотим добавить нового участника по e-mail, предоставленному пользователем. Вместо отправки http-запроса, как мы делали до сих пор, мы отправим в channel сообщение «members:add» с e-mail в качестве параметра. При получении ошибки мы перенаправим её, чтобы показать на экране. Почему мы не обрабатываем положительный результат? Потому что воспользуемся другим подходом, отправляя результат всем подключенным участникам.

BoardChannel

Сказав это, добавим соответствующий обработчик сообщения к BoardChannel:

 

# web/channels/board_channel.ex

defmodule PhoenixTrello.BoardChannel do

# …

def handle_in(«members:add», %{«email» => email}, socket) do

try do

board = socket.assigns.board

user = User

|> Repo.get_by(email: email)

changeset = user

|> build_assoc(:user_boards)

|> UserBoard.changeset(%{board_id: board.id})

case Repo.insert(changeset) do

{:ok, _board_user} ->

broadcast! socket, «member:added», %{user: user}

PhoenixTrello.Endpoint.broadcast_from! self(), «users:#{user.id}», «boards:add», %{board: board}

{:noreply, socket}

{:error, _changeset} ->

{:reply, {:error, %{error: «Error adding new member»}}, socket}

end

catch

_, _-> {:reply, {:error, %{error: «User does not exist»}}, socket}

end

end

# …

end

Каналы Phoenix обрабатывают входящие сообщения с помощью функции handle_in и мощного механизма сопоставления с шаблоном, присутствующего в Elixir. В нашем случае названием сообщения будет members:add, и будет также ожидаться параметр email, значение которого присвоится соответствующей переменной. Будет взята привязанная к сокету доска, по e-mail получен пользователь и создано отношение UserBoard с обеими этими сущностями. Если всё пройдёт хорошо, по всем доступным подключениям отправится (broadcast) сообщение member:added, сопровождаемое данными добавленного пользователя. Теперь посмотрите внимательнее на это:

 

PhoenixTrello.Endpoint.broadcast_from! self(), «users:#{user.id}», «boards:add», %{board: board}

Этим действием приложение будет отправлять сообщение boards:add вместе с данными доски в UserChannel добавленного участника, так что эта доска немедленно появится в списке досок, на которые он приглашён. Это означает, что мы можем отправить любое сообщение в любой канал откуда угодно.

Для обработки на front-end сообщения member:added нам нужно к channel добавить новый обработчик, который направит добавленного участника в хранилище:

 

// web/static/js/actions/current_board.js

import Constants  from ‘../constants’;

const Actions = {

// …

connectToChannel: (socket, boardId) => {

return dispatch => {

const channel = socket.channel(`boards:${boardId}`);

// …

channel.on(‘member:added’, (msg) => {

dispatch({

type: Constants.CURRENT_BOARD_MEMBER_ADDED,

user: msg.user,

});

});

// …

}

},

};

export default Actions;

И в точности то же самое необходимо сделать для boards:add, но перенаправив доску:

 

// web/static/js/actions/sessions.js

export function setCurrentUser(dispatch, user) {

channel.on(‘boards:add’, (msg) => {

// …

dispatch({

type: Constants.BOARDS_ADDED,

board: msg.board,

});

});

};

Наконец, нужно обновить преобразователи, чтобы и новый участник, и новая доска были добавлены к состоянию (state) приложения:

 

// web/static/js/reducers/current_board.js

export default function reducer(state = initialState, action = {}) {

// …

case Constants.CURRENT_BOARD_MEMBER_ADDED:

const { members } = state;

members.push(action.user);

return { …state, members: members, showUsersForm: false };

}

// …

}

// web/static/js/reducers/boards.js

export default function reducer(state = initialState, action = {}) {

// …

switch (action.type) {

case Constants.BOARDS_ADDED:

const { invitedBoards } = state;

return { …state, invitedBoards: [action.board].concat(invitedBoards) };

}

// …

}

Теперь аватар участника будет появляться в списке, он получит доступ к доске и необходимые для добавления и изменения списков и карточек разрешения.

Если мы вспомним ранее описанный компонент BoardMembers, className аватара зависит от того, присутствует ли id участника в списке параметра connectedUsers. Этот список хранит id всех подключенных в настоящий момент к каналу доски участников. Для создания списка и его обработки мы воспользуемся перманентным сохраняющим состояние процессом (longtime running stateful process) Elixir, но сделаем это в следующей публикации.

Отслеживаем подключение участников досок

Предупреждение от автора: эта часть была написана до появления функциональности Presence и является небольшим введением в основы поведения GenServer.

Вспомним предыдущую часть, в которой мы предоставили нашим пользователям возможность приглашать новых участников на свои доски. При добавлении e-mail существующего пользователя создавалась новая взаимосвязь между пользователями и досками, а данные нового пользователя передавались через канал (channel), в результате чего его аватар отображался всем участникам доски, находящимся онлайн. На первый взгляд это круто, но мы можем сделать гораздо лучше и полезнее, если сможем просто выделить пользователей, которые в настоящий момент находятся online и просматривают доску. Давайте начнём!

Проблема

Прежде, чем продолжить, давайте задумаемся о том, чего хотим достичь. Так, по сути, у нас есть доска и несколько участников, которые могут неожиданно посетить её url, автоматически подключаясь к каналу доски. Когда это случается, аватар участника должен быть показан без прозрачности, в противоположность участникам, находящимся офлайн, аватары которых должны быть полупрозрачными.

Когда подключённый участник покидает url доски, выходит из приложения или даже закрывает окно браузера, нам нужно оповестить об этом событии всех подключённых к каналу доски пользователей, чтобы его аватар снова стал полупрозрачным, уведомляя, что пользователь более не просматривает доску. Давайте рассмотрим несколько способов, которыми мы можем этого достичь и их недостатки:

  1. Управление списком подключённых участников на front-end в хранилище Redux. На первый взгляд это может выглядеть подходящим решением, но оно будет работать только для участников, уже подключившихся к каналу доски. Недавно подключившиеся пользователи не будут иметь этих данных.
  2. Использовать базу данных для хранения списка подключившихся участников. Это тоже может оказаться подходящим способом, но заставит нас постоянно дёргать базу данных запросами списка участников и его обновлениями при любом подключении или выходе участника, не говоря уже о смешевании данных с весьма специфическим поведением пользователя.

Так где мы можем хранить эту информацию так, чтобы дать к ней доступ для всех пользователей быстро и эффективно? Легко. В… наберитесь терпения… перманентном сохраняющем состояние процессе.

Принципы GenServer

Хотя фраза перманентный сохраняющий состояние процесс может поначалу звучать устрашающе, реализовать это гораздо проще, чем можно было бы ожидать, благодаря Elixir и его GenServer.

GenServer — это процесс, подобный любому другому процессу Elixir, и он может использоваться для хранения состояния, асинхронного выполнения кода и тому подобного.

Представьте себе это как маленький процесс, выполняющийся на нашем сервере и имеющий ассоциативный массив (map), содержащий для каждой доски список id подключившихся пользователей. Что-то вроде такого:

 

%{

«1» => [1, 2, 3],

«2» => [4, 5]

}

Теперь представьте, что этот процесс имеет доступный интерфейс для собственной инициализации и обновления ассоциативного массива состояния, для добавления и удаления досок и подключившихся пользователей. Чтож, это, в целом, процесс GenServer, и я говорю «в целом» постольку, поскольку он так же будет иметь соответствующие преимущества вроде трасировки, отчёта об ошибках и возможностями отслеживания (supervision).

Монитор BoardChannel

Итак, создадим самую начальную версию этого процесса, который будет хранить данные отслеживания списка подключившихся участников доски:

 

# /lib/phoenix_trello/board_channel/monitor.ex

defmodule PhoenixTrello.BoardChannel.Monitor do

use GenServer

#####

# Client API

def start_link(initial_state) do

GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)

end

end

Работая с GenServer необходимо продумать как функции API для внешних клиентов, так и их серверную реализацию. Первым делом необходимо реализовать функцию start_link, которая будет по-настоящему запускать GenServer, в качестве аргумента передавая в него начальное состояние, — в нашем случае пустой ассоциативный массив, — между именем модуля и названием сервера. Мы хотим стартовать этот процесс во время запуска приложения, так что добавим его в список потомков в нашем дереве отслеживания (supervision tree):

 

# /lib/phoenix_trello.ex

defmodule PhoenixTrello do

use Application

def start(_type, _args) do

import Supervisor.Spec, warn: fals

e

children = [

# …

worker(PhoenixTrello.BoardChannel.Monitor, [%{}]),

# …

]

 

# …

end

end

Теперь каждый раз при запуске приложения оно будет автоматически вызывать функцию start_link, которую мы только что создали, передавая ей в качестве начального состояния пустой ассоциативный массив %{}. Если исполнение Monitor прервётся по любой причине, приложение запустит его заново с новым пустым ассоциативным массивом. Здорово, не правда ли? Теперь, настроив всё это, давайте начнём добавлять участников в массив состояний Monitor’а.

Обработка подключений участников

Для этого нам понадобится добавить и клиентскую функцию, и соответствующий ей серверный обработчик функции обратной связи (далее просто callback-функции):

 

/lib/phoenix_trello/board_channel/monitor.ex

При вызове функции member_joined/2, передавая ей доску и пользователя, мы будем совершать обращение к процессу GenServer с сообщением {:member_joined, board, member}. По этой причине нам нужен серверный обработчик callback-функции. Callback-функция handle_call/3 из GenServerполучает сообщение-запрос, отправителя и текущее состояние. Так что в нашем случае мы попытаемся получить доску из состояния и добавить пользователя в её список пользователей. В случае, если доски ещё нет, мы добавим её с новым списком, содержащим подключившегося пользователя. В качестве ответа мы вернём список пользователей, принадлежащий этой доске.

Откуда стоит вызвать метод member_joined? Из BoardChannel в момент подключения пользователя:

 

/web/channels/board_channel.ex

Таким образом, когда он подключается, мы используем Monitor для его отслеживания, и рассылаем через сокет обновлённый список текущих пользователей доски. Теперь мы можем обработать эту рассылку на фронт-энде, чтобы обновить состояние приложения новым списком подключенных пользователей:

 

// /web/static/js/actions/current_board.js

import Constants  from ‘../constants’;

const Actions = {

// …

connectToChannel: (socket, boardId) => {

return dispatch => {

const channel = socket.channel(`boards:${boardId}`);

// …

channel.on(‘user:joined’, (msg) => {

dispatch({

type: Constants.CURRENT_BOARD_CONNECTED_USERS,

users: msg.users,

});

});

};

}

}

Единственное, что осталось сделать — изменить прозрачность аватара в зависимости от того, указан ли участник доски в этом списке или нет:

 

// /web/static/js/components/boards/users.js

export default class BoardUsers extends React.Component {

_renderUsers() {

return this.props.users.map((user) => {

const index = this.props.connectedUsers.findIndex((cu) => {

return cu.id === user.id;

});

const classes = classnames({ connected: index != -1 });

return (

<li className={classes} key={user.id}>

<ReactGravatar className=»react-gravatar» email={user.email} https/>

</li>

);

});

}

// …

}

Обработка отключений пользователей

Процесс отключения пользователя от канала доски почти такой же. Для начала давайте обновим Monitor, добавив необходимую клиентскую функцию и соответствующую ей серверную callback-функцию:

 

/lib/phoenix_trello/board_channel/monitor.ex

Как вы можете увидеть, это почти та же функциональность, что и у member_join, но развёрнутая в обратном порядке. В функции происходит поиск доски в состоянии и удаление участника, а затем замена текущего списка участников доски новым и его возврат в ответе. Так же, как и в случае с подключением, мы будем вызывать эту функцию из BoardChannel, так что давайте его обновим:

 

# /web/channels/board_channel.ex

defmodule PhoenixTrello.BoardChannel do

use PhoenixTrello.Web, :channel

# …

def terminate(_reason, socket) do

board_id = Board.slug_id(socket.assigns.board)

user_id = socket.assigns.current_user.id

broadcast! socket, «user:left», %{users: Monitor.user_left(board_id, user_id)}

:ok

end

end

Когда подключение к каналу прерывается, обработчик разошлёт обновлённый список участников через сокет, как мы делали и до этого. Для прерывания подключения к каналу мы создадим конструктор действия (action creator), которым воспользуемся при отмонтировании представления текущей доски; так же нам нужно добавить обработчик для рассылки user:left:

 

/web/static/js/actions/current_board.js

Не забудьте обновить компонент BoardShowView, чтобы при его отмонтировании обработать конструктор действия leaveChannel:

 

// /web/static/js/views/boards/show.js

import Actions              from ‘../../actions/current_board’;

// …

class BoardsShowView extends React.Component {

// …

componentWillUnmount() {

const { dispatch,  currentBoard} = this.props;

dispatch(Actions.leaveChannel(currentBoard.channel));

}

}

// …

И на этом всё! Чтобы протестировать получившееся, просто откройте два разных браузера и войдите в приложение под разными пользователями. Затем перейдите на одну и ту же доску в обоих и поиграйтесь, входя и выходя из доски одним из пользователей. Вы увидите, как прозрачность его аватара будет меняться туда и обратно, что довольно клёво.

Я надеюсь, что вы насладились работой с GenServer так же, как и я в первый раз. Но мы затронули только малую часть. GenServer и Supervisor — очень богатые инструменты из предлагаемых Elixir, причём полностью интегрированные и пуленепробиваемые (в оригинале автор употребляет термин bullet proof, подразумевая, видимо, имеющуюся в Erlang/Elixir функциональность по отслеживанию цикла жизни процессов и их перезапуску в случае необходимости — прим. переводчика), не требующие для работы сторонних зависимостей — в противоположность, например, Redis. В следующей части мы продолжим создание списков и карточек в реальном времени при помощи сокетов и каналов.

Добавляем списки и карточки

В предыдущей части мы создали простой, но уже полезный механизм для отслеживания подключённых к каналу доски пользователей с помощью OTP и функциональности GenServer. Мы также научились рассылать этот список через канал, так что каждый участник сможет видеть, кто ещё просматривает доску в то же самое время. Теперь пришло время позволить участникам добавить несколько карточек и списков, в то время как изменения будут появляться на их экранах немедленно… Сделаем это!

Миграции и модели

Доска (Board) может иметь несколько списков (lists), которые, в свою очередь, также могут иметь несколько карточек, так что держа это в голове давайте начнём с генерации модели List, используя в консоли следующую задачу mix:

 

$ mix phoenix.gen.model List lists board_id:references:board name:string

$ mix ecto.migrate

Этим мы создадим в базе данных таблицу lists и соответствующую модель:

# web/models/list.ex

defmodule PhoenixTrello.List do

use PhoenixTrello.Web, :model

alias PhoenixTrello.{Board, List}

@derive {Poison.Encoder, only: [:id, :board_id, :name]}

schema «lists» do

field :name, :string

belongs_to :board, Board

timestamps

end

@required_fields ~w(name)

@optional_fields ~w()

def changeset(model, params \\ :empty) do

model

|> cast(params, @required_fields, @optional_fields)

end

end

Генерация модели Card происходит очень похоже:

$ mix phoenix.gen.model Card cards list_id:references:lists name:string

$ mix ecto.migrate

Результирующая модель будет выглядеть как-то так:

# web/models/card.ex

defmodule PhoenixTrello.Card do

use PhoenixTrello.Web, :model

alias PhoenixTrello.{Repo, List, Card}

@derive {Poison.Encoder, only: [:id, :list_id, :name]}

schema «cards» do

field :name, :string

belongs_to :list, List

timestamps

end

@required_fields ~w(name list_id)

@optional_fields ~w()

def changeset(model, params \\ :empty) do

model

|> cast(params, @required_fields, @optional_fields)

end

end

Не забудьте добавить набор карточек к схеме lists:

# web/models/list.ex

defmodule PhoenixTrello.List do

# …

@derive {Poison.Encoder, only: [:id, :board_id, :name, :cards]}

# …

schema «lists» do

# ..

has_many :cards, Card

end

# …

end

Теперь мы можем двинуться вперёд, к фронтэнду, и создать необходимые компоненты.

Компонент формы списка

Прежде, чем продолжить, вспомним функцию render компонента BoardsShowView:

web/static/js/views/boards/show.js

В отличие от компонента BoardMembers, который мы создали последним, нам также понадобится отрисовать все списки, относящиеся к текущей доске. На данный момент у нас нет никаких списков, поэтому перейдём к функции _renderAddNewList:

 

web/static/js/views/boards/show.js

Функция _renderAddNewList для начала проверяет, выставлено ли в true свойство currentBoard.showForm, так что она отрисовывает кнопку Добавить новый список… вместо компонента ListForm.

Когда пользователь нажмёт кнопку, соответствующее действие (action) будет направлено в хранилище и установит свойство showForm в true, что вызовет отображение формы. Теперь создадим компонент формы:

 

web/static/js/components/lists/form.js

Это очень простой компонент с формой, содержащей текстое поле для названия списка, кнопкой отправки и ссылкой на отмену, которая будет направлять то же действие, что мы описали, но устанавливая showForm в false, чтобы спрятать форму. Когда форма отправлена, компонент вместе с именем пользователя направит конструктор действия save, который отправит имя на тему lists:createканала BoardChannel:

 

// web/static/js/actions/lists.js

import Constants from ‘../constants’;

const Actions = {

save: (channel, data) => {

return dispatch => {

channel.push(‘lists:create’, { list: data });

};

},

};

export default Actions;

BoardChannel

Следующим шагом нужно научить BoardChannel обрабатывать сообщение lists:create, так что займёмся этим:

 

web/channels/board_channel.ex

Используя доску, прикреплённую к каналу, функция выстроит набор изменений (changeset) модели Listна основе полученных параметров (list_params) и добавит его в базу. Если всё будет :ok, будет проведена рассылка созданного списка через канал всем подключенным пользователям, включая создателя, поэтому нам не нужно что-то отвечать, и мы возвращаем просто :noreply. Если же каким-то чудом во время добавления нового списка возникнет ошибка, сообщение об ошибке будет возвращено только создателю, так что он будет знать, что что-то пошло не так.

Преобразователь

Мы почти закончили со списками. Канал рассылает созданный лист, так что добавим обработчик этого на фронтэнд в конструктор действий текущей доски, где происходило подключение к каналу:

 

// web/static/js/actions/current_board.js

import Constants  from ‘../constants’;

const Actions = {

// …

connectToChannel: (socket, boardId) => {

return dispatch => {

const channel = socket.channel(`boards:${boardId}`);

// …

channel.on(‘list:created’, (msg) => {

dispatch({

type: Constants.CURRENT_BOARD_LIST_CREATED,

list: msg.list,

});

});

};

},

// …

}

Наконец, нам нужно обновить преобразователь (reducer) доски, чтобы добавить список к новой версии состояния, которую он возвращает:

 

// web/static/js/reducers/current_board.js

import Constants  from ‘../constants’;

export default function reducer(state = initialState, action = {}) {

switch (action.type) {

//…

case Constants.CURRENT_BOARD_LIST_CREATED:

const lists = […state.lists];

lists.push(action.list);

return { …state, lists: lists, showForm: false };

// …

}

}

Нам так же нужно установить аттрибут showForm в false, чтобы автоматически скрыть форму и вновь показать кнопку Добавить новый список… вместе с только что созданным списком.

Компонент List

Теперь на доске есть как минимум один список, и мы можем создать компонент List, которым воспользуемся для отрисовки:

 

/web/static/js/components/lists/card.js

Точно так же, как и в случае списков, сначала сосредоточимся на отрисовке формы карточек. В целом мы воспользуемся тем же подходом к отрисовке или скрытию формы, используя свойство (prop), передаваемое основным компонентом доски, и направляя действие для изменения этого свойства состояния.

Компонент формы карточки

Этот компонент будет очень похож на компонент ListForm:

 

/web/static/js/components/cards/form.js

Так же, как и ранее, при отправке формы мы направляем действие для создания карточки с именем, предоставленным пользователем. Для этого конструктор действия направит новое сообщение в канал:

 

// /web/static/js/actions/lists.js

import Constants from ‘../constants’;

const Actions = {

// …

createCard: (channel, data) => {

return dispatch => {

channel.push(‘cards:create’, { card: data });

};

},

};

// …

Давайте добавим обработчик к BoardChannel:

 

# web/channels/board_channel.ex

def handle_in(«cards:create», %{«card» => card_params}, socket) do

board = socket.assigns.board

changeset = board

|> assoc(:lists)

|> Repo.get!(card_params[«list_id»])

|> build_assoc(:cards)

|> Card.changeset(card_params)

case Repo.insert(changeset) do

{:ok, card} ->

broadcast! socket, «card:created», %{card: card}

{:noreply, socket}

{:error, _changeset} ->

{:reply, {:error, %{error: «Error creating card»}}, socket}

end

end

Тем же способом, что создавая список, новая запись Card будет создана ассоциированием с доской, прикреплённой к каналу, и со списком, передаваемым в качестве параметра. Если создание было успешно, запись будет переправлена всем подключенным к каналу участникам. Наконец, нужно добавить callback-функцию к js-каналу:

 

// web/static/js/actions/current_board.js

//…

channel.on(‘card:created’, (msg) => {

dispatch({

type: Constants.CURRENT_BOARD_CARD_CREATED,

card: msg.card,

});

});

// …

И добавим новую карточку к состоянию через преобразователь:

 

// web/static/js/reducers/current_board.js

// …

case Constants.CURRENT_BOARD_CARD_CREATED:

lists = […state.lists];

const { card } = action;

const listIndex = lists.findIndex((list) => { return list.id == card.list_id; });

lists[listIndex].cards.push(card);

return { …state, lists: lists };

// …

И это всё! Карточка будет появляться на экране каждого подключенного участника.

Что теперь?

Этим разделом мы завершили создание базовой функциональности, требуемой для регистрации пользователя, входа в систему, создания досок, приглашения на них других людей и совместной работы в реальном времени путём добавления списков и карточек. Окончательная версия в репозитории имеет значительно больше возможностей, таких, как редактирование списков, сортировка списков и карточек их перемещением, отображением более детальной информации о карточках, где вы так же можете назначить им участников и даже добавить комментарии и цветные метки, но мы не будем подробно говорить ни об одной из них, иначе это стало бы вечным тренингом. 😀

Но не волнуйтесь, осталась ещё одна часть, где мы поговорим о том, как поделиться результатом со всем миром, выложив его на Heroku.

Выкладываем проект на Heroku

Мы наконец-то сделали это (и я тоже — прим. переводчика). После 5 (в оригинале — 11 — прим. переводчика) публикаций мы узнали, как настроить новый проект Phoenix с Webpack, React и Redux. Мы создали безопасную систему аутентификации, основанную на JWT-токенах, создали миграции для требуемых нам схем нашей базы данных, запрограммировали сокеты и каналы для функциональности реального времени и построили процесс GenServer, чтобы отслеживать подключённых участников досок. Настало время поделиться всем этим с миром, выложив проект на Heroku. Давайте сделаем это!

Настраиваем Heroku

Прежде, чем двигаться дальше, предположим, что у нас уже есть аккаунт Heroku и установленный Heroku Toolbelt. Чтобы выложить на Heroku приложение Phoenix, нам понадобится использовать два различных buildpacks (набора для сборки), так что создадим новое приложение, используя multi-buildpack:

 

$ heroku create phoenix-trello —buildpack https://github.com/ddollar/heroku-buildpack-multi

Это создаст новое приложение на Heroku и добавит удалённый git-репозиторий heroku, которым мы воспользуемся для публикации. Как я сказал ранее, для приложения Phoenix нам потребуется два различных пакета для сборки:

 

  1. heroku-buildpack-elixir: Главный набор для сборки приложений Elixir.
  2. heroku-buildpack-phoenix-static: Для компиляции статических файлов.

Создадим файл .buildpacks и добавим оба набора:

 

# .buildpacks

https://github.com/HashNuke/heroku-buildpack-elixir

https://github.com/gjaldon/phoenix-static-buildpack

Если нам нужно изменить любой аспект, относящийся к рабочему окружению Elixir, мы можем сделать это, добавив файл elixir_buildpack.config:

 

# elixir_buildpack.config

# Elixir version

elixir_version=1.2.3

# Always rebuild from scratch on every deploy?

always_rebuild=true

В нашем случае мы указываем версию Elixir, а так же требуем от окружения пересобирать всё, включая зависимости, при каждой публикации. То же самое может быть сделано и для статики в файле phoenix_static_buildpack.config:

 

# phoenix_static_buildpack.config

# We can set the version of Node to use for the app here

node_version=5.3.0

# We can set the version of NPM to use for the app here

npm_version=3.5.2

В данном случае мы указываем требуемые для Webpack версии node и npm. В конце концов мы должны создать файл compile, в котором мы укажем, как компилировать наши ресурсы после очередной публикации:

 

# compile

info «Building Phoenix static assets»

webpack

mix phoenix.digest

Обратите внимание, что мы запускаем mix-задачу phoenix.digest после сборки webpack, чтобы сгенерировать переработанные и сжатые версии ресурсов.

Настройка рабочего окружения

Прежде, чем сделать первую публикацию, необходимо обновить файл prod.exs, сделав требуемые изменения конфигурации:

 

# config/prod.exs

use Mix.Config

# …

config :phoenix_trello, PhoenixTrello.Endpoint,

# ..

url: [scheme: «https», host: «phoenix-trello.herokuapp.com», port: 443],

# ..

secret_key_base: System.get_env(«SECRET_KEY_BASE»)

# ..

# Configure your database

config :phoenix_trello, PhoenixTrello.Repo,

# ..

url: System.get_env(«DATABASE_URL»),

pool_size: 20

# Configure guardian

config :guardian, Guardian,

secret_key: System.get_env(«GUARDIAN_SECRET_KEY»)

Главное, что мы тут делаем: заставляем использовать URL нашего приложения Heroku и SSL-соединение. Мы так же используем некоторые переменные окружения, чтобы сконфигурировать secret_key_base, ссылку на базу данных (url) и secret_key для guardian. Ссылка на базу данных будет создана Heroku автоматически при первой публикации, но остальные две переменные нам нужно сгенерировать самостоятельно и добавить их, используя командную строку:

 

$ mix phoenix.gen.secret

xxxxxxxxxx

$ heroku config:set SECRET_KEY_BASE=»xxxxxxxxxx»

$ mix phoenix.gen.secret

yyyyyyyyyyy

$ heroku config:set GUARDIAN_SECRET_KEY=»yyyyyyyyyyy»

И мы готовы к публикации!

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *