ساخت اپلیکیشن ToDo با Django و React — از صفر تا صد
در این راهنما با شیوه ساخت یک اپلیکیشن ToDo به صورت CRUD با استفاده از فریمورک Django REST برای بکاند و ریاکت و ریداکس برای فرانتاند آشنا خواهیم شد. این راهنما برای افراد مبتدی طراحی شده است و همه مفاهیم به صورت گام به گام و تفصیلی همراه با نمونه کدهای مربوطه توضیح داده شده است. با ما همراه باشید تا روش ساخت اپلیکیشن ToDo با Django و React را بیاموزید.
در انتهای این راهنما یک اپلیکیشن به صورت تصویر زیر خواهیم داشت:
تنظیم Django
در ابتدا باید Django را برای ساخت اپلیکیشن تنظیم کنیم. در این بخش مراحل این تنظیم را با هم مرور خواهیم کرد.
ایجاد محیط مجازی با Pipenv
قبل از هر چیز یک پوشه برای پروژه میسازیم و به آن میرویم:
$ mkdir django-react-todo $ cd django-react-todo
با اجرای دستور زیر نیز یک محیط مجازی ایجاد میکنیم:
$ pipenv --python 3
اگر هنوز Pipenv را روی سیستم خود نصب نکردهاید، ابتدا آن را با اجرای دستور زیر نصب کنید:
$ pip install pipenv
به این ترتیب پکیجهای مورد نیاز را نصب خواهیم کرد:
$ pipenv install django djangorestframework
ایجاد پروژه جدید و برخی اپلیکیشنها
در این راهنما قصد داریم پروژهای به نام todocrud ایجاد کنیم. میتوانیم پوشه اضافی که به صورت خودکار ایجاد شده را با اضافه کردن یک نقطه به انتهای دستور و اجرا کردنش ترک کنیم:
$ django-admin startproject todocrud.
سپس دو اپلیکیشن ایجاد خواهیم کرد. یکی برای بکاند و دیگری برای فرانتاند:
$ python manage.py startapp todos $ python manage.py startapp frontend
فایل settings.py را در دایرکتوری پروژه باز کنید و آن را برای استفاده از اپلیکیشنهایی که ایجاد کردیم و فریمورک Django REST پیکربندی کنید:
1# settings.py
2
3INSTALLED_APPS = [
4 'frontend.apps.FrontendConfig', # added
5 'todos.apps.TodosConfig', # added
6 'rest_framework', # added
7 'django.contrib.admin',
8 'django.contrib.auth',
9 'django.contrib.contenttypes',
10 'django.contrib.sessions',
11 'django.contrib.messages',
12 'django.contrib.staticfiles',
13]
14
15REST_FRAMEWORK = { # added
16 'DEFAULT_PERMISSION_CLASSES': [
17 'rest_framework.permissions.AllowAny'
18 ],
19 'DATETIME_FORMAT': "%m/%d/%Y %H:%M:%S",
20}
ما میتوانیم قالب خروجی تاریخ و زمان را با قرار دادن DATETIME_FORMAT در یک دیکشنری پیکربندی به نام REST_FRAMEWORK تعیین کنیم.
اینک میگریشنها را اعمال کرده و سرور توسعه را آغاز میکنیم:
$ python manage.py migrate $ python manage.py runserver
در مرورگر به آدرس http://127.0.0.1:8000/ بروید. اگر صفحهای دیدید که یک راکت در حال اوج گرفتن است، به این معنی است که همه چیز به درستی کار میکند.
نوشتن ماژولهای بکاند
ابتدا یک مدل ساده ایجاد میکنیم. فایل models.py را باز کرده و کد زیر را در آن بنویسید:
1# todos/models.py
2
3from django.db import models
4
5
6class Todo(models.Model):
7 task = models.CharField(max_length=255)
8 created_at = models.DateTimeField(auto_now_add=True)
9
10 def __str__(self):
11 return self.task
سپس یک API مدل-بکاند ساده با استفاده از فریمورک REST میسازیم. به این منظور یک پوشه به نام api ایجاد میکنیم و فایلهای جدید با نامهای __init__.py, serializers.py, views.py و urls.py را در آن میسازیم.
todos/ api/ __init__.py serializers.py urls.py views.py
از آنجا که api یک ماژول است، باید فایل init__.py__ را نیز include کنیم. در ادامه بازنمایی API را در فایل serializers.py تعریف میکنیم.
1# todos/api/serializers.py
2
3from rest_framework import serializers
4
5from todos.models import Todo
6
7
8class TodoSerializer(serializers.ModelSerializer):
9 class Meta:
10 model = Todo
11 fields = '__all__'
کلاس ModelSerializer فیلدهایی را ایجاد میکند که متناظر با فیلدهای مدل هستند. سپس رفتار را در فایل api/views.py تعریف میکنیم:
1# todos/api/views.py
2
3from rest_framework import viewsets
4
5from .serializers import TodoSerializer
6from todos.models import Todo
7
8
9class TodoViewSet(viewsets.ModelViewSet):
10 queryset = Todo.objects.all()
11 serializer_class = TodoSerializer
در نهایت پیکربندی URL را با استفاده از Routers مینویسیم:
1# todos/api/urls.py
2
3from rest_framework import routers
4
5from .views import TodoViewSet
6
7router = routers.DefaultRouter()
8router.register('todos', TodoViewSet, 'todos')
9# router.register('<The URL prefix>', <The viewset class>, '<The URL name>')
10
11urlpatterns = router.urls
ما از سه آرگومان برای متد ()register استفاده میکنیم، اما آرگومان سوم الزامی نیست.
نوشتن ماژولهای فرانتاند
در فرانتاند تنها کاری که باید انجام دهیم، نوشتن نماها-(Views)-ی ساده و الگوهای URL است.
فایل frontend/views.py را باز و دو نما ایجاد کنید:
1# frontend/views.py
2
3from django.shortcuts import render
4from django.views.generic.detail import DetailView
5
6from todos.models import Todo
7
8
9def index(request):
10 return render(request, 'frontend/index.html')
11
12
13class TodoDetailView(DetailView):
14 model = Todo
15 template_name = 'frontend/index.html'
فایل frontend/index.html را در ادامه ایجاد خواهیم کرد. فعلاً نگران آن نباشید. یک فایل به نام urls.py به همان دایرکتوری اضافه کنید و پیکربندی URL را ایجاد نمایید:
1# frontend/urls.py
2
3from django.urls import path
4
5from .views import index, TodoDetailView
6
7urlpatterns = [
8 path('', index),
9 path('edit/<int:pk>', TodoDetailView.as_view()),
10 path('delete/<int:pk>', TodoDetailView.as_view()),
11]
چنان که در فایل فوق میبینید، نمای index برای صفحه اندیس است و TodoDetailView نیز زمانی فراخوانی میشود که یک شیء خاص درخواست شده باشد.
اتصال URL-ها
URL-های بکاند و فرانتاند را در URLconf پروژه میگنجانیم:
1# frontend/urls.py
2
3from django.urls import path
4
5from .views import index, TodoDetailView
6
7urlpatterns = [
8 path('', index),
9 path('edit/<int:pk>', TodoDetailView.as_view()),
10 path('delete/<int:pk>', TodoDetailView.as_view()),
11]
با این که بخش مربوط به ادمین سایت جنگو نیز آمده است؛ اما در این راهنما از آن استفاده نخواهیم کرد.
به عنوان آخرین نکته در این بخش یک فایل میگریشن ایجاد میکنیم و با اجرای دستور زیر تغییرها را روی پایگاههای داده اعمال میکنیم:
$ python manage.py makemigrations $ python manage.py migrate
تنظیم ریاکت
در این بخش با مراحل تنظیم اولیه فرانتاند و فریمورک ریاکت آشنا میشویم.
ایجاد دایرکتوریها
قبل از هر چیز باید همه دایرکتوریهای مورد نیاز خود را ایجاد کنیم:
$ mkdir -p./frontend/src/{components,actions,reducers} $ mkdir -p./frontend/{static,templates}/frontend
دستور فوق باید دایرکتوریهایی به شرح زیر ایجاد کند:
frontend/ src/ actions/ components/ reducers/ static/ frontend/ templates/ frontend/
نصب پکیجها
در این بخش باید با اجرای دستور زیر پیش از نصب پکیجها، یک فایل به نام package.json ایجاد کنیم:
$ npm init –y
برای استفاده از npm باید Node.js روی سیستم نصب باشد. سپس باید همه پکیجها را با دستور npm زیر نصب کنیم:
$ npm i -D webpack webpack-cli $ npm i -D babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/plugin-proposal-class-properties $ npm i react react-dom react-router-dom $ npm i redux react-redux redux-thunk redux-devtools-extension $ npm i redux-form $ npm i axios $ npm i lodash
ایجاد فایلهای پیکربندی
یک فایل به نام .babelrc به دایرکتوری ریشه اضافه کرده و Babel را پیکربندی میکنیم:
1// .babelrc
2
3{
4 "presets": [
5 [
6 "@babel/preset-env",
7 {
8 "targets": {
9 "node": "current"
10 },
11 "useBuiltIns": "usage",
12 "corejs": 3
13 }
14 ],
15 "@babel/preset-react"
16 ],
17 "plugins": ["@babel/plugin-proposal-class-properties"]
18}
میتوانیم با نوشتن کدی مانند فوق، از Async/Await به همراه Babel استفاده کنیم. در وهله دوم یک فایل به نام webpack.config.js به همان دایرکتوری اضافه میکنیم و یک پیکربندی برای webpack مینویسیم:
1// webpack.config.js
2
3module.exports = {
4 module: {
5 rules: [
6 {
7 test: /\.(js|jsx)$/,
8 exclude: /node_modules/,
9 use: {
10 loader: 'babel-loader'
11 }
12 }
13 ]
14 }
15};
به علاوه، باید مشخصه "scripts” مربوط به فایل package.json را بازنویسی کنیم:
1// package.json
2
3{
4 // ...
5 "scripts": {
6 "dev": "webpack --mode development --watch ./frontend/src/index.js --output ./frontend/static/frontend/main.js",
7 "build": "webpack --mode production ./frontend/src/index.js --output ./frontend/static/frontend/main.js"
8 },
9 // ...
10}
به این ترتیب دو اسکریپت جدید تعریف شدهاند. این اسکریپتها را میتوانیم با استفاده از دستور npm run dev برای توسعه و یا دستور npm run build برای پروداکشن اجرا کنیم. زمانی که این اسکریپتها اجرا شوند، وبپک ماژولها را بستهبندی کرده و در فایل main.js عرضه میکند.
ایجاد فایلهای اصلی
در این بخش سه فایل اصلی را ایجاد کرده و نخستین کلمه را رندر میکنیم. یک فایل به نام index.js میسازیم که ابتدا زمانی که اپلیکیشن ریاکت اجرا شود، فراخوانی خواهد شد:
1// frontend/src/index.js
2
3import App from './components/App';
سپس یک فایل به نام App.js ایجاد میکنیم که کامپوننت والد است:
1// frontend/src/components/App.js
2
3import React, { Component } from 'react';
4import ReactDOM from 'react-dom';
5
6class App extends Component {
7 render() {
8 return (
9 <div>
10 <h1>ToDoCRUD</h1>
11 </div>
12 );
13 }
14}
15
16ReactDOM.render(<App />, document.querySelector('#app'));
در نهایت یک فایل قالب به نام index.html ایجاد میکنیم که در فایل views.py ذکر شده است:
1<!-- templates/frontend/index.html -->
2
3<!DOCTYPE html>
4<html lang="en">
5
6<head>
7 <meta charset="UTF-8">
8 <meta name="viewport" content="width=device-width, initial-scale=1.0">
9 <meta http-equiv="X-UA-Compatible" content="ie=edge">
10 <!-- semantic-ui CDN -->
11 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
12 <title>ToDoCRUD</title>
13</head>
14
15<body>
16 <div id='app'></div>
17
18 {% load static %}
19 <script src="{% static 'frontend/main.js' %}"></script>
20</body>
21
22</html>
ما در این راهنما از Semantic UI به عنوان فریمورک CSS استفاده میکنیم. یک پوشش برای رندر کردن کامپوننت App قرار میدهیم و اسکریپت باندلشده را درون تگ <body> قرار میدهیم.
بررسی نمایش
در این بخش بررسی میکنیم که آیا شیوه نمایش صحیح است یا نه. به این منظور ترمینال را باز کرده و اسکریپت را اجرا میکنیم:
$ npm run dev
با اجرای دستور فوق، فایل main.js باید در دایرکتوری static/frontend ایجاد شده باشد. سپس سرور توسعه را آغاز کرده و به نشانی http://127.0.0.1:8000/ میرویم:
$ python manage.py runserver
اگر کلمه $ python manage.py runserver روی صفحه نمایش یافته باشد، یعنی همه چیز تا به اینجا به درستی پیش رفته است.
دریافت دادهها از API و نمایش لیست
اینک زمان آن رسیده است که از Redux استفاده کنیم. در این بخش «اکشن» (Action)، «ردیوسر» (Reducer) و «استور» (Store) را ایجاد خواهیم کرد.
اکشنها
ابتدا همه مشخصههای نوع را از قبل تعریف میکنیم. یک فایل جدید به نام types.js به دایرکتوری src/actions اضافه کنید:
1// actions/types.js
2
3export const GET_TODOS = 'GET_TODOS';
4export const GET_TODO = 'GET_TODO';
5export const ADD_TODO = 'ADD_TODO';
6export const DELETE_TODO = 'DELETE_TODO';
7export const EDIT_TODO = 'EDIT_TODO';
برای ایجاد اکشنها باید «اکشنساز» (Action Creator) را مشخص کنیم. یک فایل جدید به نام todos.js به دایرکتوری src/actions اضافه کنید:
1// actions/todos.js
2
3import axios from 'axios';
4import { GET_TODOS } from './types';
5
6// GET TODOS
7export const getTodos = () => async dispatch => {
8 const res = await axios.get('/api/todos/');
9 dispatch({
10 type: GET_TODOS,
11 payload: res.data
12 });
13};
ردیوسرها
ردیوسرها شیوه تغییر یافتن «حالت» (State) اپلیکیشن را در پاسخ به اکشنهای ارسالی به استور تعیین میکنند.
یک فایل جدید به نام todos.js به دایرکتوری src/reducers اضافه کرده و یک ردیوسر فرزند را در آن مینویسیم:
1// reducers/todos.js
2
3import _ from 'lodash';
4import { GET_TODOS } from '../actions/types';
5
6export default (state = {}, action) => {
7 switch (action.type) {
8 case GET_TODOS:
9 return {
10 ...state,
11 ..._.mapKeys(action.payload, 'id')
12 };
13 default:
14 return state;
15 }
16};
Lodash (+) یک کتابخانه کاربردی جاوا اسکریپت است. استفاده از آن الزامی نیست، اما میتواند زمان توسعه را کاهش داده و حجم کدبیس را کوچکتر سازد.
در این بخش یک ردیوسر والد میسازیم تا همه ردیوسرهای فرزند را با استفاده از ()combineReducers گرد هم جمع کنیم. یک فایل جدید به نام index.js به دایرکتوری src/reducers اضافه کنید:
1// reducers/index.js
2
3import { combineReducers } from 'redux';
4import { reducer as formReducer } from 'redux-form';
5import todos from './todos';
6
7export default combineReducers({
8 form: formReducer,
9 todos
10});
برای استفاده از redux-form باید ردیوسر آن را در تابع combineReducers قرار دهیم.
استور
استور (Store) شیئی است که حالت اپلیکیشن را نگهداری میکند. به علاوه از میانافزار توصیهشده به نام Redux Thunk برای نوشتن منطق ناهمگام استفاده میکنیم که با استور تعامل خواهد داشت. در این بخش یک فایل جدید به نام store.js در دایرکتوری src ایجاد میکنیم:
1// fronted/src/store.js
2
3import { createStore, applyMiddleware } from 'redux';
4import { composeWithDevTools } from 'redux-devtools-extension';
5import reduxThunk from 'redux-thunk';
6import rootReducer from './reducers';
7
8const store = createStore(
9 rootReducer,
10 composeWithDevTools(applyMiddleware(reduxThunk))
11);
12
13export default store;
استفاده از Redux DevTools اختیاری است، اما کاربرد آن بسیار مفید است، زیرا تغییرهای حالت ریداکس را بصریسازی میکند. در این راهنما به توضیح شیوه استفاده از آن نمیپردازیم، اما در کل بسیار مفید است.
کامپوننتها
ابتدا یک پوشه به نام todos در دایرکتوری components ایجاد میکنیم. سپس فایل جدید به نام TodoList.js را به این پوشه ایجادشده اضافه میکنیم:
1// components/todos/TodoList.js
2
3import React, { Component } from 'react';
4import { connect } from 'react-redux';
5import { getTodos } from '../../actions/todos';
6
7class TodoList extends Component {
8 componentDidMount() {
9 this.props.getTodos();
10 }
11
12 render() {
13 return (
14 <div className='ui relaxed divided list' style={{ marginTop: '2rem' }}>
15 {this.props.todos.map(todo => (
16 <div className='item' key={todo.id}>
17 <i className='large calendar outline middle aligned icon' />
18 <div className='content'>
19 <a className='header'>{todo.task}</a>
20 <div className='description'>{todo.created_at}</div>
21 </div>
22 </div>
23 ))}
24 </div>
25 );
26 }
27}
28
29const mapStateToProps = state => ({
30 todos: Object.values(state.todos)
31});
32
33export default connect(
34 mapStateToProps,
35 { getTodos }
36)(TodoList);
ما از Semantic UI برای تزیین لیست استفاده میکنیم. تابع ()connect این کامپوننت را به استور وصل میکند. این تابع مقدار mapStateToProps را به عنوان آرگومان نخست میگیرد. اکشنساز نیز آرگومان دوم آن است. میتوانیم از حالت استور به عنوان Props برای تعیین mapStateToProps استفاده کنیم.
به این منظور یک فایل جدید به نام Dashboard.js در همان دایرکتوری ایجاد میکنیم. این فایل صرفاً یک کانتینر برای TodoList و یک فرم است که در بخش بعدی خواهیم ساخت:
1// components/todos/Dashboard.js
2
3import React, { Component } from 'react';
4import TodoList from './TodoList';
5
6class Dashboard extends Component {
7 render() {
8 return (
9 <div className='ui container'>
10 <div>Todo Create Form</div>
11 <TodoList />
12 </div>
13 );
14 }
15}
16
17export default Dashboard;
فایل App.js را باز کنید و به صورت زیر بهروزرسانی نمایید:
1// components/App.js
2
3import Dashboard from './todos/Dashboard'; // added
4
5import { Provider } from 'react-redux'; // added
6import store from '../store'; // added
7
8class App extends Component {
9 render() {
10 return (
11 <Provider store={store}>
12 <Dashboard />
13 </Provider>
14 );
15 }
16}
Provider موجب میشود که استور در اختیار کامپوننت تودرتوی درون آن قرار بگیرد.
بررسی نمایش
ابتدا به نشانی http://127.0.0.1:8000/api/todos بروید و چند شیء ایجاد کنید. سپس به نشانی http://127.0.0.1:8000 بروید. اینک باید یک لیست ساده از اشیایی که ایجاد کردید ببینید.
ایجاد فرم و افزودن ToDo جدید
در این بخش مراحل ساخت فرم مربوط به وارد کردن موارد ToDo جدید را با هم مرور میکنیم.
اکشنها
فایل actions/todos.js را باز کرده و یک اکشنساز جدید به آن اضافه کنید:
1// actions/todos.js
2
3import { reset } from 'redux-form'; // added
4import { GET_TODOS, ADD_TODO } from './types'; // added ADD_TODO
5
6// ADD TODO
7export const addTodo = formValues => async dispatch => {
8 const res = await axios.post('/api/todos/', { ...formValues });
9 dispatch({
10 type: ADD_TODO,
11 payload: res.data
12 });
13 dispatch(reset('todoForm'));
14};
دیسپچ کردن ('reset('formName موجب پاک شدن فرم پس از تحویل موفق میشود. ما نام فرم را متعاقباً در کامپوننت Form تعیین خواهیم کرد.
ردیوسرها
فایل reducers/todos.js را باز کنید و یک اکشن جدید به ردیوسر اضافه کنید:
1// reducers/todos.js
2
3import { GET_TODOS, ADD_TODO } from '../actions/types'; // added ADD_TODO
4
5export default (state = {}, action) => {
6 switch (action.type) {
7 // ...
8 case ADD_TODO: // added
9 return {
10 ...state,
11 [action.payload.id]: action.payload
12 };
13 // ...
14 }
15};
کامپوننتها
در این بخش ابتدا یک کامپوننت Form ایجاد میکنیم. یک فرم به صورت مجزا به عنوان کامپوننت با استفاده مجدد ایجاد میکنیم، به طوری که بتوان از آن برای ویرایش کردن نیز استفاده کرد. یک فایل جدید به نام TodoForm.js در دایرکتوری components/todos ایجاد کنید:
1// components/todos/TodoForm.js
2
3import React, { Component } from 'react';
4import { Field, reduxForm } from 'redux-form';
5
6class TodoForm extends Component {
7 renderField = ({ input, label, meta: { touched, error } }) => {
8 return (
9 <div className={`field ${touched && error ? 'error' : ''}`}>
10 <label>{label}</label>
11 <input {...input} autoComplete='off' />
12 {touched && error && (
13 <span className='ui pointing red basic label'>{error}</span>
14 )}
15 </div>
16 );
17 };
18
19 onSubmit = formValues => {
20 this.props.onSubmit(formValues);
21 };
22
23 render() {
24 return (
25 <div className='ui segment'>
26 <form
27 onSubmit={this.props.handleSubmit(this.onSubmit)}
28 className='ui form error'
29 >
30 <Field name='task' component={this.renderField} label='Task' />
31 <button className='ui primary button'>Add</button>
32 </form>
33 </div>
34 );
35 }
36}
37
38const validate = formValues => {
39 const errors = {};
40
41 if (!formValues.task) {
42 errors.task = 'Please enter at least 1 character';
43 }
44
45 return errors;
46};
47
48export default reduxForm({
49 form: 'todoForm',
50 touchOnBlur: false,
51 validate
52})(TodoForm);
با توجه به این که این راهنما نسبتاً طولانی است، در مورد شیوه استفاده از Redux Form توضیح ارائه نمیکنیم. برای درک طرز کار فرمهای ریداکس، بهتر است تلاش کنید با استفاده از مستندات رسمی (+) آن را سفارشیسازی کنید.
نام این فرم 'todoForm' است و آن را در اکشنسازی به نام addTodo استفاده خواهیم کرد.
زمانی که روی کادر متنی کلیک کنید و سپس فوکوس را از روی آن بردارید، یک خطای اعتبارسنجی نمایش میدهد. برای غیر فعال کردن آن باید از تنظیمات touchOnBlur: false استفاده کنیم.
سپس یک کامپوننت برای افزودن ToDo-های جدید اضافه میکنیم. یک فایل جدید به نام TodoCreate.js در دایرکتوری components/todos اضافه میکنیم:
1// components/todos/TodoCreate.js
2
3import React, { Component } from 'react';
4import { connect } from 'react-redux';
5import { addTodo } from '../../actions/todos';
6import TodoForm from './TodoForm';
7
8class TodoCreate extends Component {
9 onSubmit = formValues => {
10 this.props.addTodo(formValues);
11 };
12
13 render() {
14 return (
15 <div style={{ marginTop: '2rem' }}>
16 <TodoForm destroyOnUnmount={false} onSubmit={this.onSubmit} />
17 </div>
18 );
19 }
20}
21
22export default connect(
23 null,
24 { addTodo }
25)(TodoCreate);
تنها کاری که باید انجام دهیم این است که TodoForm را رندر کنیم. با تعیین مقدار False برای destroyOnUnmount، میتوانیم این که فرم ریداکس به صورت خودکار در زمان unmount شدن کامپوننت، حالت فرم را در استور ریداکس تخریب میکند، غیر فعال کنیم. این کار به منظور نمایش حالت فرم در فرم ویرایشی انجام میگیرد. اگر نیازی به تعیین یک تابع mapStateToProps نباشد، مقدار null برای ()connect وارد میکنیم. در ادامه فرم را مشاهده و امتحان میکنیم. به این منظور فایل Dashboard.js را باز کرده و به صورت زیر بهروزرسانی کنید:
1// components/todos/Dashboard.js
2
3import TodoCreate from './TodoCreate'; // added
4
5class Dashboard extends Component {
6 render() {
7 return (
8 <div className='ui container'>
9 <TodoCreate /> // added
10 <TodoList />
11 </div>
12 );
13 }
14}
15
16export default Dashboard;
ایجاد یک هدر
در این بخش اقدام به ساخت یک هدر میکنیم. پوشه جدیدی به نام layout بسازید و سپس یک فایل جدید به نام Header.js با محتوای زیر به آن اضافه کنید:
1// components/layout/Header.js
2
3import React, { Component } from 'react';
4
5class Header extends Component {
6 render() {
7 return (
8 <div className='ui inverted menu' style={{ borderRadius: '0' }}>
9 <a className='header item'>TodoCRUD</a>
10 <a className='item'>Home</a>
11 </div>
12 );
13 }
14}
15
16export default Header;
فایل App.js را باز کنید و کامپوننت Header را در آن به صورت تودرتو قرار دهید:
1// components/App.js
2
3import Header from './layout/Header'; // added
4
5class App extends Component {
6 render() {
7 return (
8 <Provider store={store}>
9 <Header /> // added
10 <Dashboard />
11 </Provider>
12 );
13 }
14}
در این راهنما هدر صرفاً جنبه تزیینی دارد.
حذف کردن ToDo-ها
ابتدا یک شیء به نام history با استفاده از پکیج history ایجاد میکنیم. ما میتوانیم از آن برای تغییر دادن مکان کنونی استفاده کنیم. یک فایل جدید به نام history.js در دایرکتوری frontend/src ایجاد کرده و کد زیر را در آن مینویسیم:
1// frontend/src/history.js
2
3import { createBrowserHistory } from 'history';
4
5export default createBrowserHistory();
اکشنها
فایل actions/todos.js را باز کنید و دو اکشنساز جدید به آن اضافه کنید:
1// actions/todos.js
2
3import history from '../history'; // added
4import { GET_TODOS, GET_TODO, ADD_TODO, DELETE_TODO } from './types'; // added GET_TODO and DELETE_TODO
5
6// GET TODO
7export const getTodo = id => async dispatch => { // added
8 const res = await axios.get(`/api/todos/${id}/`);
9 dispatch({
10 type: GET_TODO,
11 payload: res.data
12 });
13};
14
15// DELETE TODO
16export const deleteTodo = id => async dispatch => { // added
17 await axios.delete(`/api/todos/${id}/`);
18 dispatch({
19 type: DELETE_TODO,
20 payload: id
21 });
22 history.push('/');
getTodo را برای دریافت یک شیء خاص و getTodo را برای حذف آن شیء ایجاد کردهایم. در ادامه قصد داریم یک پنجره modal بری تأیید حذف نیز بسازیم. متد ('/')history.push به صورت خودکار پس از حذف کردن شیء ما را از پنجره modal به صفحه اندیس میبرد.
ردیوسرها
فایل reducers/todos.js را باز کرده و اکشنها را به ردیوسر اضافه کنید:
1// reducers/todos.js
2
3import _ from 'lodash'; // added
4import { GET_TODOS, GET_TODO, ADD_TODO, DELETE_TODO } from '../actions/types'; // added GET_TODO and DELETE_TODO
5
6export default (state = {}, action) => {
7 switch (action.type) {
8 // ...
9 case GET_TODO: // added
10 case ADD_TODO:
11 return {
12 ...state,
13 [action.payload.id]: action.payload
14 };
15 case DELETE_TODO: // added
16 return _.omit(state, action.payload);
17 // ...
18 }
19};
اکشن GET_TODO همان اکشن ADD_TODO است و تنها باید مقدار case را تعیین کنیم. برای اکشن DELETE_TODO نیز دوباره از Lodash به عنوان میانبر استفاده میکنیم.
کامپوننتها
در این بخش آن پنجره modal را که در بخش قبل اشاره کردیم میسازیم. ظاهر آن مانند زیر خواهد بود:
فایل جدیدی به نام Modal.js در دایرکتوری components/layout ایجاد کنید و کد زیر را در آن بنویسید:
1// components/layout/Modal.js
2
3import React from 'react';
4import ReactDOM from 'react-dom';
5
6const Modal = props => {
7 return ReactDOM.createPortal(
8 <div onClick={props.onDismiss} className='ui active dimmer'>
9 <div onClick={e => e.stopPropagation()} className='ui active modal'>
10 <div className='header'>{props.title}</div>
11 <div className='content'>{props.content}</div>
12 <div className='actions'>{props.actions}</div>
13 </div>
14 </div>,
15 document.querySelector('#modal')
16 );
17};
18
19export default Modal;
برای رندر کردن کامپوننت modal در خارج از سلسله مراتب DOM کامپوننت والد، باید یک پورتال با استفاده از ()createPortal ایجاد کنیم. آرگومان نخست یک عنصر فرزند قابل رندر است و آرگومان دوم نیز آن عنصر DOM است که باید رندر شود. سپس فایل index.html را باز کرده و یک کانتینر برای modal درون تگ <bosy> اضافه میکنیم:
1<!-- templates/frontend/index.html -->
2
3<body>
4 <div id='app'></div>
5 <div id="modal"></div>
6
7 {% load static %}
8 <script src="{% static 'frontend/main.js' %}"></script>
9</body>
در ادامه یک کامپوننت جدید به نام TodoDelete.js در دایرکتوری components/todos ایجاد میکنیم:
1// components/todos/TodoDelete.js
2
3import React, { Component, Fragment } from 'react';
4import { connect } from 'react-redux';
5import { Link } from 'react-router-dom';
6import Modal from '../layout/Modal';
7import history from '../../history';
8import { getTodo, deleteTodo } from '../../actions/todos';
9
10class TodoDelete extends Component {
11 componentDidMount() {
12 this.props.getTodo(this.props.match.params.id);
13 }
14
15 renderContent() {
16 if (!this.props.todo) {
17 return 'Are you sure you want to delete this task?';
18 }
19 return `Are you sure you want to delete the task: ${this.props.todo.task}`;
20 }
21
22 renderActions() {
23 const { id } = this.props.match.params;
24 return (
25 <Fragment>
26 <button
27 onClick={() => this.props.deleteTodo(id)}
28 className='ui negative button'
29 >
30 Delete
31 </button>
32 <Link to='/' className='ui button'>
33 Cancel
34 </Link>
35 </Fragment>
36 );
37 }
38
39 render() {
40 return (
41 <Modal
42 title='Delete Todo'
43 content={this.renderContent()}
44 actions={this.renderActions()}
45 onDismiss={() => history.push('/')}
46 />
47 );
48 }
49}
50
51const mapStateToProps = (state, ownProps) => ({
52 todo: state.todos[ownProps.match.params.id]
53});
54
55export default connect(
56 mapStateToProps,
57 { getTodo, deleteTodo }
58)(TodoDelete);
این کد کمی طولانی است، اما دشواری خاصی ندارد. تابعهای کمکی که محتوا را نمایش میدهند و دکمههای اکشن روی پنجره modal تعریف شدهاند. سپس آنها را به صورت Props به کامپوننت Modal ارسال میکنیم. onDismiss طوری تنظیم شده است که وقتی روی بخش تاریک پنجره modal کلیک میشود به صفحه اندیس بازگردد.
دادهها را میتوانیم از props خودش با تعیین onDismiss به عنوان آرگومان دوم mapStateToProps بازیابی کنیم. در ادامه فایل TodoList.js را باز کرده و دکمه delete را در آن قرار میدهیم:
1// components/todos/TodoList.js
2
3import { Link } from 'react-router-dom'; // added
4import { getTodos, deleteTodo } from '../../actions/todos'; // added deleteTodo
5
6class TodoList extends Component {
7 // ...
8
9 render() {
10 return (
11 <div className='ui relaxed divided list' style={{ marginTop: '2rem' }}>
12 {this.props.todos.map(todo => (
13 <div className='item' key={todo.id}>
14 <div className='right floated content'> // added
15 <Link
16 to={`/delete/${todo.id}`}
17 className='small ui negative basic button'
18 >
19 Delete
20 </Link>
21 </div>
22 <i className='large calendar outline middle aligned icon' />
23 <div className='content'>
24 <a className='header'>{todo.task}</a>
25 <div className='description'>{todo.created_at}</div>
26 </div>
27 </div>
28 ))}
29 </div>
30 );
31 }
32}
33
34// ...
35
36export default connect(
37 mapStateToProps,
38 { getTodos, deleteTodo } // added deleteTodo
39)(TodoList);
در نهایت، باید مسیریابی را با استفاده از React Router پیکربندی کنیم. فایل App.js را بازکرده و مانند زیر پیکربندی کنید:
1// components/App.js
2
3import { Router, Route, Switch } from 'react-router-dom'; // added
4
5import history from '../history'; // added
6import TodoDelete from './todos/TodoDelete'; // added
7
8class App extends Component {
9 render() {
10 return (
11 <Provider store={store}>
12 <Router history={history}>
13 <Header />
14 <Switch>
15 <Route exact path='/' component={Dashboard} />
16 <Route exact path='/delete/:id' component={TodoDelete} />
17 </Switch>
18 </Router>
19 </Provider>
20 );
21 }
22}
دلیل استفاده از Router به جای BrowserRouter در مستندات تمرینات ریاکت به صورت زیر توضیح داده شده است:
رایجترین کاربرد استفاده از <Router> سطح پایین، همگامسازی یک تاریخچه سفارشی با یک کتابخانه مدیریت حالت مانند ریداکس یا Mobx است. توجه داشته باشید که این کار برای استفاده از کتابخانههای مدیریت حالت در کنار React Router ضرورتی ندارد و صرفاً به منظور یکپارچگی بیشتر صورت میگیرد. پارامتر exact که در Route تعیین شده است، تنها در صورتی که مسیر بازگشت میدهد که path دقیقاً با URL جاری مطابقت داشته باشد.
بدین ترتیب این بخش خاتمه مییابد. تلاش کنید اشیا را حذف کنید و نتیجه کار را مورد بررسی قرار دهید.
ویرایش کردن ToDo-ها
تا به اینجا اغلب بخشهای اپلیکیشن ما تکمیل شده است. در این بخش آخر، امکان ویرایش کردن ToDo-ها را نیز به اپلیکیشن خود اضافه میکنیم.
اکشنها
فایل actions/todos.js را باز کرده و یک اکشنساز جدید به آن اضافه کنید:
1// actions/todos.js
2
3import { GET_TODOS, GET_TODO, ADD_TODO, DELETE_TODO, EDIT_TODO } from './types'; // added EDIT_TODO
4
5// EDIT TODO
6export const editTodo = (id, formValues) => async dispatch => {
7 const res = await axios.patch(`/api/todos/${id}/`, formValues);
8 dispatch({
9 type: EDIT_TODO,
10 payload: res.data
11 });
12 history.push('/');
13};
ردیوسرها
فایل reducers/todos.js را باز کنید و اکشن را به ردیوسر اضافه کنید:
1// reducers/todos.js
2
3import {
4 GET_TODOS,
5 GET_TODO,
6 ADD_TODO,
7 DELETE_TODO,
8 EDIT_TODO // added
9} from '../actions/types';
10
11export default (state = {}, action) => {
12 switch (action.type) {
13 // ...
14 case GET_TODO:
15 case ADD_TODO:
16 case EDIT_TODO: // added
17 return {
18 ...state,
19 [action.payload.id]: action.payload
20 };
21 // ...
22 }
23};
کامپوننتها
یک کامپوننت جدید به نام TodoEdit.js در دایرکتوری components/todos بسازید:
1// components/todos/TodoEdit.js
2
3import _ from 'lodash';
4import React, { Component } from 'react';
5import { connect } from 'react-redux';
6import { getTodo, editTodo } from '../../actions/todos';
7import TodoForm from './TodoForm';
8
9class TodoEdit extends Component {
10 componentDidMount() {
11 this.props.getTodo(this.props.match.params.id);
12 }
13
14 onSubmit = formValues => {
15 this.props.editTodo(this.props.match.params.id, formValues);
16 };
17
18 render() {
19 return (
20 <div className='ui container'>
21 <h2 style={{ marginTop: '2rem' }}>Edit Todo</h2>
22 <TodoForm
23 initialValues={_.pick(this.props.todo, 'task')}
24 enableReinitialize={true}
25 onSubmit={this.onSubmit}
26 />
27 </div>
28 );
29 }
30}
31
32const mapStateToProps = (state, ownProps) => ({
33 todo: state.todos[ownProps.match.params.id]
34});
35
36export default connect(
37 mapStateToProps,
38 { getTodo, editTodo }
39)(TodoEdit);
یک شیء در initialValues تعیین کنید. ما تنها میتوانیم مقدار task را در تابع _.pick مربوط به Lodash دریافت کنیم. به علاوه مقدار enableReinitialize را روی True قرار دهید تا بتوانیم در زمان بارگذاری مجدد صفحه نیز این مقدار را دریافت کنیم. این مشخصههای اختیاری را به TodoForm ارسال کنید.
اکنون فایل TodoList.js را باز کرده و <a className='header'>{todo.task}</a> را به صورت زیر بهروزرسانی کنید:
1// components/todos/TodoList.js
2
3<Link to={`/edit/${todo.id}`} className='header'>
4 {todo.task}
5</Link>
یک کامپوننت جدید به فایل App.js اضافه میکنیم:
1// components/App.js
2
3import TodoEdit from './todos/TodoEdit'; // added
4
5class App extends Component {
6 render() {
7 return (
8 <Provider store={store}>
9 <Router history={history}>
10 <Header />
11 <Switch>
12 <Route exact path='/' component={Dashboard} />
13 <Route exact path='/delete/:id' component={TodoDelete} />
14 <Route exact path='/edit/:id' component={TodoEdit} /> // added
15 </Switch>
16 </Router>
17 </Provider>
18 );
19 }
20}
در نهایت، متن دکمه را روی فرم ادیت از Add به Update تغییر میدهیم. فایل TodoForm.js را باز کرده و به صورت زیر بهروزرسانی میکنیم:
1// components/todos/TodoForm.js
2
3class TodoForm extends Component {
4 // ...
5
6 render() {
7 const btnText = `${this.props.initialValues ? 'Update' : 'Add'}`; // added
8 return (
9 <div className='ui segment'>
10 <form
11 onSubmit={this.props.handleSubmit(this.onSubmit)}
12 className='ui form error'
13 >
14 <Field name='task' component={this.renderField} label='Task' />
15 <button className='ui primary button'>{btnText}</button> // updated
16 </form>
17 </div>
18 );
19 }
20}
اینک میتوانید روی هر وظیفهای در صفحه اندیس کلیک کرده و تلاش کنید آن را ویرایش کنید:
امکان تغییر دادن مقدار از سمت فرم نیز وجود دارد.
به این ترتیب به انتهای این راهنما میرسیم. سورس کد کامل این پروژه را میتوانید در این ریپوی گیتهاب (+) ملاحظه کنید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای جاوا اسکریپت
- مجموعه آموزشهای برنامهنویسی
- آموزش جنگو (Django) – فریمورک تحت وب با پایتون (Python)
- جنگو (Django) چیست؟ — از صفر تا صد
- ساخت یک CRM مقدماتی با Django و React روی اوبونتو ۱۸.۰۴ — از صفر تا صد
==