[Перевод] Клон Trello на Phoenix и React. Части 6-7

243e801065424d89b26bf7d322e36c0a.png



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

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


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


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


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

import { IndexRoute, Route }        from 'react-router';
import React                        from 'react';
import MainLayout                   from '../layouts/main';
import AuthenticatedContainer       from '../containers/authenticated';
import HomeIndexView                from '../views/home';
import RegistrationsNew             from '../views/registrations/new';
import SessionsNew                  from '../views/sessions/new';
import BoardsShowView               from '../views/boards/show';
import CardsShowView               from '../views/cards/show';

export default (
  
    
    

    
      

      
        
      
    
  
);

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


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


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


import React, {PropTypes}   from 'react';
import { connect }          from 'react-redux';
import { Link }             from 'react-router';

import { setDocumentTitle } from '../../utils';
import Actions              from '../../actions/sessions';

class SessionsNew extends React.Component {
  componentDidMount() {
    setDocumentTitle('Sign in');
  }

  _handleSubmit(e) {
    e.preventDefault();

    const { email, password } = this.refs;
    const { dispatch } = this.props;

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

  _renderError() {
    const { error } = this.props;

    if (!error) return false;

    return (
      
{error}
); } render() { return (
{::this._renderError()}
Create new account
); } } const mapStateToProps = (state) => ( state.session ); export default connect(mapStateToProps)(SessionsNew);

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


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


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


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

import { routeActions }                   from 'redux-simple-router';
import Constants                          from '../constants';
import { Socket }                         from 'phoenix';
import { httpGet, httpPost, httpDelete }  from '../utils';

function setCurrentUser(dispatch, user) {
  dispatch({
    type: Constants.CURRENT_USER,
    currentUser: user,
  });

  // ...
};

const Actions = {
  signIn: (email, password) => {
    return dispatch => {
      const data = {
        session: {
          email: email,
          password: password,
        },
      };

      httpPost('/api/v1/sessions', data)
      .then((data) => {
        localStorage.setItem('phoenixAuthToken', data.jwt);
        setCurrentUser(dispatch, data.user);
        dispatch(routeActions.push('/'));
      })
      .catch((error) => {
        error.response.json()
        .then((errorJSON) => {
          dispatch({
            type: Constants.SESSIONS_ERROR,
            error: errorJSON.error,
          });
        });
      });
    };
  },

  // ...
};

export default Actions;

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


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


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


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

import Constants from '../constants';

const initialState = {
  currentUser: null,
  error: null,
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case Constants.CURRENT_USER:
      return { ...state, currentUser: action.currentUser, error: null };

    case Constants.SESSIONS_ERROR:
      return { ...state, error: action.error };

    default:
      return state;
  }
}

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


Контейнер authenticated


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

import React            from 'react';
import { connect }      from 'react-redux';
import Actions          from '../actions/sessions';
import { routeActions } from 'redux-simple-router';
import Header           from '../layouts/header';

class AuthenticatedContainer extends React.Component {
  componentDidMount() {
    const { dispatch, currentUser } = this.props;
    const phoenixAuthToken = localStorage.getItem('phoenixAuthToken');

    if (phoenixAuthToken && !currentUser) {
      dispatch(Actions.currentUser());
    } else if (!phoenixAuthToken) {
      dispatch(routeActions.push('/sign_in'));
    }
  }

  render() {
    const { currentUser, dispatch } = this.props;

    if (!currentUser) return false;

    return (
      
{this.props.children}
); } } const mapStateToProps = (state) => ({ currentUser: state.session.currentUser, }); export default connect(mapStateToProps)(AuthenticatedContainer);

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


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

const Actions = {
  // ...

  currentUser: () => {
    return dispatch => {
      httpGet('/api/v1/current_user')
      .then(function(data) {
        setCurrentUser(dispatch, data);
      })
      .catch(function(error) {
        console.log(error);
        dispatch(routeActions.push('/sign_in'));
      });
    };
  },

  // ...
}

// ...

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


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


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


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

import React          from 'react';
import { Link }       from 'react-router';
import Actions        from '../actions/sessions';
import ReactGravatar  from 'react-gravatar';

export default class Header extends React.Component {
  constructor() {
    super();
  }

  _renderCurrentUser() {
    const { currentUser } = this.props;

    if (!currentUser) {
      return false;
    }

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

    return (
      
         {fullName}
      
    );
  }

  _renderSignOutLink() {
    if (!this.props.currentUser) {
      return false;
    }

    return (
       Sign out
    );
  }

  _handleSignOutClick(e) {
    e.preventDefault();

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

  render() {
    return (
      
); } }

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


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

const Actions = {
  // ...

  signOut: () => {
    return dispatch => {
      httpDelete('/api/v1/sessions')
      .then((data) => {
        localStorage.removeItem('phoenixAuthToken');

        dispatch({
          type: Constants.USER_SIGNED_OUT,
        });

        dispatch(routeActions.push('/sign_in'));
      })
      .catch(function(error) {
        console.log(error);
      });
    };
  },

  // ...
}

// ...

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


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

import Constants from '../constants';

const initialState = {
  currentUser: null,
  error: null,
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    // ...

    case Constants.USER_SIGNED_OUT:
      return initialState;

    // ...
  }
}

Ещё кое-что


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



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


Оригинал


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


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


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


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


# lib/phoenix_trello/endpoint.ex

defmodule PhoenixTrello.Endpoint do
  use Phoenix.Endpoint, otp_app: :phoenix_trello

  socket "/socket", PhoenixTrello.UserSocket

  # ...
end

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


# web/channels/user_socket.ex

defmodule PhoenixTrello.UserSocket do
  use Phoenix.Socket

  alias PhoenixTrello.{Repo, User}

  # Channels
  channel "users:*", PhoenixTrello.UserChannel
  channel "boards:*", PhoenixTrello.BoardChannel

  # Transports
  transport :websocket, Phoenix.Transports.WebSocket
  transport :longpoll, Phoenix.Transports.LongPoll

  # ...
end

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


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

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


# web/channels/user_socket.ex

defmodule PhoenixTrello.UserSocket do
  # ...

  def connect(%{"token" => token}, socket) do
    case Guardian.decode_and_verify(token) do
      {:ok, claims} ->
        case GuardianSerializer.from_token(claims["sub"]) do
          {:ok, user} ->
            {:ok, assign(socket, :current_user, user)}
          {:error, _reason} ->
            :error
        end
      {:error, _reason} ->
        :error
    end
  end

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

  def id(socket), do: "users_socket:#{socket.assigns.current_user.id}"
end

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


Прим. переводчика

Обратите внимание, приведено два описания функции connect: def connect(%{"token" => token}, socket) do ... end и def connect(_params, _socket), do: :error. Благодаря механизму сопоставления с шаблоном (pattern matching) первый вариант будет вызван при наличии в ассоциативном массиве, передаваемом первым параметром, ключа «token» (а значение, связанное с этим ключом, попадёт в переменную, названную token), а второй — в любых других случаях. Функция connect вызывается фреймворком автоматически при соединении с сокетом.


Функция id используется для идентификации текущего подключения к сокету и может использоваться, к примеру, для завершения всех активных каналов и сокетов для данного пользователя. При желании это можно сделать из любой части приложения, отправив сообщение "disconnect" вызовом PhoenixTrello.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{})


Кстати, с помощью .Endpoint.broadcast(topic, message, payload) можно отправить сообщение не только об отключении пользователя, но и вообще любое сообщение всем пользователям, подписанным на соответствующую тему. При этом topic — это строка с темой, (например, "boards:877"), message — это строка с сообщением (например, "boards:update"), а payload — ассоциативный массив с данными, который перед отправкой будет преобразован в json. Например, вы можете отправить пользователям, которые находятся online, какие-то изменения, произведённые с помощью REST api, прямо из контроллера или из любого другого процесса.


Канал user


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


# web/channels/user_channel.ex

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

  def join("users:" <> user_id, _params, socket) do
    {:ok, socket}
  end
end

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


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


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


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

import Constants                          from '../constants';
import { Socket }                         from 'phoenix';

// ...

export function setCurrentUser(dispatch, user) {
  dispatch({
    type: Constants.CURRENT_USER,
    currentUser: user,
  });

  const socket = new Socket('/socket', {
    params: { token: localStorage.getItem('phoenixAuthToken') },
  });

  socket.connect();

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

  channel.join().receive('ok', () => {
    dispatch({
        type: Constants.SOCKET_CONNECTED,
        socket: socket,
        channel: channel,
      });
  });
};

// ...

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


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

import Constants from '../constants';

const initialState = {
  currentUser: null,
  socket: null,
  channel: null,
  error: null,
};

export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case Constants.CURRENT_USER:
      return { ...state, currentUser: action.currentUser, error: null };

    case Constants.USER_SIGNED_OUT:
      return initialState;

    case Constants.SOCKET_CONNECTED:
      return { ...state, socket: action.socket, channel: action.channel };

    case Constants.SESSIONS_ERROR:
      return { ...state, error: action.error };

    default:
      return state;
  }
}

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


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


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

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

  • 22 августа 2016 в 15:29

    0

    Перемещение карточек не такое плавное как в Trello, но в остальном все выглядит очень круто!

© Habrahabr.ru