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

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

卡片展示和创建

现在我们已经完成用户注册和权限管,同时连接到Socket,并加入到channels。我们将进入下一阶段:让用户展示和创建自己的卡片。

卡片迁移

首先,我们需要创建迁移和模型,如下所示:

  1. $ mix phoenix.gen.model Board boards board_id:references:board name:string

将会创建新的类似如下所示的迁移文件:

  1. # priv/repo/migrations/20151224093233_create_board.exs
  2. defmodule PhoenixTrello.Repo.Migrations.CreateBoard do
  3. use Ecto.Migration
  4. def change do
  5. create table(:boards) do
  6. add :name, :string, null: false
  7. add :user_id, references(:users, on_delete: :delete_all), null: false
  8. timestamps
  9. end
  10. create index(:boards, [:user_id])
  11. end
  12. end

新建数据表表名是boards,包含id和timestamps字段,以及name字段,name字段是外键,关联于users表。注意如果用户被删除了,数据库将删除这个用户所有的卡片。同时为这个表添加索引,便于查询,和name添加空约束。

完成这些修改后,运行它:

  1. $ mix ecto.migrate

Board模型

让我们看看Board模型:

  1. # web/models/board.ex
  2. defmodule PhoenixTrello.Board do
  3. use PhoenixTrello.Web, :model
  4. alias __MODULE__
  5. @derive {Poison.Encoder, only: [:id, :name, :user]}
  6. schema "boards" do
  7. field :name, :string
  8. belongs_to :user, User
  9. timestamps
  10. end
  11. @required_fields ~w(name user_id)
  12. @optional_fields ~w()
  13. @doc """
  14. Creates a changeset based on the `model` and `params`.
  15. If no params are provided, an invalid changeset is returned
  16. with no validation performed.
  17. """
  18. def changeset(model, params \\ :empty) do
  19. model
  20. |> cast(params, @required_fields, @optional_fields))
  21. end
  22. end

到现在还没有重要的需要提醒的事情,我们仅需要更新User模型,并关联到owned_boards:

  1. # web/models/user.ex
  2. defmodule PhoenixTrello.User do
  3. use PhoenixTrello.Web, :model
  4. # ...
  5. schema "users" do
  6. # ...
  7. has_many :owned_boards, PhoenixTrello.Board
  8. # ...
  9. end
  10. # ...
  11. end

为什么是owned_boards?用户创建的卡片和用户添加其他人的卡片是有区别的,现在不用考虑这些,以后我们会更深入地讨论。

BoardController

为了实现新的boards,我们将更新路由文件,并添加必要的入口,便于处理各种请求。

  1. # web/router.ex
  2. defmodule PhoenixTrello.Router do
  3. use PhoenixTrello.Web, :router
  4. # ...
  5. scope "/api", PhoenixTrello do
  6. # ...
  7. scope "/v1" do
  8. # ...
  9. resources "boards", BoardController, only: [:index, :create]
  10. end
  11. end
  12. # ...
  13. end

在添加boards资源时,仅创建了index和create动作,BoardController将会处理这些请求:

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

让我们创建新的控制器:

  1. # web/controllers/board_controller.ex
  2. defmodule PhoenixTrello.BoardController do
  3. use PhoenixTrello.Web, :controller
  4. plug Guardian.Plug.EnsureAuthenticated, handler: PhoenixTrello.SessionController
  5. alias PhoenixTrello.{Repo, Board}
  6. def index(conn, _params) do
  7. current_user = Guardian.Plug.current_resource(conn)
  8. owned_boards = current_user
  9. |> assoc(:owned_boards)
  10. |> Board.preload_all
  11. |> Repo.all
  12. render(conn, "index.json", owned_boards: owned_boards)
  13. end
  14. def create(conn, %{"board" => board_params}) do
  15. current_user = Guardian.Plug.current_resource(conn)
  16. changeset = current_user
  17. |> build_assoc(:owned_boards)
  18. |> Board.changeset(board_params)
  19. case Repo.insert(changeset) do
  20. {:ok, board} ->
  21. conn
  22. |> put_status(:created)
  23. |> render("show.json", board: board )
  24. {:error, changeset} ->
  25. conn
  26. |> put_status(:unprocessable_entity)
  27. |> render("error.json", changeset: changeset)
  28. end
  29. end
  30. end

注意:我们从Guardian添加了 EnsureAuthenticated 插件,因此在这个控制器中只允许有授权的连接。在index动作中,我们从连接中获取当前用户,并从数据库检索相关卡片信息,便于在BoardView展示。几乎同时,在create动作中,我们从user建立了owned_board变更,并插入到数据库,如果一切顺利就渲染卡片。

让我们创建 BoardView:

  1. # web/views/board_view.ex
  2. defmodule PhoenixTrello.BoardView do
  3. use PhoenixTrello.Web, :view
  4. def render("index.json", %{owned_boards: owned_boards}) do
  5. %{owned_boards: owned_boards}
  6. end
  7. def render("show.json", %{board: board}) do
  8. board
  9. end
  10. def render("error.json", %{changeset: changeset}) do
  11. errors = Enum.map(changeset.errors, fn {field, detail} ->
  12. %{} |> Map.put(field, detail)
  13. end)
  14. %{
  15. errors: errors
  16. }
  17. end
  18. end

React 视图组件

服务器后端已经准备好处理卡片展示和创建请求,现在让我们着重于前端。在用户登陆后,首先将展示用户的卡片,同时可以点击窗口创建新的卡片,让我们来创建HomeIndexView:

  1. // web/static/js/views/home/index.js
  2. import React from 'react';
  3. import { connect } from 'react-redux';
  4. import classnames from 'classnames';
  5. import { setDocumentTitle } from '../../utils';
  6. import Actions from '../../actions/boards';
  7. import BoardCard from '../../components/boards/card';
  8. import BoardForm from '../../components/boards/form';
  9. class HomeIndexView extends React.Component {
  10. componentDidMount() {
  11. setDocumentTitle('Boards');
  12. const { dispatch } = this.props;
  13. dispatch(Actions.fetchBoards());
  14. }
  15. _renderOwnedBoards() {
  16. const { fetching } = this.props;
  17. let content = false;
  18. const iconClasses = classnames({
  19. fa: true,
  20. 'fa-user': !fetching,
  21. 'fa-spinner': fetching,
  22. 'fa-spin': fetching,
  23. });
  24. if (!fetching) {
  25. content = (
  26. <div className="boards-wrapper">
  27. {::this._renderBoards(this.props.ownedBoards)}
  28. {::this._renderAddNewBoard()}
  29. </div>
  30. );
  31. }
  32. return (
  33. <section>
  34. <header className="view-header">
  35. <h3><i className={iconClasses} /> My boards</h3>
  36. </header>
  37. {content}
  38. </section>
  39. );
  40. }
  41. _renderBoards(boards) {
  42. return boards.map((board) => {
  43. return <BoardCard
  44. key={board.id}
  45. dispatch={this.props.dispatch}
  46. {...board} />;
  47. });
  48. }
  49. _renderAddNewBoard() {
  50. let { showForm, dispatch, formErrors } = this.props;
  51. if (!showForm) return this._renderAddButton();
  52. return (
  53. <BoardForm
  54. dispatch={dispatch}
  55. errors={formErrors}
  56. onCancelClick={::this._handleCancelClick}/>
  57. );
  58. }
  59. _renderAddButton() {
  60. return (
  61. <div className="board add-new" onClick={::this._handleAddNewClick}>
  62. <div className="inner">
  63. <a id="add_new_board">Add new board...</a>
  64. </div>
  65. </div>
  66. );
  67. }
  68. _handleAddNewClick() {
  69. let { dispatch } = this.props;
  70. dispatch(Actions.showForm(true));
  71. }
  72. _handleCancelClick() {
  73. this.props.dispatch(Actions.showForm(false));
  74. }
  75. render() {
  76. return (
  77. <div className="view-container boards index">
  78. {::this._renderOwnedBoards()}
  79. </div>
  80. );
  81. }
  82. }
  83. const mapStateToProps = (state) => (
  84. state.boards
  85. );
  86. export default connect(mapStateToProps)(HomeIndexView);

这里内容很多,让我们一个一个的看:

  • 首先我们必须牢记:组件需要连接到sotre,并且属性改变来自于我们创建的卡片reducer。
  • When it mounts it will change the document’s title to Boards and will dispatch and action creator to fetch the boards on the back-end.
  • For now it will just render the owned_boards array in the store and also the BoardForm component.
  • Before rendering this two, it will first check if the fetching prop is set to true. If so, it will mean that boards are still being fetched so it will render a spinner. Otherwise it will render the list of boards and the button for adding a new board.
  • When clicking the add new board button it will dispatch a new action creator for hiding the button and showing the form.

现在让我们来添加BoardForm 组件:

  1. // web/static/js/components/boards/form.js
  2. import React, { PropTypes } from 'react';
  3. import PageClick from 'react-page-click';
  4. import Actions from '../../actions/boards';
  5. import {renderErrorsFor} from '../../utils';
  6. export default class BoardForm extends React.Component {
  7. componentDidMount() {
  8. this.refs.name.focus();
  9. }
  10. _handleSubmit(e) {
  11. e.preventDefault();
  12. const { dispatch } = this.props;
  13. const { name } = this.refs;
  14. const data = {
  15. name: name.value,
  16. };
  17. dispatch(Actions.create(data));
  18. }
  19. _handleCancelClick(e) {
  20. e.preventDefault();
  21. this.props.onCancelClick();
  22. }
  23. render() {
  24. const { errors } = this.props;
  25. return (
  26. <PageClick onClick={::this._handleCancelClick}>
  27. <div className="board form">
  28. <div className="inner">
  29. <h4>New board</h4>
  30. <form id="new_board_form" onSubmit={::this._handleSubmit}>
  31. <input ref="name" id="board_name" type="text" placeholder="Board name" required="true"/>
  32. {renderErrorsFor(errors, 'name')}
  33. <button type="submit">Create board</button> or <a href="#" onClick={::this._handleCancelClick}>cancel</a>
  34. </form>
  35. </div>
  36. </div>
  37. </PageClick>
  38. );
  39. }
  40. }

这是一个很简单的组件。用于渲染表格This is a very simple component. It renders the form and when submitted it dispatches an action creator to create the new board with the supplied name. The PageClick component is an external component I found which detects page clicks outside the wrapper element. In our case we will use it to hide the form and show the Add new board… button again.

action creators

我们最少需要 3个 action creators:

  1. // web/static/js/actions/boards.js
  2. import Constants from '../constants';
  3. import { routeActions } from 'react-router-redux';
  4. import { httpGet, httpPost } from '../utils';
  5. import CurrentBoardActions from './current_board';
  6. const Actions = {
  7. fetchBoards: () => {
  8. return dispatch => {
  9. dispatch({ type: Constants.BOARDS_FETCHING });
  10. httpGet('/api/v1/boards')
  11. .then((data) => {
  12. dispatch({
  13. type: Constants.BOARDS_RECEIVED,
  14. ownedBoards: data.owned_boards
  15. });
  16. });
  17. };
  18. },
  19. showForm: (show) => {
  20. return dispatch => {
  21. dispatch({
  22. type: Constants.BOARDS_SHOW_FORM,
  23. show: show,
  24. });
  25. };
  26. },
  27. create: (data) => {
  28. return dispatch => {
  29. httpPost('/api/v1/boards', { board: data })
  30. .then((data) => {
  31. dispatch({
  32. type: Constants.BOARDS_NEW_BOARD_CREATED,
  33. board: data,
  34. });
  35. dispatch(routeActions.push(`/boards/${data.id}`));
  36. })
  37. .catch((error) => {
  38. error.response.json()
  39. .then((json) => {
  40. dispatch({
  41. type: Constants.BOARDS_CREATE_ERROR,
  42. errors: json.errors,
  43. });
  44. });
  45. });
  46. };
  47. },
  48. };
  49. export default Actions;
  • fetchBoards: it will first dispatch the BOARDS_FETCHING action type so we can render the spinner previously mentioned. I will also launch the http request to the back-end to retrieve the boards owned by the user which will be handled by the BoardController:index action. When the response is back, it will dispatch the boards to the store.
  • showForm: this one is pretty simple and it will just dispatch the BOARDS_SHOW_FORM action to set whether we want to show the form or not.
  • create: it will send a POST request to create the new board. If the response is successful then it will dispatch the BOARDS_NEW_BOARD_CREATED action with the created board, so its added to the boards in the store and it will navigate to the show board route. In case there is any error it will dispatch the BOARDS_CREATE_ERROR.

The reducer

The last piece of the puzzle would be the reducer which is very simple:

  1. // web/static/js/reducers/boards.js
  2. import Constants from '../constants';
  3. const initialState = {
  4. ownedBoards: [],
  5. showForm: false,
  6. formErrors: null,
  7. fetching: true,
  8. };
  9. export default function reducer(state = initialState, action = {}) {
  10. switch (action.type) {
  11. case Constants.BOARDS_FETCHING:
  12. return { ...state, fetching: true };
  13. case Constants.BOARDS_RECEIVED:
  14. return { ...state, ownedBoards: action.ownedBoards, fetching: false };
  15. case Constants.BOARDS_SHOW_FORM:
  16. return { ...state, showForm: action.show };
  17. case Constants.BOARDS_CREATE_ERROR:
  18. return { ...state, formErrors: action.errors };
  19. case Constants.BOARDS_NEW_BOARD_CREATED:
  20. const { ownedBoards } = state;
  21. return { ...state, ownedBoards: [action.board].concat(ownedBoards) };
  22. default:
  23. return state;
  24. }
  25. }

Note how we set the fetching attribute to false once we load the boards and how we concat the new board created to the existing ones.

Enough work for today! In the next post we will build the view to show a board and we will also add the functionality for adding new members to it, broadcasting the board to the related users so it appears in their invited boards list that we will also have to add. 同样,别忘记查看运行演示和下载最终的源代码:

演示 源代码