Клон Trello на Phoenix і React. Частини 8-9


Зміст (поточний виділений матеріал)
  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> або <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> або <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 { e } = 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 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.