Клон Trello на Phoenix и React. Части 6-7

в 12:03, , рубрики: Elixir, elixir-lang, Erlang/OTP, phoenix framework, React, redux, ruby on rails, Trello, отсебятина, перевод, Программирование, Разработка веб-сайтов, функциональное программирование
Клон Trello на Phoenix и React. Части 6-7 - 1

Оглавление (текущий материал выделен)

  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 (
  <Route component={MainLayout}>
    <Route path="/sign_up" component={RegistrationsNew} />
    <Route path="/sign_in" component={SessionsNew} />

    <Route path="/" component={AuthenticatedContainer}>
      <IndexRoute component={HomeIndexView} />

      <Route path="/boards/:id" component={BoardsShowView}>
        <Route path="cards/:id" component={CardsShowView}/>
      </Route>
    </Route>
  </Route>
);

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

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

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

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

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

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

  _handleSubmit(e) {
    e.preventDefault();

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

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

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

    if (!error) return false;

    return (
      <div className="error">
        {error}
      </div>
    );
  }

  render() {
    return (
      <div className='view-container sessions new'>
        <main>
          <header>
            <div className="logo" />
          </header>
          <form onSubmit={::this._handleSubmit}>
            {::this._renderError()}
            <div className="field">
              <input ref="email" type="Email" placeholder="Email" required="true" defaultValue="john@phoenix-trello.com"/>
            </div>
            <div className="field">
              <input ref="password" type="password" placeholder="Password" required="true" defaultValue="12345678"/>
            </div>
            <button type="submit">Sign in</button>
          </form>
          <Link to="/sign_up">Create new account</Link>
        </main>
      </div>
    );
  }
}

const mapStateToProps = (state) => (
  state.session
);

export default connect(mapStateToProps)(SessionsNew);

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

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

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

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

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

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

  // ...
};

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

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

  // ...
};

export default Actions;

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

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

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

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

import Constants from '../constants';

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

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

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

    default:
      return state;
  }
}

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

Контейнер authenticated

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

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

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

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

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

    if (!currentUser) return false;

    return (
      <div className="application-container">
        <Header
          currentUser={currentUser}
          dispatch={dispatch}/>

        <div className="main-container">
          {this.props.children}
        </div>
      </div>
    );
  }
}

const mapStateToProps = (state) => ({
  currentUser: state.session.currentUser,
});

export default connect(mapStateToProps)(AuthenticatedContainer);

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

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

const Actions = {
  // ...

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

  // ...
}

// ...

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

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

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

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

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

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

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

    if (!currentUser) {
      return false;
    }

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

    return (
      <a className="current-user">
        <ReactGravatar email={currentUser.email} https /> {fullName}
      </a>
    );
  }

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

    return (
      <a href="#" onClick={::this._handleSignOutClick}><i className="fa fa-sign-out"/> Sign out</a>
    );
  }

  _handleSignOutClick(e) {
    e.preventDefault();

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

  render() {
    return (
      <header className="main-header">
        <nav>
          <ul>
            <li>
              <Link to="/"><i className="fa fa-columns"/> Boards</Link>
            </li>
          </ul>
        </nav>
        <Link to='/'>
          <span className='logo'/>
        </Link>
        <nav className="right">
          <ul>
            <li>
              {this._renderCurrentUser()}
            </li>
            <li>
              {this._renderSignOutLink()}
            </li>
          </ul>
        </nav>
      </header>
    );
  }
}

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

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

const Actions = {
  // ...

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

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

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

  // ...
}

// ...

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

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

import Constants from '../constants';

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

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

    case Constants.USER_SIGNED_OUT:
      return initialState;

    // ...
  }
}

Ещё кое-что

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

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

Оригинал

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

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

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

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

# lib/phoenix_trello/endpoint.ex

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

  socket "/socket", PhoenixTrello.UserSocket

  # ...
end

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

# web/channels/user_socket.ex

defmodule PhoenixTrello.UserSocket do
  use Phoenix.Socket

  alias PhoenixTrello.{Repo, User}

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

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

  # ...
end

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

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

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

# web/channels/user_socket.ex

defmodule PhoenixTrello.UserSocket do
  # ...

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

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

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

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

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

Обратите внимание, приведено два описания функции 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", %{})

Кстати, с помощью <AppName>.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, где мы покажем все доски, принадлежащие пользователю, равно как и те, куда он был приглашён в качестве участника. В следующей части мы раскроем, как создать новую доску и пригласить существующих пользователей, используя каналы для передачи результирующих данных вовлечёнными пользователям.

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

Автор: heathen

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js