- Введение и выбор стека технологий
- Начальная настройка проекта Phoenix Framework
- Модель User и JWT-аутентификация
- Front-end для регистрации на React и Redux
- Начальное заполнение базы данных и контроллер для входа в приложение
- Аутентификация на front-end на React и Redux
- Настраиваем сокеты и каналы
- Выводим список и создаём новые доски
- Добавляем новых пользователей досок
- Отслеживаем подключённых пользователей досок
- Добавляем списки и карточки
- Выкладываем проект на Heroku
Выводим список и создаём новые доски
В настоящий момент мы реализовали все важные аспекты регистрации пользователя и управления аутентификацией, равно как и подключение к сокету и вход на каналы, так что готовы перейти на следующий уровень, и дать пользователю возможность выводить список и создавать собственные доски.
Особо длинные листинги спрятал под спойлер — прим. переводчика
Миграция для модели досок
Для начала нам нужно создать миграцию и модель. Для этого просто запустите:
$ 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
defmodule PhoenixTrello.BoardController do
use PhoenixTrello.Web, :controller
plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController
alias PhoenixTrello.{Repo, Board}
def index(conn, _params) do
current_user = Guardian.Plug.current_resource(conn)
owned_boards = current_user
|> assoc(:owned_boards)
|> Board.preload_all
|> Repo.all
render(conn, "index.json", owned_boards: owned_boards)
end
def create(conn, %{"board" => board_params}) do
current_user = Guardian.Plug.current_resource(conn)
changeset = current_user
|> build_assoc(:owned_boards)
|> Board.changeset(board_params)
case Repo.insert(changeset) do
{:ok, board} ->
conn
|> put_status(:created)
|> render("show.json", board: board )
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render("error.json", changeset: changeset)
end
end
end
Отметьте, что мы добавляем 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
:
// web/static/js/views/home/index.js
import React from 'react';
import { connect } from 'react-redux';
import classnames from 'classnames';
import { setDocumentTitle } from '../../utils';
import Actions from '../../actions/boards';
import BoardCard from '../../components/boards/card';
import BoardForm from '../../components/boards/form';
class HomeIndexView extends React.Component {
componentDidMount() {
setDocumentTitle('Boards');
const { dispatch } = this.props;
dispatch(Actions.fetchBoards());
}
_renderOwnedBoards() {
const { fetching } = this.props;
let content = false;
const iconClasses = classnames({
fa: true,
'fa-user': !fetching,
'fa-spinner': fetching,
'fa-spin': fetching,
});
if (!fetching) {
content = (
<div className="boards-wrapper">
{::this._renderBoards(this.props.ownedBoards)}
{::this._renderAddNewBoard()}
</div>
);
}
return (
<section>
<header className="view-header">
<h3><i className={iconClasses} /> My boards</h3>
</header>
{content}
</section>
);
}
_renderBoards(boards) {
return boards.map((board) => {
return <BoardCard
key={board.id}
dispatch={this.props.dispatch}
{...board} />;
});
}
_renderAddNewBoard() {
let { showForm, dispatch, formErrors } = this.props;
if (!showForm) return this._renderAddButton();
return (
<BoardForm
dispatch={dispatch}
errors={formErrors}
onCancelClick={::this._handleCancelClick}/>
);
}
_renderAddButton() {
return (
<div className="board add-new" onClick={::this._handleAddNewClick}>
<div className="inner">
<a id="add_new_board">Add new board...</a>
</div>
</div>
);
}
_handleAddNewClick() {
let { dispatch } = this.props;
dispatch(Actions.showForm(true));
}
_handleCancelClick() {
this.props.dispatch(Actions.showForm(false));
}
render() {
return (
<div className="view-container boards index">
{::this._renderOwnedBoards()}
</div>
);
}
}
const mapStateToProps = (state) => (
state.boards
);
export default connect(mapStateToProps)(HomeIndexView);
Тут много чего происходит, так давайте рассмотрим по порядку:
- Для начала помните, что этот компонент соединён с хранилищем (store) и в случае изменений будет получать свои параметры (
props
) с помощью преобразователяboards
, который мы вскоре создадим. - При подключении компонент поменяет заголовок документа на Boards и запросит конструктор действия получить с back-end список досок.
- Пока что произойдёт только отображение массива
owned_boards
, как и компонентаBoardForm
. - Прежде, чем отобразить эти два элемента, будет проверено, установлено ли свойство
fetching
в true. Если да, это будет означать, что список ещё скачивается, так что отобразится индикатор загрузки. В противном случае будет показан список досок и кнопка для добавления новой. - При нажатии на кнопку Add new будет запрошен новый конструктор действия для сокрытия этой кнопки и вывода формы.
Теперь добавим компонент BoardForm
:
// web/static/js/components/boards/form.js
import React, { PropTypes } from 'react';
import PageClick from 'react-page-click';
import Actions from '../../actions/boards';
import {renderErrorsFor} from '../../utils';
export default class BoardForm extends React.Component {
componentDidMount() {
this.refs.name.focus();
}
_handleSubmit(e) {
e.preventDefault();
const { dispatch } = this.props;
const { name } = this.refs;
const data = {
name: name.value,
};
dispatch(Actions.create(data));
}
_handleCancelClick(e) {
e.preventDefault();
this.props.onCancelClick();
}
render() {
const { errors } = this.props;
return (
<PageClick onClick={::this._handleCancelClick}>
<div className="board form">
<div className="inner">
<h4>New board</h4>
<form id="new_board_form" onSubmit={::this._handleSubmit}>
<input ref="name" id="board_name" type="text" placeholder="Board name" required="true"/>
{renderErrorsFor(errors, 'name')}
<button type="submit">Create board</button> or <a href="#" onClick={::this._handleCancelClick}>cancel</a>
</form>
</div>
</div>
</PageClick>
);
}
}
Этот компонент крайне прост. Он отображает форму и при отправке запрашивает конструктор действия создать новую доску с предоставленным именем. PageClick
— найденный мной внешний компонент, который отслеживает клики по странице за пределами элемента-контейнера. В нашем случае мы воспользуемся им для того, чтобы скрыть форму и снова показать кнопку Add new.
Конструкторы действия
// web/static/js/actions/boards.js
import Constants from '../constants';
import { routeActions } from 'react-router-redux';
import { httpGet, httpPost } from '../utils';
import CurrentBoardActions from './current_board';
const Actions = {
fetchBoards: () => {
return dispatch => {
dispatch({ type: Constants.BOARDS_FETCHING });
httpGet('/api/v1/boards')
.then((data) => {
dispatch({
type: Constants.BOARDS_RECEIVED,
ownedBoards: data.owned_boards
});
});
};
},
showForm: (show) => {
return dispatch => {
dispatch({
type: Constants.BOARDS_SHOW_FORM,
show: show,
});
};
},
create: (data) => {
return dispatch => {
httpPost('/api/v1/boards', { board: data })
.then((data) => {
dispatch({
type: Constants.BOARDS_NEW_BOARD_CREATED,
board: data,
});
dispatch(routeActions.push(`/boards/${data.id}`));
})
.catch((error) => {
error.response.json()
.then((json) => {
dispatch({
type: Constants.BOARDS_CREATE_ERROR,
errors: json.errors,
});
});
});
};
},
};
export default Actions;
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
import Constants from '../constants';
const initialState = {
ownedBoards: [],
showForm: false,
formErrors: null,
fetching: true,
};
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case Constants.BOARDS_FETCHING:
return { ...state, fetching: true };
case Constants.BOARDS_RECEIVED:
return { ...state, ownedBoards: action.ownedBoards, fetching: false };
case Constants.BOARDS_SHOW_FORM:
return { ...state, showForm: action.show };
case Constants.BOARDS_CREATE_ERROR:
return { ...state, formErrors: action.errors };
case Constants.BOARDS_NEW_BOARD_CREATED:
const { ownedBoards } = state;
return { ...state, ownedBoards: [action.board].concat(ownedBoards) };
default:
return state;
}
}
Отметьте, что при завершении загрузки досок мы устанавливаем аттрибут 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
, который нужно создать:
// web/static/js/views/boards/show.js
import React, {PropTypes} from 'react';
import { connect } from 'react-redux';
import Actions from '../../actions/current_board';
import Constants from '../../constants';
import { setDocumentTitle } from '../../utils';
import BoardMembers from '../../components/boards/members';
class BoardsShowView extends React.Component {
componentDidMount() {
const { socket } = this.props;
if (!socket) {
return false;
}
this.props.dispatch(Actions.connectToChannel(socket, this.props.params.id));
}
componentWillUnmount() {
this.props.dispatch(Actions.leaveChannel(this.props.currentBoard.channel));
}
_renderMembers() {
const { connectedUsers, showUsersForm, channel, error } = this.props.currentBoard;
const { dispatch } = this.props;
const members = this.props.currentBoard.members;
const currentUserIsOwner = this.props.currentBoard.user.id === this.props.currentUser.id;
return (
<BoardMembers
dispatch={dispatch}
channel={channel}
currentUserIsOwner={currentUserIsOwner}
members={members}
connectedUsers={connectedUsers}
error={error}
show={showUsersForm} />
);
}
render() {
const { fetching, name } = this.props.currentBoard;
if (fetching) return (
<div className="view-container boards show">
<i className="fa fa-spinner fa-spin"/>
</div>
);
return (
<div className="view-container boards show">
<header className="view-header">
<h3>{name}</h3>
{::this._renderMembers()}
</header>
<div className="canvas-wrapper">
<div className="canvas">
<div className="lists-wrapper">
{::this._renderAddNewList()}
</div>
</div>
</div>
</div>
);
}
}
const mapStateToProps = (state) => ({
currentBoard: state.currentBoard,
socket: state.session.socket,
currentUser: state.session.currentUser,
});
export default connect(mapStateToProps)(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
:
# web/controllers/api/v1/board_controller.ex
defmodule PhoenixTrello.BoardController do
use PhoenixTrello.Web, :controller
#...
def create(conn, %{"board" => board_params}) do
current_user = Guardian.Plug.current_resource(conn)
changeset = current_user
|> build_assoc(:owned_boards)
|> Board.changeset(board_params)
if changeset.valid? do
board = Repo.insert!(changeset)
board
|> build_assoc(:user_boards)
|> UserBoard.changeset(%{user_id: current_user.id})
|> Repo.insert!
conn
|> put_status(:created)
|> render("show.json", board: board )
else
conn
|> put_status(:unprocessable_entity)
|> render("error.json", changeset: changeset)
end
end
end
Отметьте, как мы создаём объединение UserBoard
и добавляем его после проверки на корректность.
Компонент участников доски
Этот компонент будет показывать аватары всех участников и форму для добавления нового участника:
Как вы видите, благодаря предыдущим изменениям в BoardController
, сейчас владелец показан единственным участником. Посмотрим, как этот компонент будет выглядеть:
// web/static/js/components/boards/members.js
import React, {PropTypes} from 'react';
import ReactGravatar from 'react-gravatar';
import classnames from 'classnames';
import PageClick from 'react-page-click';
import Actions from '../../actions/current_board';
export default class BoardMembers extends React.Component {
_renderUsers() {
return this.props.members.map((member) => {
const index = this.props.connectedUsers.findIndex((cu) => {
return cu === member.id;
});
const classes = classnames({ connected: index != -1 });
return (
<li className={classes} key={member.id}>
<ReactGravatar className="react-gravatar" email={member.email} https/>
</li>
);
});
}
_renderAddNewUser() {
if (!this.props.currentUserIsOwner) return false;
return (
<li>
<a onClick={::this._handleAddNewClick} className="add-new" href="#"><i className="fa fa-plus"/></a>
{::this._renderForm()}
</li>
);
}
_renderForm() {
if (!this.props.show) return false;
return (
<PageClick onClick={::this._handleCancelClick}>
<ul className="drop-down active">
<li>
<form onSubmit={::this._handleSubmit}>
<h4>Add new members</h4>
{::this._renderError()}
<input ref="email" type="email" required={true} placeholder="Member email"/>
<button type="submit">Add member</button> or <a onClick={::this._handleCancelClick} href="#">cancel</a>
</form>
</li>
</ul>
</PageClick>
);
}
_renderError() {
const { error } = this.props;
if (!error) return false;
return (
<div className="error">
{error}
</div>
);
}
_handleAddNewClick(e) {
e.preventDefault();
this.props.dispatch(Actions.showMembersForm(true));
}
_handleCancelClick(e) {
e.preventDefault();
this.props.dispatch(Actions.showMembersForm(false));
}
_handleSubmit(e) {
e.preventDefault();
const { email } = this.refs;
const { dispatch, channel } = this.props;
dispatch(Actions.addNewMember(channel, email.value));
}
render() {
return (
<ul className="board-users">
{::this._renderUsers()}
{::this._renderAddNewUser()}
</ul>
);
}
}
По сути, мы будем перебирать параметр 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
добавленного участника, так что эта доска немедленно появится в списке досок, на которые он приглашён. Это озачает, что мы можем отправить любое сообщение в любой канал откуда угодно, что просто улётно и привносит новую кучу возможностей и веселья.
Насколько я заметил в различных источниках, иногда у людей возникает непонимание различий между разными вариантами отправки сообщений и возврата результатов в канал. Постараюсь в общем описать каждую из возможностей.
При отправке из любой функции канала:
- при завершении обработчика с помощью одного из вариантов
{:reply, :ok, socket}
,{:reply, {:ok, message}, socket}
или{:reply, {:error, message}, socket}
, гдеmessage
— ассоциативный массив с данными, которые должны быть отправлены клиенту (либо пустой). В этом случае данные поступят непосредственно в ответ после завершения работы с сообщением, которое получил обработчик, и должны ожидаться и обрабатываться на клиенте прямо в одной из callback функции-отправителя оригинального сообщения; push(socket, event, message)
, гдеevent
— это строка с заголовком сообщения: отправка сообщения только в текущее подключение (в этом случае и далее на клиенте сообщения будут обработаны только в случае подписки на получение сообщений с соответствующим заголовком. В нашем приложении на front-end для этого используетсяchannel.on(...)
);broadcast(socket, event, message)
: отправка сообщения всем клиентам, подключённым к соответствующему каналу;broadcast_from(socket, event, message)
: аналогично предыдущему, но сообщение не будет отправлено на текущее подключение.
При отправке откуда угодно (например, из контроллера):
AppName.Endpoint.broadcast(topic, event, message)
, гдеtopic
— название канала: отправка сообщения всем пользователям, подписанным на заданный канал (при желании можно подписываться на определённые топики (названия, или темы, каналов), будучи соединённым с каналом, имеющим другую тему)
Все функции, кроме push
, также имеют "опасный" вариант с восклицательным знаком на конце. В предыдущих случаях при возникновении каких-либо проблем при отправке возвращается ошибка, в случае "опасного" варианта генерируется "исключение", и если не используется try do ... end
, то вызвавший эту функцию процесс прерывается (причём так как мы говорим об Elixir, прерывается только конкретный процесс, а не всё приложение).
Для обработки на 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, но сделаем это в следующей публикации.
А тем временем не забудьте взглянуть на живое демо и исходный код конечного результата.