🎆

ReactとDjangoでレコードの編集と削除機能を実装してみた

2024/08/30に公開

元美容師のDjangoポートフォリオリニューアル日記Part.5:編集・削除機能実装編

お疲れ様です!やぎです。
引き続き、DjangoとReactを使ってポートフォリオの作成を進めていきます!
前回の記事ではブラウザからサロン(レコード)を登録する実装しました。
今回は、さらに機能を拡張し、サロン情報の編集と削除機能を追加します。
この実装が完了すればシンプルではありますがCRUDの全ての実装が完了します。

それでは実装開始!

今回の実装内容

  1. サロン情報編集機能の実装
  2. サロン削除機能の実装

1. サロン情報編集機能の実装

まずはサロンリストに登録されている既存のサロン情報を編集する機能を追加しました。
流れとしてはDjangoにレコードを編集するためのAPIを設定、APIテスト、ReactをAPIと連携しフロントからの編集を可能にするといった感じです。
ではまずはDjangoの実装から始めます!

バックエンドの準備

編集機能を追加するために、DjangoのAPIを設定します。
views.pyに以下のコードを追加します。

from rest_framework import generics

class SalonUpdate(generics.RetrieveUpdateDestroyAPIView): #追記
    queryset = Salon.objects.all()
    serializer_class = SalonSerializer

generics.RetrieveUpdateDestroyAPIViewはこれだけで、PUT(更新)、PATCH(部分更新)、DELETE(削除)の操作をサポートできるビューです!

続いてこのビューにアクセスするためのURLをurls.pyに追記します。

from django.urls import path
from .views import SalonListCreate,SalonUpdate

urlpatterns = [
    path('salons/', SalonListCreate.as_view(), name='salon-list-create'),
    path('salons/<int:pk>/',SalonUpdate.as_view(), name='salon-detail'), #追記
]

int:pkでサロンのidを指定し、そのレコードに対してPUTやDELETEのリクエストを実行することで編集や削除が可能になります。

それでは実際にPostmanでテストしてみます
まずGETでサロン一覧を表示してみます

今回はid[6]のサロンを編集してみます
ではPUTで以下のURLを指定し、Jsonで編集内容を記載します。
'http://localhost:8000/api/salons/6/'

{
    "name": "SalonB",
    "address": "千葉県〇〇-〇〇",
    "description": "美容室Bの情報を更新しました。",
    "phone_number": "99988887777",
    "email": "salon.b@mail.com"
}

SENDをクリックしてリクエストします。

Statusに「200 OK」が表示されているのでうまくいってそうです!
もう一度GETでサロンリストを表示してid[6]のサロンが更新されているか確認します。

無事にサロン情報が更新されました!これでAPIは正しく動作していることを確認できました!
続いてReactの実装をして、フロントから更新を実施できるようにします。

フロントエンドの実装

SalonList.jsの更新

まずサロン一覧の各サロンに編集ボタンを追加するため、SalonList.jsxに以下の追記をします

import React from 'react';
import { Button, List, ListItem, Typography } from '@mui/material';

function SalonList({ salons, onEdit }) { # onEditを追記
  return (
    <div>
      <Typography variant="h5">サロン一覧</Typography>
      {salons.length === 0 ? (
        <Typography>サロンは登録されていません</Typography>
      ) : (
        <List>
          {salons.map(salon => (
            <ListItem key={salon.id}>
              <div>
                <Typography variant="h6">{salon.name}</Typography>
                <Typography>住所: {salon.address}</Typography>
                <Typography>電話番号: {salon.phone_number}</Typography>
                <Typography>メールアドレス: {salon.email}</Typography>
                <Typography>紹介文: {salon.description}</Typography>
                <Button onClick = {() => onEdit(salon)}>編集</Button> #追記
              </div>
            </ListItem>
          ))}
        </List>
      )}
    </div>
  );
}

export default SalonList;

SalonListでは以下の追加をしています
・編集ボタンを追加しています。onClick時にonEditにsalonをpropsとして渡します。

SalonForm.jsの更新

続いてSalonForm.jsの更新です。
新規登録時にまっさらな状態のフォームを表示していますが、編集時は受け取ったサロン情報をフォームに表示した状態で表示できるようにします。

jsxCopyimport React, { useState, useEffect } from 'react';
import { Button, TextField } from '@mui/material';

function SalonForm({ salon, onSalonSubmit }) { #propsにsalonを追加
  const [formData, setFormData] = useState({ #setSalonをformDataに変更
    name: '',
    address: '',
    description: '',
    phone_number: '',
    email: ''
  });

  useEffect(() => {  #追記 salonを受けっとっていればフォームにサロン情報を表示
    if(salon){
      setFormData(salon);
    }
  }, [salon]);

  const handleInputChange = (e) => {
    setFormData({ ...formData, [e.target.name]: e.target.value });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    onSalonSubmit(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* フォームフィールド */}
      <Button type="submit" variant="contained" color="primary">
        {salon ? '更新' : '登録'} #追記 salonを受け取っていれば更新ボタンを表示
      </Button>
    </form>
  );
}

export default SalonForm;

SalonForm.jsでは以下の変更、追記をしています。
SalonForm関数
・propsにsalonを追加 これでonEditで渡されたsalonを受け取ります
・useEffectを追記して、salonを受け取った際にフォームにサロン情報を表示します
・salonを受け取っていればボタンの表示を「登録」ではなく「更新」と表示します

App.jsの更新

続いてApp.jsの更新をします!

import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Button, Container, Typography, Snackbar, Alert } from  '@mui/material'; #SnackbarとAlertを追記
import SalonList from './components/SalonList';
import SalonForm from './components/SalonForm';
import Modal from './components/Modal';

function App() {
  const [salons, setSalons] = useState([]);
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [editingSalon, setEditingSalon] = useState(null); #追記 サロン編集のuseState
  const [snackbar, setSnackbar] = useState({open:false, message:'',severity:'success'}); #追記 Snackbar表示のuseState

  useEffect(() => {
    fetchSalons();
  }, []);

  const fetchSalons = () => {
    axios.get('http://localhost:8000/api/salons/')
      .then(response => {
        setSalons(response.data);
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
  };

  const handleSalonSubmit = (salonData) => {   #追記 編集時のAPI連携、挙動を記載
    if(editingSalon){
      axios.put(`http://localhost:8000/api/salons/${editingSalon.id}/`, salonData)
        .then(response => {
          setSalons(salons.map(salon => salon.id === editingSalon.id ? response.data : salon));
          setIsModalOpen(false);
          setEditingSalon(null);
          setSnackbar({open: true, message: '更新完了', severity: 'success'});
          
        })
    }else{
      axios.post('http://localhost:8000/api/salons/', salonData)
      .then(response => {
        setSalons([...salons, response.data]);
        setIsModalOpen(false);
        setSnackbar({open: true, message: '登録完了', severity: 'success'});
      })
      .catch(error => {
        console.error("エラー発生", error);
      });
    }
  };


  const handleEdit = (salon) => { #追記 handleEdit関数を追加
    setEditingSalon(salon);
    setIsModalOpen(true);
  };



  const handleCloseSnackbar = (event, reason) => { #追記 handleCloseSnackbar関数を追加
    if(reason === 'clickaway'){
      return;
    }
    setSnackbar({...snackbar, open:false});
  };

  return (
    <Container>
      <Typography variant="h4">SalonLink</Typography>
      <Button variant="contained" onClick={() => setIsModalOpen(true)}>
        サロン登録はこちら
      </Button>
      <Modal isOpen={isModalOpen} onClose={() => {
        setIsModalOpen(false);
        setEditingSalon(null);
      }}>
        <SalonForm salon={editingSalon} onSalonSubmit={handleSalonSubmit} />
      </Modal>
      <SalonList salons={salons} onEdit={handleEdit} /> #追記 onEditをpropsとして渡す
      <Snackbar open={snackbar.open} autoHideDuration={6000} onClose={handleCloseSnackbar}>
        <Alert onClose={handleCloseSnackbar} severity={snackbar.severity}>
          {snackbar.message}
        </Alert>
      </Snackbar>
    </Container>
  );
}

export default App;

App.jsでは以下の更新、追記をしています
・更新した際に「更新完了」とSnackbarを表示するため、MUIのインポートにSnackbarとAlertを追加しました。最初はデフォルトのalertを使用予定でしたがせっかくMUIを導入しているので使用してみました
・editingSalonのuseStateを追加しました。
・snackbarのuseStateを追加しました。
・handleSalonSubmit関数に更新時の処理も加え、新規登録と更新どちらにも対応できるようにしました
・handleEdit関数を追加しました
・handleCloseSnackbar関数を追加しました
・リターンレンダリング内にonEditを追加しました

では実際のサロン一覧画面を見てみましょう!

編集ボタンは表示できていますね。編集ボタンをクリックしてモーダルにサロン情報が表示されるか確認します。今回は一番上のサロンを選択します。

無事に選択したサロン情報がフォームに表示されています。新規登録の際は「登録」と表示されていたボタンも「更新」に表示が変わっていますね!
では更新する値を入力して更新ボタンをクリックします。

モーダルが閉じて、無事にサロン情報が更新されました。

Snackbarがキャプチャできなかったため、2番目のサロンも更新してすぐにキャプチャを取りましたw

無事に「更新完了」のSnackbarも表示されていることを確認できました!!

2. サロン削除機能の実装

次に、サロンをDelete(削除)する機能を追加しました。
実は更新ができれば削除はすぐに実装できます。
既に実装しているSalonUpdate(genericsのRetrieveUpdateDestroyAPIView)には更新機能に加えて削除機能が備わっているためAPIに関してもそのままでOKです。
アクセスポイントにアクセスする際に更新時はPUTでアクセスしましたが、削除時はDeleteでアクセスすることでしていのレコードを削除することができます。

ただビューの名前がUpdateのままなのでSalonUpdateDelete等に変更予定ですw
今回は一旦このままにします。

APIのテスト

では先ほどと同じく、まずPostmanを使用してDeleteのAPIをテストを実施します。

先にサロン一覧をGETで呼び出します。今回は一番下のid7のサロンをDeleteしたいと思います。

続いてDeleteでサロンが削除されるか確認します。今回はidが[7]のサロンを削除するため、<int/pk>の部分に7を指定します。
HTTPメソッドを「DELETE」に設定
URLに http://localhost:8000/api/salons/7/

SENDをクリックします

送信後、ステータスコード204(No Content)が表示されているので多分成功しているはずです。
もう一度GETでサロン一覧を表示して確かめてみましょう。

無事に一番下のid[7]のサロンが削除されているのでAPIは問題なさそうです!
続いてフロントの実装に移ります!

フロントエンド(React)の実装

SalonList.jsに削除ボタンを追加:

jsxCopyfunction SalonList({ salons, onEdit, onDelete }) {
  return (
    <List>
      {salons.map(salon => (
        <ListItem key={salon.id}>
          {/* サロン情報 */}
          <Button onClick={() => onEdit(salon)}>編集</Button>
          <Button onClick={() => onDelete(salon.id)}>削除</Button> #追記 削除ボタン
        </ListItem>
      ))}
    </List>
  );
}

SalonList.jsでは以下の追記をしています
・削除ボタンを追加 ここではonDeleteとしてsalonのidを渡しています

App.jsの更新

const handleDelete = (id) => { #追記 handleDelete関数を追加
  const salonToDelete = salons.find(salon => salon.id === id);
  if (window.confirm(`本当に「${salonToDelete.name}」を削除しますか?`)) { #削除時にconfirmでサロン名と削除を実行するかを確認
    axios.delete(`http://localhost:8000/api/salons/${id}/`)
      .then(() => {
        setSalons(salons.filter(salon => salon.id !== id));
        setSnackbar({open: true, message: 'サロンが削除されました', severity: 'success'}); #削除成功時にSnackbarを表示
      })
      .catch(error => {
        console.error("エラー発生", error);
        setSnackbar({open: true, message: '削除に失敗しました', severity: 'error'});
      });
  }
};

#既存のコード

 return (
    <Container>
      <Typography variant="h4">SalonLink</Typography>
      <Button variant="contained" onClick={() => setIsModalOpen(true)}>
        サロン登録はこちら
      </Button>
      <Modal isOpen={isModalOpen} onClose={() => {
        setIsModalOpen(false);
        setEditingSalon(null);
      }}>
        <SalonForm salon={editingSalon} onSalonSubmit={handleSalonSubmit} />
      </Modal>
      <SalonList salons={salons} onEdit={handleEdit} onDelete={handleDelete} /> #追記 onDeleteを追加
      <Snackbar open={snackbar.open} autoHideDuration={6000} onClose={handleCloseSnackbar}>
        <Alert onClose={handleCloseSnackbar} severity={snackbar.severity}>
          {snackbar.message}
        </Alert>
      </Snackbar>
    </Container>
  );

App.jsでは以下の更新、追加をしています
・handleDelete関数を追加 更新時に使用しているAPI連携をdeleteを渡しています。また削除実行時にwindow.confirmでサロン名と削除を本当に実行するかを確認しTrueになった際にAPIを実行するようにしています。
・リターンレンダリングでonCloseをSalonListに渡しています

削除機能はこれだけの追加で実装できているはずです。
実際の画面で確認してみます。


削除ボタンが表示されています!
今回は上から3番目の「美容室1」のサロンを削除してみます。

しっかりとwindow.confirmが動作してサロン名と削除実行の確認ができています。
「はい」をクリックしてAPIを実行します!

無事に「美容室1」が削除されました!
これで今回の実装は完了です!!

最後に

今回の実装で、CRUDの基本操作がすべて揃いました!
また今回はAPIテストの重要性や、ユーザービリティを考慮した実装の大切さも学ぶことができました。
まだまだ完成までは長い道のりですが、データベース操作の基本が完了して安心しました。

次回は、サロンの写真を登録し、サロン一覧に表示する機能を実装する予定です。
次回の実装もお楽しみに!!

やぎ

Discussion