プログラミングを頑張る土木系専攻大学院生のブログ

主にプログラミングについて開発備忘録的な形で投稿しています。

食材検索機能を実装 【JavaScript解説】 レシピ提案アプリ④

 

siip.hateblo.jp

Djangoを用いてWebアプリケーションを構築中です。

 

前回の記事で構築したレシピ追加機能の中で
セレクトボックスをリアルタイムで絞り込む検索機能について
フォーカスした記事となります

完成イメージ 

  • ページロード時に「食材を検索」用のテキストボックスが自動で追加される

  • テキストボックスに入力すると、該当する食材だけがリストに表示される

  • 食材名だけでなく、読み方(ふりがな)でも検索できる!

 

食材モデル (models.py)

from django.db import models

class Ingredient(models.Model):
    TYPE_CHOICES = [
        ('肉・魚・大豆・卵', '肉・魚・大豆・卵'),
        ('野菜', '野菜'),
        ('米・麺・パスタ', '米・麺・パスタ'),
        ('その他', 'その他'),
    ]
    name = models.CharField(max_length=100, unique=True, help_text="食材名を入力してください")  # 食材名
    reading = models.CharField(max_length=100, help_text="食材の読み方を入力してください")  # 読み方
    type = models.CharField(max_length=50, choices=TYPE_CHOICES, help_text="食材の種類を選択してください")  # 種類

    def __str__(self):
        return self.name


使用するHTML全文 

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="Add your favorite recipes with details like name, YouTube URL, thumbnail, ingredients, and notes.">
    <meta name="keywords" content="recipe, add recipe, cooking, ingredients, YouTube, thumbnail">
    <title>レシピ追加</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
    <div class="container mt-5">
      <h1 class="text-center">レシピ追加</h1>
      <form id="add-recipe-form" class="mt-4">
        <!-- フォーム要素 -->
        <div class="mb-3">
          <label for="recipe-name" class="form-label">レシピ名</label>
          <input type="text" class="form-control" id="recipe-name" name="name" required>
        </div>
        <div class="mb-3">
          <label for="youtube-url" class="form-label">YouTube URL</label>
          <input type="url" class="form-control" id="youtube-url" name="youtube_url" required>
        </div>
        <div class="mb-3">
          <label for="thumbnail" class="form-label">サムネイル画像</label>
          <input type="text" class="form-control" id="thumbnail" name="thumbnail">
        </div>
        <div class="mb-3">
          <label for="ingredients" class="form-label">使用する食材</label>
          <select class="form-control" id="ingredients" name="ingredients" multiple>
            {% for ingredient in ingredients %}
              <option value="{{ ingredient.id }}" data-reading="{{ ingredient.reading }}">{{ ingredient.name }}</option>
            {% endfor %}
          </select>
        </div>
        <div class="mb-3">
          <label for="categories" class="form-label">カテゴリ</label>
          <select class="form-control" id="categories" name="categories" multiple>
            {% for category in categories %}
              <option value="{{ category.id }}">{{ category.name }}</option>
            {% endfor %}
          </select>
        </div>
        <div class="mb-3">
          <label for="notes" class="form-label">備考</label>
          <textarea class="form-control" id="notes" name="notes" rows="3"></textarea>
        </div>
        <button type="submit" class="btn btn-primary">追加</button>
      </form>
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js"></script>
    <script>
      function getCSRFToken() {
        const cookies = document.cookie.split(';');
        for (let cookie of cookies) {
          const [name, value] = cookie.trim().split('=');
          if (name === 'csrftoken') {
            return value;
          }
        }
        return null;
      }

      document.getElementById('add-recipe-form').addEventListener('submit', async function(event) {
        event.preventDefault();
        const formData = new FormData(event.target);
        const data = {
          name: formData.get('name'),
          url: formData.get('youtube_url'),
          thumbnail: formData.get('thumbnail'),
          ingredients: formData.getAll('ingredients'),
          categories: formData.getAll('categories'),
          notes: formData.get('notes')
        };

        if (!data.name || !data.url || !data.ingredients.length || !data.categories.length) {
          alert('必須フィールドをすべて入力してください。');
          return;
        }

        try {
          const response = await fetch('/recipes/add-recipe/', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'X-CSRFToken': getCSRFToken(),
            },
            body: JSON.stringify(data)
          });

          if (response.ok) {
            alert('レシピが正常に追加されました!');
            event.target.reset();
          } else {
            const errorData = await response.json();
            if (errorData.errors) {
              alert(`エラーが発生しました:\n${errorData.errors.join('\n')}`);
            } else {
              alert(`エラーが発生しました: ${errorData.message || '不明なエラー'}`);
            }
          }
        } catch (error) {
          alert(`通信エラーが発生しました: ${error.message}`);
        }
      });

      document.addEventListener('DOMContentLoaded', function() {
        const ingredientSelect = document.getElementById('ingredients');
        const searchBox = document.createElement('input');
        searchBox.type = 'text';
        searchBox.placeholder = '食材を検索';
        searchBox.classList.add('form-control', 'mb-3');

        ingredientSelect.parentNode.insertBefore(searchBox, ingredientSelect);

        searchBox.addEventListener('input', function() {
          const filter = searchBox.value.toLowerCase();
          const options = ingredientSelect.options;
          for (let i = 0; i < options.length; i++) {
            const option = options[i];
            const text = option.textContent.toLowerCase();
            const reading = option.getAttribute('data-reading')?.toLowerCase() || '';
            option.style.display = text.includes(filter) || reading.includes(filter) ? '' : 'none';
          }
        });
      });
    </script>
  </body>
</html>

コードの流れを解説 (JavaScriptのみ)

1. DOMContentLoaded イベントの設定

document.addEventListener('DOMContentLoaded', function() {

  • 目的: ページのHTMLが完全に読み込まれた後に実行される処理を定義します。
  • 理由: JavaScriptがHTML要素を操作する前に、要素が確実に存在している必要があるためです。

2. 食材選択欄と検索ボックスの準備

const ingredientSelect = document.getElementById('ingredients');
const searchBox = document.createElement('input');
searchBox.type = 'text';
searchBox.placeholder = '食材を検索';
searchBox.classList.add('form-control', 'mb-3');

  • ingredientSelect:
    • <select>要素(食材選択欄)を取得します。
    • この要素に対して検索機能を適用します。
  • searchBox:
    • 新しい<input>要素を作成します。
    • type: 'text': ユーザーが文字列を入力できるテキストボックス。
    • placeholder: '食材を検索': 入力欄に「食材を検索」というヒントを表示。
    • classList.add('form-control', 'mb-3'): Bootstrapのスタイルを適用して見た目を整えます。

3. 検索ボックスを食材選択欄の上に挿入

ingredientSelect.parentNode.insertBefore(searchBox, ingredientSelect);

  • 目的: 検索ボックスを食材選択欄の直前に挿入します。
  • insertBefore:
    • 親要素(ingredientSelect.parentNode)の中で、ingredientSelectの前にsearchBoxを追加します。

4. 検索ボックスの入力イベントを監視

searchBox.addEventListener('input', function() {
    const filter = searchBox.value;
    const options = ingredientSelect.options;

  • addEventListener('input'):
    • ユーザーが検索ボックスに文字を入力するたびに実行される処理を定義します。
  • filter:
    • サーチボックスの入力を元にフィルター。
  • options:
    • 食材選択欄のすべての<option>要素を取得します。

5. 食材リストの絞り込み

for (let i = 0; i < options.length; i++) {
    const option = options[i];
    const text = option.textContent.toLowerCase();
    const reading = option.getAttribute('data-reading')?.toLowerCase() || '';
    option.style.display = text.includes(filter) || reading.includes(filter) ? '' : 'none';
}

  • ループ処理:
    • すべての<option>要素を順番に処理します。
  • text:
    • <option>要素の表示テキスト(例: 食材名)を取得します。
  • reading:
    • <option>要素のdata-reading属性(例: 食材の読み方)取得します。
    • 属性が存在しない場合は空文字列を返します。
  • 絞り込み条件:
    • 入力された文字列(filter)がtextまたはreadingに含まれている場合、その<option>を表示します。
    • 含まれていない場合は非表示にします(style.display = 'none')。


まとめ 

食材リストが多いので、簡単に見つけられるように検索できるセレクトボックスを実装しました。

次回の記事

 

siip.hateblo.jp