📜 ⬆️ ⬇️

Trello Clone on Phoenix and React. Parts 8-9




We display the list and create new boards


Original


At the moment, we have implemented all the important aspects of user registration and authentication management, as well as connecting to a socket and entering channels, so we are ready to go to the next level and allow the user to list and create their own boards.


Particularly long listings hidden under spoiler - approx. translator


Migration for the board model


First we need to create a migration and a model. To do this, simply run:


$ mix phoenix.gen.model Board boards user_id:references:users name:string 

This will create a new migration file that looks like:


 # 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 

The new table under the name of boards will receive, in addition to the id and timestamps fields ( in fact, the latter is a macro for creating a pair of inserted_at and created_at fields with a type similar to the corresponding created_at type in the base ), the name field and the foreign key to the table users . Please note that we rely on the database to clear the list of boards related to the user in case of deletion. An index is also added to the migration file to speed up the user_id field and a null limit for the name field.


Having completed the modification of the migration file, you need to run:


 $ mix ecto.migrate 

Model Board


Take a look at the model 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 

Note translator

I remind every fireman that models are now being generated a little differently, so I recommend making edits to the generated model, rather than copying the code one-on-one.


While there is something worth mentioning, there is a need to update the User model to add a link to your own boards:


 # web/models/user.ex defmodule PhoenixTrello.User do use PhoenixTrello.Web, :model # ... schema "users" do # ... has_many :owned_boards, PhoenixTrello.Board # ... end # ... end 

Why exactly owned_boards (own boards)? To distinguish user-made boards from boards to which it has been added by other users; but let's not worry about it yet, we will dive deeper into this question later.


BoardController Controller


So, to create new boards, you will need to update the route file in order to add the corresponding entry for processing requests:


 # 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 

We added resource boards , limiting the action handlers to the list from :index and :create , so the BoardController will serve the following requests:


 $ mix phoenix.routes board_path GET /api/v1/boards PhoenixTrello.BoardController :index board_path POST /api/v1/boards PhoenixTrello.BoardController :create 

Create a new controller:


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 

Note that we are adding a plug EnsureAuthenticated from Guardian , so that only authenticated connections will be allowed in this controller. In the index handler, we retrieve the current user data from the connection and query the database for a list of the boards belonging to it in order to be able to display them using BoardView . In the create handler, almost the same thing happens: we create the changeset of the owned_board using the data of the current user, and add it to the database, displaying board as an answer if everything went as expected.


Create 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 component (view) React


Now that the back-end is ready to process requests for getting a list of boards, as well as creating them, it's time to focus on the front-end. After authenticating the user and logging in to the application, the first thing we want is to show a list of his boards and a form for adding a new one, so let's create a 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); 

There is a lot going on here, so let's take it in order:



Now add the BoardForm component:


Boardform
 // web/static/js/components/boards/form.js import React, { PropTypes } from 'react'; import PageClick from 'react-page-click'; import Actions from '../../actions/boards'; import {renderErrorsFor} from '../../utils'; export default class BoardForm extends React.Component { componentDidMount() { this.refs.name.focus(); } _handleSubmit(e) { e.preventDefault(); const { dispatch } = this.props; const { name } = this.refs; const data = { name: name.value, }; dispatch(Actions.create(data)); } _handleCancelClick(e) { e.preventDefault(); this.props.onCancelClick(); } render() { const { errors } = this.props; return ( <PageClick onClick={::this._handleCancelClick}> <div className="board form"> <div className="inner"> <h4>New board</h4> <form id="new_board_form" onSubmit={::this._handleSubmit}> <input ref="name" id="board_name" type="text" placeholder="Board name" required="true"/> {renderErrorsFor(errors, 'name')} <button type="submit">Create board</button> or <a href="#" onClick={::this._handleCancelClick}>cancel</a> </form> </div> </div> </PageClick> ); } } 

This component is extremely simple. It displays the form and when sending it requests the action constructor to create a new board with the provided name. PageClick is an external component that I found that tracks clicks on a page outside the container element. In our case, we will use it to hide the form and again show the Add new button.


Action constructors


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; 


Converter


The last piece of the puzzle will be a very simple converter:


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; } } 

Note that when the boards are finished loading, we set the fetching attribute to false , as well as how we merge ( concat ) the new board created with the existing ones.


Enough work for today! In the next part, we will build a view to display the contents of the board and add functionality to add new participants to the board, to send board data to its associated users, so that it appears in the list of boards that you have been invited to join; this list is also to be created.



Add new board users


Original


In the previous section, we created a table for storing boards, a model Board and generated a controller responsible for enumerating and creating new boards for authenticated users. We also programmed the front-end, so that the existing boards and the form for adding a new board can be shown. Let me remind you, not what we stopped: after receiving confirmation from the controller after creating a new board, we want to redirect the user to her presentation so that he can see all the details and add existing users as participants. Let's do it!


React presentation component


Before continuing, take a look at the React routes:


 // 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> ); 

The route /boards/:id will be processed by the BoardsShowView component to create:


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); 

When connected, the component will connect to the board channel using the custom socket we created in Part 7 . When displayed, it first checks whether the fetching attribute is set to true , and if the data is still being downloaded, the loading indicator will be shown. As we can see, it receives its parameters from the currentBoard element stored in the state, which is created by the following converter.


Transducer and action constructors


As a starting point of the current board state, we need to store only the board , channel and fetching data:


 // 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; } } 

Let's take a look at the current_board action constructor to check how to connect to the channel and process all the required data:


 // 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; 

Just like with UserChannel , we use a socket to create and connect to a new channel, defined as boards:${boardId} , and receive as a response the presentation of the board as JSON , which will be sent to the repository along with the BOARDS_SET_CURRENT_BOARD action. From this point on, the constructor will be connected to the channel, receiving all changes made on the board by any participant, automatically displaying these changes on the screen thanks to React and Redux . But first you need to create a BoardChannel .


Board channel


Although almost all the remaining functionality will be implemented in this module, we are currently implementing a very simple version of it:


 # 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 

The join method gets the current board associated with the user assigned to the socket, returns it and assigns it to the socket, with the result that it will be available for further messages ( without additional queries to the database - comment of the translator ).





Board members


Once the board is shown to the user, the next step is to allow him to add existing users as participants so that they can work on it together. To link the board to other users, we need to create a new table to hold this relationship. Switch to the console and run:


 $ mix phoenix.gen.model UserBoard user_boards user_id:references:users board_id:references:boards 

You need to slightly update the resulting migration file:


 # 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 

In addition to the null restrictions, we will add a unique index for user_id and board_id , so User cannot be added to the same Board twice. After running mix ecto.migrate let's move on to the UserBoard model:


 # 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 

There is nothing unusual here, but you also need to add a new relationship to the User model:


 # 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 

We have two more relationships, but the most important one is :boards , which we will use to control access. Also add to the Board model:


 # 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 

Now, thanks to these changes, we can distinguish the boards created by the user and the boards to which he was invited. This is very important, because in the board view we want to show the form for adding participants only to its creator. In addition, we want to automatically add the creator as a member in order to show it by default, so we will make small changes to the 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 

Note how we create the UserBoard union and add it after checking for correctness.


Component board members


This component will show the avatars of all participants and the form for adding a new participant:




As you can see, thanks to the previous changes in BoardController , the owner is now shown as the only member. Let's see how this component will look like:


Component board members
 // web/static/js/components/boards/members.js import React, {PropTypes} from 'react'; import ReactGravatar from 'react-gravatar'; import classnames from 'classnames'; import PageClick from 'react-page-click'; import Actions from '../../actions/current_board'; export default class BoardMembers extends React.Component { _renderUsers() { return this.props.members.map((member) => { const index = this.props.connectedUsers.findIndex((cu) => { return cu === member.id; }); const classes = classnames({ connected: index != -1 }); return ( <li className={classes} key={member.id}> <ReactGravatar className="react-gravatar" email={member.email} https/> </li> ); }); } _renderAddNewUser() { if (!this.props.currentUserIsOwner) return false; return ( <li> <a onClick={::this._handleAddNewClick} className="add-new" href="#"><i className="fa fa-plus"/></a> {::this._renderForm()} </li> ); } _renderForm() { if (!this.props.show) return false; return ( <PageClick onClick={::this._handleCancelClick}> <ul className="drop-down active"> <li> <form onSubmit={::this._handleSubmit}> <h4>Add new members</h4> {::this._renderError()} <input ref="email" type="email" required={true} placeholder="Member email"/> <button type="submit">Add member</button> or <a onClick={::this._handleCancelClick} href="#">cancel</a> </form> </li> </ul> </PageClick> ); } _renderError() { const { error } = this.props; if (!error) return false; return ( <div className="error"> {error} </div> ); } _handleAddNewClick(e) { e.preventDefault(); this.props.dispatch(Actions.showMembersForm(true)); } _handleCancelClick(e) { e.preventDefault(); this.props.dispatch(Actions.showMembersForm(false)); } _handleSubmit(e) { e.preventDefault(); const { email } = this.refs; const { dispatch, channel } = this.props; dispatch(Actions.addNewMember(channel, email.value)); } render() { return ( <ul className="board-users"> {::this._renderUsers()} {::this._renderAddNewUser()} </ul> ); } } 

In essence, we will iterate through the members parameter, displaying their avatars. The component will also show the Add new button if the current user is the owner of the board. Clicking this button will show the form requesting the participant's e-mail and when sending the form, the calling action constructor addNewMember .


Action constructor addNewMember


From now on, instead of using the controller to create and retrieve the data necessary for our React front-end, we will transfer the responsibility for this to BoardChannel , so that any changes will be sent to each connected user. Not forgetting this, we add the required action constructors:


 // 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 , , . , , .


Note translator

As I have noticed in various sources, sometimes people have a misunderstanding of the differences between different options for sending messages and returning results to the channel. I will try to describe in general each of the possibilities.


When sending from any channel function:


  • {: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 , .


.


')

Source: https://habr.com/ru/post/308382/


All Articles