Оглавление (текущий материал выделен)
  1. Введение и выбор стека технологий
  2. Начальная настройка проекта Phoenix Framework
  3. Модель User и JWT-аутентификация
  4. Front-end для регистрации на React и Redux
  5. Начальное заполнение базы данных и контроллер для входа в приложение
  6. Аутентификация на front-end на React и Redux
  7. Настраиваем сокеты и каналы
  8. Выводим список и создаём новые доски
  9. Добавляем новых пользователей досок
  10. Отслеживаем подключённых пользователей досок
  11. Добавляем списки и карточки
  12. Выкладываем проект на 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
# 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:


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:


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.


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


action creators
// 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
// 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, который нужно создать:


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:


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, но сделаем это в следующей публикации.


А тем временем не забудьте взглянуть на живое демо и исходный код конечного результата.

Поделиться с друзьями
-->

Комментарии (0)