├── 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 | 
43 |
44 | 主页面包用户的含卡片列表,以及他添加其他用户的卡片列表:
45 |
46 | 
47 |
48 | 最后是卡片页面,所有连接的用户都可以看到,同时可以管理列表和卡片。
49 |
50 | 
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 | 
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 | 
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 |
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 |
253 |
254 |
255 | );
256 | }
257 | }
258 | ```
259 | 
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 | 
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 |
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 | 
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 |
500 |
501 |
502 | );
503 | }
504 | }
505 | ```
506 |
507 | Just as we previously did, on submitting the form we'll dispatch an action to create the card with name provided by the user. The action creator for this, will push a new message to the board channel:
508 |
509 | ```javascipt
510 | // /web/static/js/actions/lists.js
511 |
512 | import Constants from '../constants';
513 |
514 | const Actions = {
515 | // ...
516 |
517 | createCard: (channel, data) => {
518 | return dispatch => {
519 | channel.push('cards:create', { card: data });
520 | };
521 | },
522 | };
523 |
524 | // ...
525 | ```
526 | Let's add the handler to the BoardChannel:
527 |
528 | ```javascript
529 | # web/channels/board_channel.ex
530 |
531 | def handle_in("cards:create", %{"card" => card_params}, socket) do
532 | board = socket.assigns.board
533 | changeset = board
534 | |> assoc(:lists)
535 | |> Repo.get!(card_params["list_id"])
536 | |> build_assoc(:cards)
537 | |> Card.changeset(card_params)
538 |
539 | case Repo.insert(changeset) do
540 | {:ok, card} ->
541 | broadcast! socket, "card:created", %{card: card}
542 |
543 | {:noreply, socket}
544 | {:error, _changeset} ->
545 | {:reply, {:error, %{error: "Error creating card"}}, socket}
546 | end
547 | end
548 | ```
549 |
550 | In the same way we did when creating the list, the new Card will be created associating it to the board assigned on the channel and the list passed as parameter. If the creation succeed it will be again dispatched to all connected members on the channel. Finally we have to add the callback to the js channel:
551 |
552 | ```javascript
553 | // web/static/js/actions/current_board.js
554 | //...
555 |
556 | channel.on('card:created', (msg) => {
557 | dispatch({
558 | type: Constants.CURRENT_BOARD_CARD_CREATED,
559 | card: msg.card,
560 | });
561 | });
562 |
563 | // ...
564 | ```
565 |
566 | And add the new card to the state via the reducer:
567 |
568 | ```javascript
569 | // web/static/js/reducers/current_board.js
570 |
571 | // ...
572 |
573 | case Constants.CURRENT_BOARD_CARD_CREATED:
574 | lists = [...state.lists];
575 | const { card } = action;
576 |
577 | const listIndex = lists.findIndex((list) => { return list.id == card.list_id; });
578 | lists[listIndex].cards.push(card);
579 |
580 | return { ...state, lists: lists };
581 |
582 | // ...
583 | ```
584 | And that's it! The card will appear on every connected member screen.
585 |
586 | 
587 |
588 | ##Now what?
589 |
590 | With this part we have covered the basic functionality we need for letting users register, sign in, create boards, invite people to them and collaborate in realtime adding lists and cards. The final version in the repository has a lot more features like editing lists, sorting lists and cards by dragging them around, showing the card details where you can also assign members to them and even adding comments and color tags, but we are not going to talk about any of them here otherwise this would be the never-ending tutorial :D
591 |
592 | But don't worry, there's still one more part left where we'll talk about sharing the final result with the world by deploying it on Heroku. Meanwhile, don't forget to check out the live demo and final source code:
593 |
594 | Live demo Source code
595 | Happy coding!
596 |
--------------------------------------------------------------------------------
/12-Deploying on Heroku.md:
--------------------------------------------------------------------------------
1 | #Trello clone with Phoenix and React (第十二章节)
2 |
3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列
4 |
5 | #Deploying on Heroku
6 |
7 | We finally made it. After 11 parts we've learned how to setup a new Phoenix project with Webpack, React and Redux. We have created a secure authentication system based on JWT tokens, created migrations for the necessary schemas for our database, coded socket and channels for realtime features and built a GenServer process to keep track of connected board members. Now is time to share it with the world by deploying it on Heroku. Let's do this!
8 |
9 | ##Setting up Heroku
10 |
11 | Before going any further we'll assume we already have a Heroku account and the Heroku Toolbet installed. For deploying Phoenix applications on Heroku we need to use two different buildpacks, so lets create the new application using the multi-buildpack:
12 | ```
13 | $ heroku create phoenix-trello --buildpack https://github.com/ddollar/heroku-buildpack-multi
14 | ```
15 | This will create our new application on Heroku and add the git remote heroku repository that we'll use for deploying. As just said before, we need two different buildpacks for a Phoenix application:
16 |
17 | * heroku-buildpack-elixir: Main buildpack for Elixir applications.
18 | * heroku-buildpack-phoenix-static: For static assets compilation.
19 | To add both of them lets create a .buildpacks file and add both of them:
20 | ```
21 | # .buildpacks
22 |
23 | https://github.com/HashNuke/heroku-buildpack-elixir
24 | https://github.com/gjaldon/phoenix-static-buildpack
25 | ```
26 | If we need to change any aspect regarding the new Elixir production environment, we can do it by adding a elixir_buildpack.config file:
27 | ```
28 | # elixir_buildpack.config
29 |
30 | # Elixir version
31 | elixir_version=1.2.3
32 |
33 | # Always rebuild from scratch on every deploy?
34 | always_rebuild=true
35 | ```
36 | In our case we are specifying the Elixir version and also forcing the environment to rebuild everything, included dependencies, on every deployment. The same can be done for static assets by adding a phoenix_static_buildpack.config file:
37 | ```
38 | # phoenix_static_buildpack.config
39 |
40 | # We can set the version of Node to use for the app here
41 | node_version=5.3.0
42 |
43 | # We can set the version of NPM to use for the app here
44 | npm_version=3.5.2
45 | ```
46 |
47 | In this case we are specifying the node and npm versions we need for Webpack to build our static assets. Finally we have to create a compile file where we'll set how to compile our assets after every new deployment:
48 | ```
49 | # compile
50 |
51 | info "Building Phoenix static assets"
52 | webpack
53 | mix phoenix.digest
54 | ```
55 | Note that we run the phoenix.digest mix task after the webpack build to generate the digested and compressed versions of the assets.
56 |
57 | #Setting up our production environment
58 |
59 | Before deploying for the first time, we need to update the prod.exs file with some necessary configuration changes:
60 | ```elixir
61 | # config/prod.exs
62 |
63 | use Mix.Config
64 | # ...
65 |
66 | config :phoenix_trello, PhoenixTrello.Endpoint,
67 | # ..
68 | url: [scheme: "https", host: "phoenix-trello.herokuapp.com", port: 443],
69 | # ..
70 | secret_key_base: System.get_env("SECRET_KEY_BASE")
71 |
72 | # ..
73 |
74 | # Configure your database
75 | config :phoenix_trello, PhoenixTrello.Repo,
76 | # ..
77 | url: System.get_env("DATABASE_URL"),
78 | pool_size: 20
79 |
80 | # Configure guardian
81 | config :guardian, Guardian,
82 | secret_key: System.get_env("GUARDIAN_SECRET_KEY")
83 | ```
84 | Basically what we are doing is enforcing it to use our Heroku application's url and enforce the SSL connection provided. We are also using some environment variables to configure the secret_key_base, database url and guardian's secret_key. The database url will be automatically created by Heroku once we deploy it for the first time, but for the other two we need to generate them and add them using the command line:
85 | ```
86 | $ mix phoenix.gen.secret
87 | xxxxxxxxxx
88 | $ heroku config:set SECRET_KEY_BASE="xxxxxxxxxx"
89 | ...
90 | ...
91 |
92 | $ mix phoenix.gen.secret
93 | yyyyyyyyyyy
94 | $ heroku config:set GUARDIAN_SECRET_KEY="yyyyyyyyyyy"
95 | ...
96 | ...
97 | ```
98 | And we are ready to deploy!
99 |
100 | ##Deploying
101 |
102 | After committing all this changes to our repository, we can deploy the application by simply running:
103 | ```
104 | $ git push heroku master
105 | ...
106 | ...
107 | ...
108 | ```
109 | If we take a look to the console output we can see how both buildpacks do their job by installing Erlang and Elixir with their necessary dependencies as well as node and npm among other tasks. Finally we need to run the migration in order to create the database tables:
110 | ```
111 | $ heroku run mix ecto.migrate
112 | ```
113 | And that's it, our application is deployed and ready to go!
114 |
115 | ##Conclusion
116 |
117 | Deploying a Phoenix application on Heroku is pretty easy and straightforward. It might not be the best solution around, but for a demo application like this it works really well. I hope you have enjoyed building and deploying this application as much as I have. While writing the whole series I've made a lot of changes to the final codebase, correcting some stuff and adding a lot more features. If you want to check them don't forget to visit de demo or fork the repository:
118 |
119 | Live demo Source code
120 | Thanks for reading and for the support :)
121 |
122 | Happy coding!
--------------------------------------------------------------------------------
/2-Phoenix Framework project setup.md:
--------------------------------------------------------------------------------
1 | #Trello clone with Phoenix and React (第二章节)
2 |
3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列
4 |
5 | #项目设置
6 |
7 |
8 | 现在我们已经选择了[框架](1-Intro and selected stack.md),让我们开始创建新的Phoenix项目。在这之前,请确保我们的系统已经按照官方提供的[安装指南](http://www.phoenixframework.org/docs/installation)安装好了[Elixir](http://elixir-lang.org/)和[Phoenix](http://www.phoenixframework.org/)
9 |
10 |
11 | ##Webpack打包静态资源
12 |
13 | 对比Ruby on Rails,Phoenix没有自己的资源管理,而使用[Brunch](http://brunch.io/)作为其资源打包工具,Brunch使用起来很现代和灵活。更有趣的事情是,可以不使用Brunch,我们可以使用[Webpack](https://webpack.github.io/)。我以前也没有用过Brunch,后面就使用Webpack打包。
14 |
15 | node.js需要作为Phoenix[可选项](http://www.phoenixframework.org/docs/installation#section-node-js-5-0-0-),如果Phoenix 使用Brunch作为静态资源管理,就需要安装node.js。同时Webpack也需要node.js,因此必须确保node.js安装正确。
16 |
17 | 第一步:不使用Brunch创建新的Phoenix项目:
18 |
19 | ```
20 | $ mix phoenix.new --no-brunch phoenix_trello
21 | ...
22 | ...
23 | ...
24 | $ cd phoenix_trello
25 | ```
26 |
27 | 现在我们创建了新的项目,项目没有使用资源打包工具。
28 |
29 | 第二步:创建`package.json`文件,并安装Webpack作为dev依赖:
30 |
31 | ```
32 | $ npm init
33 | ...按照提示的默认值确认
34 | ...
35 | ...
36 | $ npm install webpack --save-dev
37 | ```
38 |
39 | 现在`package.json`文件中可以看到与下面相似的内容:
40 |
41 | ```json
42 | {
43 | "name": "phoenix_trello",
44 | "devDependencies": {
45 | "webpack": "^1.12.9"
46 | },
47 | "dependencies": {
48 |
49 | },
50 | }
51 | ```
52 |
53 | 在项目中我们将使用一系列依赖,在这里就不列出他们,大家可以查看项目仓库中的的[源文件](https://github.com/bigardone/phoenix-trello/blob/master/package.json)。(备注:大家可以复制这个文件到项目文件夹,然后执行`npm install`就行)
54 |
55 | 第三步:我们需要添加[webpack.config.js](https://github.com/bigardone/phoenix-trello/blob/master/webpack.config.js)配置文件,便于Webpack打包资源:
56 |
57 |
58 | ```javascript
59 | 'use strict';
60 |
61 | var path = require('path');
62 | var ExtractTextPlugin = require('extract-text-webpack-plugin');
63 | var webpack = require('webpack');
64 |
65 | // helpers for writing path names
66 | // e.g. join("web/static") => "/full/disk/path/to/hello/web/static"
67 | function join(dest) { return path.resolve(__dirname, dest); }
68 |
69 | function web(dest) { return join('web/static/' + dest); }
70 |
71 | var config = module.exports = {
72 | // our application's entry points - for this example we'll use a single each for
73 | // css and js
74 | entry: {
75 | application: [
76 | web('css/application.sass'),
77 | web('js/application.js'),
78 | ],
79 | },
80 |
81 | // where webpack should output our files
82 | output: {
83 | path: join('priv/static'),
84 | filename: 'js/application.js',
85 | },
86 |
87 | resolve: {
88 | extensions: ['', '.js', '.sass'],
89 | modulesDirectories: ['node_modules'],
90 | },
91 |
92 | // more information on how our modules are structured, and
93 | //
94 | // in this case, we'll define our loaders for JavaScript and CSS.
95 | // we use regexes to tell Webpack what files require special treatment, and
96 | // what patterns to exclude.
97 | module: {
98 | noParse: /vendor\/phoenix/,
99 | loaders: [
100 | {
101 | test: /\.js$/,
102 | exclude: /node_modules/,
103 | loader: 'babel',
104 | query: {
105 | cacheDirectory: true,
106 | plugins: ['transform-decorators-legacy'],
107 | presets: ['react', 'es2015', 'stage-2', 'stage-0'],
108 | },
109 | },
110 | {
111 | test: /\.sass$/,
112 | loader: ExtractTextPlugin.extract('style', 'css!sass?indentedSyntax&includePaths[]=' + __dirname + '/node_modules'),
113 | },
114 | ],
115 | },
116 |
117 | // what plugins we'll be using - in this case, just our ExtractTextPlugin.
118 | // we'll also tell the plugin where the final CSS file should be generated
119 | // (relative to config.output.path)
120 | plugins: [
121 | new ExtractTextPlugin('css/application.css'),
122 | ],
123 | };
124 |
125 | // if running webpack in production mode, minify files with uglifyjs
126 | if (process.env.NODE_ENV === 'production') {
127 | config.plugins.push(
128 | new webpack.optimize.DedupePlugin(),
129 | new webpack.optimize.UglifyJsPlugin({ minimize: true })
130 | );
131 | }
132 | ```
133 |
134 | 这里需要指出的是,我们需要两个不同webpack[入口文件](https://webpack.github.io/docs/multiple-entry-points.html),一个用于javascript,另一个用于stylesheets,都位于 web/static文件夹。我们的输出文件位于`private/static`文件夹。同时,为了使用了S6/7 和 JSX 特性,我们使用Babel来设计。
135 |
136 | 最后一步:当我们启动服务器时,告诉Phoenix需要每次启动Webpack,这样Webpack可以监控我们开发过程中的任何更改,并产生页面需要的最终资源文件。下面是在`config/dev.exs`添加一个监视:
137 |
138 | ```elixir
139 | # config/dev.exs
140 |
141 | config :phoenix_trello, PhoenixTrello.Endpoint,
142 | http: [port: 4000],
143 | debug_errors: true,
144 | code_reloader: true,
145 | cache_static_lookup: false,
146 | check_origin: false,
147 | watchers: [
148 | node: ["node_modules/webpack/bin/webpack.js", "--watch", "--color"]
149 | ]
150 |
151 | ...
152 | ```
153 |
154 | 如果我们现在运行服务器,可以看到Webpakc已经在运行并监视着项目:
155 |
156 | ```
157 | $ mix phoenix.server
158 | [info] Running PhoenixTrello.Endpoint with Cowboy using http on port 4000
159 | Hash: 93bc1d4743159d9afc35
160 | Version: webpack 1.12.10
161 | Time: 6488ms
162 | Asset Size Chunks Chunk Names
163 | js/application.js 1.28 MB 0 [emitted] application
164 | css/application.css 49.3 kB 0 [emitted] application
165 | [0] multi application 40 bytes {0} [built]
166 | + 397 hidden modules
167 | Child extract-text-webpack-plugin:
168 | + 2 hidden modules
169 | ```
170 |
171 | 还有一件事需要做,如果我们查看 `private/static/js`目录,可以发现`phoenix.js`文件。这个文件包含了我们需要使用的 websockets和channels,因此把这个文件复制到`web/static/js`文件夹中,这样便于我们使用。
172 |
173 | ##前端基本结构
174 |
175 | 现在我们可以编写代码了,让我们开始创建前端应用结构,需要以下npm包:
176 |
177 | * bourbon , bourbon-neat, 我最喜欢的Sass mixin库
178 | * history 用于管理history .
179 | * react 和 react-dom.
180 | * redux 和 react-redux 用于状态处理.
181 | * react-router 路由库.
182 | * redux-simple-router 在线变更路由.
183 |
184 | 我不打算在stylesheets上面浪费更多的时间,后面始终需要修改它们。但是我需要提醒的是,我经常使用[css-burrito](http://css-burrito.com/)创建合适的文件结构来组织Sass文件,这是我个人认为非常有用的。
185 |
186 | 我们需要配置Redux store,创建如下文件:
187 |
188 | ```javascript
189 | //web/static/js/store/index.js
190 |
191 | import { createStore, applyMiddleware } from 'redux';
192 | import createLogger from 'redux-logger';
193 | import thunkMiddleware from 'redux-thunk';
194 | import reducers from '../reducers';
195 |
196 | const loggerMiddleware = createLogger({
197 | level: 'info',
198 | collapsed: true,
199 | });
200 |
201 | const createStoreWithMiddleware = applyMiddleware(thunkMiddleware, loggerMiddleware)(createStore);
202 |
203 | export default function configureStore() {
204 | return createStoreWithMiddleware(reducers);
205 | }
206 |
207 | ```
208 |
209 | 基本上配置store需要中间件。
210 | * reduxRouterMiddleware 用于分配路由的 actions 到 store。
211 | * redux-thunk 用于处理 async 动作.
212 | * redux-logger 用于记录一切动作和浏览器控制台的状态变化
213 |
214 | 同时,需要传递所有reducer state,创建如下的基础文件:
215 |
216 | ```javascript
217 | //web/static/js/reducers/index.js
218 |
219 | import { combineReducers } from 'redux';
220 | import { routeReducer } from 'redux-simple-router';
221 | import session from './session';
222 |
223 | export default combineReducers({
224 | routing: routeReducer,
225 | session: session,
226 | });
227 | ```
228 |
229 | 需要指出的是,我们只需要两个reducer,`routeReducer`自动设置路由管理状态变化,`session reducer`如下所示:
230 |
231 | ```javascript
232 | //web/static/js/reducers/session.js
233 |
234 | const initialState = {
235 | currentUser: null,
236 | socket: null,
237 | error: null,
238 | };
239 |
240 | export default function reducer(state = initialState, action = {}) {
241 | return state;
242 | }
243 | ```
244 |
245 | 初始化状态包含`currentUser`对象,这些对象用于用户验证,以及需要连接channels部分的socket,和验证用户过程中出现的`error`便于追溯问题。
246 |
247 | 有了这些准备,可以编写`application.js`文件,和渲染Root组件:
248 |
249 | ```javascript
250 | //web/static/js/application.js
251 |
252 | import React from 'react';
253 | import ReactDOM from 'react-dom';
254 | import createBrowserHistory from 'history/lib/createBrowserHistory';
255 | import { syncReduxAndRouter } from 'redux-simple-router';
256 | import configureStore from './store';
257 | import Root from './containers/root';
258 |
259 | const store = configureStore();
260 | const history = createBrowserHistory();
261 |
262 | syncReduxAndRouter(history, store);
263 |
264 | const target = document.getElementById('main_container');
265 | const node = ;
266 |
267 | ReactDOM.render(node, target);
268 | ```
269 |
270 | 我们创建history和配置store,在主应用布局中渲染Root组件,将作为一个Redux Provider作用于路由。
271 |
272 | ```javascript
273 | //web/static/js/containers/root.js
274 |
275 | import React from 'react';
276 | import { Provider } from 'react-redux';
277 | import { Router } from 'react-router';
278 | import invariant from 'invariant';
279 | import { RoutingContext } from 'react-router';
280 | import routes from '../routes';
281 |
282 | export default class Root extends React.Component {
283 | _renderRouter() {
284 | invariant(
285 | this.props.routingContext || this.props.routerHistory,
286 | ' needs either a routingContext or routerHistory to render.'
287 | );
288 |
289 | if (this.props.routingContext) {
290 | return ;
291 | } else {
292 | return (
293 |
294 | {routes}
295 |
296 | );
297 | }
298 | }
299 |
300 | render() {
301 | return (
302 |
303 | {this._renderRouter()}
304 |
305 | );
306 | }
307 | }
308 | ```
309 |
310 |
311 | 现在定义我们基本的路由文件:
312 |
313 | ```javascript
314 | //web/static/js/routes/index.js
315 |
316 | import { IndexRoute, Route } from 'react-router';
317 | import React from 'react';
318 | import MainLayout from '../layouts/main';
319 | import RegistrationsNew from '../views/registrations/new';
320 |
321 | export default (
322 |
323 |
324 |
325 | );
326 | ```
327 |
328 | 我们的应用将包含在`MainLayout`组件和`Root`路径中,路径将渲染`registrations`视图。文件最终版本可能有些复杂,后面将涉及到用户验证机制,这会在下一章节讲到。
329 |
330 | 最后,我们添加html容器,在主Phoenix应用布局中呈现`root`组件:
331 |
332 | ```html
333 |
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
343 |
344 | Phoenix Trello
345 | ">
346 |
347 |
348 |
349 |
350 |
351 |
352 |
353 |
354 |
355 |
356 | ```
357 |
358 | 注意:link 和script标记,可以参考Webpack打包生产的静态资源。
359 |
360 | 因为我们是在前端管理路由,需要告诉Phoenix处理任何通过`index`动作产生的http请求,这个动作位于`PageControler`中,用于渲染主布局和`Root`组件:
361 |
362 | ```elixir
363 | # master/web/router.ex
364 |
365 | defmodule PhoenixTrello.Router do
366 | use PhoenixTrello.Web, :router
367 |
368 | pipeline :browser do
369 | plug :accepts, ["html"]
370 | plug :fetch_session
371 | plug :fetch_flash
372 | plug :protect_from_forgery
373 | plug :put_secure_browser_headers
374 | end
375 |
376 | scope "/", PhoenixTrello do
377 | pipe_through :browser # Use the default browser stack
378 |
379 | get "*path", PageController, :index
380 | end
381 | end
382 | ```
383 |
384 | 现在就是这样。下一章节将介绍如何创建第一个数据库迁移,`User`模型和创建新用户所需的所有功能。在此期间,你可以查看运行演示和下载最终的源代码:
385 |
386 | [演示](https://phoenix-trello.herokuapp.com/) [源代码](https://github.com/bigardone/phoenix-trello)
387 |
388 |
--------------------------------------------------------------------------------
/3-The User model and JWT auth.md:
--------------------------------------------------------------------------------
1 | #Trello clone with Phoenix and React (第三章节)
2 |
3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列
4 |
5 | #用户注册
6 |
7 | 项目已经完成了[基本项目设置](2-Phoenix Framework project setup.md),现在我们准备创建`User`数据库迁移和`User`模型。在这一章,我们将看到这整个过程以及来宾如何创建一个新的账户。
8 |
9 | ##User数据库迁移与User 模型
10 |
11 | **Phoenix**使用[Ecto](https://github.com/elixir-lang/ecto)解决与数据库相关的任何操作。如果使用过 **Rails**,**Ecto** 有些类似于 **ActiveRecord**,**ActiveRecord**把相似的功能分隔为不同的模块。
12 |
13 | 在这之前,我们需要创建运行的数据库:
14 |
15 | `$ mix ecto.create`
16 |
17 | 现在,让我们创建新的 **Ecto** 迁移和模型。模型生成命令后面的参数是模块的名字,模块名称的复数(小写)就是表名称,表的字段需要使用`name:type`语法,让我们运行它:
18 |
19 | `$ mix phoenix.gen.model User users first_name:string last_name:string email:string encrypted_password:string`
20 |
21 | 如果我们查看我们刚刚创建迁移文件,会发现它与 **Rails** 的迁移文件十分类似:
22 |
23 | ```elixir
24 | # priv/repo/migrations/20151224075404_create_user.exs
25 |
26 | defmodule PhoenixTrello.Repo.Migrations.CreateUser do
27 | use Ecto.Migration
28 |
29 | def change do
30 | create table(:users) do
31 | add :first_name, :string, null: false
32 | add :last_name, :string, null: false
33 | add :email, :string, null: false
34 | add :encrypted_password, :string, null: false
35 | #add :crypted_password, :string, null: false 原文是crypted_password,作者后面添加了user_password_fix
36 | timestamps
37 | end
38 |
39 | create unique_index(:users, [:email])
40 | end
41 | end
42 | ```
43 |
44 | 字段已经添加了 `null` 限制和`email`索引唯一。因为,我喜欢数据库负责数据完整性,而不是依赖于应用开发者。我猜这是个人喜好的事情。
45 |
46 | 现在迁移文件已经准备好了,让我们运行它创建 `users`表:
47 |
48 | `$ mix ecto.migrate`
49 |
50 | 现在是我们仔细看看 **User** 模型的时候了:
51 |
52 | ```elixir
53 | # web/models/user.ex
54 |
55 | defmodule PhoenixTrello.User do
56 | use Ecto.Schema
57 | import Ecto.Changeset
58 |
59 | schema "users" do
60 | field :first_name, :string
61 | field :last_name, :string
62 | field :email, :string
63 | field :encrypted_password, :string
64 |
65 | timestamps
66 | end
67 |
68 | @required_fields ~w(first_name last_name email)
69 | @optional_fields ~w(encrypted_password)
70 |
71 | def changeset(model, params \\ :empty) do
72 | model
73 | |> cast(params, @required_fields, @optional_fields)
74 | end
75 | end
76 | ```
77 |
78 | 这里可以找到两个主要不同的部分:
79 |
80 | * **schema**部分所有元数据对应于表字段
81 | * **changeset** 功能:可以定义所有的验证和转变,这些都可以应用于之前在程序中需要使用的数据。
82 |
83 |
84 | ##changeset验证和转换
85 |
86 | 因为之前我们已经在表字段上添加了null限制和email唯一约束,当用户注册的时候,我们希望添加一些其他验证过程。我们必须在 `User`模型中反应这些,这样便于处理非法数据引起的运行错误。同时我们想加密 `encrypted_password`字段,即使我们将使用普通字符串来指定用户的密码,它也将以安全的方式被插入。
87 |
88 | 首先更新User模型和添加一些验证:
89 |
90 | ```elixir
91 | # web/models/user.ex
92 |
93 | defmodule PhoenixTrello.User do
94 | # ...
95 |
96 | schema "users" do
97 | # ...
98 | field :password, :string, virtual: true
99 | # ...
100 | end
101 |
102 | @required_fields ~w(first_name last_name email password)
103 | @optional_fields ~w(encrypted_password)
104 |
105 | def changeset(model, params \\ :empty) do
106 | model
107 | |> cast(params, @required_fields, @optional_fields)
108 | |> validate_format(:email, ~r/@/)
109 | |> validate_length(:password, min: 5)
110 | |> validate_confirmation(:password, message: "Password does not match")
111 | |> unique_constraint(:email, message: "Email already taken")
112 | end
113 | end
114 | ```
115 |
116 | 基本上我们修改了如下内容:
117 |
118 | * 添加了虚拟的`password`字段,虚拟字段不会存在到数据库中,却可以和其他字段一样使用。在这里,我们主要用户注册表单。
119 | * 添加`pasword`字段。
120 | * 添加`email`格式检测验证。
121 | * 添加`password`最小长度为5个字符验证,以及检查密码是否输入同一个值。
122 | * 添加`email`唯一检测验证。
123 |
124 | 经过这些修改,我们完成了验证。但是在保存数据前,我们需要填充`encrypted_password`字段。为了完成这,我们需要使用[comeonin](https://github.com/elixircnx/comeonin) 密码加密库,在`mix.exs`文件中添加`comeonin`依赖,并作为应用程序:
125 |
126 | ```elixir
127 | # mix.exs
128 |
129 | defmodule PhoenixTrello.Mixfile do
130 | use Mix.Project
131 | # ...
132 |
133 | def application do
134 | [mod: {PhoenixTrello, []},
135 | applications: [
136 | # ...
137 | :comeonin
138 | ]
139 | ]
140 | end
141 |
142 | #...
143 |
144 | defp deps do
145 | [
146 | # ...
147 | {:comeonin, "~> 2.0"},
148 | # ...
149 | ]
150 | end
151 | end
152 | ```
153 |
154 | 别忘记运行如下命令:
155 |
156 | `$ mix deps.get`
157 |
158 | 现在 **comeonin**已经安装完毕,让我们回到`User`模型,下一步在 `changeset` pipeline中添加生成`encrypted_password`字段:
159 |
160 | ```elixir
161 | # web/models/user.ex
162 |
163 | defmodule PhoenixTrello.User do
164 | # ...
165 |
166 | def changeset(model, params \\ :empty) do
167 | model
168 | # ... other validations and contraints
169 | |> generate_encrypted_password
170 | end
171 |
172 | defp generate_encrypted_password(current_changeset) do
173 | case current_changeset do
174 | %Ecto.Changeset{valid?: true, changes: %{password: password}} ->
175 | put_change(current_changeset, :encrypted_password, Comeonin.Bcrypt.hashpwsalt(password))
176 | _ ->
177 | current_changeset
178 | end
179 | end
180 | end
181 | ```
182 |
183 | 在新的函数中,我们首先检测changeset是否合法以及密码是否改变。如果合法,我们使用 **comeonin**加密密码并放入`encrypted_password`字段中,否则返回changeset。
184 |
185 | ##路由
186 |
187 |
188 | 现在`User`模型已经准备好了,让我们通过修改`router.ex`继续完成注册过程,在这个文件中创建`:api` pipeline和我们的第一个路由:
189 |
190 | ```elixir
191 | # web/router.ex
192 |
193 | defmodule PhoenixTrello.Router do
194 | use PhoenixTrello.Web, :router
195 |
196 | #...
197 |
198 | pipeline :api do
199 | plug :accepts, ["json"]
200 | end
201 |
202 | scope "/api", PhoenixTrello do
203 | pipe_through :api
204 |
205 | scope "/v1" do
206 | post "/registrations", RegistrationController, :create
207 | end
208 | end
209 |
210 | #...
211 | end
212 | ```
213 |
214 | 任何通过`/api/v1/registrations`的`POST`请求,都会被`RegistrationController`中的`create`动作中处理,也接受 **JSON**...相当于解释:)
215 |
216 | ##控制器
217 |
218 | 在完成控制器之前,让我们想想我们都需要什么。新用户将访问注册页面,填写表格并确认。如果控制器接收到的数据是合法的,我们将向数据库中插入一条新`User`数据,在系统中记录,并返回[jwt](https://en.wikipedia.org/wiki/JSON_Web_Token)验证token,在前端登陆过程是以 **json**形式返回。这个token不仅在每次用户验证时需要,而且当用户访问程序的私有页面时需要。
219 |
220 | 为了处理这个验证和 **jwt**生成器,我们将使用[Guardian](https://github.com/ueberauth/guardian)库,这个库非常好用。仅需要在`mix.exs`文件中添加:
221 |
222 | ```elixir
223 | # mix.exs
224 |
225 | defmodule PhoenixTrello.Mixfile do
226 | use Mix.Project
227 |
228 | #...
229 |
230 | defp deps do
231 | [
232 | # ...
233 | {:guardian, "~> 0.9.0"},
234 | # ...
235 | ]
236 | end
237 | end
238 | ```
239 |
240 | 在运行`mix deps.get`之后,我们需要配置`config.exs`文件:
241 |
242 | ```elixir
243 | # config/confg.exs
244 |
245 | #...
246 |
247 | config :guardian, Guardian,
248 | issuer: "PhoenixTrello",
249 | ttl: { 3, :days },
250 | verify_issuer: true,
251 | secret_key: ,
252 | serializer: PhoenixTrello.GuardianSerializer
253 | ```
254 |
255 | 同时,我们需要创建`GuardianSerializer`,便于告诉 **Guardian**如何编码和解码用户进出token:
256 |
257 | ```elixir
258 | # lib/phoenix_trello/guardian_serializer.ex
259 |
260 | defmodule PhoenixTrello.GuardianSerializer do
261 | @behaviour Guardian.Serializer
262 |
263 | alias PhoenixTrello.{Repo, User}
264 |
265 | def for_token(user = %User{}), do: { :ok, "User:#{user.id}" }
266 | def for_token(_), do: { :error, "Unknown resource type" }
267 |
268 | def from_token("User:" <> id), do: { :ok, Repo.get(User, String.to_integer(id)) }
269 | def from_token(_), do: { :error, "Unknown resource type" }
270 | end
271 | ```
272 |
273 | 现在一切就绪,让我们完成`RegistrationController`:
274 |
275 | ```elixir
276 | # web/controllers/api/v1/registration_controller.ex
277 |
278 | defmodule PhoenixTrello.RegistrationController do
279 | use PhoenixTrello.Web, :controller
280 |
281 | alias PhoenixTrello.{Repo, User}
282 |
283 | plug :scrub_params, "user" when action in [:create]
284 |
285 | def create(conn, %{"user" => user_params}) do
286 | changeset = User.changeset(%User{}, user_params)
287 |
288 | case Repo.insert(changeset) do
289 | {:ok, user} ->
290 | {:ok, jwt, _full_claims} = Guardian.encode_and_sign(user, :token)
291 |
292 | conn
293 | |> put_status(:created)
294 | |> render(PhoenixTrello.SessionView, "show.json", jwt: jwt, user: user)
295 |
296 | {:error, changeset} ->
297 | conn
298 | |> put_status(:unprocessable_entity)
299 | |> render(PhoenixTrello.RegistrationView, "error.json", changeset: changeset)
300 | end
301 | end
302 | end
303 | ```
304 |
305 | 感谢 **Elixir**[模式匹配](http://elixir-lang.org/getting-started/pattern-matching.html),在`create`动作中获取`"user"`中参数。通过这些参数,我们将创建新的`User`changeset并插入到数据库。如果一切顺利,我们将使用 **Guardian**的`encode_and_sign`功能来索取新用户的`jwt` token,并返回用户`json`数据。另外,如果changeset非法,将返回错误`json`数据,我们可以在注册表单中看到这些。
306 |
307 | ##JSON 序列化
308 |
309 | **Phoenix**使用[Poison](https://github.com/devinus/poison)作为默认 **JSON**库。因为 **Phoenix**依赖中已经包含了,我们不需要添加。我们仅需要更新`User`模型的时候指定那些字段需要序列化:
310 |
311 | ```elixir
312 | # web/models/user.ex
313 |
314 | defmodule PhoenixTrello.User do
315 | use PhoenixTrello.Web, :model
316 | # ...
317 |
318 | @derive {Poison.Encoder, only: [:id, :first_name, :last_name, :email]}
319 |
320 | # ...
321 | end
322 | ```
323 |
324 | 从现在起,当我们呈现一个用户或者用户的列表时候,控制器动作或通道将做出相应,它将只返回那些指定的字段。非常容易!
325 |
326 | 后端已经为注册新用户准备好了,在下一章节我们将转移到前端并使用 **React**和 **Redux**这些有趣的东西完成注册过程。同样,别忘记查看运行演示和下载最终的源代码:
327 |
328 | [演示](https://phoenix-trello.herokuapp.com/) [源代码](https://github.com/bigardone/phoenix-trello)
329 |
--------------------------------------------------------------------------------
/4-Front-end for sign up with React and Redux.md:
--------------------------------------------------------------------------------
1 | #Trello clone with Phoenix and React (第四章节)
2 |
3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列
4 |
5 | #用户注册
6 |
7 | [上一章节](3-The User model and JWT auth.md),我们创建了`User`模型,并完成了相关验证以及加密密码等变更,同时更新了路由文件,创建了`RegistrationControlle`以便于处理新用户请求和返回验证需要的 **JSON**数据以及 **jwt** token。现在让我们转移到前端这边。
8 |
9 | #准备Recat路由
10 |
11 | 我们的主要目标拥有两种公共路由,一种是`/sign_in`和`/sign_up`,任何访问者将能够通过他们登录到应用程序或这注册新的用户帐户。
12 |
13 | 另一方面我们需要`/`作为根路由,用于显示所有属于用户的卡片,而`/boards/:id`路由用于显示用户所选择的开片。访问最后两个路由,用户必须是验证过的,否则,我们将他重定向到注册页面。
14 |
15 | 让我们更新`react-router`路由文件,如下所示:
16 |
17 | ```javascript
18 | // web/static/js/routes/index.js
19 |
20 | import { IndexRoute, Route } from 'react-router';
21 | import React from 'react';
22 | import MainLayout from '../layouts/main';
23 | import AuthenticatedContainer from '../containers/authenticated';
24 | import HomeIndexView from '../views/home';
25 | import RegistrationsNew from '../views/registrations/new';
26 | import SessionsNew from '../views/sessions/new';
27 | import BoardsShowView from '../views/boards/show';
28 |
29 | export default (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | ```
42 |
43 | 复杂的部分是`AuthenticatedContainer`,让我们看看:
44 |
45 | ```javascript
46 | // web/static/js/containers/authenticated.js
47 |
48 | import React from 'react';
49 | import { connect } from 'react-redux';
50 | import { routeActions } from 'redux-simple-router';
51 |
52 | class AuthenticatedContainer extends React.Component {
53 | componentDidMount() {
54 | const { dispatch, currentUser } = this.props;
55 |
56 | if (localStorage.getItem('phoenixAuthToken')) {
57 | dispatch(Actions.currentUser());
58 | } else {
59 | dispatch(routeActions.push('/sign_up'));
60 | }
61 | }
62 |
63 | render() {
64 | // ...
65 | }
66 | }
67 |
68 | const mapStateToProps = (state) => ({
69 | currentUser: state.session.currentUser,
70 | });
71 |
72 | export default connect(mapStateToProps)(AuthenticatedContainer);
73 | ```
74 |
75 | 在这里我们主要做的是,当组件mount时,检测在当前浏览器是否本地存储有 **jwt** token。后面我们将看到如何设置它,但现在,让我们试想一下,它不存在,这要感谢`redux-simple-router`库将用户重定向到注册页面。
76 |
77 | ##注册视图组件
78 |
79 | 一旦发现他没有通过验证,我们将呈现给用户:
80 |
81 | ```javascript
82 | // web/static/js/views/registrations/new.js
83 |
84 | import React, {PropTypes} from 'react';
85 | import { connect } from 'react-redux';
86 | import { Link } from 'react-router';
87 |
88 | import { setDocumentTitle, renderErrorsFor } from '../../utils';
89 | import Actions from '../../actions/registrations';
90 |
91 | class RegistrationsNew extends React.Component {
92 | componentDidMount() {
93 | setDocumentTitle('Sign up');
94 | }
95 |
96 | _handleSubmit(e) {
97 | e.preventDefault();
98 |
99 | const { dispatch } = this.props;
100 |
101 | const data = {
102 | first_name: this.refs.firstName.value,
103 | last_name: this.refs.lastName.value,
104 | email: this.refs.email.value,
105 | password: this.refs.password.value,
106 | password_confirmation: this.refs.passwordConfirmation.value,
107 | };
108 |
109 | dispatch(Actions.signUp(data));
110 | }
111 |
112 | render() {
113 | const { errors } = this.props;
114 |
115 | return (
116 |
317 | );
318 | }
319 | }
320 |
321 | const mapStateToProps = (state) => (
322 | state.boards
323 | );
324 |
325 | export default connect(mapStateToProps)(HomeIndexView);
326 | ```
327 | 这里内容很多,让我们一个一个的看:
328 |
329 | * 首先我们必须牢记:组件需要连接到sotre,并且属性改变来自于我们创建的卡片reducer。
330 | * 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.
331 | * For now it will just render the owned_boards array in the store and also the BoardForm component.
332 | * 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.
333 | * When clicking the add new board button it will dispatch a new action creator for hiding the button and showing the form.
334 |
335 | 现在让我们来添加BoardForm 组件:
336 |
337 | ```javascript
338 | // web/static/js/components/boards/form.js
339 |
340 | import React, { PropTypes } from 'react';
341 | import PageClick from 'react-page-click';
342 | import Actions from '../../actions/boards';
343 | import {renderErrorsFor} from '../../utils';
344 |
345 | export default class BoardForm extends React.Component {
346 | componentDidMount() {
347 | this.refs.name.focus();
348 | }
349 |
350 | _handleSubmit(e) {
351 | e.preventDefault();
352 |
353 | const { dispatch } = this.props;
354 | const { name } = this.refs;
355 |
356 | const data = {
357 | name: name.value,
358 | };
359 |
360 | dispatch(Actions.create(data));
361 | }
362 |
363 | _handleCancelClick(e) {
364 | e.preventDefault();
365 |
366 | this.props.onCancelClick();
367 | }
368 |
369 | render() {
370 | const { errors } = this.props;
371 |
372 | return (
373 |
374 |
375 |
376 |
New board
377 |
382 |
383 |
384 |
385 | );
386 | }
387 | }
388 | ```
389 | 这是一个很简单的组件。用于渲染表格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.
390 |
391 | ##action creators
392 |
393 | 我们最少需要 3个 action creators:
394 |
395 | ```javascript
396 | // web/static/js/actions/boards.js
397 |
398 | import Constants from '../constants';
399 | import { routeActions } from 'react-router-redux';
400 | import { httpGet, httpPost } from '../utils';
401 | import CurrentBoardActions from './current_board';
402 |
403 | const Actions = {
404 | fetchBoards: () => {
405 | return dispatch => {
406 | dispatch({ type: Constants.BOARDS_FETCHING });
407 |
408 | httpGet('/api/v1/boards')
409 | .then((data) => {
410 | dispatch({
411 | type: Constants.BOARDS_RECEIVED,
412 | ownedBoards: data.owned_boards
413 | });
414 | });
415 | };
416 | },
417 |
418 | showForm: (show) => {
419 | return dispatch => {
420 | dispatch({
421 | type: Constants.BOARDS_SHOW_FORM,
422 | show: show,
423 | });
424 | };
425 | },
426 |
427 | create: (data) => {
428 | return dispatch => {
429 | httpPost('/api/v1/boards', { board: data })
430 | .then((data) => {
431 | dispatch({
432 | type: Constants.BOARDS_NEW_BOARD_CREATED,
433 | board: data,
434 | });
435 |
436 | dispatch(routeActions.push(`/boards/${data.id}`));
437 | })
438 | .catch((error) => {
439 | error.response.json()
440 | .then((json) => {
441 | dispatch({
442 | type: Constants.BOARDS_CREATE_ERROR,
443 | errors: json.errors,
444 | });
445 | });
446 | });
447 | };
448 | },
449 | };
450 |
451 | export default Actions;
452 | ```
453 | * 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.
454 | * 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.
455 | * 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.
456 |
457 | ##The reducer
458 |
459 | The last piece of the puzzle would be the reducer which is very simple:
460 |
461 | ```javascript
462 | // web/static/js/reducers/boards.js
463 |
464 | import Constants from '../constants';
465 |
466 | const initialState = {
467 | ownedBoards: [],
468 | showForm: false,
469 | formErrors: null,
470 | fetching: true,
471 | };
472 |
473 | export default function reducer(state = initialState, action = {}) {
474 | switch (action.type) {
475 | case Constants.BOARDS_FETCHING:
476 | return { ...state, fetching: true };
477 |
478 | case Constants.BOARDS_RECEIVED:
479 | return { ...state, ownedBoards: action.ownedBoards, fetching: false };
480 |
481 | case Constants.BOARDS_SHOW_FORM:
482 | return { ...state, showForm: action.show };
483 |
484 | case Constants.BOARDS_CREATE_ERROR:
485 | return { ...state, formErrors: action.errors };
486 |
487 | case Constants.BOARDS_NEW_BOARD_CREATED:
488 | const { ownedBoards } = state;
489 |
490 | return { ...state, ownedBoards: [action.board].concat(ownedBoards) };
491 |
492 | default:
493 | return state;
494 | }
495 | }
496 | ```
497 | 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.
498 |
499 | 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.
500 | 同样,别忘记查看运行演示和下载最终的源代码:
501 |
502 | [演示](https://phoenix-trello.herokuapp.com/) [源代码](https://github.com/bigardone/phoenix-trello)
--------------------------------------------------------------------------------
/9-Adding board members.md:
--------------------------------------------------------------------------------
1 | #Trello clone with Phoenix and React (第九章节)
2 |
3 | 这篇文章属于基于Phoenix Framework 和React的Trello系列
4 |
5 | #Adding board members
6 |
7 | 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!
8 |
9 | ##The React view component
10 |
11 | Before continuing let's take a look at the React routes:
12 |
13 | ```javascript
14 | // web/static/js/routes/index.js
15 |
16 | import { IndexRoute, Route } from 'react-router';
17 | import React from 'react';
18 | import MainLayout from '../layouts/main';
19 | import AuthenticatedContainer from '../containers/authenticated';;
20 | import BoardsShowView from '../views/boards/show';
21 | // ...
22 |
23 | export default (
24 |
25 | ...
26 |
27 |
28 |
29 |
30 | ...
31 |
32 |
33 |
34 |
35 | );
36 | ```
37 |
38 | The /boards/:id route is going to be handled by the BoardsShowView component that we need to create:
39 |
40 | ```javascript
41 | // web/static/js/views/boards/show.js
42 |
43 | import React, {PropTypes} from 'react';
44 | import { connect } from 'react-redux';
45 |
46 | import Actions from '../../actions/current_board';
47 | import Constants from '../../constants';
48 | import { setDocumentTitle } from '../../utils';
49 | import BoardMembers from '../../components/boards/members';
50 |
51 |
52 | class BoardsShowView extends React.Component {
53 | componentDidMount() {
54 | const { socket } = this.props;
55 |
56 | if (!socket) {
57 | return false;
58 | }
59 |
60 | this.props.dispatch(Actions.connectToChannel(socket, this.props.params.id));
61 | }
62 |
63 | componentWillUnmount() {
64 | this.props.dispatch(Actions.leaveChannel(this.props.currentBoard.channel));
65 | }
66 |
67 | _renderMembers() {
68 | const { connectedUsers, showUsersForm, channel, error } = this.props.currentBoard;
69 | const { dispatch } = this.props;
70 | const members = this.props.currentBoard.members;
71 | const currentUserIsOwner = this.props.currentBoard.user.id === this.props.currentUser.id;
72 |
73 | return (
74 |
82 | );
83 | }
84 |
85 |
86 | render() {
87 | const { fetching, name } = this.props.currentBoard;
88 |
89 | if (fetching) return (
90 |
91 |
92 |
93 | );
94 |
95 | return (
96 |
97 |
98 |
{name}
99 | {::this._renderMembers()}
100 |
101 |
102 |
103 |
104 | {::this._renderAddNewList()}
105 |
106 |
107 |
108 |
109 | );
110 | }
111 | }
112 |
113 | const mapStateToProps = (state) => ({
114 | currentBoard: state.currentBoard,
115 | socket: state.session.socket,
116 | currentUser: state.session.currentUser,
117 | });
118 |
119 | export default connect(mapStateToProps)(BoardsShowView);
120 | ```
121 |
122 | 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.
123 |
124 | ##The reducer and actions creator
125 |
126 | As a starting point of the current board state we will only need to store the board data, the channel and the fetching flag:
127 |
128 | ```javascript
129 | // web/static/js/reducers/current_board.js
130 |
131 | import Constants from '../constants';
132 |
133 | const initialState = {
134 | channel: null,
135 | fetching: true,
136 | };
137 |
138 | export default function reducer(state = initialState, action = {}) {
139 | switch (action.type) {
140 | case Constants.CURRENT_BOARD_FETHING:
141 | return { ...state, fetching: true };
142 |
143 | case Constants.BOARDS_SET_CURRENT_BOARD:
144 | return { ...state, fetching: false, ...action.board };
145 |
146 | case Constants.CURRENT_BOARD_CONNECTED_TO_CHANNEL:
147 | return { ...state, channel: action.channel };
148 |
149 | default:
150 | return state;
151 | }
152 | }
153 | ```
154 |
155 | 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:
156 |
157 | ```javascript
158 | // web/static/js/actions/current_board.js
159 |
160 | import Constants from '../constants';
161 |
162 | const Actions = {
163 | connectToChannel: (socket, boardId) => {
164 | return dispatch => {
165 | const channel = socket.channel(`boards:${boardId}`);
166 |
167 | dispatch({ type: Constants.CURRENT_BOARD_FETHING });
168 |
169 | channel.join().receive('ok', (response) => {
170 | dispatch({
171 | type: Constants.BOARDS_SET_CURRENT_BOARD,
172 | board: response.board,
173 | });
174 |
175 | dispatch({
176 | type: Constants.CURRENT_BOARD_CONNECTED_TO_CHANNEL,
177 | channel: channel,
178 | });
179 | });
180 | };
181 | },
182 |
183 | // ...
184 | };
185 |
186 | export default Actions;
187 | ```
188 |
189 | 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.
190 |
191 | ##The BoardChannel
192 |
193 | 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:
194 |
195 | ```javascript
196 | # web/channels/board_channel.ex
197 |
198 | defmodule PhoenixTrello.BoardChannel do
199 | use PhoenixTrello.Web, :channel
200 | alias PhoenixTrello.Board
201 |
202 | def join("boards:" <> board_id, _params, socket) do
203 | board = get_current_board(socket, board_id)
204 |
205 | {:ok, %{board: board}, assign(socket, :board, board)}
206 | end
207 |
208 | defp get_current_board(socket, board_id) do
209 | socket.assigns.current_user
210 | |> assoc(:boards)
211 | |> Repo.get(board_id)
212 | end
213 | end
214 | ```
215 | 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.
216 |
217 | 
218 |
219 | ##Board members
220 |
221 | 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:
222 | ```
223 | $ mix phoenix.gen.model UserBoard user_boards user_id:references:users board_id:references:boards
224 | ```
225 | We need to update a bit the resulting migration file:
226 |
227 | ```elixir
228 | # priv/repo/migrations/20151230081546_create_user_board.exs
229 |
230 | defmodule PhoenixTrello.Repo.Migrations.CreateUserBoard do
231 | use Ecto.Migration
232 |
233 | def change do
234 | create table(:user_boards) do
235 | add :user_id, references(:users, on_delete: :delete_all), null: false
236 | add :board_id, references(:boards, on_delete: :delete_all), null: false
237 |
238 | timestamps
239 | end
240 |
241 | create index(:user_boards, [:user_id])
242 | create index(:user_boards, [:board_id])
243 | create unique_index(:user_boards, [:user_id, :board_id])
244 | end
245 | end
246 | ```
247 |
248 | 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:
249 |
250 | ```elixir
251 | # web/models/user_board.ex
252 |
253 | defmodule PhoenixTrello.UserBoard do
254 | use PhoenixTrello.Web, :model
255 |
256 | alias PhoenixTrello.{User, Board}
257 |
258 | schema "user_boards" do
259 | belongs_to :user, User
260 | belongs_to :board, Board
261 |
262 | timestamps
263 | end
264 |
265 | @required_fields ~w(user_id board_id)
266 | @optional_fields ~w()
267 |
268 | def changeset(model, params \\ :empty) do
269 | model
270 | |> cast(params, @required_fields, @optional_fields)
271 | |> unique_constraint(:user_id, name: :user_boards_user_id_board_id_index)
272 | end
273 | end
274 | ```
275 |
276 | Nothing unusual about it, but we also need to add this new relationships to the User model:
277 |
278 | ```elixir
279 | # web/models/user.ex
280 |
281 | defmodule PhoenixTrello.User do
282 | use PhoenixTrello.Web, :model
283 | # ...
284 |
285 | schema "users" do
286 | # ...
287 |
288 | has_many :user_boards, UserBoard
289 | has_many :boards, through: [:user_boards, :board]
290 |
291 | # ...
292 | end
293 |
294 | # ...
295 | end
296 | ```
297 |
298 | 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:
299 |
300 | ```elixir
301 | # web/models/board.ex
302 |
303 | defmodule PhoenixTrello.Board do
304 | # ...
305 |
306 | schema "boards" do
307 | # ...
308 |
309 | has_many :user_boards, UserBoard
310 | has_many :members, through: [:user_boards, :user]
311 |
312 | timestamps
313 | end
314 | end
315 | ```
316 |
317 | 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:
318 |
319 | ```elixir
320 | # web/controllers/api/v1/board_controller.ex
321 |
322 | defmodule PhoenixTrello.BoardController do
323 | use PhoenixTrello.Web, :controller
324 | #...
325 |
326 | def create(conn, %{"board" => board_params}) do
327 | current_user = Guardian.Plug.current_resource(conn)
328 |
329 | changeset = current_user
330 | |> build_assoc(:owned_boards)
331 | |> Board.changeset(board_params)
332 |
333 | if changeset.valid? do
334 | board = Repo.insert!(changeset)
335 |
336 | board
337 | |> build_assoc(:user_boards)
338 | |> UserBoard.changeset(%{user_id: current_user.id})
339 | |> Repo.insert!
340 |
341 | conn
342 | |> put_status(:created)
343 | |> render("show.json", board: board )
344 | else
345 | conn
346 | |> put_status(:unprocessable_entity)
347 | |> render("error.json", changeset: changeset)
348 | end
349 | end
350 | end
351 | ```
352 |
353 | Note how we build the new UserBoard association and insert it after previously checking if the board is valid.
354 |
355 | ##The board members component
356 |
357 | This component will display all the board's members avatars and the form to add new ones:
358 |
359 | 
360 |
361 | 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:
362 |
363 | ```javascipt
364 | // web/static/js/components/boards/members.js
365 |
366 | import React, {PropTypes} from 'react';
367 | import ReactGravatar from 'react-gravatar';
368 | import classnames from 'classnames';
369 | import PageClick from 'react-page-click';
370 | import Actions from '../../actions/current_board';
371 |
372 | export default class BoardMembers extends React.Component {
373 | _renderUsers() {
374 | return this.props.members.map((member) => {
375 | const index = this.props.connectedUsers.findIndex((cu) => {
376 | return cu === member.id;
377 | });
378 |
379 | const classes = classnames({ connected: index != -1 });
380 |
381 | return (
382 |
458 | );
459 | }
460 | }
461 | ```
462 |
463 | 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.
464 |
465 | ##The addNewMember action creator
466 |
467 | 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:
468 |
469 | ```javascript
470 | // web/static/js/actions/current_board.js
471 |
472 | import Constants from '../constants';
473 |
474 | const Actions = {
475 | // ...
476 |
477 | showMembersForm: (show) => {
478 | return dispatch => {
479 | dispatch({
480 | type: Constants.CURRENT_BOARD_SHOW_MEMBERS_FORM,
481 | show: show,
482 | });
483 | };
484 | },
485 |
486 | addNewMember: (channel, email) => {
487 | return dispatch => {
488 | channel.push('members:add', { email: email })
489 | .receive('error', (data) => {
490 | dispatch({
491 | type: Constants.CURRENT_BOARD_ADD_MEMBER_ERROR,
492 | error: data.error,
493 | });
494 | });
495 | };
496 | },
497 |
498 | // ...
499 |
500 | }
501 |
502 | export default Actions;
503 | ```
504 | 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.
505 |
506 | ##The BoardChannel
507 |
508 | Having this said let's add the underlying message handler to the BoardChannel
509 |
510 | ``elixir
511 | # web/channels/board_channel.ex
512 |
513 | defmodule PhoenixTrello.BoardChannel do
514 | # ...
515 |
516 | def handle_in("members:add", %{"email" => email}, socket) do
517 | try do
518 | board = socket.assigns.board
519 | user = User
520 | |> Repo.get_by(email: email)
521 |
522 | changeset = user
523 | |> build_assoc(:user_boards)
524 | |> UserBoard.changeset(%{board_id: board.id})
525 |
526 | case Repo.insert(changeset) do
527 | {:ok, _board_user} ->
528 | broadcast! socket, "member:added", %{user: user}
529 |
530 | PhoenixTrello.Endpoint.broadcast_from! self(), "users:#{user.id}", "boards:add", %{board: board}
531 |
532 | {:noreply, socket}
533 | {:error, _changeset} ->
534 | {:reply, {:error, %{error: "Error adding new member"}}, socket}
535 | end
536 | catch
537 | _, _-> {:reply, {:error, %{error: "User does not exist"}}, socket}
538 | end
539 | end
540 |
541 | # ...
542 | end
543 | ```
544 |
545 | 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:
546 |
547 | PhoenixTrello.Endpoint.broadcast_from! self(), "users:#{user.id}", "boards:add", %{board: board}
548 | 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.
549 |
550 | 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:
551 |
552 | ```javascript
553 | // web/static/js/actions/current_board.js
554 |
555 | import Constants from '../constants';
556 |
557 | const Actions = {
558 | // ...
559 |
560 | connectToChannel: (socket, boardId) => {
561 | return dispatch => {
562 | const channel = socket.channel(`boards:${boardId}`);
563 |
564 | // ...
565 |
566 | channel.on('member:added', (msg) => {
567 | dispatch({
568 | type: Constants.CURRENT_BOARD_MEMBER_ADDED,
569 | user: msg.user,
570 | });
571 | });
572 |
573 | // ...
574 | }
575 | },
576 | };
577 |
578 | export default Actions;
579 | ```
580 |
581 | And we have to do exactly the same for the boards:add, but dispatching the board:
582 |
583 | ```javascript
584 | // web/static/js/actions/sessions.js
585 |
586 | export function setCurrentUser(dispatch, user) {
587 | channel.on('boards:add', (msg) => {
588 | // ...
589 |
590 | dispatch({
591 | type: Constants.BOARDS_ADDED,
592 | board: msg.board,
593 | });
594 | });
595 | };
596 | Finally, we need to update the reducers so both the new member and the new board are added into the application state:
597 |
598 | // web/static/js/reducers/current_board.js
599 |
600 | export default function reducer(state = initialState, action = {}) {
601 | // ...
602 |
603 | case Constants.CURRENT_BOARD_MEMBER_ADDED:
604 | const { members } = state;
605 | members.push(action.user);
606 |
607 | return { ...state, members: members, showUsersForm: false };
608 | }
609 |
610 | // ...
611 | }
612 | // web/static/js/reducers/boards.js
613 |
614 | export default function reducer(state = initialState, action = {}) {
615 | // ...
616 |
617 | switch (action.type) {
618 | case Constants.BOARDS_ADDED:
619 | const { invitedBoards } = state;
620 |
621 | return { ...state, invitedBoards: [action.board].concat(invitedBoards) };
622 | }
623 |
624 | // ...
625 | }
626 | ```
627 | 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.
628 |
629 | 
630 |
631 | 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:
632 |
633 | Live demo Source code
634 | Happy coding!
635 |
636 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #Trello clone with Phoenix and React(English)
2 | 
3 | This post belongs to the Trello clone with Phoenix Framework and React series.
4 | 1. [Intro and selected stack](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-1)
5 | 2. [Phoenix Framework project setup](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-2)
6 | 3. [The User model and JWT auth](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-3)
7 | 4. [Front-end for sign up with React and Redux](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-4)
8 | 5. [Database seeding and sign in controller](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-5)
9 | 6. [Front-end authentication with React and Redux](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-6)
10 | 7. [Setting up sockets and channels](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-7)
11 | 8. [Listing and creating new boards](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-8)
12 | 9. [Adding board member](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-9)
13 | 10. [Tracking connected board members](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-10)
14 | 11. [Adding lists and cards](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-11)
15 | 12. [Deploying on Heroku](https://blog.diacode.com/trello-clone-with-phoenix-and-react-pt-12)
16 |
17 | Thanks bigardone!
18 | After the author's permission, I translated into Chinese.
19 |
20 | ##Project address
21 | [Project address](https://github.com/bigardone/phoenix-trello)
22 |
23 | #基于Phoenix Framework 和React的Trello(中文)
24 | 这篇文章属于基于Phoenix Framework 和React的Trello系列
25 |
26 | 1. [介绍和架构选择](1-Intro and selected stack.md)
27 | 2. [Phoenix Framework 项目设置](2-Phoenix Framework project setup.md)
28 | 3. [User模型和JWT权限设置](3-The User model and JWT auth.md)
29 | 4. [使用React 和 Redux实现前端用户注册](4-Front-end for sign up with React and Redux.md)
30 | 5. [数据库初始化和用户登录Controller ](5-Database seeding and sign in controller.md)
31 | 6. [基于React和Redux的前端验证](6-Front-end authentication with React and Redux.md)
32 | 7. [sockets和channels 配置](7-Setting up sockets and channels.md)
33 | 8. [展示和创建新Board](8-Listing and creating new boards.md)
34 | 9. [添加Board用户](9-Adding board members.md)
35 | 10. [追踪已连接的Board用户](10-Tracking connected board members.md)
36 | 11. [添加列表和卡片](11-Adding lists and cards.md)
37 | 12. [在Heroku上部署](12-Deploying on Heroku.md)
38 |
39 |
--------------------------------------------------------------------------------
/images/part1/boards.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part1/boards.jpg
--------------------------------------------------------------------------------
/images/part1/show-board.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part1/show-board.jpg
--------------------------------------------------------------------------------
/images/part1/sign-in.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part1/sign-in.jpg
--------------------------------------------------------------------------------
/images/part10/board_4 .jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part10/board_4 .jpg
--------------------------------------------------------------------------------
/images/part11/card_form.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part11/card_form.jpg
--------------------------------------------------------------------------------
/images/part11/list_form.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part11/list_form.jpg
--------------------------------------------------------------------------------
/images/part11/new_card.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part11/new_card.jpg
--------------------------------------------------------------------------------
/images/part11/new_list.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part11/new_list.jpg
--------------------------------------------------------------------------------
/images/part11/no_lists.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part11/no_lists.jpg
--------------------------------------------------------------------------------
/images/part9/board_1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part9/board_1.jpg
--------------------------------------------------------------------------------
/images/part9/board_3.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part9/board_3.jpg
--------------------------------------------------------------------------------
/images/part9/board_4.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wuming123057/Trello_clone_with_Phoenix_and_React/2c8d571a0abccaad6a1dd300958f5a8ce996f252/images/part9/board_4.jpg
--------------------------------------------------------------------------------