Django でフィールド編集用のダイアログを開く

# 概要

[Django](https://www.djangoproject.com) で編集用の View を作っていて、ページのレイアウトをすっきりさせるために **一部のフィールドについてはダイアログを開いて編集する** ようにしたい時があります。

この記事ではそのような編集用のダイアログを開く方法や、編集結果を元の View に反映する方法についてまとめます。

## 動作確認したバージョン

- Python: 3.11.4
- Django: 4.2.7

## 記事の書き方について

- あるクラスのインスタンスのことを、そのクラスの「オブジェクト」と表現することがあります。
- あるクラスのサブクラスやそのオブジェクトについて、単に元のクラス名で表現することがあります。    
たとえば、Form のサブクラスやそのオブジェクトについて「この Form を～」等と書くことがあります。

## ソースコード

最終的なソースコードは [GitHub のこちらのリポジトリ](https://github.com/sada-east/django_edit_dialog) で公開しています。

# ゴール

**メインのゴール**

- ある View で表示中のデータの一部のフィールドを、ダイアログを開いて別画面で編集できるようにする
- ダイアログで編集した結果を呼び出し元のページに反映する

**ついでにやりたいこと**

- 編集結果を保存する時に、ダイアログを閉じるか開いたままにするか選べるようにする
- 呼び出し元のページを閉じたら (または別画面に遷移したら)、ダイアログを閉じる

# 具体的なイメージ

この記事で扱うデータは **「地図上のスポット」** とし、その座標をダイアログで編集できるようにします。  
座標は1個の辞書ですが、ダイアログでは緯度と経度をそれぞれ編集できるようにします。

![02_opening_dialog.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/907ea3a7-8140-c882-59db-b839582c1678.png)

# 準備

以下のような Django プロジェクトを作ります。

- プロジェクト名: 任意
- アプリケーション名: app
- スポット一覧の URL: /app/spot-list/
- 座標編集画面の URL: /app/edit-coords/\<int:pk>

## モデルの準備

フィールドは名前と座標だけです。  
便利関数として、座標を文字列で返す関数と、Google Maps の URL を返す関数を作っておきます。

```python:app/models.py
import json
from django.db import models

class Spot(models.Model):
    '''地図上のスポットのデータ
    '''
    name = models.CharField('名前', max_length=100)
    coords = models.JSONField('座標', default=dict)

    def __str__(self):
        return self.name

    def coords_str(self):
        '''座標を JSON 文字列で返す
        '''
        return json.dumps(self.coords, indent=4)

    def gmap_url(self):
        '''Google Maps のその座標の URL を返す
        '''
        coords = f'{self.coords["latitude"]}%2C{self.coords["longitude"]}'
        return 'https://www.google.com/maps/search/?api=1&query=' + coords
```

[`JSONField`](https://docs.djangoproject.com/ja/4.2/ref/models/fields/#jsonfield) は内部的には JSON 文字列を保持しますが、このフィールドの値を取得するとそれをパースしたオブジェクトになります。  
ここでは `coords` フィールドは以下の構造を想定しているので、辞書型となります。

```json
{
  "latitude": 緯度 (float),
  "longitude": 経度 (float)
}
```

## スポット一覧画面の準備

ビュー (ListView) とそのテンプレートを作ります。

```python:app/views.py
from django.views.generic import ListView
from .models import Spot

class SpotListView(ListView):
    '''スポット一覧画面'''
    model = Spot
    template_name = 'app/spot_list.html'
```

```django:app/templates/app/spot_list.html
{# spot_list.html の table 部分 #}
  <table rules="rows">
    <tr><th>スポット</th><th>座標</th></tr>
    {% for spot in object_list %}
      <tr>
        <td>
          <!-- スポット名を Google Maps へのリンクにしています -->
          <a href="{{ spot.gmap_url }}" target="_blank">{{ spot.name }}</a>
        </td>
        <td>
          <pre>{{ spot.coords_str }}</pre>
        </td>
        <td>
          <!-- 後で編集用のダイアログを開くリンクにします -->
          編集
        </td>
      </tr>
    {% endfor %}
  </table>
```

## URL の準備

urls.py を編集して、`/app/spot-list/` にアクセスしたら `SpotListView` を表示するようにします (詳細は割愛します)。

以上で、この記事のテーマである「編集用のダイアログ」**以外**の部分ができました。  
Django の管理画面で適当なスポットを作って、スポット一覧画面を確認します。

![03_spot_list_nolink.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/4838307e-115e-04ea-217e-43e337c0be1e.png)

# 実装プラン

編集機能の実装のおおまかな流れです。  
次のチャプターから、この流れに沿って実装していきます。

**1. 座標編集用のビューとフォームを作る**

- URL から `Spot` オブジェクトの `pk` を取れるようにします。
- ただし UpdateView ではなく FormView とし、モデルと密接に紐づかないようにします。

**2. 座標編集用のビューに保存処理を作る**

- ビューがフォームデータを受け取ったら、該当のスポットの座標を更新して保存するようにします。

**3. スポット一覧画面からリンクする**

- スポット一覧画面で「編集」をクリックすると編集用のダイアログが開くようにします。
- あとで編集用のダイアログからメッセージを受け取るので、そのための準備も必要になります。

**4. 座標編集画面にボタンを追加する**

- 「閉じる」「編集前に戻す」「保存して閉じる」というボタンを追加します。

**5. スポット一覧画面の更新処理を作る**

- 編集用のダイアログからメッセージを受け取ったら、表示を更新するようにします。

# 座標編集用のビューとフォームを作る

ダイアログに表示する座標編集用のビューと、そのためのフォームを作ります。  
先にフォームを作って、それをビューで使うようにします。

## フォーム

フォームはモデルとは関係ないシンプルなものにして、ビュー側でデータのハンドリングをします。

```python:app/forms.py
from django import forms

class CoordsForm(forms.Form):
    latitude = forms.FloatField(label='緯度', min_value=-90.0, max_value=90.0)
    longitude = forms.FloatField(label='経度', min_value=-180.0, max_value=180.0)
```

## ビュー

ビューは FormView として基本的な属性を設定し、それに加えて3つの関数を作ります。

- 編集対象のオブジェクトを取得するために `get_object()` を作ります。
- フォームに初期値を与えるために `get_initial()` をオーバーライドします。
- テンプレートにデータを渡すために `get_context_data()` をオーバーライドします。

```python:app/views.py
'''views.py に以下を追加する'''
from django.shortcuts import get_object_or_404
from django.views.generic.edit import FormView
from .forms import CoordsForm

class CoordsEditor(FormView):
    '''座標編集画面
    '''
    template_name = 'app/coords_edit.html'
    form_class = CoordsForm

    def get_object(self):
        '''編集対象のスポットを取得する
        '''
        if not hasattr(self, 'object'):
            id = self.kwargs.get('pk')
            self.object = get_object_or_404(Spot, id=id)
        return self.object

    def get_initial(self):
        '''フォームに与える初期値を取得する
        '''
        spot = self.get_object()
        return {
            'latitude': spot.coords['latitude'],
            'longitude': spot.coords['longitude'],
        }

    def get_context_data(self, **kwargs):
        '''テンプレートに渡すデータを取得する
        '''
        context = super().get_context_data(**kwargs)

        spot = self.get_object()
        context['spot_id'] = spot.pk        # URL 生成や呼び出し元の判定用
        context['spot_name'] = spot.name    # スポット名の表示用

        return context
```

:::note
[`get_object()`](https://docs.djangoproject.com/en/4.2/ref/class-based-views/mixins-single-object/#django.views.generic.detail.SingleObjectMixin.get_object) は [UpdateView](https://docs.djangoproject.com/ja/4.2/ref/class-based-views/generic-editing/#django.views.generic.edit.UpdateView) 等には備わっている関数です。  
自前で `get_object()` を作るくらいなら FormView でなく UpdateView にしたら良いのではないか、と思うかもしれません。  
もちろん、そういう方法もあります。  
ここでは、保存処理をカスタムで作っていることを意識するために FormView とし、自前で `get_object()` を作っています。  
:::

## テンプレート

とりあえず、フォームを表示するだけのテンプレートを作ります。

```django:app/coords_edit.html
{# coords_edit.html の body の中 #}
<h1>{{ spot_name }} の座標</h1>

<form method="POST">
  {% csrf_token %}
  {{ form.as_p }}
</form>
```

## URL

urls.py を編集して `/app/edit-coords/<int:pk>` でアクセスできるようにします。  
これで、URL を直打ちすればブラウザで表示できるようになりました。  
のちほどダイアログとして開くようにします。

![04_coords_editor_nobutton.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/30f8c9db-da19-1677-0088-ecb8ce3ac973.png)

# 座標編集用のビューに保存処理を作る

## 保存ボタン

まず、テンプレートに保存用のボタンを作ります。  
ただしボタンを押された時にやることがあるので、form を submit するボタンではなく **JavaScript の関数を呼ぶボタン** にします。

最終的には「保存して編集を続ける」ボタンと「保存して閉じる」ボタンを作りますが、ここで作るボタンは **「保存して編集を続ける」** にしておきます。

```django:app/coords_edit.html
{# coords_edit.html の </form> の下 #}
<button onclick="save();">保存して編集を続ける</button>

<script>
  // form を送信する関数
  function save() {
    const form = document.forms[0];
    let url = "{% url 'app:edit-coords' spot_id %}";
    form.action = url;
    form.submit();
  }
</script>
```

![05_coords_editor_savebutton.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/ccfd2645-6866-a25c-a480-6d1cc59f9fdd.png)

`save()` でやっていることは form の submit なので、これだけだと関数にする意味がなさそうですが、あとで少し処理を追加します。

また、このあとの実装で GET パラメタを使うのですが、form を送信する時は **GET パラメタをリセットしたい** のでこのように URL を作って `form.action` にセットしています。

## 保存処理

まず、保存が成功した時のために `success_url` が必要になります。  
`success_url` はあとで動的に決めたくなりそうなので、`get_success_url()` をオーバーライドする形にしておきます。  
今は「保存して編集を続ける」が押された想定なので、単に同じビューの URL を返しているだけです。

保存処理は、フォームのバリデーションが成功した時に呼ばれる `form_valid()` で実行します。  
対象のオブジェクトの **`coords` (座標) フィールドだけを更新** して保存します。

```python:app/views.py
'''views.py の CoordsEditor の中'''
    def get_success_url(self) -> str:
        url = self.request.path
        return url

    def form_valid(self, form):
        spot = self.get_object()
        spot.coords = {
            'latitude': form.cleaned_data['latitude'],
            'longitude': form.cleaned_data['longitude']
        }
        spot.save()
        return super().form_valid(form)
```

これで編集結果を保存できるようになりました。  
「保存して編集を続ける」を押してからスポット一覧画面を表示 (またはリロード) すると、編集結果が反映されます。  

※のちほど、リロードしなくても反映されるようにします。

# スポット一覧画面からリンクする

最初に作ったスポット一覧画面で単に「編集」としていた列をハイパーリンクにします。  
また、スポット名と座標を表示している要素に `id` を持たせて、あとで動的に更新できるようにします。

```django:app/templates/app/spot_list.html
{# spot_list.html の table 部分 #}
  <table rules="rows">
    <tr><th>スポット</th><th>座標</th></tr>
    {% for spot in object_list %}
      <tr>
        <td>
          <!-- id 属性を追加 -->
          <a id="link_{{ spot.id }}" href="{{ spot.gmap_url }}" target="_blank">{{ spot.name }}</a>
        </td>
        <td>
          <!-- id 属性を追加 -->
          <pre id="coords_{{ spot.id }}">{{ spot.coords_str }}</pre>
        </td>
        <td>
          <!-- 関数を実行するハイパーリンクに変更 -->
          <a href="javascript:void(0);" onclick="openEditDialog(event);" spotId="{{ spot.id }}">編集</a>
        </td>
      </tr>
    {% endfor %}
  </table>
```

## ダイアログを開く処理

上で追加したハイパーリンクは、JavaScript の関数を実行するようになっています。  
ここで実行する関数では、以下の処理をしたいです。

1. クリックされた行の座標を編集するための URL を決める
1. その URL をダイアログで開く
1. 後の処理のために、開いたダイアログを憶えておく

```django:app/templates/app/spot_list.html
{# spot_list.html の </body> の前に追加 #}
  <script>
    // 開いたダイアログを憶えておく連想配列
    const dialogs = {};

    // 座標編集用のダイアログを開く
    function openEditDialog(event) {
      // クリックされた要素からスポットの id を取得して URL を作る
      const spotId = event.target.attributes.spotId.value;
      const url = `/app/edit-coords/${spotId}`;

      // 座標編集画面をダイアログとして開く
      const coordsEditor = window.open(url, `coords_${spotId}`, "popup, width=600, height=400");

      // 開いたダイアログを dialogs に登録する (すでに開いていれば上書きする)
      dialogs[spotId] = coordsEditor;
    }
  </script>
```

**ポイント解説**

- ダイアログに 'coords_1' のような名前を付けて、同じスポットで2回クリックしてもダイアログが増えないようにしています。
- 開いたダイアログをあとで参照できるように、スポットの id をキーとして連想配列にセットしています。
- `window.open()` の引数や返り値については [こちら](https://developer.mozilla.org/ja/docs/Web/API/Window/open) を参考にしてください。

ここまでで、「編集」をクリックしてダイアログを開いて、そのダイアログで座標を編集・保存できるようになりました。

# 座標編集画面にボタンを追加する

座標編集画面のテンプレートを編集して、ボタンを4個にします。

```django:app/coords_edit.html
{# coords_edit.html の </form> の下 #}
<button onclick="window.close();">閉じる</button>
<button onclick="revert();">編集前に戻す</button>
<button onclick="save(false);">保存して編集を続ける</button>
<button onclick="save(true);">保存して閉じる</button>
```

新たに `revert()` という関数を追加し、`save()` には引数を追加しています。  
順番に見ていきます。

## 「閉じる」ボタン

これは単に `window` を閉じているだけです。

## 「編集前に戻す」ボタン

編集前に戻したい時、単に `<input type="reset" />` でボタンを作ってしまうと、エラー画面を経由した場合に最初の値に戻ってくれません。  
また、このあと追加する GET パラメタもリセットしたいので、リロードではなく URL を指定して読み込み直します。

ボタンを押したときに実行する `revert()` 関数は以下のようになります。

```django:app/coords_edit.html
{# coords_edit.html の <script> の中に関数を追加 #}
<script>

  // - 中略 -

  // 編集前に戻す関数
  function revert() {
    const url = "{% url 'app:edit-coords' spot_id %}";
    location.replace(url);
  }
</script>
```

## 「保存して編集を続ける / 保存して閉じる」ボタン

「保存して編集を続ける」ボタンはすでにそのような挙動になっていますが、このボタンによって呼ばれる `save()` 関数を修正し、「保存して閉じる」ボタンでも使えるようにします。

```django:app/coords_edit.html
{# coords_edit.html の <script> の中の save() 関数を修正 #}
<script>
  // form を送信する関数
  function save(close) {
    const form = document.forms[0];
    let url = "{% url 'app:edit-coords' spot_id %}";

    // 保存して閉じるなら、閉じるフラグを GET パラメタにセットする
    if (close) {
      url += "?close=1";
    }

    form.action = url;
    form.submit();
  }

  // - 中略 -

</script>
```

引数 `close` の値は、form 送信時の GET パラメタ (`close`) の有無を決めているだけです。  
つまり、**ここでは `window.close()` しません**。  
理由は、ここで `window` を閉じてしまうとサーバ側のビューやフォームで**エラー**が出た時にそれを**表示する術がなくなってしまう**からです。  
`window` を閉じるのは、**保存が成功した後**です。

:::note
form の method は `POST` にしていますが、GET パラメタは有効です。
:::

## ダイアログを閉じるための仕組み

「保存して閉じる」ボタンを押した時に、 **「保存が成功したら `window` を閉じる」** という挙動を実装しなければなりません。  
つまり、`window` を閉じるかどうかは保存が成功するまで**フラグとして持っておく**必要があります。  
先ほど追加した GET パラメタ (`close`) がそのフラグになります。

しかし、最終的に `window.close()` を実行するのはフロントの JavaScript になります。  
また、呼び出し元のページに変更を反映するので、いずれにしても保存後に**何等かのスクリプトを実行するページを返す仕組み**が必要です。

そこで以下のような仕組みを考えます。

1. 保存に成功したら `CoordsEditor` を再度表示する (すでにそうなっています)。
1. その際、保存に成功したというフラグ (`success`) を GET パラメタに含める。
1. その際、GET パラメタ `close` を受け取って保存処理をしたなら、そのパラメタも引き継ぐ。
1. フロント側の処理として、GET パラメタ `success` と `close` を受け取ったなら `window.close()` する。

:::note
この仕組みのために、これまでの保存に成功した場合以外の画面遷移では GET パラメタを引き継がないようにしてきました。
:::

サーバ側 (ビュー) では以下のように `get_success_url()` を修正します。

```python:app/views.py
'''views.py の CoordsEditor の中'''
    def get_success_url(self) -> str:
        # POST 処理が正常終了したら、GET パラメタを付けて同じページを再表示する
        url = self.request.path + '?success=1'

        # 「保存して閉じる」(close=1) だったら閉じるための GET パラメタを付ける
        close = self.request.GET.get('close')
        if (close):
            url += '&close=1'

        return url
```

また、フロント側 (テンプレート)でこれらの GET パラメタを参照できるように、`get_context_data()` で追加しておきます。

```python:app/views.py
'''views.py の CoordsEditor の 中'''
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        # - 中略 -

        # GET パラメタを参照できるようにする
        context['success'] = self.request.GET.get('success')  # 保存成功後か
        context['close'] = self.request.GET.get('close')      # 閉じるか

        return context
```

:::note
context に入れずに、テンプレート側で JavaScript で GET パラメタを取得することもできます。
:::

フロント側 (テンプレート) には以下のスクリプトを追加します。

```django:app/coords_edit.html
{# coords_edit.html の既存の <script> の上に追加 #}
{# 「保存して閉じる」が成功してリダイレクトして来た場合 #}
{% if success and close %}
<script>
  window.close();
</script>
{% endif %}
```

:::note
「保存して閉じる」が成功した場合は後続のスクリプトは意味がないので、`if` 文の `else` 句にしてしまっても良いです。
:::

これで、すべてのボタンが動作するようになりました。

![06_coords_editor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/339253/a72990b9-4c5c-a3f2-6ad9-7f6a10a3ff33.png)

# スポット一覧画面の更新処理を作る

スポット一覧画面の `window` に対してメッセージを配信することで、編集結果を反映することができます。  
使用する API は `postMessage()` です。

> [Window.postMessage() - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/Window/postMessage)

## メッセージの配信

配信のしかたは以下のようになります。

<table>
<tr><td>配信を実行するページ</td><td>座標編集画面</td></tr>
<tr><td>配信のタイミング</td><td>保存が成功してリダイレクトして来た時</td></tr>
<tr><td>配信先のページ</td><td>座標編集画面の呼び出し元 (スポット一覧画面)</td></tr>
<tr><td>メッセージの内容</td><td>スポットの id と、保存された座標</td></tr>
</table>

配信を実行するのは座標編集画面なのでそのテンプレートに JavaScript を書きますが、その前に、`postMessage()` API で必要になる [オリジン](https://developer.mozilla.org/ja/docs/Glossary/Origin) の準備をします。

`CoordsEditor` の `get_context_data()` を再度、編集します。

```python:app/views.py
'''views.py に import を追加'''
from urllib.parse import urlparse

'''views.py の CoordsEditor'''
class CoordsEditor(FormView):
    # - 中略 -

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)

        # - 中略 -

        # メッセージ配信で指定するためのオリジン
        referer = self.request.META.get('HTTP_REFERER', '')
        if referer:
            parsed_url = urlparse(referer)
            origin = parsed_url.scheme + "://" + parsed_url.netloc
            context['origin'] = origin

        return context
    
    # - 後略 -
```

この `origin` を使って、テンプレート側で配信処理を追加します。  
追加する場所は、先ほどの `window` を閉じる処理の前にします。

呼び出し元の Window は `window.opener` で取得できます。

```django:app/coords_edit.html
{# coords_edit.html の既存の <script> の上に追加 #}
{# 「保存して編集を続ける / 保存して閉じる」が成功してリダイレクトして来た場合 #}
{% if success and origin %}
<script>
  // 呼び出し元のウィンドウに、座標の情報を送信する
  const coords = {
    latitude: {{ form.latitude.value }},
    longitude: {{ form.longitude.value }}
  };
  const message = {
    spotId: "{{ spot_id }}",
    coords: JSON.stringify(coords, null, '    ')
  };
  window.opener.postMessage(message, "{{ origin }}");
</script>
{% endif %}
```

## メッセージの受信

メッセージを受信するのはスポット一覧画面です。  
`window` に [`message` イベント](https://developer.mozilla.org/ja/docs/Web/API/Window/message_event) のハンドラを追加して処理します。  
二度手間に思えるかも知れませんが、Google Maps の URL を作る関数がここでも必要になります。

```django:app/templates/app/spot_list.html
{# spot_list.html の <script> の中に関数とイベントハンドラを追加 #}
  <script>
    // - 中略 -

    // 座標の JSON から Google Maps のその座標の URL を作る
    function getGMapUrl(coords) {
      const coords_obj = JSON.parse(coords);
      const query = `${coords_obj.latitude}%2C${coords_obj.longitude}`;
      return 'https://www.google.com/maps/search/?api=1&query=' + query;
    }

    // dialog からのメッセージを受け取るようにする
    window.addEventListener('message', e => {
      // dialogs に登録された Window からのメッセージのみ処理する
      const spotId = e.data.spotId;
      if (dialogs[spotId] === e.source) {
        // 表示中の座標の値を更新する
        const coordsPre = document.getElementById(`coords_${e.data.spotId}`);
        coordsPre.innerText = e.data.coords

        // Google Maps へのリンクを更新する
        const link = document.getElementById(`link_${e.data.spotId}`);
        link.setAttribute('href', getGMapUrl(e.data.coords));
      }
    });
  </script>
```

:::note
実は `message` イベントハンドラの中で単にページを再読み込みしても同じ結果になります。  
その方が Google Maps の URL を作る関数も不要で、処理としては簡単です。  
ただ、これが編集画面だった場合は再読み込みをすると他の編集中の値が失われてしまいます。  
そのようなケースも考えると、編集結果を受け取ったのならそのデータを使って表示中の値を更新するようにすべきです。
:::

# 呼び出し元を閉じたらダイアログも閉じる

最後に、呼び出し元のスポット一覧を閉じた時に、その時点で開いているダイアログも閉じるようにします。  
正確にはタブやウィンドウを閉じなくても、画面遷移などでコンテキストが失われる状況になったらダイアログを閉じます。

スポット一覧画面のテンプレートに、`beforeunload` イベントのハンドラを追加するスクリプトを追加します。

```django:app/templates/app/spot_list.html
{# spot_list.html の <script> の中でイベントハンドラを追加 #}
  <script>
    // - 中略 -

    // このページを離れたら開いたダイアログも閉じるようにする
    window.addEventListener('beforeunload', e => {
      for (let key in dialogs) {
        dialogs[key].close();
      }
    });
  </script>
```

:::note
`dialogs` に登録されているすべての Window に対して `close()` していますが、`dialogs` の中にはすでに閉じている Window も残っています。  
すでに閉じている Window を `close()` してもエラーにはならないので、このようなシンプルな処理にしています。  
閉じているかどうか判定したい場合は [Window.closed 属性](https://developer.mozilla.org/ja/docs/Web/API/Window/closed) が使えます。
:::