【Spring Boot】生年月日などの日付プルダウンを作る

### はじめに
Spring Bootで個人開発をしています（実務経験なし）。
Java自体、実務でのコーディング経験が浅いため、拙いコードかと思いますがよろしくお願いいたします。

ユーザ登録画面で生年月日など「年」「月」「日」で分かれている日付プルダウンを作ってみました。
### Thymeleafで作成する
1年前に作成したものが以下です。
記事を参考に作成しており、一つの方法として問題ないとは思います。
※生年月日のみ抜粋

html上で1ずつ増加させるプルダウンを作成していたため、エンティティは日付型のLocalDateでも、画面表示や入力の際（html、Formクラス）はintになっており、登録する際にはint→String→LocalDateと変換していました。
```Users.java
@Data
public class Users {
    // 生年月日
    private LocalDate birth_date;
}

```
```UsersController.java
@Controller
public class UsersController {

    @Autowired
    UsersMapper usersMapper;

    // 登録画面初期表示
    @GetMapping("/user/create")
    public String create(@ModelAttribute UsersForm usersForm, Model model) {
        // 生年月日プルダウン
        int currentYear = YearMonth.now().getYear();
        int birthYearFrom = currentYear - 70;
        int birthYearTo = currentYear - 20;
        int birthYearDefault = currentYear - 30;

        model.addAttribute("birthYearFrom", birthYearFrom);
        model.addAttribute("birthYearTo", birthYearTo);
        model.addAttribute("birthYearDefault", birthYearDefault);
        return "user/create";
    }

    // 登録画面登録処理
    @PostMapping("/user/create")
    public String create(@ModelAttribute @Validated(GroupOrder.class) UsersForm usersForm, BindingResult result, Model model) {
        // 入力エラーチェック
        if (result.hasErrors()) {
            return create(usersForm, model);
        }

        Users users = new Users();

        // 生年月日
        String birthYear = String.valueOf(usersForm.getBirthYear());
        String birthMonth = String.valueOf(usersForm.getBirthMonth());
        String birthDay = String.valueOf(usersForm.getBirthDay());
        if (birthMonth.length() == 1) {
            birthMonth = "0" + birthMonth;
        }
        if (birthDay.length() == 1) {
            birthDay = "0" + birthDay;
        }
        users.setBirth_date(LocalDate.parse(birthYear + birthMonth + birthDay, DateTimeFormatter.ofPattern("yyyyMMdd")));

        usersMapper.insert(users);
        return "redirect:/user/list";
    }

```
```UsersForm.java
@Data
public class UsersForm {
    // 生年月日_年
    @Positive(groups = ValidGroup1.class, message = "選択してください")
    private int birthYear;

    // 生年月日_月
    @Positive(groups = ValidGroup1.class, message = "選択してください")
    private int birthMonth;

    // 生年月日_日
    @Positive(groups = ValidGroup1.class, message = "選択してください")
    private int birthDay;
}
```
※Bootstrap5のクラスが入っており見にくくてすみません
```create.html
<form method="POST" th:action="@{/user/create}" th:object="${usersForm}">
    <div class="row mb-3">
        <div class="col-12 col-md-3 text-md-end">
            <label for="birthDay" class="col-form-label">生年月日
                <span class="badge bg-secondary ms-1">必須</span>
            </label>
        </div>

        <div class="col-12 col-md-8 d-flex align-items-center">
            <select class="form-select" th:errorclass="is-invalid" name="birthYear">
                <option th:each="birthYear : ${#numbers.sequence(birthYearFrom, birthYearTo)}"
                        th:value="${birthYear}"
                        th:text="${birthYear}"
                        th:selected="${birthYear == birthYearDefault}">
                </option>
            </select>
            <span class="p-2">年</span>

            <div class="input-group has-validation">
                <select class="form-select w20" th:errorclass="is-invalid" name="birthMonth">
                    <option value="0"></option>
                    <option th:each="birthMonth : ${#numbers.sequence(1, 12)}"
                            th:value="${birthMonth}"
                            th:text="${birthMonth}">
                    </option>
                </select>
                <span class="invalid-feedback" th:if="${#fields.hasErrors('birthMonth')}" th:errors="*{birthMonth}"></span>
            </div>
            <span class="p-2">月</span>

            <div class="input-group has-validation">
                <select class="form-select w20" th:errorclass="is-invalid" name="birthDay">
                    <option value="0"></option>
                    <option th:each="birthDay : ${#numbers.sequence(1, 31)}"
                            th:value="${birthDay}"
                            th:text="${birthDay}">
                    </option>
                </select>
                <span class="invalid-feedback" th:if="${#fields.hasErrors('birthDay')}" th:errors="*{birthDay}"></span>
            </div>
            <span class="p-2">日</span>
        </div>
    </div>
</form>
```

### Controllerで作成する
別言語での現場経験を経て、画面表示や入力値はStringで登録の際はLocalDateに変換がいいのではないかと思い修正しました。
Serviceに書くのがいいかもしれませんが手っ取り早くControllerでの記載とします。
```UsersController.java
@Controller
public class UsersController {

    @Autowired
    UsersMapper usersMapper;

    // 登録画面初期表示
    @GetMapping("/user/create")
    public String create(@ModelAttribute UsersForm usersForm, Model model) { 
        // 年プルダウン
        List<String> birthYearList = new ArrayList<>();
        for (int i = currentYear-70; i <= currentYear-20; i++) {
            birthYearList.add(String.valueOf(i));
        }
        // 月プルダウン
        List<String> monthList = new ArrayList<>();
        for (int i = 1; i <= 12; i++) {
            monthList.add(String.valueOf(i));
        }
        // 初期値
        String birthYearDefault = String.valueOf(currentYear-30);

        model.addAttribute("birthYearList", birthYearList);
        model.addAttribute("birthYearDefault", birthYearDefault);
        model.addAttribute("monthList", monthList);
        return "user/create";
    }

    // 登録画面登録処理
    @PostMapping("/user/create")
    public String create(@ModelAttribute @Validated(GroupOrder.class) UsersForm usersForm, BindingResult result, Model model) {
        // 入力エラーチェック
        if (result.hasErrors()) {
            return create(usersForm, model, loginUser);
        }

        Users users = new Users();

        // 生年月日
        String birthYear = usersForm.getBirthYear();
        String birthMonth = usersForm.getBirthMonth();
        String birthDay = usersForm.getBirthDay();
        if (birthMonth.length() == 1) {
            birthMonth = "0" + birthMonth;
        }
        if (birthDay.length() == 1) {
            birthDay = "0" + birthDay;
        }
        users.setBirth_date(LocalDate.parse(birthYear + birthMonth + birthDay, DateTimeFormatter.ofPattern("yyyyMMdd")));

        usersMapper.insert(users);
        return "redirect:/user/list";
    }
}
```
```UsersForm.java
@Data
public class UsersForm {
    // 生年月日_年
    @NotBlank(groups = ValidGroup1.class, message = "選択してください")
    private String birthYear;

    // 生年月日_月
    @NotBlank(groups = ValidGroup1.class, message = "選択してください")
    private String birthMonth;

    // 生年月日_日
    @NotBlank(groups = ValidGroup1.class, message = "選択してください")
    private String birthDay;

}
```
```create.html
<form method="POST" th:action="@{/user/create}" th:object="${usersForm}">
    <div class="row mb-3">
        <div class="col-12 col-md-3 text-md-end">
            <label for="birthDay" class="col-form-label">生年月日
                <span class="badge bg-secondary ms-1">必須</span>
            </label>
        </div>

        <div class="col-12 col-md-8 d-flex align-items-center">
            <select class="form-select" th:errorclass="is-invalid" name="birthYear" id="yearList">
                <option th:each="birthYear : ${birthYearList}"
                        th:value="${birthYear}"
                        th:text="${birthYear}"
                        th:selected="${birthYear.equals(birthYearDefault)}">
                </option>
            </select>
            <span class="p-2">年</span>

            <div class="input-group has-validation">
                <select class="form-select w20" th:errorclass="is-invalid" name="birthMonth" id="monthList">
                    <option value=""></option>
                    <option th:each="birthMonth : ${monthList}"
                            th:value="${birthMonth}"
                            th:text="${birthMonth}">
                    </option>
                </select>
                <span class="invalid-feedback" th:if="${#fields.hasErrors('birthMonth')}" th:errors="*{birthMonth}"></span>
            </div>
            <span class="p-2">月</span>

            <div class="input-group has-validation">
                <select class="form-select w20" th:errorclass="is-invalid" name="birthDay" id="dayList"></select>
                <span class="invalid-feedback" th:if="${#fields.hasErrors('birthDay')}" th:errors="*{birthDay}"></span>
            </div>
            <span class="p-2">日</span>
        </div>
    </div>
</form>
```

### プルダウンを動的に作成する
日は選択した月によって30日だったり31日だったりします。
それを動的に作成するにはJavaScriptを使用します。

2023/10/7修正
@jsdtue55さんからコメントいただき、私がわかる範囲で修正いたしました（うるう年判定も不要でした）。
私はjQueryで書いておりますが、JavaScriptで書かれる方はご参考ください。
また、日だけでなく年月も全てjs側でやればいいのではないかと思い追加しています。

<details><summary>ちなみに元コードは折りたたみの中</summary><div>

日だけjQueryにしました（BingのGPT-4を参考に書き換えたもの）
上記にプラスして以下を追加すればよいです（htmlにidを追加。上記のコードはすでにid追加済）。
```create.js
/* --------------------------------
    日プルダウン生成
-------------------------------- */
$(function() {
    $('#yearList, #monthList').on('change', function() {
        var year = $('#yearList').val();
        var month = $('#monthList').val();
        if (month === "") {
            return
        }
        var daysInMonth = new Date(year, month, 0).getDate();
        $('#dayList').empty();
        for (var i = 1; i <= daysInMonth; i++) {
            $('#dayList').append($('<option>', {
                value: i,
                text: i
            }));
        }
        if (month === 2 && isLeapYear(year)) {
            $('#dayList').append($('<option>', {
                value: 29,
                text: 29
            }));
        }
    });
});

/* --------------------------------
    うるう年判定
-------------------------------- */
function isLeapYear(year) {
    if (year % 4 === 0) {
        if (year % 100 === 0){
            if (year % 400 !== 0) return false;
        }
        return true;
    }
    return false;
}
```
- new Date(year, month, 0).getDate()：指定された年月の最終日を取得し、その日数分の配列を生成
→BingのGPT-4に教えてもらって初めて知りました。現場では自作で判定と日付設定をしていたような。
※しかし、うるう年に対応していないので別途チェックが必要
</div></details>

【修正後】
```create.js
$(function() {
    // 現在
    const nowYear = new Date().getFullYear();

    // 年プルダウン作成
    $('#yearList').empty();
    for (let i = nowYear-70; i <= nowYear-20; i++) {
        $('#yearList').append($('<option>').val(i).text(i));
    }
    $('#yearList').val(nowYear-30);

    // 月プルダウン作成
    $('#monthList').empty();
    $('#monthList').append($('<option>').val('').text(''));
    for (let i = 1; i <= 12; i++) {
        $('#monthList').append($('<option>').val(i).text(i));
    }

    // 日プルダウン作成
    $('#yearList, #monthList').on('change', function() {
        const year = $('#yearList').val();
        const month = $('#monthList').val();
        if (month === '') {
            return;
        }
        const daysInMonth = new Date(year, month, 0).getDate();
        $('#dayList').empty();
        $('#dayList').append($('<option>').val('').text(''));
        for (let i = 1; i <= daysInMonth; i++) {
            $('#dayList').append($('<option>').val(i).text(i));
        }
    });
});
```
```create.html
<form method="POST" th:action="@{/test/create}" th:object="${usersForm}">
    <div class="row mb-3">
        <div class="col-12 col-md-3 text-md-end">
            <label for="birthDay" class="col-form-label">生年月日
                <span class="badge bg-secondary ms-1">必須</span>
            </label>
        </div>

        <div class="col-12 col-md-8 d-flex align-items-center">
            <select class="form-select" th:errorclass="is-invalid" name="birthYear" id="yearList"></select>
            <span class="p-2">年</span>

            <div class="input-group has-validation">
                <select class="form-select w20" th:errorclass="is-invalid" name="birthMonth" id="monthList"></select>
                <span class="invalid-feedback" th:if="${#fields.hasErrors('birthMonth')}" th:errors="*{birthMonth}"></span>
            </div>
            <span class="p-2">月</span>

            <div class="input-group has-validation">
                <select class="form-select w20" th:errorclass="is-invalid" name="birthDay" id="dayList"></select>
                <span class="invalid-feedback" th:if="${#fields.hasErrors('birthDay')}" th:errors="*{birthDay}"></span>
            </div>
            <span class="p-2">日</span>
        </div>
    </div>
</form>
```
UsersController.javaは初期処理のプルダウン作成の処理削除。
登録処理は変更なしです。
```UsersController.java
@Controller
public class UsersController {

    @Autowired
    UsersMapper usersMapper;

    // 登録画面初期表示
    @GetMapping("/user/create")
    public String create(@ModelAttribute UsersForm usersForm, Model model) {
        　// プルダウン作成の処理削除

        return "user/create";
    }

    // 登録画面登録処理
    @PostMapping("/user/create")
    public String create(@ModelAttribute @Validated(GroupOrder.class) UsersForm usersForm, BindingResult result, Model model) {
        // 入力エラーチェック
        if (result.hasErrors()) {
            // リダイレクトだと入力エラーの値が引き継がれない
            // return "redirect:/user/create";
            return create(usersForm, model, loginUser);
        }

        Users users = new Users();

        // 生年月日
        String birthYear = usersForm.getBirthYear();
        String birthMonth = usersForm.getBirthMonth();
        String birthDay = usersForm.getBirthDay();
        if (birthMonth.length() == 1) {
            birthMonth = "0" + birthMonth;
        }
        if (birthDay.length() == 1) {
            birthDay = "0" + birthDay;
        }
        users.setBirth_date(LocalDate.parse(birthYear + birthMonth + birthDay, DateTimeFormatter.ofPattern("yyyyMMdd")));
}
```

### さいごに
今回はJavaScriptがメインではなかったのですが、結果としてjQueryでプルダウンを作成しました。
JavaScriptについてはまた時間を取って学習したいと思います。