[Перевод] Клон Trello на Phoenix и React. Части 10-12. Финиш долгостроя

86ee8d2930024c60a834695276f5de15.jpg



Оглавление (текущий материал выделен)
  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

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



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


Оригинал


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


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


Проблема


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


3e4f090964204d388fdd00f5a2310da2.jpg


Когда подключённый участник покидает 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
# /lib/phoenix_trello/board_channel/monitor.ex

defmodule PhoenixTrello.BoardChannel.Monitor do
  use GenServer

  #####
  # Client API

  # ...

  def member_joined(board, member) do
   GenServer.call(__MODULE__, {:member_joined, board, member})
  end

  #####
 # Server callbacks

 def handle_call({:member_joined, board, member}, _from, state) do
   state = case Map.get(state, board) do
     nil ->
       state = state
       |> Map.put(board, [member])

       {:reply, [member], state}
     members ->
       state = state
       |> Map.put(board, Enum.uniq([member | members]))

       {:reply, Map.get(state, board), state}
   end
 end
end

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


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


/web/channels/board_channel.ex
# /web/channels/board_channel.ex

defmodule PhoenixTrello.BoardChannel do
  use PhoenixTrello.Web, :channel

  alias PhoenixTrello.{User, Board, UserBoard, List, Card, Comment, CardMember}
  alias PhoenixTrello.BoardChannel.Monitor

  def join("boards:" <> board_id, _params, socket) do
    current_user = socket.assigns.current_user
    board = get_current_board(socket, board_id)

    connected_users = Monitor.user_joined(board_id, current_user.id)

    send(self, {:after_join, connected_users})

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

  def handle_info({:after_join, connected_users}, socket) do
    broadcast! socket, "user:joined", %{users: connected_users}

    {:noreply, socket}
  end

  # ...
end

Таким образом, когда он подключается, мы используем 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 (
        
  • ); }); } // ... }

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


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


    /lib/phoenix_trello/board_channel/monitor.ex
    # /lib/phoenix_trello/board_channel/monitor.ex
    
    defmodule PhoenixTrello.BoardChannel.Monitor do
      use GenServer
    
      #####
      # Client API
    
      # ...
    
      def member_left(board, member) do
        GenServer.call(__MODULE__, {:member_left, board, member})
      end
    
      #####
      # Server callbacks
    
      # ...
    
      def handle_call({:member_left, board, member}, _from, state) do
        new_members = state
          |> Map.get(board)
          |> List.delete(member)
    
        state = state
          |> Map.update!(board, fn(_) -> new_members end)
    
        {:reply, new_members, state}
      end
    end

    Как вы можете увидеть, это почти та же функциональность, что и у 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
    // /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:left', (msg) => {
            dispatch({
              type: Constants.CURRENT_BOARD_CONNECTED_USERS,
              users: msg.users,
            });
          });
        };
      },
    
      leaveChannel: (channel) => {
        return dispatch => {
          channel.leave();
        };
      },
    }

    Не забудьте обновить компонент 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
    // web/static/js/views/boards/show.js
    
    //...
    //...
    _renderLists() {
      const { lists, channel, id, addingNewCardInListId } = this.props.currentBoard;
    
      return lists.map((list) => {
        return (
          
        );
      });
    }
    
    render() {
        const { fetching, name } = this.props.currentBoard;
    
        if (fetching) return (
          
    ); return (

    {name}

    {::this._renderMembers()}
    {::this._renderLists()} {::this._renderAddNewList()}
    {this.props.children}
    ); }

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


    web/static/js/views/boards/show.js
    // web/static/js/views/boards/show.js
    
    // ...
    
      _renderAddNewList() {
        const { dispatch, formErrors, currentBoard } = this.props;
    
        if (!currentBoard.showForm) return this._renderAddButton();
    
        return (
          
        );
      }
    
      _renderAddButton() {
        return (
          
    Add new list...
    ); } _handleAddNewClick() { const { dispatch } = this.props; dispatch(Actions.showForm(true)); } _handleCancelClick() { this.props.dispatch(Actions.showForm(false)); } // ...

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


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


    web/static/js/components/lists/form.js
    // web/static/js/components/lists/form.js
    
    import React, { PropTypes } from 'react';
    import Actions              from '../../actions/lists';
    
    export default class ListForm extends React.Component {
      componentDidMount() {
        this.refs.name.focus();
      }
    
      _handleSubmit(e) {
        e.preventDefault();
    
        const { dispatch, channel } = this.props;
        const { name } = this.refs;
    
        const data = {
          name: name.value,
        };
    
        dispatch(Actions.save(channel, data));
      }
    
      _handleCancelClick(e) {
        e.preventDefault();
    
        this.props.onCancelClick();
      }
    
      render() {
        return (
          
    or cancel
    ); } }

    84b6dd5b4c9b4da18d0a31dd033ce6f1.jpg


    Это очень простой компонент с формой, содержащей текстое поле для названия списка, кнопкой отправки и ссылкой на отмену, которая будет направлять то же действие, что мы описали, но устанавливая 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
    # web/channels/board_channel.ex
    
    defmodule PhoenixTrello.BoardChannel do
      # ...
    
      def handle_in("lists:create", %{"list" => list_params}, socket) do
        board = socket.assigns.board
    
        changeset = board
          |> build_assoc(:lists)
          |> List.changeset(list_params)
    
        case Repo.insert(changeset) do
          {:ok, list} ->
            list = Repo.preload(list, [:cards])
    
            broadcast! socket, "list:created", %{list: list}
    
            {:noreply, socket}
          {:error, _changeset} ->
            {:reply, {:error, %{error: "Error creating list"}}, socket}
        end
      end
    
      # ...
    end

    Используя доску, прикреплённую к каналу, функция выстроит набор изменений (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, чтобы автоматически скрыть форму и вновь показать кнопку Добавить новый список… вместе с только что созданным списком:


    947041bbf48b466c98c2329e3111ac03.jpg



    Компонент List


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


    /web/static/js/components/lists/card.js
    // /web/static/js/components/lists/card.js
    
    import React, {PropTypes}       from 'react';
    import Actions                  from '../../actions/current_board';
    import CardForm                 from '../../components/cards/form';
    import Card                     from '../../components/cards/card';
    
    export default class ListCard extends React.Component {
      // ...
    
      _renderForm() {
        const { isAddingNewCard } = this.props;
        if (!isAddingNewCard) return false;
    
        let { id, dispatch, formErrors, channel } = this.props;
    
        return (
          
        );
      }
    
      _renderAddNewCard() {
        const { isAddingNewCard } = this.props;
        if (isAddingNewCard) return false;
    
        return (
          Add a new card...
        );
      }
    
      _handleAddClick(e) {
        e.preventDefault();
    
        const { dispatch, id } = this.props;
    
        dispatch(Actions.showCardForm(id));
      }
    
      _hideCardForm() {
        const { dispatch } = this.props;
    
        dispatch(Actions.showCardForm(null));
      }
    
      render() {
        const { id, connectDragSource, connectDropTarget, connectCardDropTarget, isDragging } = this.props;
    
        const styles = {
          display: isDragging ? 'none' : 'block',
        };
    
        return (
          

    {this.props.name}

    {::this._renderCards()}
    {::this._renderForm()} {::this._renderAddNewCard()}
    ); } }

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


    88af14b5bcdc476c8412ff424541c24c.jpg



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


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


    /web/static/js/components/cards/form.js
    // /web/static/js/components/cards/form.js
    
    import React, { PropTypes } from 'react';
    import Actions              from '../../actions/lists';
    import PageClick            from 'react-page-click';
    
    export default class CardForm extends React.Component {
      _handleSubmit(e) {
        e.preventDefault();
    
        let { dispatch, channel } = this.props;
        let { name }              = this.refs;
    
        let data = {
          list_id: this.props.listId,
          name: name.value,
        };
    
        dispatch(Actions.createCard(channel, data));
        this.props.onSubmit();
      }
    
      componentDidMount() {
        this.refs.name.focus();
      }
    
      _handleCancelClick(e) {
        e.preventDefault();
    
        this.props.onCancelClick();
      }
    
      render() {
        return (