Trello clone with Phoenix and React (第九章节)

这篇文章属于基于Phoenix Framework 和React的Trello系列

Adding board members

On the last part we created the boards table, the Board model and we also generated the controller which will be in charge of listing and creating new boards for the authenticated users. We also coded the front-end so the boards and the creation form could be displayed. Recalling where we left it, after receiving the successful response from the controller while creating a new board, we wanted to redirect the user to its view so he could see all the details and add more existing users as members. Let’s do this!

The React view component

Before continuing let’s take a look at the React routes:

  1. // web/static/js/routes/index.js
  2. import { IndexRoute, Route } from 'react-router';
  3. import React from 'react';
  4. import MainLayout from '../layouts/main';
  5. import AuthenticatedContainer from '../containers/authenticated';;
  6. import BoardsShowView from '../views/boards/show';
  7. // ...
  8. export default (
  9. <Route component={MainLayout}>
  10. ...
  11. <Route path="/" component={AuthenticatedContainer}>
  12. <IndexRoute component={HomeIndexView} />
  13. ...
  14. <Route path="/boards/:id" component={BoardsShowView}/>
  15. </Route>
  16. </Route>
  17. );

The /boards/:id route is going to be handled by the BoardsShowView component that we need to create:

  1. // web/static/js/views/boards/show.js
  2. import React, {PropTypes} from 'react';
  3. import { connect } from 'react-redux';
  4. import Actions from '../../actions/current_board';
  5. import Constants from '../../constants';
  6. import { setDocumentTitle } from '../../utils';
  7. import BoardMembers from '../../components/boards/members';
  8. class BoardsShowView extends React.Component {
  9. componentDidMount() {
  10. const { socket } = this.props;
  11. if (!socket) {
  12. return false;
  13. }
  14. this.props.dispatch(Actions.connectToChannel(socket, this.props.params.id));
  15. }
  16. componentWillUnmount() {
  17. this.props.dispatch(Actions.leaveChannel(this.props.currentBoard.channel));
  18. }
  19. _renderMembers() {
  20. const { connectedUsers, showUsersForm, channel, error } = this.props.currentBoard;
  21. const { dispatch } = this.props;
  22. const members = this.props.currentBoard.members;
  23. const currentUserIsOwner = this.props.currentBoard.user.id === this.props.currentUser.id;
  24. return (
  25. <BoardMembers
  26. dispatch={dispatch}
  27. channel={channel}
  28. currentUserIsOwner={currentUserIsOwner}
  29. members={members}
  30. connectedUsers={connectedUsers}
  31. error={error}
  32. show={showUsersForm} />
  33. );
  34. }
  35. render() {
  36. const { fetching, name } = this.props.currentBoard;
  37. if (fetching) return (
  38. <div className="view-container boards show">
  39. <i className="fa fa-spinner fa-spin"/>
  40. </div>
  41. );
  42. return (
  43. <div className="view-container boards show">
  44. <header className="view-header">
  45. <h3>{name}</h3>
  46. {::this._renderMembers()}
  47. </header>
  48. <div className="canvas-wrapper">
  49. <div className="canvas">
  50. <div className="lists-wrapper">
  51. {::this._renderAddNewList()}
  52. </div>
  53. </div>
  54. </div>
  55. </div>
  56. );
  57. }
  58. }
  59. const mapStateToProps = (state) => ({
  60. currentBoard: state.currentBoard,
  61. socket: state.session.socket,
  62. currentUser: state.session.currentUser,
  63. });
  64. export default connect(mapStateToProps)(BoardsShowView);

When it mounts it will connect to the board’s channel using the user socket we already created on part 7. When rendering it will first check if the fetching attribute is set to true, if so it will render a spinner while the board’s data is still being fetched. As we can see it takes its props from the currentBoard element in the state which is created by the following reducer.

The reducer and actions creator

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

  1. // web/static/js/reducers/current_board.js
  2. import Constants from '../constants';
  3. const initialState = {
  4. channel: null,
  5. fetching: true,
  6. };
  7. export default function reducer(state = initialState, action = {}) {
  8. switch (action.type) {
  9. case Constants.CURRENT_BOARD_FETHING:
  10. return { ...state, fetching: true };
  11. case Constants.BOARDS_SET_CURRENT_BOARD:
  12. return { ...state, fetching: false, ...action.board };
  13. case Constants.CURRENT_BOARD_CONNECTED_TO_CHANNEL:
  14. return { ...state, channel: action.channel };
  15. default:
  16. return state;
  17. }
  18. }

Let’s take a look to the current_board actions creator to check how do we connect to the channel and dispatch all the necessary data:

  1. // web/static/js/actions/current_board.js
  2. import Constants from '../constants';
  3. const Actions = {
  4. connectToChannel: (socket, boardId) => {
  5. return dispatch => {
  6. const channel = socket.channel(`boards:${boardId}`);
  7. dispatch({ type: Constants.CURRENT_BOARD_FETHING });
  8. channel.join().receive('ok', (response) => {
  9. dispatch({
  10. type: Constants.BOARDS_SET_CURRENT_BOARD,
  11. board: response.board,
  12. });
  13. dispatch({
  14. type: Constants.CURRENT_BOARD_CONNECTED_TO_CHANNEL,
  15. channel: channel,
  16. });
  17. });
  18. };
  19. },
  20. // ...
  21. };
  22. export default Actions;

Just as with the UserChannel, we use the socket to create a new channel identified as boards:${boardId} and we join it, receiving as response the JSON representation of the board, which will be dispatched to the store along with the BOARDS_SET_CURRENT_BOARD action. From now on it will be connected to the channel receiving any change done to the board by any member, refreshing automatically those updates in the screen thanks to React and Redux. But first we need to create the BoardChannel.

The BoardChannel

Although almost all of the remaining functionality is going to be placed in this module, we are now going to just create a very simple version of it:

  1. # web/channels/board_channel.ex
  2. defmodule PhoenixTrello.BoardChannel do
  3. use PhoenixTrello.Web, :channel
  4. alias PhoenixTrello.Board
  5. def join("boards:" <> board_id, _params, socket) do
  6. board = get_current_board(socket, board_id)
  7. {:ok, %{board: board}, assign(socket, :board, board)}
  8. end
  9. defp get_current_board(socket, board_id) do
  10. socket.assigns.current_user
  11. |> assoc(:boards)
  12. |> Repo.get(board_id)
  13. end
  14. end

The join method gets the current board from the assigned user in the socket, returns it and assigns it to the socket so its available for future messages.

第九章:增加董事会成员 - 图1

Board members

Once the board is displayed to the user, the following step is to allow him to add other existing users as members so they can work together on it. To associate boards with other users we have to create a new table to store this relation. Let’s jump to the console and run:

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

We need to update a bit the resulting migration file:

  1. # priv/repo/migrations/20151230081546_create_user_board.exs
  2. defmodule PhoenixTrello.Repo.Migrations.CreateUserBoard do
  3. use Ecto.Migration
  4. def change do
  5. create table(:user_boards) do
  6. add :user_id, references(:users, on_delete: :delete_all), null: false
  7. add :board_id, references(:boards, on_delete: :delete_all), null: false
  8. timestamps
  9. end
  10. create index(:user_boards, [:user_id])
  11. create index(:user_boards, [:board_id])
  12. create unique_index(:user_boards, [:user_id, :board_id])
  13. end
  14. end

Apart from the null constraints, we are going to add a unique index for the user_id and the board_id so a User can’t be added twice to the same Board. After running the necessary mix ecto.migrate lets head to the UserBoard model:

  1. # web/models/user_board.ex
  2. defmodule PhoenixTrello.UserBoard do
  3. use PhoenixTrello.Web, :model
  4. alias PhoenixTrello.{User, Board}
  5. schema "user_boards" do
  6. belongs_to :user, User
  7. belongs_to :board, Board
  8. timestamps
  9. end
  10. @required_fields ~w(user_id board_id)
  11. @optional_fields ~w()
  12. def changeset(model, params \\ :empty) do
  13. model
  14. |> cast(params, @required_fields, @optional_fields)
  15. |> unique_constraint(:user_id, name: :user_boards_user_id_board_id_index)
  16. end
  17. end

Nothing unusual about it, but we also need to add this new relationships to the User model:

  1. # web/models/user.ex
  2. defmodule PhoenixTrello.User do
  3. use PhoenixTrello.Web, :model
  4. # ...
  5. schema "users" do
  6. # ...
  7. has_many :user_boards, UserBoard
  8. has_many :boards, through: [:user_boards, :board]
  9. # ...
  10. end
  11. # ...
  12. end

We have two more relationships, but the one that matters the most to us is the :boards one, which we are going to use for security checks. Let’s also add the collection to the Board model:

  1. # web/models/board.ex
  2. defmodule PhoenixTrello.Board do
  3. # ...
  4. schema "boards" do
  5. # ...
  6. has_many :user_boards, UserBoard
  7. has_many :members, through: [:user_boards, :user]
  8. timestamps
  9. end
  10. end

By doing these changes now we can differentiate between boards created by a user and boards where the user has been invited to. This is very important because when a user is in the board’s view we only want to show the members form if he is the original creator. We also want to automatically add the creator as a member so he gets listed by default, therefore we have to make a small change in the BoardController:

  1. # web/controllers/api/v1/board_controller.ex
  2. defmodule PhoenixTrello.BoardController do
  3. use PhoenixTrello.Web, :controller
  4. #...
  5. def create(conn, %{"board" => board_params}) do
  6. current_user = Guardian.Plug.current_resource(conn)
  7. changeset = current_user
  8. |> build_assoc(:owned_boards)
  9. |> Board.changeset(board_params)
  10. if changeset.valid? do
  11. board = Repo.insert!(changeset)
  12. board
  13. |> build_assoc(:user_boards)
  14. |> UserBoard.changeset(%{user_id: current_user.id})
  15. |> Repo.insert!
  16. conn
  17. |> put_status(:created)
  18. |> render("show.json", board: board )
  19. else
  20. conn
  21. |> put_status(:unprocessable_entity)
  22. |> render("error.json", changeset: changeset)
  23. end
  24. end
  25. end

Note how we build the new UserBoard association and insert it after previously checking if the board is valid.

The board members component

This component will display all the board’s members avatars and the form to add new ones:

第九章:增加董事会成员 - 图2

As you can see, thanks to the previous change in the BoardController, the owner will be displayed as the only member for now. Let’s see how this component will look like:

  1. // web/static/js/components/boards/members.js
  2. import React, {PropTypes} from 'react';
  3. import ReactGravatar from 'react-gravatar';
  4. import classnames from 'classnames';
  5. import PageClick from 'react-page-click';
  6. import Actions from '../../actions/current_board';
  7. export default class BoardMembers extends React.Component {
  8. _renderUsers() {
  9. return this.props.members.map((member) => {
  10. const index = this.props.connectedUsers.findIndex((cu) => {
  11. return cu === member.id;
  12. });
  13. const classes = classnames({ connected: index != -1 });
  14. return (
  15. <li className={classes} key={member.id}>
  16. <ReactGravatar className="react-gravatar" email={member.email} https/>
  17. </li>
  18. );
  19. });
  20. }
  21. _renderAddNewUser() {
  22. if (!this.props.currentUserIsOwner) return false;
  23. return (
  24. <li>
  25. <a onClick={::this._handleAddNewClick} className="add-new" href="#"><i className="fa fa-plus"/></a>
  26. {::this._renderForm()}
  27. </li>
  28. );
  29. }
  30. _renderForm() {
  31. if (!this.props.show) return false;
  32. return (
  33. <PageClick onClick={::this._handleCancelClick}>
  34. <ul className="drop-down active">
  35. <li>
  36. <form onSubmit={::this._handleSubmit}>
  37. <h4>Add new members</h4>
  38. {::this._renderError()}
  39. <input ref="email" type="email" required={true} placeholder="Member email"/>
  40. <button type="submit">Add member</button> or <a onClick={::this._handleCancelClick} href="#">cancel</a>
  41. </form>
  42. </li>
  43. </ul>
  44. </PageClick>
  45. );
  46. }
  47. _renderError() {
  48. const { error } = this.props;
  49. if (!error) return false;
  50. return (
  51. <div className="error">
  52. {error}
  53. </div>
  54. );
  55. }
  56. _handleAddNewClick(e) {
  57. e.preventDefault();
  58. this.props.dispatch(Actions.showMembersForm(true));
  59. }
  60. _handleCancelClick(e) {
  61. e.preventDefault();
  62. this.props.dispatch(Actions.showMembersForm(false));
  63. }
  64. _handleSubmit(e) {
  65. e.preventDefault();
  66. const { email } = this.refs;
  67. const { dispatch, channel } = this.props;
  68. dispatch(Actions.addNewMember(channel, email.value));
  69. }
  70. render() {
  71. return (
  72. <ul className="board-users">
  73. {::this._renderUsers()}
  74. {::this._renderAddNewUser()}
  75. </ul>
  76. );
  77. }
  78. }

Basically it will loop through its members prop displaying their avatars. It will also display the add new button if the current user happens to be the owner of the board. When clicking this button the form will be shown, prompting the user to enter a member email and calling the addNewMember action creator when the form is submitted.

The addNewMember action creator

From now on, instead of using controllers to create and retrieve the necessary data for our React front-end we will move this responsibility into the BoardChannel so any change can be broadcasted to every joined user. Having this in mind let’s add the necessary action creators:

  1. // web/static/js/actions/current_board.js
  2. import Constants from '../constants';
  3. const Actions = {
  4. // ...
  5. showMembersForm: (show) => {
  6. return dispatch => {
  7. dispatch({
  8. type: Constants.CURRENT_BOARD_SHOW_MEMBERS_FORM,
  9. show: show,
  10. });
  11. };
  12. },
  13. addNewMember: (channel, email) => {
  14. return dispatch => {
  15. channel.push('members:add', { email: email })
  16. .receive('error', (data) => {
  17. dispatch({
  18. type: Constants.CURRENT_BOARD_ADD_MEMBER_ERROR,
  19. error: data.error,
  20. });
  21. });
  22. };
  23. },
  24. // ...
  25. }
  26. export default Actions;

The showMembersForm will make the form show or hide, easy as pie. The tricky part comes when we want to add the new member with the email provided by the user. Instead of making the typical http request we’ve been doing so far, we push the message members:add to the channel with the email as parameter. If we receiver an error we will dispatch it so it’s displayed in the screen. Why aren’t we handling the case for a success response? Because we are going to take a different approach, broadcasting the result to all the connected members.

The BoardChannel

Having this said let’s add the underlying message handler to the BoardChannel

``elixir

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)

  1. changeset = user
  2. |> build_assoc(:user_boards)
  3. |> UserBoard.changeset(%{board_id: board.id})
  4. case Repo.insert(changeset) do
  5. {:ok, _board_user} ->
  6. broadcast! socket, "member:added", %{user: user}
  7. PhoenixTrello.Endpoint.broadcast_from! self(), "users:#{user.id}", "boards:add", %{board: board}
  8. {:noreply, socket}
  9. {:error, _changeset} ->
  10. {:reply, {:error, %{error: "Error adding new member"}}, socket}
  11. end
  12. catch
  13. _, _-> {:reply, {:error, %{error: "User does not exist"}}, socket}
  14. end

end

end

  1. Phoenix channels handle incoming messages using the handle_in function and Elixir's powerful pattern matching to handle incoming messages. In our case the message name will be members:add, and it will be also be expecting an email parameter which will be matched to the corresponding variable. It will get the assigned board in the channel, find the user by his email and create a new UserBoard with both of them. If everything goes fine it will broadcast the message member:added to all the available connections passing the added user. Now let's take a closer look to this:
  2. PhoenixTrello.Endpoint.broadcast_from! self(), "users:#{user.id}", "boards:add", %{board: board}
  3. By doing this, it will be broadcasting the message boards:add along with the board to the UserChannel of the added member so the board suddenly appears in his invited boards list. This means we can broadcast any message to any channel from anywhere, which is awesome and brings a new bunch of possibilities and fun.
  4. To handle the member:added message in the front-end we have to add a new handler to the channel where it will dispatch the added member to the store:
  5. ```javascript
  6. // web/static/js/actions/current_board.js
  7. import Constants from '../constants';
  8. const Actions = {
  9. // ...
  10. connectToChannel: (socket, boardId) => {
  11. return dispatch => {
  12. const channel = socket.channel(`boards:${boardId}`);
  13. // ...
  14. channel.on('member:added', (msg) => {
  15. dispatch({
  16. type: Constants.CURRENT_BOARD_MEMBER_ADDED,
  17. user: msg.user,
  18. });
  19. });
  20. // ...
  21. }
  22. },
  23. };
  24. export default Actions;

And we have to do exactly the same for the boards:add, but dispatching the board:

  1. // web/static/js/actions/sessions.js
  2. export function setCurrentUser(dispatch, user) {
  3. channel.on('boards:add', (msg) => {
  4. // ...
  5. dispatch({
  6. type: Constants.BOARDS_ADDED,
  7. board: msg.board,
  8. });
  9. });
  10. };
  11. Finally, we need to update the reducers so both the new member and the new board are added into the application state:
  12. // web/static/js/reducers/current_board.js
  13. export default function reducer(state = initialState, action = {}) {
  14. // ...
  15. case Constants.CURRENT_BOARD_MEMBER_ADDED:
  16. const { members } = state;
  17. members.push(action.user);
  18. return { ...state, members: members, showUsersForm: false };
  19. }
  20. // ...
  21. }
  22. // web/static/js/reducers/boards.js
  23. export default function reducer(state = initialState, action = {}) {
  24. // ...
  25. switch (action.type) {
  26. case Constants.BOARDS_ADDED:
  27. const { invitedBoards } = state;
  28. return { ...state, invitedBoards: [action.board].concat(invitedBoards) };
  29. }
  30. // ...
  31. }

Now the new member’s avatar will appear in the list, and he will have access to the board and the necessary permissions to add and update new lists and cards.

第九章:增加董事会成员 - 图3

If we recall the BoardMembers component we previously described, the className of the avatar depends on wether the member id exists in the connectedUsers list prop or not. This list stores all the ids of the currently connected members to the board’s channel. To create and handle this list we will be using a longtime running stateful Elixir process, but we will do this on the next part. Meanwhile, don’t forget to check out the live demo and final source code:

Live demo Source code Happy coding!