ساخت اپلیکیشن 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 پیکربندی کنید:

# settings.py

INSTALLED_APPS = [
    'frontend.apps.FrontendConfig',  # added
    'todos.apps.TodosConfig',  # added
    'rest_framework',  # added
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

REST_FRAMEWORK = {  # added
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.AllowAny'
    ],
    'DATETIME_FORMAT': "%m/%d/%Y %H:%M:%S",
}

ما می‌توانیم قالب خروجی تاریخ و زمان را با قرار دادن DATETIME_FORMAT در یک دیکشنری پیکربندی به نام REST_FRAMEWORK تعیین کنیم.

اینک میگریشن‌ها را اعمال کرده و سرور توسعه را آغاز می‌کنیم:

$ python manage.py migrate
$ python manage.py runserver

در مرورگر به آدرس http://127.0.0.1:8000/‎ بروید. اگر صفحه‌ای دیدید که یک راکت در حال اوج گرفتن است، به این معنی است که همه چیز به درستی کار می‌کند.

نوشتن ماژول‌های بک‌اند

ابتدا یک مدل ساده ایجاد می‌کنیم. فایل models.py را باز کرده و کد زیر را در آن بنویسید:

# todos/models.py

from django.db import models


class Todo(models.Model):
    task = models.CharField(max_length=255)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        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 تعریف می‌کنیم.

# todos/api/serializers.py

from rest_framework import serializers

from todos.models import Todo


class TodoSerializer(serializers.ModelSerializer):
    class Meta:
        model = Todo
        fields = '__all__'

کلاس ModelSerializer فیلدهایی را ایجاد می‌کند که متناظر با فیلدهای مدل هستند. سپس رفتار را در فایل api/views.py تعریف می‌کنیم:

# todos/api/views.py

from rest_framework import viewsets

from .serializers import TodoSerializer
from todos.models import Todo


class TodoViewSet(viewsets.ModelViewSet):
    queryset = Todo.objects.all()
    serializer_class = TodoSerializer

در نهایت پیکربندی URL را با استفاده از Routers می‌نویسیم:

# todos/api/urls.py

from rest_framework import routers

from .views import TodoViewSet

router = routers.DefaultRouter()
router.register('todos', TodoViewSet, 'todos')
# router.register('<The URL prefix>', <The viewset class>, '<The URL name>')

urlpatterns = router.urls

ما از سه آرگومان برای متد ()register استفاده می‌کنیم، اما آرگومان سوم الزامی نیست.

نوشتن ماژول‌های فرانت‌اند

در فرانت‌اند تنها کاری که باید انجام دهیم، نوشتن نماها-(Views)-ی ساده و الگوهای URL است. فایل frontend/views.py را باز و دو نما ایجاد کنید:

# frontend/views.py

from django.shortcuts import render
from django.views.generic.detail import DetailView

from todos.models import Todo


def index(request):
    return render(request, 'frontend/index.html')


class TodoDetailView(DetailView):
    model = Todo
    template_name = 'frontend/index.html'

فایل frontend/index.html را در ادامه ایجاد خواهیم کرد. فعلاً نگران آن نباشید. یک فایل به نام urls.py به همان دایرکتوری اضافه کنید و پیکربندی URL را ایجاد نمایید:

# frontend/urls.py

from django.urls import path

from .views import index, TodoDetailView

urlpatterns = [
    path('', index),
    path('edit/<int:pk>', TodoDetailView.as_view()),
    path('delete/<int:pk>', TodoDetailView.as_view()),
]

چنان که در فایل فوق می‌بینید، نمای index برای صفحه اندیس است و TodoDetailView نیز زمانی فراخوانی می‌شود که یک شیء خاص درخواست شده باشد.

اتصال URL-ها

URL-های بک‌اند و فرانت‌اند را در URLconf پروژه می‌گنجانیم:

# frontend/urls.py

from django.urls import path

from .views import index, TodoDetailView

urlpatterns = [
    path('', index),
    path('edit/<int:pk>', TodoDetailView.as_view()),
    path('delete/<int:pk>', TodoDetailView.as_view()),
]

با این که بخش مربوط به ادمین سایت جنگو نیز آمده است؛ اما در این راهنما از آن استفاده نخواهیم کرد.

به عنوان آخرین نکته در این بخش یک فایل میگریشن ایجاد می‌کنیم و با اجرای دستور زیر تغییرها را روی پایگاه‌های داده اعمال می‌کنیم:

$ 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 را پیکربندی می‌کنیم:

// .babelrc

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        },
        "useBuiltIns": "usage",
        "corejs": 3
      }
    ],
    "@babel/preset-react"
  ],
  "plugins": ["@babel/plugin-proposal-class-properties"]
}

می‌توانیم با نوشتن کدی مانند فوق، از Async/Await به همراه Babel استفاده کنیم. در وهله دوم یک فایل به نام webpack.config.js به همان دایرکتوری اضافه می‌کنیم و یک پیکربندی برای webpack می‌نویسیم:

// webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader'
        }
      }
    ]
  }
};

به علاوه، باید مشخصه “scripts” مربوط به فایل package.json را بازنویسی کنیم:

// package.json

{
  // ...
  "scripts": {
    "dev": "webpack --mode development --watch ./frontend/src/index.js --output ./frontend/static/frontend/main.js",
    "build": "webpack --mode production ./frontend/src/index.js --output ./frontend/static/frontend/main.js"
  },
  // ...
}

به این ترتیب دو اسکریپت جدید تعریف شده‌اند. این اسکریپت‌ها را می‌توانیم با استفاده از دستور npm run dev برای توسعه و یا دستور npm run build برای پروداکشن اجرا کنیم. زمانی که این اسکریپت‌ها اجرا شوند، وب‌پک ماژول‌ها را بسته‌بندی کرده و در فایل main.js عرضه می‌کند.

ایجاد فایل‌های اصلی

در این بخش سه فایل اصلی را ایجاد کرده و نخستین کلمه را رندر می‌کنیم. یک فایل به نام index.js می‌سازیم که ابتدا زمانی که اپلیکیشن ری‌اکت اجرا شود، فراخوانی خواهد شد:

// frontend/src/index.js

import App from './components/App';

سپس یک فایل به نام App.js ایجاد می‌کنیم که کامپوننت والد است:

// frontend/src/components/App.js

import React, { Component } from 'react';
import ReactDOM from 'react-dom';

class App extends Component {
  render() {
    return (
      <div>
        <h1>ToDoCRUD</h1>
      </div>
    );
  }
}

ReactDOM.render(<App />, document.querySelector('#app'));

در نهایت یک فایل قالب به نام index.html ایجاد می‌کنیم که در فایل views.py ذکر شده است:

<!-- templates/frontend/index.html -->

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <!-- semantic-ui CDN -->
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
  <title>ToDoCRUD</title>
</head>

<body>
  <div id='app'></div>

  {% load static %}
  <script src="{% static 'frontend/main.js' %}"></script>
</body>

</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 اضافه کنید:

// actions/types.js

export const GET_TODOS = 'GET_TODOS';
export const GET_TODO = 'GET_TODO';
export const ADD_TODO = 'ADD_TODO';
export const DELETE_TODO = 'DELETE_TODO';
export const EDIT_TODO = 'EDIT_TODO';

برای ایجاد اکشن‌ها باید «اکشن‌ساز» (Action Creator) را مشخص کنیم. یک فایل جدید به نام todos.js به دایرکتوری src/actions اضافه کنید:

// actions/todos.js

import axios from 'axios';
import { GET_TODOS } from './types';

// GET TODOS
export const getTodos = () => async dispatch => {
  const res = await axios.get('/api/todos/');
  dispatch({
    type: GET_TODOS,
    payload: res.data
  });
};

ردیوسرها

ردیوسرها شیوه تغییر یافتن «حالت» (State) اپلیکیشن را در پاسخ به اکشن‌های ارسالی به استور تعیین می‌کنند.

یک فایل جدید به نام todos.js به دایرکتوری src/reducers اضافه کرده و یک ردیوسر فرزند را در آن می‌نویسیم:

// reducers/todos.js

import _ from 'lodash';
import { GET_TODOS } from '../actions/types';

export default (state = {}, action) => {
  switch (action.type) {
    case GET_TODOS:
      return {
        ...state,
        ..._.mapKeys(action.payload, 'id')
      };
    default:
      return state;
  }
};

Lodash (+) یک کتابخانه کاربردی جاوا اسکریپت است. استفاده از آن الزامی نیست، اما می‌تواند زمان توسعه را کاهش داده و حجم کدبیس را کوچک‌تر سازد.

در این بخش یک ردیوسر والد می‌سازیم تا همه ردیوسرها‌ی فرزند را با استفاده از ()combineReducers گرد هم جمع کنیم. یک فایل جدید به نام index.js به دایرکتوری src/reducers اضافه کنید:

// reducers/index.js

import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import todos from './todos';

export default combineReducers({
  form: formReducer,
  todos
});

برای استفاده از redux-form باید ردیوسر آن را در تابع combineReducers قرار دهیم.

استور

استور (Store) شیئی است که حالت اپلیکیشن را نگه‌داری می‌کند. به علاوه از میان‌افزار توصیه‌شده به نام Redux Thunk برای نوشتن منطق ناهمگام استفاده می‌کنیم که با استور تعامل خواهد داشت. در این بخش یک فایل جدید به نام store.js در دایرکتوری src ایجاد می‌کنیم:

// fronted/src/store.js

import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
import reduxThunk from 'redux-thunk';
import rootReducer from './reducers';

const store = createStore(
  rootReducer,
  composeWithDevTools(applyMiddleware(reduxThunk))
);

export default store;

استفاده از Redux DevTools اختیاری است، اما کاربرد آن بسیار مفید است، زیرا تغییرهای حالت ریداکس را بصری‌سازی می‌کند. در این راهنما به توضیح شیوه استفاده از آن نمی‌پردازیم، اما در کل بسیار مفید است.

کامپوننت‌ها

ابتدا یک پوشه به نام todos در دایرکتوری components ایجاد می‌کنیم. سپس فایل جدید به نام TodoList.js را به این پوشه ایجادشده اضافه می‌کنیم:

// components/todos/TodoList.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getTodos } from '../../actions/todos';

class TodoList extends Component {
  componentDidMount() {
    this.props.getTodos();
  }

  render() {
    return (
      <div className='ui relaxed divided list' style={{ marginTop: '2rem' }}>
        {this.props.todos.map(todo => (
          <div className='item' key={todo.id}>
            <i className='large calendar outline middle aligned icon' />
            <div className='content'>
              <a className='header'>{todo.task}</a>
              <div className='description'>{todo.created_at}</div>
            </div>
          </div>
        ))}
      </div>
    );
  }
}

const mapStateToProps = state => ({
  todos: Object.values(state.todos)
});

export default connect(
  mapStateToProps,
  { getTodos }
)(TodoList);

ما از Semantic UI برای تزیین لیست استفاده می‌کنیم. تابع ()connect این کامپوننت را به استور وصل می‌کند. این تابع مقدار mapStateToProps را به عنوان آرگومان نخست می‌گیرد. اکشن‌ساز نیز آرگومان دوم آن است. می‌توانیم از حالت استور به عنوان Props برای تعیین mapStateToProps استفاده کنیم.

به این منظور یک فایل جدید به نام Dashboard.js در همان دایرکتوری ایجاد می‌کنیم. این فایل صرفاً یک کانتینر برای TodoList و یک فرم است که در بخش بعدی خواهیم ساخت:

// components/todos/Dashboard.js

import React, { Component } from 'react';
import TodoList from './TodoList';

class Dashboard extends Component {
  render() {
    return (
      <div className='ui container'>
        <div>Todo Create Form</div>
        <TodoList />
      </div>
    );
  }
}

export default Dashboard;

فایل App.js را باز کنید و به صورت زیر به‌روزرسانی نمایید:

// components/App.js

import Dashboard from './todos/Dashboard'; // added

import { Provider } from 'react-redux'; // added
import store from '../store'; // added

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <Dashboard />
      </Provider>
    );
  }
}

Provider موجب می‌شود که استور در اختیار کامپوننت تودرتوی درون آن قرار بگیرد.

بررسی نمایش

ابتدا به نشانی http://127.0.0.1:8000/api/todos بروید و چند شیء ایجاد کنید. سپس به نشانی http://127.0.0.1:8000 بروید. اینک باید یک لیست ساده از اشیایی که ایجاد کردید ببینید.

ایجاد فرم و افزودن ToDo جدید

در این بخش مراحل ساخت فرم مربوط به وارد کردن موارد ToDo جدید را با هم مرور می‌کنیم.

اکشن‌ها

فایل actions/todos.js را باز کرده و یک اکشن‌ساز جدید به آن اضافه کنید:

// actions/todos.js

import { reset } from 'redux-form'; // added
import { GET_TODOS, ADD_TODO } from './types'; // added ADD_TODO

// ADD TODO
export const addTodo = formValues => async dispatch => {
  const res = await axios.post('/api/todos/', { ...formValues });
  dispatch({
    type: ADD_TODO,
    payload: res.data
  });
  dispatch(reset('todoForm'));
};

دیسپچ کردن (‘reset(‘formName موجب پاک شدن فرم پس از تحویل موفق می‌شود. ما نام فرم را متعاقباً در کامپوننت ‌Form تعیین خواهیم کرد.

ردیوسرها

فایل reducers/todos.js را باز کنید و یک اکشن جدید به ردیوسر اضافه کنید:

// reducers/todos.js

import { GET_TODOS, ADD_TODO } from '../actions/types'; // added ADD_TODO

export default (state = {}, action) => {
  switch (action.type) {
    // ...
    case ADD_TODO: // added
      return {
        ...state,
        [action.payload.id]: action.payload
      };
    // ...
  }
};

کامپوننت‌ها

در این بخش ابتدا یک کامپوننت Form ایجاد می‌کنیم. یک فرم به صورت مجزا به عنوان کامپوننت با استفاده مجدد ایجاد می‌کنیم، به طوری که بتوان از آن برای ویرایش کردن نیز استفاده کرد. یک فایل جدید به نام TodoForm.js در دایرکتوری components/todos ایجاد کنید:

// components/todos/TodoForm.js

import React, { Component } from 'react';
import { Field, reduxForm } from 'redux-form';

class TodoForm extends Component {
  renderField = ({ input, label, meta: { touched, error } }) => {
    return (
      <div className={`field ${touched && error ? 'error' : ''}`}>
        <label>{label}</label>
        <input {...input} autoComplete='off' />
        {touched && error && (
          <span className='ui pointing red basic label'>{error}</span>
        )}
      </div>
    );
  };

  onSubmit = formValues => {
    this.props.onSubmit(formValues);
  };

  render() {
    return (
      <div className='ui segment'>
        <form
          onSubmit={this.props.handleSubmit(this.onSubmit)}
          className='ui form error'
        >
          <Field name='task' component={this.renderField} label='Task' />
          <button className='ui primary button'>Add</button>
        </form>
      </div>
    );
  }
}

const validate = formValues => {
  const errors = {};

  if (!formValues.task) {
    errors.task = 'Please enter at least 1 character';
  }

  return errors;
};

export default reduxForm({
  form: 'todoForm',
  touchOnBlur: false,
  validate
})(TodoForm);

با توجه به این که این راهنما نسبتاً طولانی است، در مورد شیوه استفاده از Redux Form توضیح ارائه نمی‌کنیم. برای درک طرز کار فرم‌های ریداکس، بهتر است تلاش کنید با استفاده از مستندات رسمی (+) آن را سفارشی‌سازی کنید.

نام این فرم ‘todoForm’ است و آن را در اکشن‌سازی به نام addTodo استفاده خواهیم کرد.

زمانی که روی کادر متنی کلیک کنید و سپس فوکوس را از روی آن بردارید، یک خطای اعتبارسنجی نمایش می‌دهد. برای غیر فعال کردن آن باید از تنظیمات touchOnBlur: false استفاده کنیم.

سپس یک کامپوننت برای افزودن ToDo-های جدید اضافه می‌کنیم. یک فایل جدید به نام TodoCreate.js در دایرکتوری components/todos اضافه می‌کنیم:

// components/todos/TodoCreate.js

import React, { Component } from 'react';
import { connect } from 'react-redux';
import { addTodo } from '../../actions/todos';
import TodoForm from './TodoForm';

class TodoCreate extends Component {
  onSubmit = formValues => {
    this.props.addTodo(formValues);
  };

  render() {
    return (
      <div style={{ marginTop: '2rem' }}>
        <TodoForm destroyOnUnmount={false} onSubmit={this.onSubmit} />
      </div>
    );
  }
}

export default connect(
  null,
  { addTodo }
)(TodoCreate);

تنها کاری که باید انجام دهیم این است که TodoForm را رندر کنیم. با تعیین مقدار False برای destroyOnUnmount، می‌توانیم این که فرم ریداکس به صورت خودکار در زمان unmount شدن کامپوننت، حالت فرم را در استور ریداکس تخریب می‌کند، غیر فعال کنیم. این کار به منظور نمایش حالت فرم در فرم ویرایشی انجام می‌گیرد. اگر نیازی به تعیین یک تابع mapStateToProps نباشد، مقدار null برای ()connect وارد می‌کنیم. در ادامه فرم را مشاهده و امتحان می‌کنیم. به این منظور فایل Dashboard.js را باز کرده و به صورت زیر به‌روزرسانی کنید:

// components/todos/Dashboard.js

import TodoCreate from './TodoCreate'; // added

class Dashboard extends Component {
  render() {
    return (
      <div className='ui container'>
        <TodoCreate /> // added
        <TodoList />
      </div>
    );
  }
}

export default Dashboard;

ایجاد یک هدر

در این بخش اقدام به ساخت یک هدر می‌کنیم. پوشه جدیدی به نام layout بسازید و سپس یک فایل جدید به نام Header.js با محتوای زیر به آن اضافه کنید:

// components/layout/Header.js

import React, { Component } from 'react';

class Header extends Component {
  render() {
    return (
      <div className='ui inverted menu' style={{ borderRadius: '0' }}>
        <a className='header item'>TodoCRUD</a>
        <a className='item'>Home</a>
      </div>
    );
  }
}

export default Header;

فایل App.js را باز کنید و کامپوننت Header را در آن به صورت تودرتو قرار دهید:

// components/App.js

import Header from './layout/Header'; // added

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <Header /> // added
        <Dashboard />
      </Provider>
    );
  }
}

در این راهنما هدر صرفاً جنبه تزیینی دارد.

حذف کردن ToDo-ها

ابتدا یک شیء به نام history با استفاده از پکیج history ایجاد می‌کنیم. ما می‌توانیم از آن برای تغییر دادن مکان کنونی استفاده کنیم. یک فایل جدید به نام history.js در دایرکتوری frontend/src ایجاد کرده و کد زیر را در آن می‌نویسیم:

// frontend/src/history.js

import { createBrowserHistory } from 'history';

export default createBrowserHistory();

اکشن‌ها

فایل actions/todos.js را باز کنید و دو اکشن‌ساز جدید به آن اضافه کنید:

// actions/todos.js

import history from '../history'; // added
import { GET_TODOS, GET_TODO, ADD_TODO, DELETE_TODO } from './types'; // added GET_TODO and DELETE_TODO

// GET TODO
export const getTodo = id => async dispatch => { // added
  const res = await axios.get(`/api/todos/${id}/`);
  dispatch({
    type: GET_TODO,
    payload: res.data
  });
};

// DELETE TODO
export const deleteTodo = id => async dispatch => { // added
  await axios.delete(`/api/todos/${id}/`);
  dispatch({
    type: DELETE_TODO,
    payload: id
  });
  history.push('/');

getTodo را برای دریافت یک شیء خاص و getTodo را برای حذف آن شیء ایجاد کرده‌ایم. در ادامه قصد داریم یک پنجره modal بری تأیید حذف نیز بسازیم. متد (‘/’)history.push به صورت خودکار پس از حذف کردن شیء ما را از پنجره modal به صفحه اندیس می‌برد.

ردیوسرها

فایل reducers/todos.js را باز کرده و اکشن‌ها را به ردیوسر اضافه کنید:

// reducers/todos.js

import _ from 'lodash'; // added
import { GET_TODOS, GET_TODO, ADD_TODO, DELETE_TODO } from '../actions/types'; // added GET_TODO and DELETE_TODO

export default (state = {}, action) => {
  switch (action.type) {
    // ...
    case GET_TODO: // added
    case ADD_TODO:
      return {
        ...state,
        [action.payload.id]: action.payload
      };
    case DELETE_TODO: // added
      return _.omit(state, action.payload);
    // ...
  }
};

اکشن GET_TODO همان اکشن ADD_TODO است و تنها باید مقدار case را تعیین کنیم. برای اکشن DELETE_TODO نیز دوباره از Lodash به عنوان میانبر استفاده می‌کنیم.

کامپوننت‌ها

در این بخش آن پنجره modal را که در بخش قبل اشاره کردیم می‌سازیم. ظاهر آن مانند زیر خواهد بود:

فایل جدیدی به نام Modal.js در دایرکتوری components/layout ایجاد کنید و کد زیر را در آن بنویسید:

// components/layout/Modal.js

import React from 'react';
import ReactDOM from 'react-dom';

const Modal = props => {
  return ReactDOM.createPortal(
    <div onClick={props.onDismiss} className='ui active dimmer'>
      <div onClick={e => e.stopPropagation()} className='ui active modal'>
        <div className='header'>{props.title}</div>
        <div className='content'>{props.content}</div>
        <div className='actions'>{props.actions}</div>
      </div>
    </div>,
    document.querySelector('#modal')
  );
};

export default Modal;

برای رندر کردن کامپوننت modal در خارج از سلسله مراتب DOM کامپوننت والد، باید یک پورتال با استفاده از ()createPortal ایجاد کنیم. آرگومان نخست یک عنصر فرزند قابل رندر است و آرگومان دوم نیز آن عنصر DOM است که باید رندر شود. سپس فایل index.html را باز کرده و یک کانتینر برای modal درون تگ <bosy> اضافه می‌کنیم:

<!-- templates/frontend/index.html -->

<body>
  <div id='app'></div>
  <div id="modal"></div>

  {% load static %}
  <script src="{% static 'frontend/main.js' %}"></script>
</body>

در ادامه یک کامپوننت جدید به نام TodoDelete.js در دایرکتوری components/todos ایجاد می‌کنیم:

// components/todos/TodoDelete.js

import React, { Component, Fragment } from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import Modal from '../layout/Modal';
import history from '../../history';
import { getTodo, deleteTodo } from '../../actions/todos';

class TodoDelete extends Component {
  componentDidMount() {
    this.props.getTodo(this.props.match.params.id);
  }

  renderContent() {
    if (!this.props.todo) {
      return 'Are you sure you want to delete this task?';
    }
    return `Are you sure you want to delete the task: ${this.props.todo.task}`;
  }

  renderActions() {
    const { id } = this.props.match.params;
    return (
      <Fragment>
        <button
          onClick={() => this.props.deleteTodo(id)}
          className='ui negative button'
        >
          Delete
        </button>
        <Link to='/' className='ui button'>
          Cancel
        </Link>
      </Fragment>
    );
  }

  render() {
    return (
      <Modal
        title='Delete Todo'
        content={this.renderContent()}
        actions={this.renderActions()}
        onDismiss={() => history.push('/')}
      />
    );
  }
}

const mapStateToProps = (state, ownProps) => ({
  todo: state.todos[ownProps.match.params.id]
});

export default connect(
  mapStateToProps,
  { getTodo, deleteTodo }
)(TodoDelete);

این کد کمی طولانی است، اما دشواری خاصی ندارد. تابع‌های کمکی که محتوا را نمایش می‌دهند و دکمه‌های اکشن روی پنجره modal تعریف شده‌اند. سپس آن‌ها را به صورت Props به کامپوننت Modal ارسال می‌کنیم. onDismiss طوری تنظیم شده است که وقتی روی بخش تاریک پنجره modal کلیک می‌شود به صفحه اندیس بازگردد.

داده‌ها را می‌توانیم از props خودش با تعیین onDismiss به عنوان آرگومان دوم mapStateToProps بازیابی کنیم. در ادامه فایل TodoList.js را باز کرده و دکمه delete را در آن قرار می‌دهیم:

// components/todos/TodoList.js

import { Link } from 'react-router-dom'; // added
import { getTodos, deleteTodo } from '../../actions/todos'; // added deleteTodo

class TodoList extends Component {
  // ...

  render() {
    return (
      <div className='ui relaxed divided list' style={{ marginTop: '2rem' }}>
        {this.props.todos.map(todo => (
          <div className='item' key={todo.id}>
            <div className='right floated content'> // added
              <Link
                to={`/delete/${todo.id}`}
                className='small ui negative basic button'
              >
                Delete
              </Link>
            </div>
            <i className='large calendar outline middle aligned icon' />
            <div className='content'>
              <a className='header'>{todo.task}</a>
              <div className='description'>{todo.created_at}</div>
            </div>
          </div>
        ))}
      </div>
    );
  }
}

// ...

export default connect(
  mapStateToProps,
  { getTodos, deleteTodo } // added deleteTodo
)(TodoList);

در نهایت، باید مسیریابی را با استفاده از React Router پیکربندی کنیم. فایل App.js را بازکرده و مانند زیر پیکربندی کنید:

// components/App.js

import { Router, Route, Switch } from 'react-router-dom'; // added

import history from '../history'; // added
import TodoDelete from './todos/TodoDelete'; // added

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <Router history={history}>
          <Header />
          <Switch>
            <Route exact path='/' component={Dashboard} />
            <Route exact path='/delete/:id' component={TodoDelete} />
          </Switch>
        </Router>
      </Provider>
    );
  }
}

دلیل استفاده از Router به جای BrowserRouter در مستندات تمرینات ری‌اکت به صورت زیر توضیح داده شده است:

رایج‌ترین کاربرد استفاده از <Router> سطح پایین، همگام‌سازی یک تاریخچه سفارشی با یک کتابخانه مدیریت حالت مانند ریداکس یا Mobx است. توجه داشته باشید که این کار برای استفاده از کتابخانه‌های مدیریت حالت در کنار React Router ضرورتی ندارد و صرفاً به منظور یکپارچگی بیشتر صورت می‌گیرد. پارامتر exact که در Route تعیین شده است، تنها در صورتی که مسیر بازگشت می‌دهد که path دقیقاً با URL جاری مطابقت داشته باشد.

بدین ترتیب این بخش خاتمه می‌یابد. تلاش کنید اشیا را حذف کنید و نتیجه کار را مورد بررسی قرار دهید.

ویرایش کردن ToDo-ها

تا به اینجا اغلب بخش‌های اپلیکیشن ما تکمیل شده است. در این بخش آخر، امکان ویرایش کردن ToDo-ها را نیز به اپلیکیشن خود اضافه می‌کنیم.

اکشن‌ها

فایل actions/todos.js را باز کرده و یک اکشن‌ساز جدید به آن اضافه کنید:

// actions/todos.js

import { GET_TODOS, GET_TODO, ADD_TODO, DELETE_TODO, EDIT_TODO } from './types'; // added EDIT_TODO

// EDIT TODO
export const editTodo = (id, formValues) => async dispatch => {
  const res = await axios.patch(`/api/todos/${id}/`, formValues);
  dispatch({
    type: EDIT_TODO,
    payload: res.data
  });
  history.push('/');
};

ردیوسرها

فایل reducers/todos.js را باز کنید و اکشن را به ردیوسر اضافه کنید:

// reducers/todos.js

import {
  GET_TODOS,
  GET_TODO,
  ADD_TODO,
  DELETE_TODO,
  EDIT_TODO // added
} from '../actions/types';

export default (state = {}, action) => {
  switch (action.type) {
    // ...
    case GET_TODO:
    case ADD_TODO:
    case EDIT_TODO: // added
      return {
        ...state,
        [action.payload.id]: action.payload
      };
    // ...
  }
};

کامپوننت‌ها

یک کامپوننت جدید به نام TodoEdit.js در دایرکتوری components/todos بسازید:

// components/todos/TodoEdit.js

import _ from 'lodash';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { getTodo, editTodo } from '../../actions/todos';
import TodoForm from './TodoForm';

class TodoEdit extends Component {
  componentDidMount() {
    this.props.getTodo(this.props.match.params.id);
  }

  onSubmit = formValues => {
    this.props.editTodo(this.props.match.params.id, formValues);
  };

  render() {
    return (
      <div className='ui container'>
        <h2 style={{ marginTop: '2rem' }}>Edit Todo</h2>
        <TodoForm
          initialValues={_.pick(this.props.todo, 'task')}
          enableReinitialize={true}
          onSubmit={this.onSubmit}
        />
      </div>
    );
  }
}

const mapStateToProps = (state, ownProps) => ({
  todo: state.todos[ownProps.match.params.id]
});

export default connect(
  mapStateToProps,
  { getTodo, editTodo }
)(TodoEdit);

یک شیء در initialValues تعیین کنید. ما تنها می‌توانیم مقدار task را در تابع ‎_.pick مربوط به Lodash دریافت کنیم. به علاوه مقدار enableReinitialize را روی ‌True قرار دهید تا بتوانیم در زمان بارگذاری مجدد صفحه نیز این مقدار را دریافت کنیم. این مشخصه‌های اختیاری را به TodoForm ارسال کنید.

اکنون فایل TodoList.js را باز کرده و <a className=’header’>{todo.task}</a> را به صورت زیر به‌روزرسانی کنید:

// components/todos/TodoList.js

<Link to={`/edit/${todo.id}`} className='header'>
  {todo.task}
</Link>

یک کامپوننت جدید به فایل App.js اضافه می‌کنیم:

// components/App.js

import TodoEdit from './todos/TodoEdit'; // added

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <Router history={history}>
          <Header />
          <Switch>
            <Route exact path='/' component={Dashboard} />
            <Route exact path='/delete/:id' component={TodoDelete} />
            <Route exact path='/edit/:id' component={TodoEdit} /> // added
          </Switch>
        </Router>
      </Provider>
    );
  }
}

در نهایت، متن دکمه را روی فرم ادیت از Add به Update تغییر می‌دهیم. فایل TodoForm.js را باز کرده و به صورت زیر به‌روزرسانی می‌کنیم:

// components/todos/TodoForm.js

class TodoForm extends Component {
  // ...

  render() {
    const btnText = `${this.props.initialValues ? 'Update' : 'Add'}`; // added
    return (
      <div className='ui segment'>
        <form
          onSubmit={this.props.handleSubmit(this.onSubmit)}
          className='ui form error'
        >
          <Field name='task' component={this.renderField} label='Task' />
          <button className='ui primary button'>{btnText}</button> // updated
        </form>
      </div>
    );
  }
}

اینک می‌توانید روی هر وظیفه‌ای در صفحه اندیس کلیک کرده و تلاش کنید آن را ویرایش کنید:

ساخت اپلیکیشن ToDo با Django و React

امکان تغییر دادن مقدار از سمت فرم نیز وجود دارد.

به این ترتیب به انتهای این راهنما می‌رسیم. سورس کد کامل این پروژه را می‌توانید در این ریپوی گیت‌هاب (+) ملاحظه کنید.

اگر این مطلب برای شما مفید بوده است، ‌آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۱ نفر
آیا این مطلب برای شما مفید بود؟
شما قبلا رای داده‌اید!
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
technest

نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *