├── 1-Intro and selected stack.md ├── 10-Tracking connected board members.md ├── 11-Adding lists and cards.md ├── 12-Deploying on Heroku.md ├── 2-Phoenix Framework project setup.md ├── 3-The User model and JWT auth.md ├── 4-Front-end for sign up with React and Redux.md ├── 5-Database seeding and sign in controller.md ├── 6-Front-end authentication with React and Redux.md ├── 7-Setting up sockets and channels.md ├── 8-Listing and creating new boards.md ├── 9-Adding board members.md ├── README.md └── images ├── part1 ├── boards.jpg ├── show-board.jpg └── sign-in.jpg ├── part10 └── board_4 .jpg ├── part11 ├── card_form.jpg ├── list_form.jpg ├── new_card.jpg ├── new_list.jpg └── no_lists.jpg └── part9 ├── board_1.jpg ├── board_3.jpg └── board_4.jpg /1-Intro and selected stack.md: -------------------------------------------------------------------------------- 1 | #Trello clone with Phoenix and React (第一章节) 2 | 3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列 4 | 5 | [Trello](https://trello.com/) 是我一直喜欢的一款Web应用。从这款应用发布的时候我就使用它,我喜欢它的工作机制——简单而且灵活。每当我学习新技术的时候,自己很喜欢创建一个实际的应用,这样我可以把一切都放在实践中,从中学到实际中可能遇到的问题,以及查找并解决这些问题。当自己开始学习[Elixir](http://elixir-lang.org/)和[Phoenix framework](http://www.phoenixframework.org/)的时候,我很清楚应该把我即将遇到许多的未知问题都放到实践中,编写一个简单但功能完备的Trello,并分享他。 6 | 7 | ##创建的内容 8 | 9 | 基本上我们打算编写单页应用,用户可以登录、创建卡片、分享卡片以及用户之间添加列表和卡片。当查看一个卡片,将显示所有连接用户,任何修改将实时的以Trello风格自动反馈到每一个连接用户。 10 | 11 | ###当前架构 12 | 13 | **Phoenix**使用 **npm** 管理静态资源,可以使用 **Brunch** 或者 **Webpack**构建它们,非常简单的实现前后端分离,同时它们有相同的代码库。以下是后端需要使用的: 14 | 15 | * Elixir. 16 | * Phoenix framework. 17 | * Ecto. 18 | * PostgreSQL. 19 | 20 | 单页面前端将使用以下技术: 21 | * Webpack. 22 | * Sass 用于stylesheets. 23 | * React. 24 | * React router. 25 | * Redux. 26 | * ES6/ES7 JavaScript. 27 | 28 | 同时,将使用一些Elixir依赖和npm包,我将在使用的时候讨论他们。 29 | 30 | ###为什么是这个架构? 31 | 32 | Elixir是一种基于Erlang的快速而且强大的函数式编程语言,同时拥有非常友好的类似于Ruby的语法。得益于Erlang的虚拟机,Elixir拥有非常强大且专业的并发特性,它可以自动地管理成千上万的并发进程。我是一名Elixir新手,因此还需要学习很多,但是我可以说经过这个练习后我将获得实质上提高。 33 | 34 | 我们现在将使用Phoenix这个Elixir语言最流行的Web框架,这个框架不仅可以使用由Rails Web开发带来的部分部件和标准,而且提供了很多很酷的特点,例如上面提到的静态资源管理,更重要的是,box的实时功能可以通过Channels(原文为websockets)很容易实现,而且不需要额外的依赖(相信我,它非常有魅力)。 35 | 36 | 另一方面,我们使用了React, react-router 和Redux,因为我喜欢使用这个组合来创建单页面应用和管理他们的状态。作为替代CoffeeScript,今年我开始使用ES6和ES7,这是一个非常好开始使用它的机会 37 | 38 | ###最终效果 39 | 40 | 应用包含四个不同页面。首先是用户注册和用户登录画面。 41 | 42 | ![登录画面](/images/part1/sign-in.jpg) 43 | 44 | 主页面包用户的含卡片列表,以及他添加其他用户的卡片列表: 45 | 46 | ![卡片画面](/images/part1/boards.jpg) 47 | 48 | 最后是卡片页面,所有连接的用户都可以看到,同时可以管理列表和卡片。 49 | 50 | ![卡片内容](/images/part1/show-board.jpg) 51 | 52 | 因此,这些就足够聊聊了。就看到这里吧,让我开始这个系列的第二章节。第二章节包含了如何创建一个Phoenix项目,并且如何使用Webpack替代Brunch作为Phoenix静态资源管理,以及后端基本设置。 53 | 54 | -------------------------------------------------------------------------------- /10-Tracking connected board members.md: -------------------------------------------------------------------------------- 1 | #Trello clone with Phoenix and React (第十章节) 2 | 3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列 4 | 5 | Disclaimer: 6 | This post is written before the Presence functionality and intended to be a small introduction to the basics of the GenServer behaviour. 7 | 8 | #Tracking connect board members 9 | 10 | Recalling last part, we supplied our users with the ability of adding new members to their boards. When an existing user email was added, the new relationship between users and boards was created and the new user was broadcasted along the channel so his avatar would be displayed to all connected members of the board. At first this is cool, but we can do it much better and useful if we could just highlight the members that are currently online and viewing the board. Let's get started! 11 | 12 | ##The problem 13 | 14 | Before continuing let's first think about what do we want to achieve. So basically we have a board and multiple members that can suddenly visit its url automatically connecting them to the board channel. When this happens, the member's avatar should be displayed without opacity, contrary to offline members avatars which are displayed semitransparent to differentiate them. 15 | 16 | ![](/images/part10/board_4.jpg) 17 | 18 | When a connected member leaves the board's url, signs out or even closes his browser we want to broadcast again this event to all connected users in the board channel so his avatar gets semitransparent again, reflecting the user is no longer viewing the board. Let's think about some ways we could achieve this and their drawbacks: 19 | 20 | 1. Managing the connected members list on the front-end in the Redux store. This can sound as a valid approach at first but it will only work for members which are already connected to the board channel. Recently connected users will not have that data on their application state. 21 | 2. Using the database to keep track of connected members. This could also be valid, but will force us to constantly be hitting the database to ask for connected members and update it whenever a members connects or leaves, not to mention mixing data with a very specific user behavior. 22 | 23 | So where can we store this information so it's accessible to all users in a fast and efficient way? Easy. In a... wait for it... long running stateful process. 24 | 25 | ##The GenServer behavior 26 | 27 | Although long running stateful process might sound a bit intimidating at first, it's a lot more easier to implement than we might expect, thanks to Elixir and it's GensServer behavior. 28 | 29 | > A GenServer is a process as any other Elixir process and it can be used to keep state, execute code asynchronously and so on. 30 | 31 | 32 | Imagine it as a small process running in our server with a map containing the list of connected user ids per board. Something like this: 33 | 34 | ```elixir 35 | %{ 36 | "1" => [1, 2, 3], 37 | "2" => [4, 5] 38 | } 39 | ``` 40 | 41 | Now imagine that this process ​had a public interface to init itself and update its state map, for adding or removing boards and connected users. Well, that's basically a GenServer process, and I say basically because it will also have underlying advantages like tracing, error reporting and supervision capabilities. 42 | 43 | ##The BoardChannel Monitor 44 | 45 | So let's create our very basic version of this process which is going to keep track of the list of board connected members: 46 | 47 | ```elixir 48 | # /lib/phoenix_trello/board_channel/monitor.ex 49 | 50 | defmodule PhoenixTrello.BoardChannel.Monitor do 51 | use GenServer 52 | 53 | ##### 54 | # Client API 55 | 56 | def start_link(initial_state) do 57 | GenServer.start_link(__MODULE__, initial_state, name: __MODULE__) 58 | end 59 | end 60 | ``` 61 | 62 | When working with GenServer we have to think both in the external client API functions and the server implementation of them. The first we need to implement is the start_link one, which will really start our GenServer passing the initial state, in our case an empty map, as an argument among the module and the name of the server. We want this process to start when our application starts too, so let's add it to the children list of our application supervision tree: 63 | 64 | ```elixir 65 | # /lib/phoenix_trello.ex 66 | 67 | defmodule PhoenixTrello do 68 | use Application 69 | 70 | def start(_type, _args) do 71 | import Supervisor.Spec, warn: false 72 | 73 | children = [ 74 | # ... 75 | worker(PhoenixTrello.BoardChannel.Monitor, [%{}]), 76 | # ... 77 | ] 78 | 79 | # ... 80 | end 81 | end 82 | ``` 83 | 84 | By doing this, every time our application starts it will automatically call the start_link function we've just created passing the %{} empty map as initial state. If the Monitor happened to break for any reason, the application will also automatically restart it again with a new empty map. Cool, isn't it? Now that we have setup everything let's beging with adding members to the Monitor's state map. 85 | 86 | ##Handling joining members 87 | 88 | For this we need to add both the client function and it's server callback handler: 89 | 90 | ```elixir 91 | # /lib/phoenix_trello/board_channel/monitor.ex 92 | 93 | defmodule PhoenixTrello.BoardChannel.Monitor do 94 | use GenServer 95 | 96 | ##### 97 | # Client API 98 | 99 | # ... 100 | 101 | def member_joined(board, member) do 102 | GenServer.call(__MODULE__, {:member_joined, board, member}) 103 | end 104 | 105 | ##### 106 | # Server callbacks 107 | 108 | def handle_call({:member_joined, board, member}, _from, state) do 109 | state = case Map.get(state, board) do 110 | nil -> 111 | state = state 112 | |> Map.put(board, [member]) 113 | 114 | {:reply, [member], state} 115 | members -> 116 | state = state 117 | |> Map.put(board, Enum.uniq([member | members])) 118 | 119 | {:reply, Map.get(state, board), state} 120 | end 121 | end 122 | end 123 | ``` 124 | 125 | When calling the member_joined/2 function passing a board and a user, we will internally make a call to the GenServer process with the message {:member_joined, board, member}. Thus why we need to add a server callback handler for it. The handle_call/3 callback function from GenServer receives the request message, the caller, and the current state. So in our case we will try to get the board from the state, and add the user to the list of users for it. In case we don't have that board yet, we'll add it with a new list containing the joined user. As response we will return the user list belonging to the board. 126 | 127 | Having this done, where should we call the member_joined method? In the BoardChannel while the user joins: 128 | 129 | ```elixir 130 | # /web/channels/board_channel.ex 131 | 132 | defmodule PhoenixTrello.BoardChannel do 133 | use PhoenixTrello.Web, :channel 134 | 135 | alias PhoenixTrello.{User, Board, UserBoard, List, Card, Comment, CardMember} 136 | alias PhoenixTrello.BoardChannel.Monitor 137 | 138 | def join("boards:" <> board_id, _params, socket) do 139 | current_user = socket.assigns.current_user 140 | board = get_current_board(socket, board_id) 141 | 142 | connected_users = Monitor.user_joined(board_id, current_user.id) 143 | 144 | send(self, {:after_join, connected_users}) 145 | 146 | {:ok, %{board: board}, assign(socket, :board, board)} 147 | end 148 | 149 | def handle_info({:after_join, connected_users}, socket) do 150 | broadcast! socket, "user:joined", %{users: connected_users} 151 | 152 | {:noreply, socket} 153 | end 154 | 155 | # ... 156 | end 157 | ``` 158 | 159 | So when he joins we use the new Monitor to track him, and broadcast through the socket the updated list of users currently in the board. Now we can handle this broadcast in the front-end to update the application state with the new list of connected users: 160 | 161 | ```javascript 162 | // /web/static/js/actions/current_board.js 163 | 164 | import Constants from '../constants'; 165 | 166 | const Actions = { 167 | 168 | // ... 169 | connectToChannel: (socket, boardId) => { 170 | return dispatch => { 171 | const channel = socket.channel(`boards:${boardId}`); 172 | // ... 173 | 174 | channel.on('user:joined', (msg) => { 175 | dispatch({ 176 | type: Constants.CURRENT_BOARD_CONNECTED_USERS, 177 | users: msg.users, 178 | }); 179 | }); 180 | }; 181 | } 182 | } 183 | ``` 184 | 185 | The only thing left is to change the avatar's opacity depending on whether the board member is listed in this array or not: 186 | 187 | ```javascript 188 | // /web/static/js/components/boards/users.js 189 | 190 | export default class BoardUsers extends React.Component { 191 | _renderUsers() { 192 | return this.props.users.map((user) => { 193 | const index = this.props.connectedUsers.findIndex((cu) => { 194 | return cu.id === user.id; 195 | }); 196 | 197 | const classes = classnames({ connected: index != -1 }); 198 | 199 | return ( 200 |
  • 201 | 202 |
  • 203 | ); 204 | }); 205 | } 206 | 207 | // ... 208 | } 209 | ``` 210 | Handling member disconnection 211 | 212 | The process when a user leaves the board channel is almost the same. Let's first update the Monitor to add the necessary client function and its server callback handler: 213 | 214 | ```elixir 215 | # /lib/phoenix_trello/board_channel/monitor.ex 216 | 217 | defmodule PhoenixTrello.BoardChannel.Monitor do 218 | use GenServer 219 | 220 | ##### 221 | # Client API 222 | 223 | # ... 224 | 225 | def member_left(board, member) do 226 | GenServer.call(__MODULE__, {:member_left, board, member}) 227 | end 228 | 229 | ##### 230 | # Server callbacks 231 | 232 | # ... 233 | 234 | def handle_call({:member_left, board, member}, _from, state) do 235 | new_members = state 236 | |> Map.get(board) 237 | |> List.delete(member) 238 | 239 | state = state 240 | |> Map.update!(board, fn(_) -> new_members end) 241 | 242 | {:reply, new_members, state} 243 | end 244 | end 245 | ``` 246 | 247 | As you can see, it's almost the same functionality as the member_joined but reversed. It looks for the board in the state and deletes the member, replacing the existing members list with this new one and returning it in the response. As in the join functionality we are also going to call this function from the BoardChannel so let's update it: 248 | 249 | ```elixir 250 | # /web/channels/board_channel.ex 251 | 252 | defmodule PhoenixTrello.BoardChannel do 253 | use PhoenixTrello.Web, :channel 254 | # ... 255 | 256 | def terminate(_reason, socket) do 257 | board_id = Board.slug_id(socket.assigns.board) 258 | user_id = socket.assigns.current_user.id 259 | 260 | broadcast! socket, "user:left", %{users: Monitor.user_left(board_id, user_id)} 261 | 262 | :ok 263 | end 264 | end 265 | ``` 266 | 267 | When the connection to the channel terminates, it will broadcast the updated list of members through the socket just like we did before. To terminate the channel connection we will create an action creator that we'll use once the current board view is unmounted, and we also need to add the handler for the user:left broadcast: 268 | 269 | ```javascript 270 | // /web/static/js/actions/current_board.js 271 | 272 | import Constants from '../constants'; 273 | 274 | const Actions = { 275 | 276 | // ... 277 | 278 | connectToChannel: (socket, boardId) => { 279 | return dispatch => { 280 | const channel = socket.channel(`boards:${boardId}`); 281 | // ... 282 | 283 | channel.on('user:left', (msg) => { 284 | dispatch({ 285 | type: Constants.CURRENT_BOARD_CONNECTED_USERS, 286 | users: msg.users, 287 | }); 288 | }); 289 | }; 290 | }, 291 | 292 | leaveChannel: (channel) => { 293 | return dispatch => { 294 | channel.leave(); 295 | }; 296 | }, 297 | } 298 | ``` 299 | 300 | Don't forget to update the BoardsShowView component to dispatch the leaveChannel action creator when it unmounts: 301 | 302 | ```javascript 303 | // /web/static/js/views/boards/show.js 304 | 305 | import Actions from '../../actions/current_board'; 306 | // ... 307 | 308 | class BoardsShowView extends React.Component { 309 | // ... 310 | 311 | componentWillUnmount() { 312 | const { dispatch, currentBoard} = this.props; 313 | 314 | dispatch(Actions.leaveChannel(currentBoard.channel)); 315 | } 316 | 317 | } 318 | // ... 319 | ``` 320 | And that's it! To test it just open two different browsers and sign in with a different user on each. Then navigate to the same board wit both and and play around entering and leaving with the other. You'll se his avatar transitioning from semitransparent and back again, which is pretty cool. 321 | 322 | I hope you have enjoyed working with GenServer as much as I did the first time. But we have only scratched the surface. GenServer and Supervisors are very powerful tools Elixir offers us, which are completely native and bullet proof, without the need of third party dependencies contrary to Redis, for instance. In the next post we will continue creating lists and cards in realtime with the help of the socket and channels. Meanwhile, don't forget to check out the live demo and final source code: 323 | 324 | Live demo Source code 325 | Happy coding! 326 | 327 | 328 | -------------------------------------------------------------------------------- /11-Adding lists and cards.md: -------------------------------------------------------------------------------- 1 | #Trello clone with Phoenix and React (第十一章节) 2 | 3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列 4 | 5 | #Adding lists and cards 6 | 7 | In the last part we created a simple, yet useful, mechanism for tracking connected members to a board's channel with the help of OTP and the GenServer behaviour. We also learned how to broadcast this list through the channel so every member could see who else is viewing the board at the the same time. Now it's time to let the members add some lists and cards while the changes appear in their screens in realtime... Lets do this! 8 | 9 | ![](/images/part11/no_lists.jpg) 10 | 11 | ##Migrations and models 12 | 13 | A Board can have multiple lists, which in turn may have multiple cards as well, so having this in mind lets start by generating the List model using the following mix task from the console: 14 | 15 | ``` 16 | $ mix phoenix.gen.model List lists board_id:references:board name:string 17 | ... 18 | ... 19 | $ mix ecto.migrate 20 | ``` 21 | 22 | This will generate the lists table in the database and the model: 23 | 24 | ```elixir 25 | # web/models/list.ex 26 | 27 | defmodule PhoenixTrello.List do 28 | use PhoenixTrello.Web, :model 29 | 30 | alias PhoenixTrello.{Board, List} 31 | 32 | @derive {Poison.Encoder, only: [:id, :board_id, :name]} 33 | 34 | schema "lists" do 35 | field :name, :string 36 | belongs_to :board, Board 37 | 38 | timestamps 39 | end 40 | 41 | @required_fields ~w(name) 42 | @optional_fields ~w() 43 | 44 | def changeset(model, params \\ :empty) do 45 | model 46 | |> cast(params, @required_fields, @optional_fields) 47 | end 48 | end 49 | ``` 50 | 51 | Generating the Card model is going to be pretty similar: 52 | 53 | ``` 54 | $ mix phoenix.gen.model Card cards list_id:references:lists name:string 55 | ... 56 | ... 57 | $ mix ecto.migrate 58 | ``` 59 | 60 | The resulting model will be something similar to this: 61 | 62 | ```elixir 63 | # web/models/card.ex 64 | 65 | defmodule PhoenixTrello.Card do 66 | use PhoenixTrello.Web, :model 67 | 68 | alias PhoenixTrello.{Repo, List, Card} 69 | 70 | @derive {Poison.Encoder, only: [:id, :list_id, :name]} 71 | 72 | schema "cards" do 73 | field :name, :string 74 | belongs_to :list, List 75 | 76 | timestamps 77 | end 78 | 79 | @required_fields ~w(name list_id) 80 | @optional_fields ~w() 81 | 82 | def changeset(model, params \\ :empty) do 83 | model 84 | |> cast(params, @required_fields, @optional_fields) 85 | end 86 | end 87 | ``` 88 | 89 | Don't forget to add the collection of cards to the lists schema: 90 | 91 | ```elixir 92 | # web/models/list.ex 93 | 94 | defmodule PhoenixTrello.List do 95 | # ... 96 | 97 | @derive {Poison.Encoder, only: [:id, :board_id, :name, :cards]} 98 | 99 | # ... 100 | 101 | schema "lists" do 102 | # .. 103 | 104 | has_many :cards, Card 105 | end 106 | 107 | # ... 108 | end 109 | ``` 110 | 111 | Now we can move forward to the front-end and create the necessary components. 112 | 113 | ##The list form component 114 | 115 | Before continuing, lets recall the render function of the BoardsShowView component: 116 | 117 | ```javascript 118 | // web/static/js/views/boards/show.js 119 | 120 | //... 121 | //... 122 | _renderLists() { 123 | const { lists, channel, id, addingNewCardInListId } = this.props.currentBoard; 124 | 125 | return lists.map((list) => { 126 | return ( 127 | 134 | ); 135 | }); 136 | } 137 | 138 | render() { 139 | const { fetching, name } = this.props.currentBoard; 140 | 141 | if (fetching) return ( 142 |
    143 | 144 |
    145 | ); 146 | 147 | return ( 148 |
    149 |
    150 |

    {name}

    151 | {::this._renderMembers()} 152 |
    153 |
    154 |
    155 |
    156 | {::this._renderLists()} 157 | {::this._renderAddNewList()} 158 |
    159 |
    160 |
    161 | {this.props.children} 162 |
    163 | ); 164 | } 165 | ``` 166 | 167 | Apart from the BoardMembers component we created the last time, we need to render all the lists belonging to the board as well. For the time being we don't have any lists, therefore lets move on to the _renderAddNewList function: 168 | 169 | ```javascript 170 | // web/static/js/views/boards/show.js 171 | 172 | // ... 173 | 174 | _renderAddNewList() { 175 | const { dispatch, formErrors, currentBoard } = this.props; 176 | 177 | if (!currentBoard.showForm) return this._renderAddButton(); 178 | 179 | return ( 180 | 185 | ); 186 | } 187 | 188 | _renderAddButton() { 189 | return ( 190 |
    191 |
    192 | Add new list... 193 |
    194 |
    195 | ); 196 | } 197 | 198 | _handleAddNewClick() { 199 | const { dispatch } = this.props; 200 | 201 | dispatch(Actions.showForm(true)); 202 | } 203 | 204 | _handleCancelClick() { 205 | this.props.dispatch(Actions.showForm(false)); 206 | } 207 | 208 | // ... 209 | ``` 210 | 211 | The _renderAddNewList function first checks if the currentBoard.showForm property is set to true so it renders the Add new list... button instead of the ListForm component. 212 | 213 | When the user clicks the button, an action will be dispatched to the store and will set its property showForm to true making form to be displayed. Now lets create the form component: 214 | 215 | ```javascript 216 | // web/static/js/components/lists/form.js 217 | 218 | import React, { PropTypes } from 'react'; 219 | import Actions from '../../actions/lists'; 220 | 221 | export default class ListForm extends React.Component { 222 | componentDidMount() { 223 | this.refs.name.focus(); 224 | } 225 | 226 | _handleSubmit(e) { 227 | e.preventDefault(); 228 | 229 | const { dispatch, channel } = this.props; 230 | const { name } = this.refs; 231 | 232 | const data = { 233 | name: name.value, 234 | }; 235 | 236 | dispatch(Actions.save(channel, data)); 237 | } 238 | 239 | _handleCancelClick(e) { 240 | e.preventDefault(); 241 | 242 | this.props.onCancelClick(); 243 | } 244 | 245 | render() { 246 | return ( 247 |
    248 |
    249 |
    250 | 251 | or cancel 252 |
    253 |
    254 |
    255 | ); 256 | } 257 | } 258 | ``` 259 | ![](/images/part11/list_form.jpg) 260 | 261 | This is a very simple component with a form containing a text input for the name of the list, a submit button and a cancel link, which will dispatch the same action we have previously described but setting the showForm property to false to remove the form. When the form is submitted it will dispatch the save action creator with the name the user has provided, which will push it to the lists:create topic of the BoardChannel: 262 | 263 | ```javascript 264 | // web/static/js/actions/lists.js 265 | 266 | import Constants from '../constants'; 267 | 268 | const Actions = { 269 | save: (channel, data) => { 270 | return dispatch => { 271 | channel.push('lists:create', { list: data }); 272 | }; 273 | }, 274 | }; 275 | 276 | export default Actions; 277 | ``` 278 | 279 | ##The BoardChannel 280 | 281 | The following step will be making the BoardChannel handle the lists:create message, so lets do it: 282 | 283 | ```elixir 284 | # web/channels/board_channel.ex 285 | 286 | defmodule PhoenixTrello.BoardChannel do 287 | # ... 288 | 289 | def handle_in("lists:create", %{"list" => list_params}, socket) do 290 | board = socket.assigns.board 291 | 292 | changeset = board 293 | |> build_assoc(:lists) 294 | |> List.changeset(list_params) 295 | 296 | case Repo.insert(changeset) do 297 | {:ok, list} -> 298 | list = Repo.preload(list, [:cards]) 299 | 300 | broadcast! socket, "list:created", %{list: list} 301 | 302 | {:noreply, socket} 303 | {:error, _changeset} -> 304 | {:reply, {:error, %{error: "Error creating list"}}, socket} 305 | end 306 | end 307 | 308 | # ... 309 | end 310 | ``` 311 | 312 | Using the board assigned to the channel, it will build a List changeset with the received params and insert it. If everything goes :ok it will broadcast the created list through the channel to all connected members, including the creator, thus we don't really need to reply anything and we just return a :noreply. If by any chance there's been an error while inserting the new list, it will return an error message just to the creator, so he knows that something went wrong. 313 | 314 | ##The reducer 315 | 316 | Regarding lists we're almost done. The channel is broadcasting the created list, so let's add a handler in the front-end for it in the current board actions creator where the channel was joined: 317 | 318 | ```javascript 319 | // web/static/js/actions/current_board.js 320 | 321 | import Constants from '../constants'; 322 | 323 | const Actions = { 324 | // ... 325 | 326 | connectToChannel: (socket, boardId) => { 327 | return dispatch => { 328 | const channel = socket.channel(`boards:${boardId}`); 329 | // ... 330 | 331 | channel.on('list:created', (msg) => { 332 | dispatch({ 333 | type: Constants.CURRENT_BOARD_LIST_CREATED, 334 | list: msg.list, 335 | }); 336 | }); 337 | }; 338 | }, 339 | // ... 340 | } 341 | ``` 342 | 343 | Finally we need to update the board reducer to append the list to the new state version it returns: 344 | 345 | ```javascript 346 | // web/static/js/reducers/current_board.js 347 | 348 | import Constants from '../constants'; 349 | 350 | export default function reducer(state = initialState, action = {}) { 351 | 352 | switch (action.type) { 353 | //... 354 | 355 | case Constants.CURRENT_BOARD_LIST_CREATED: 356 | const lists = [...state.lists]; 357 | 358 | lists.push(action.list); 359 | 360 | return { ...state, lists: lists, showForm: false }; 361 | 362 | // ... 363 | } 364 | } 365 | ``` 366 | We also set the showForm attribute to false so the form automatically hides, displaying again the Add new list... button and the recently created list: 367 | 368 | ![](/images/part11/new_list.jpg) 369 | 370 | ##The List component 371 | 372 | Now that we have at least one list in the board we can create the List component we will use to render them: 373 | 374 | ```javascript 375 | // /web/static/js/components/lists/card.js 376 | 377 | import React, {PropTypes} from 'react'; 378 | import Actions from '../../actions/current_board'; 379 | import CardForm from '../../components/cards/form'; 380 | import Card from '../../components/cards/card'; 381 | 382 | export default class ListCard extends React.Component { 383 | // ... 384 | 385 | _renderForm() { 386 | const { isAddingNewCard } = this.props; 387 | if (!isAddingNewCard) return false; 388 | 389 | let { id, dispatch, formErrors, channel } = this.props; 390 | 391 | return ( 392 | 399 | ); 400 | } 401 | 402 | _renderAddNewCard() { 403 | const { isAddingNewCard } = this.props; 404 | if (isAddingNewCard) return false; 405 | 406 | return ( 407 | Add a new card... 408 | ); 409 | } 410 | 411 | _handleAddClick(e) { 412 | e.preventDefault(); 413 | 414 | const { dispatch, id } = this.props; 415 | 416 | dispatch(Actions.showCardForm(id)); 417 | } 418 | 419 | _hideCardForm() { 420 | const { dispatch } = this.props; 421 | 422 | dispatch(Actions.showCardForm(null)); 423 | } 424 | 425 | render() { 426 | const { id, connectDragSource, connectDropTarget, connectCardDropTarget, isDragging } = this.props; 427 | 428 | const styles = { 429 | display: isDragging ? 'none' : 'block', 430 | }; 431 | 432 | return ( 433 |
    434 |
    435 |
    436 |

    {this.props.name}

    437 |
    438 |
    439 | {::this._renderCards()} 440 |
    441 |
    442 | {::this._renderForm()} 443 | {::this._renderAddNewCard()} 444 |
    445 |
    446 |
    447 | ); 448 | } 449 | } 450 | ``` 451 | Just as we did with the lists, lets first focus on rendering the cards form. Basically we take the same approach of rendering or not the form using a prop passed by the main board component, and dispatching an action to change that state property. 452 | 453 | ![](/images/part11/card_form.jpg) 454 | 455 | ##The card form component 456 | 457 | This component is going to be very similar to the ListForm one: 458 | 459 | ```javascript 460 | // /web/static/js/components/cards/form.js 461 | 462 | import React, { PropTypes } from 'react'; 463 | import Actions from '../../actions/lists'; 464 | import PageClick from 'react-page-click'; 465 | 466 | export default class CardForm extends React.Component { 467 | _handleSubmit(e) { 468 | e.preventDefault(); 469 | 470 | let { dispatch, channel } = this.props; 471 | let { name } = this.refs; 472 | 473 | let data = { 474 | list_id: this.props.listId, 475 | name: name.value, 476 | }; 477 | 478 | dispatch(Actions.createCard(channel, data)); 479 | this.props.onSubmit(); 480 | } 481 | 482 | componentDidMount() { 483 | this.refs.name.focus(); 484 | } 485 | 486 | _handleCancelClick(e) { 487 | e.preventDefault(); 488 | 489 | this.props.onCancelClick(); 490 | } 491 | 492 | render() { 493 | return ( 494 | 495 |
    496 |
    497 |