Skip to main content

Представление

В этой главе мы напишем простейший фронтенд с использованием фреймворка Bootstrap.

cmd

Исходники этого проекта можно скачать здесь.

Концепция

Для создания фронтендов написано множество самостоятельных фреймворков, для которых всё взаимодействие с контроллером осуществляется посредством REST-запросов. Но мы напишем своё представление, которое только отчасти задействует REST.

Сейчас концепция фронтенд-фреймворков находится на переломном моменте. Долгое время все популярные фреймворки динамически достраивали страницы по ситуации. Самые популярным фрейворки с такой концепцией - это VueJS и ReactJS`.

Две буквы в конце названия говорят о том, что это фреймворки написаны на языке JavaScript. Хотя в его названии и есть слово Java, в действительности его поместили туда только в целях продвижения. Да, это тоже Си-подобный язык и его команды не обрабатываются напрямую процессором, но в остальном язык JavaScript - совершенно другой язык, разрабатываемый для других целей.

Этот язык создавался для динамического изменения содержимого страницы без перезагрузки. Если страница очень большая, а изменения незначительны, то загружать всю страницу полностью неэффективно. Гораздо проще написать набор команд - скрипт, который выполнит нужные нам изменения на стороне клиента, это разгрузит и наш сервер, и канал связи.

Конечно, прогресс не стоит на месте, и теперь с помощью этого языка пишутся полноценные фреймворки.

В последнее время всё большую популярность набирают компилируемые фронтенд-фреймворки. Например, сайт, на котором вы сейчас сидите тоже написан на компилируемом фреймворке Docusaurus. Общая идея таких решений заключается в том, что мы также пишем только часть кода для страниц, однако, он не напрямую встраивается в страницу, а на его основе формируются полноценные статические страницы, которые можно просто скопировать на сервер, и они заработают.

Этим достигается качественное повышение производительности сайта, а также в целом эффективности разработки. Самые популярные компилируемые фронтенд-фреймворки - это SvelteJS и SolidJS.

Однако сейчас оба фреймворка довольно сырые, поэтому советую начать самостоятельное изучение, если будет интересно, с интепретируемых фронтенд-фреймворков, лучше всего, с VueJS.

Шаблоны

Можно было бы сразу работать полноценным над Full-Stack приложением, т.е. писать и фронтенд, и бэкенд с использованием фреймворков. Такой подход, на мой взгляд, слишком сложен для обучения.

Поэтому представление мы напишем с использованием встроенных решений фреймворка Spring.

В дальнейшем переписать готовую логику бэкэнда под фронтенд стороннего фреймворка будет гораздо проще.

В предыдущем примере мы поместили код главной страницы в папку static. Так было сделано потому что та страница никак не взаимодействовала с контроллером, не меняла своё содержимое.

Однако теперь мы будем работать не со статическими HTML-страницами. Мы будем использовать шаблоны Thymeleaf. По ссылке находится первая статья из цикла об этом решении на хабре.

Общая идея заключается в том, что часть аргументов дополняются игнорируемыми классическим HTML интерпретатором, но при этом они позволяют не только подставлять данные от контроллера в то или иное место разметки, но и генерировать новые блоки. Например, аргумент тега th:each перебирает все элементы переданной структуры.

Т.к. теперь наша страница - шаблон, то переместим её в папку templates. В создании веб-страниц тоже используется подход "Модель-Представление-Контроллер". Модель - это HTML-код сайта с разметкой тегами, контроллер - это JavaScript программы (их ещё называют js-скрипты), в представление - стили CSS(Cascading Style Sheets), переводится как каскадные таблицы стилей.

С помощью стилей определяется внешний вид элементов, описанных в HTML-файле. Раньше и стили, и js скрипты, и css стили указывали просто в HTML файле, но при современной обработке принято помещать их в отдельные файлы: js/bundle.js и css/style.css, а в самом HTML просто указывается, где их взять. Это - некий аналог импорта библиотек в программировании. При этом стили для конкретной страницы или скрипты до сих пор при необходимости прописывают прямо на странице.

Создадим эти два файла:

cmd

Теперь перепишем код главной страницы:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title th:utext="${title}">..!..</title>
<link rel="stylesheet" type="text/css" th:href="@{/css/style.css}"/>
</head>
<body>
<h1 th:utext="${title}">..!..</h1>
<h2 th:utext="${message}">..!..</h2>

<a th:href="@{/taskList}">Task List</a>

<script th:src="@{/js/bundle.js}"></script>

</body>
</html>

Т.к. у thymeleaf своя интерпретация тегов, своё пространство имён, то нужно в аргументе<html> указать, откуда можно его скачать.

Чтобы задать текст внутри того или иного тега, необходимо дописать ему аргумент th:utext="${text}. Между ${ и } указывается поле объекта модели шаблона. Об этой модели мы поговорим позже. Со стороны шаблона нужно воспринимать её как набор переменных, значения которых получены от контроллера. Используя этот параметр, мы параметризовали тег заголовка.

Чтобы показать, что внутрь тега что-то будет выведено по умолчанию я добавляю строку ..!... Хотя эта строка и не должна никогда вывестись, но так повышается читаемость кода, к тому же можно лего понять, какое значение не прогрузилось. Ведь при рисовании страницы вместо ожидаемых будут выведены эти самые символы.

Стили

С помощью тега

<link rel="stylesheet" type="text/css" href="path/to/style.css">

осуществляется импорт стиля, находящегося по адресу path/to/style.css.

Пропишем простейшие стили. Чтобы задать стиль того или иного тега, необходимо сначала написать его название, а после внутри фигурных скобок указать пары ключ-значение, задающие стиль тега. Стиль можно указать сразу для нескольких тегов, для этого вместо одного названия перечисляются названия всех тегов, стиль которых будет задан внутри фигурных скобок.

h1 {
color:#0000FF;
}

h2 {
color:#FF0000;
}

Например, с помощью ключа color задаётся цвет текста, содержащегося внутри тега. Значение составляется из трёх компонент цвета RGB, на каждую компоненту отведено по два разряда шестнадцатеричного числа.

Thymeleaf сам обрабатывает адреса, поэтому необходимо указать только путь внутри папки static и к href приписать префикс :th.

Тело страницы

Уже внутри тела страницы согласно этой же логике мы добавим импорт скрипта <script th:src="@{/js/bundle.js}"></script>. Правда при вызове скрипта нужно использовать парный тег, а у стилей для закрытия тега было достаточно просто добавить / в его конце. Обратите внимание: у скриптов аргумент, задающий путь называется src, а не href, как у стилей.

С помощью парного тега <h1>...</h1> текст превращается в заголовок первого уровня(самый большой), с помощью <h2>...</h2> - второго уровня и так до <h6>...</h6>.

Новый контроллер

Теперь напишем контроллер WebController в пакете controller, обрабатывающий запросы веб-страниц.

package com.example.demo.controller;

import lombok.extern.java.Log;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

/**
* Контроллер веб-страниц
*/
@Controller
@Log
public class WebController {
/**
* Сообщение на главной странице
*/
private static final String MAIN_PAGE_MESSAGE = "Это главная страница";
/**
* Заголовок главной страницы
*/
private static final String MAIN_PAGE_TITLE = "Здравствуйте!";

/**
* Главная страница доступна по адресам `/` и `/index`,
*
* @param model модель
* @return возвращает путь к шаблону
*/
@GetMapping(value = {"/", "/index"})
public String index(Model model) {
// задаём сообщение
model.addAttribute("message", MAIN_PAGE_MESSAGE);
// задаём заголовок
model.addAttribute("title", MAIN_PAGE_TITLE);
// возвращаем шаблон главной страницы
return "index";
}
}

Чтобы показать спринг, что новый класс является контроллером, добавлена аннотация @Controller. Также сразу добавлена аннотация @Log, чтобы был доступ к логу спринга.

Любой метод обработки запроса страницы в спринг должен быть аннотирован @GetMapping, причём в аргументе следует перечислить пути запроса, на которые отвечает этот метод. Также в качестве аргумента необходимо указать модель.

Интерфейс Model в своей сути некоторый аналог интерфейса словаря, правда он предназначен для передачи переменных от контроллера представлению, поэтому значительно отличается.

Пока что нам нужен только один его метод addAttribute(key,value). Он просто добавляет в модель пару ключ-значение. В шаблоне страницы каждая пара превратится в переменную с названием, как ключ, а значение скопируется. Правда в шаблоне с ними можно будет работать только как с thymeleaf-переменными, а не как с java-объектами.

Между ними есть существенная разница, но в простых приложениях можно считать, что происходит простойй перенос, как описано выше.

Запустим теперь сервер. Если он уже запущен, перезапустите его, чтобы применились изменения. Главная страница отображается теперь так:

cmd

Фрагменты

Пропишем теперь вторую страницу. На ней будем выводить список задач. Для начала создадим её шаблон taskList.html в папке templates. Сначала просто скопируем всё содержимое index.html и будем модифицировать код поэтапно.

Блок заголовка у нас будет таким же, как и у главной страницы. Поэтому разумно вынести этот блок в отдельный фрагмент, а потом просто импортировать его. Конечно, стандартный HTML такого не позволяет, а вот thymeleaf - вполне.

Создадим в папке шаблонов templates папку fragments, а в ней файл head.html.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title th:utext="${title}">..!..</title>
<link rel="stylesheet" type="text/css" th:href="@{/css/style.css}"/>
</head>
</html>

Теперь вместо явного указания, как строить заголовок страницы, мы просто заменяем с помощью команды th:replace. Её первый аргумент отвечает за то, где искать файл, а второй после :: - какой именно тэг надо подставить вместо имеющегося.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body>
<h1 th:utext="${title}">..!..</h1>
<h2 th:utext="${message}">..!..</h2>

<script th:src="@{/js/bundle.js}"></script>

</body>
</html>

Подключение скрипта bundle.js тоже повторяется, вынесем его во фрагмент footer.html - переводится как подвал. Обычно в него ещё добавляют рисование нижней области страницы, в нём же выполняется импорт всех скриптов. Пока что только его мы и пропишем

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="footer">
<script th:src="@{/js/bundle.js}"></script>
</div>
</html>

Теперь код страницы списка будет таким:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body>
<h1 th:utext="${title}">..!..</h1>
<h2 th:utext="${message}">..!..</h2>

<div th:replace="fragments/footer :: footer"></div>

</body>
</html>

Перепишем в той же логике главную страницу и добавим ссылку на список страниц

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body>
<h1 th:utext="${title}">..!..</h1>
<h2 th:utext="${message}">..!..</h2>

<a th:href="@{/taskList}">Список задач</a>

<div th:replace="fragments/footer :: footer"></div>

</body>
</html>

Чтобы сделать тот или иной участок страницы ссылкой, необходимо обернуть его парным тегом <a>...</a>. Путь, куда указывает ссылка, задаётся с помощью аргумента href. Его значение также, как и у стилей необходимо указать при помощи th:href.

cmd

CRUD

Основные операции с базами данных обозначаются аббревиатурой CRUD(Create, Read, Update и Delete), т.е. Создание, Чтение, Изменение, Удаление. Мы уже прописали их для REST-запросов. Пропишем их теперь для работы через веб-страницы.

Список задач

Пропишем теперь формирование списка задач, для этого в WebController свяжем сначала этот контроллер с сервисом задач.

Для этого добавим поле

    /**
* Сервис задач
*/
private final TasksService tasksService;

Также пропишем конструктор с автоматическим связыванием

    /**
* Конструктор контроллера задач
*
* @param tasksService сервис задач
*/
@Autowired
public WebController(TasksService tasksService) {
this.tasksService = tasksService;
}

Теперь напишем сам метод обработки страницы списка

  /**
* Cтраница списка задач доступна по адресу `taskList`,
*
* @param model - модель
* @return - возвращает путь к шаблону
*/
@GetMapping(value = {"/taskList"})
public String tasksList(Model model) {
// читаем все задачи
final List<Tasks> tasks = tasksService.readAll();
// добавляем их в список
model.addAttribute("tasks", tasks);
// задаём сообщение
model.addAttribute("message", LIST_PAGE_MESSAGE);
// задаём заголовок
model.addAttribute("title", LIST_PAGE_TITLE);
return "taskList";
}

Перезапустим сервер. Теперь страница списка задач загружается, но самого списка нет.

cmd

Это неудивительно. Ведь мы пока что не прописали его вывод.

Чтобы выводить списки, чаще всего используют таблицы. Таблицы в HTML создаются с помощью парного тега <table>...</table>. Внутри него необходимо каждую строку таблицы обернуть парным тегом <tr>...</tr>, а каждую ячейку дополнительно в парный тег <td>...</td>.

Например, такой код:

<table>
<tr>
<td>1</td><td>2</td>
</tr>
<tr>
<td>3</td><td>4</td>
</tr>
</table>

Соответствует таблице

12
34

Т.к. мы не знаем, сколько будет элементов в списке, таблицу заранее не заполнить. Поэтому нам понадобится команда th:each ="task : ${tasks}". Она берёт список, переданный в переменной, которая указывается внутри ${...} и перебирает все элементы в нём, подставляя их в новую переменную, которую мы укажем до :. В нашем случае эта переменная task.

Внутри тега будет добавлено всё его содержимое с подстановкой каждого значения переменной из списка.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body>
<h1 th:utext="${title}">..!..</h1>
<h2 th:utext="${message}">..!..</h2>

<table border="1">
<tr>
<th>Заголовок</th>
<th>Текст</th>
<th>Автор</th>
</tr>
<tr th:each ="task : ${tasks}">
<td th:utext="${task.title}">...</td>
<td th:utext="${task.text}">...</td>
<td th:utext="${task.author}">...</td>
</tr>
</table>

<div th:replace="fragments/footer :: footer"></div>

</body>
</html>

У первой строки я заменил тег <td> на тег <th>. С его помощью указывается, что ячейка находится в заголовке таблицы.

cmd

Добавление задачи

Теперь было бы неплохо с помощью сайта не только выводить список задач, но и добавлять новые.

Чтобы добавить новую задачу, нам понадобится создать элементы управления, объединить их в общую структуру, обработать событие клика мышью по одному из этих элементов - кнопке отправки запроса и непосредственно отправить запрос.

К счастью, в HTML есть готовое решение - это формы. Чтобы добавить форму на тело страницы, необходимо просто написать парный тег <form></form>. Всё, что мы напишем внутри будет элементами этой формы.

Каждый элемент управления обозначается одиночным тегом <input/>. В зависимости от того, какой именно элемент нам нужен, указывается соответствующий тип с помощью аргумента type. Например, текстовое поле ввода создаётся с помощью такого тега:

    <input type="text">

А кнопка отправки:

<input type="submit" value="Добавить" />

У кнопки указывается второй параметр - это текст, который будет на ней отображён.

Добавим форму в тело страницы списка задач. Для этого просто перед подвалом добавьте следующие строки:

...
<form>
Заголовок <br>
<input type="text" th:field="*{title}"/><br>
Текст<br>
<input type="text" th:field="*{text}"/><br>
Автор<br>
<input type="submit" value="Добавить"/><br>

</form>
...

Перезапустим сервер. Теперь на странице списка рисуется ещё и форма

cmd

Конечно, формы можно выводить без таблиц, но это без использования стилей они выглядят очень плохо. Расположение элементов всё равно придётся так или иначе указать. Проще всего это сделать с помощью таблицы. Перепишем форму:

...
<form>
<table>
<tr>
<th>Заголовок</th>
<td>
<input type="text" th:field="*{title}"/>
</td>
</tr>
<tr>
<th>Текст</th>
<td>
<input type="text" th:field="*{text}"/>
</td>
</tr>
<tr>
<th>Автор</th>
<td>
<input type="text" th:field="*{author}"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" value="Добавить"/>
</td>
</tr>
</table>
</form>
...

В этот раз заголовочные ячейки не в одной строке, а в одном столбце. Также я указал в ячейке кнопки, что она должна занимать две ячейки. Это делается с помощью аргумента colspan. Span переводится как охватывать, а col** - это сокращение от колонки(column)

Чтобы указать, сколько ячеек должна занять по вертикали - используйте аргумент rowspan. Row переводится как ряд, в англоязычной традиции принято называть строки таблицы рядами, поэтому и rowspan.

cmd

Также я добавил выравнивание ячейки кнопки по горизонтали с помощью аргумента align. Если вы хотите выровнять содержимое ячейки по правому краю, используйте значение аргумента align="right".

Ячейки таблицы можно выравнивать и по высоте. За это отвечает метод аргумент valign:

  • valign="top" - выравнивание по верхнему краю
  • valign="center" - выравнивание по центру краю
  • valign="bottom" - выравнивание по нижнему краю

Теперь нам осталось написать отправку запроса формой.

В самой форме необходимо указать, какой метод она использует при формировании запроса. Т.к. он изменяет данные внутри системы, он должен быть строго POST. Также необходимо указать адрес запроса с помощью аргумента th:action и какой объект формы необходимо подставить с помощью аргумента th:object.

Если бы вы писали форму на чистом HTML, то префикс th: необходимо было бы убрать, а значения просто передавать в кавычках, как с аргументами href у ссылок или стилей.

<form th:action="@{/addTask}"
th:object="${taskForm}" method="POST">

Сразу же после формы добавим блок для вывода сообщений об ошибке

<div th:if="${errorMessage}" th:utext="${errorMessage}"
style="color:red;font-style:italic;">
...
</div>

Тег <div> можно сравнить с классом Object в java сам по себе он ничего кроме как выделения некой области не обозначает, но за счёт стилей и скриптов из него можно сделать всё что угодно.

Во-первых, выводить текст сообщения стоит только, если сообщение получено. Проверка этого выполняется с помощью аргумента th:if. Если переменная передана в модели, тогда её текст задаётся внутри блока <div>. Также мы вручную прописали стиль сообщения об ошибке style="color:red;font-style:italic;".

Если стили задаются вручную, тогда фигурные скобки не нужны, а все параметры перечисляются через ;.

Переменную формы мы назвали taskForm. Теперь необходимо создать класс формы. С использованием аннотаций lombok он довольно прост. Конструкторы, геттеры и сеттеры создались сами.

package com.example.demo.form;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* Форма добавления задачи
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TaskForm {
/**
* Заголовок
*/
private String title;
/**
* Автор
*/
private String author;
/**
* Текст
*/
private String text;
}

Теперь слегка перепишем tasksList() метод:

    /**
* Cтраница списка задач доступна по адресу `taskList`,
*
* @param model - модель
* @return - возвращает путь к шаблону
*/
@GetMapping(value = {"/taskList"})
public String tasksList(Model model) {
// читаем все задачи
final List<Tasks> tasks = tasksService.readAll();
// добавляем их в список
model.addAttribute("tasks", tasks);
// задаём сообщение
model.addAttribute("message", LIST_PAGE_MESSAGE);
// задаём заголовок
model.addAttribute("title", LIST_PAGE_TITLE);
// добавляем фому
TaskForm taskForm = new TaskForm();
model.addAttribute("taskForm", taskForm);
return "taskList";
}

В конце метода я дописал создание формы и добавление её в модель.

Теперь необходимо прописать обработку POST запроса на добавление

Отправим запрос

cmd

Всё окей, новая запись добавилась

cmd

Удаление

Создание и чтение мы уже прописали. Пропишем теперь удаление. Удалять мы будем по кнопке.

Для начала просто добавим по одной ячейке в каждую строку таблицы списка задач. Внутрь ячейки поместим кнопку.

<table border="1">
<tr>
<th>Заголовок</th>
<th>Текст</th>
<th>Автор</th>
<th></th>
</tr>
<tr th:each="task : ${tasks}">
<td th:utext="${task.title}">...</td>
<td th:utext="${task.text}">...</td>
<td th:utext="${task.author}">...</td>
<td>
<button class="btn-delete" th:id="${task.id}">Удалить</button>
</td>
</tr>
</table>

У кнопки я указал два аргумента. Первый - это название класса. В первую очередь классы были созданы для того, чтобы указывать стили сразу для набора элементов. Это более узкое решение по отношению к тегам. Когда мы задаём стиль для тега, все теги рисуются так, а классы нужны, чтобы рисовать только часть.

Мы будем использовать класс для обработки нажатия, но об этом ниже. Второй аргумент - это id, он нужен для того, чтобы различать теги одного класса. Нам это понадобится, чтобы удалить сделать запрос на удаление по id. В общем виде id тега может быть любым, просто нам удобнее всего сделать его равны id задачи.

В список добавились кнопки

cmd

Сам JavaScript - это язык, он довольно ограничен в функционале и значительная часть методов и библиотек не идёт в комплекте. Чтобы обрабатывать события на веб-странице и отправлять запросы, проще всего использовать готовую библиотеку jQuery.

Чтобы её подключить, просто добавьте строчку

<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>

в фрагмент подвала. Скрипты лучше всего помещать один за другим.

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="footer">
<script th:src="@{/js/bundle.js}"></script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
</div>
</html>

Сразу после подвала добавим скрипт обработки нажатия кнопки.

<script>
$(document).ready(function() {
// здесь код скрипта, где в $ будет находиться объект, предоставляющий доступ к функциям jQuery
$(".btn-delete").click(function(event) {
alert("Hello world!");
});
});
</script>

Теперь по нажатию кнопки в бразуере выводится сообщение Hello world!.

cmd

Рассмотрим теперь сам код обработки кнопки. Весь код обработки событий, например, нажатия кнопок необходимо поместить внутрь блока

  $(document).ready(function() {
// команды
});

Чтобы обработать событие того или иного документа, используется конструкция @("селектор_элемента"). Селектор элемента - это обычно либо класс, тогда перед ним ставят ., либо id, тогда в перед ним ставится #.

В нашем случае мы указываем селектор всех элементов класса btn-delete.

Чтобы добавить обработку нажатия всех элементов, удовлетворяющих селектору, у него вызывается метод click(), который в свою очередь принимает в качестве аргумента функцию-обработчик клика. Это некий аналог анонимных классов в java.

У этой функции всего один аргумент event, в нём лежит вся информация о событии клика. Чтобы получить id элемента, по которому мы кликнули, используется команда event.target.id.

Теперь нам осталось просто сформировать POST-запрос на удаление, используя полученный id. Он делается с помощью команды $.post(...).

Метод удаления у нас уже прописан в классе TaskController, поэтому нам останется только вызвать его.

<script>
$(document).ready(function () {
// здесь код скрипта, где в $ будет находиться объект, предоставляющий доступ к функциям jQuery
$(".btn-delete").click(function (event) {
$.post("/tasks/delete/" + event.target.id, {}, function (data) {
// выводим сообщение
alert("Ответ сервера: " + data);
// перезагружаем страницу
location.reload()
});
});
});
</script>

В методе, который отправляет запрос, три аргумента. Первый - это адрес запроса, второй - его аргументы, заданные с помощью словаря. Например, если бы нам надо было передать параметр name равный test, тогда бы вызов метода выглядел так:

   $.post("/tasks/delete/" + event.target.id, {"name":"test"}, function (data) {});

Третий аргумент - это функция-обработчик ответа. В нашем случае мы выводим просто строковое представление данных в ответе. После чего перезагружаем страницу.

Перезаупустим сервер и попробуем уделить первую задачу task1.

cmd

После того, как мы нажмём во всплывающем окне "ОК", страница перезагрузится, а задача удалится.

cmd

Теперь оставим только перезагрузку страницы

<script>
$(document).ready(function () {
// здесь код скрипта, где в $ будет находиться объект, предоставляющий доступ к функциям jQuery
$(".btn-delete").click(function (event) {
$.post("/tasks/delete/" + event.target.id, {}, function (data) {
// перезагружаем страницу
location.reload()
});
});
});
</script>

Bootstrap

Для офорления элементов представления существует множество готовых фреймворков, в которых уже прописаны основные скрипты и стили. Самый распространённый - это Bootstrap

Здесь мы рассмотрим только часть его концепций и элементов управления, остальное вы можете изучить на странице документации

На данный момент у Bootstrap последняя версия - это 5.

Главная страница

Чтобы подключить бутстрап, надо добавить стиль в head.html:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<title th:utext="${title}">..!..</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" th:href="@{/css/style.css}"/>
</head>
</html>

Обратите внимание: пользовательские стили, в нашем случе style.css необходимо импортироовать после всех внешних.

И скрипт в подвал footer.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="footer">
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
<script th:src="@{/js/bundle.js}"></script>
</div>
</html>

Теперь для начала стиль у кнопки "Список задач" на главной странице.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body>
<h1 th:utext="${title}">..!..</h1>
<h2 th:utext="${message}">..!..</h2>

<a th:href="@{/taskList}" class="btn btn-success">Список задач</a>
<div th:replace="fragments/footer :: footer"></div>

</body>
</html>

Также очистим пользовательские стили. Для этого просто очистим файл style.css

cmd

Стили bootstrap задаются с помощью классов. Чтобы любой объект начал выглядеть, как кнопка, ему нужно просто добавить класс btn, чтобы кнопка была зелёного цвета, нужно добавить второй класс btn-success

cmd

Чтобы задать другой цвет, просто замените слово после btn- на то, которое соответствует нужному вам цвету

cmd

Например, красная кнопка задаётся классами btn btn-danger

Теперь оформим саму страницу.

У Bootstrap есть набор готовых страниц.

cmd

Посмотреть их демо и исходные коды можно здесь

Сделаем главную страницу такой, чтобы она просто содержала приветственный текст и меню. Для этого возьмём шаблон Cover.

Перейдите по этой ссылке и нажмите правой кнопкой мыши и выберите пункт "Просмотреть код". Я использую браузер Chrome, если у вас другой - поищите в интернет-поисковике, как в вашем браузере смотреть код.

cmd

Откроется окно просмотра кода страницы

cmd

Как видите, вся страница обёрнута тегом div. Просто скопируем его. Для этого нажмите по тегу <div> правой кнопкой и выберите пункт

"Копировать->Копировать элемент"

cmd

После этого просто вставьте скопированный код в тело страницы

cmd

Теперь добавим стили в файл стилей style.css

.btn-secondary,
.btn-secondary:hover,
.btn-secondary:focus {
color: #333;
text-shadow: none; /* Prevent inheritance from `body` */
}


.index-page {
text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5);
box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5);
}

.cover-container {
max-width: 42em;
}

.nav-masthead .nav-link {
padding: .25rem 0;
font-weight: 700;
color: rgba(255, 255, 255, .5);
background-color: transparent;
border-bottom: .25rem solid transparent;
}

.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, .25);
}

.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}

.nav-masthead .active {
color: #fff;
border-bottom-color: #fff;
}

.well {
position: absolute;
height: 100%;
width: 100%;
}

Его содержимое я просто скопировал из файла стилей, на который ссылается демонстрационная страница. Правда, если будете копировать, припишите в конце стиль класса

.well {
position: absolute;
height: 100%;
width: 100%;
}

Это нужно, чтобы элемент был гарантированно во весь экран. После этого я указал этот класс у тега <body> и ещё несколько таких же, как в примере бутстрап.

И вместо стиля для тега <body> необходимо указывать стиль для класса index-page

Также я немного видоизменил сам шаблон в соответствии с нашей задачей

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>

<body class="d-flex h-100 text-center text-white bg-dark well index-page">

<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
<header class="mb-auto">
<div>
<h3 class="float-md-start mb-0">Демо</h3>
<nav class="nav nav-masthead justify-content-center float-md-end">
<a class="nav-link active" aria-current="page" href="#">Главная</a>
<a class="nav-link" th:href="@{/taskList}">Список задач</a>
<a class="nav-link" href="#">Контакты</a>
</nav>
</div>
</header>

<main class="px-3">
<h1 th:utext="${title}">..!..</h1>
<p class="lead" th:utext="${message}">...!...</p>
<p class="lead">
<a th:href="@{/taskList}" class="btn btn-lg btn-secondary fw-bold border-white bg-white">Список задач</a>
</p>
</main>

<footer class="mt-auto text-white-50">
<p>Cover template for <a href="https://getbootstrap.com/" class="text-white">Демо</a>, by <a
href="https://ege.buran.rest/docs/project/web/view" class="text-white">@buran</a>.</p>

<div th:replace="fragments/footer :: footer"></div>
</footer>
</div>


</body>
</html>

Перезапустите сервер, теперь главная страница должна выглядеть так

cmd

Здесь с помощью тегов <header>...</header>, <main>...</main>, <footer>...</footer> выделяется заголовок страницы, её главная часть и подвал соответственно. Заголовок страницы не следует путать с заголовком html-документа. Заголовок страницы - это её верхняя отображаемая часть. В неё обычно помещают блок навигации.

У нас в блоке навигации три ссылки, на страницу контактов - просто заглушка для демонстрации возможностей. Если вы захотите добавить ещё пункт верхнего меню, просто внутри тега <nav>...</nav> добавьте ещё одну ссылку <a class="nav-link">...</a>. Без указания класса бутстрап не поймёт, что это элемент меню. Чтобы обозначить элемент меню, как текущий, ему необходимо добавить класс active.

В главной части страницы задаётся само содержимое. У нас оно особо не поменялось, только текст стал выделяться парным тегом <p>...</p>. Им выделяют абзацы.

В подвале просто выводится информация о том, что это за сайт и даются ссылки.

Список задач

Теперь пропишем страницу списка задач. Для начала выберем заголовок страницы. Для этого перейдём на демо-страницу заголовков и откроем исходный код страницы. Чтобы посмотреть исходный код нужного заголовка, просто кликните правой кнопкой мыши именно по нему и выберите пункт "Просмотреть код"

cmd

И скопируйте тег <header>...</header>.

Теперь добавим в тег <body> списка задач класс well, чтобы он тоже растягивался на всю страницу.

Теперь внутри тега просто вставим скопированный код

cmd

Здесь элементы меню задаются схожим образом. Только необходимо указать дополнительные классы.

Также по умолчанию добавлена форма и кнопки входа/регистрации. Пока что они будут просто заглушками.

Форма поиска нам не пригодится, удалим её, а также немного перепишем меню:

<header class="p-3 bg-dark text-white">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
<svg class="bi me-2" width="40" height="32" role="img" aria-label="Bootstrap"><use xlink:href="#bootstrap"></use></svg>
</a>

<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/" class="nav-link px-2 text-white">Главная</a></li>
<li><a href="/taskList" class="nav-link px-2 text-secondary">Список задач</a></li>
<li><a href="#" class="nav-link px-2 text-white">Контакты</a></li>
</ul>

<div class="text-end">
<button type="button" class="btn btn-outline-light me-2">Вход</button>
<button type="button" class="btn btn-warning">Регистрация</button>
</div>
</div>
</div>
</header>

Здесь активные и неактивные элементы меню задаются немного по-другому. Активный элемент обозначается классом text-secondary, а неактивный - text-white.

Перезапустим сервер и откроем страницу списка задач

cmd

Теперь пропишем тело страницы. Образец тела возьмём из примера страницы альбома.

Скопируйте тэг <main>

cmd

И вставьте его сразу после блока заголовка страницы

cmd

Получилось довольно много, но из всего кода нам нужны только блок <section>...</section> и оболочка карточек альбомов


<main>

<section class="py-5 text-center container">
<div class="row py-lg-5">
<div class="col-lg-6 col-md-8 mx-auto">
<h1 th:utext="${title}">..!..</h1>
<p class="lead text-muted" th:utext="${message}">..!..</p>
<p>
<a href="#" class="btn btn-primary my-2">Main call to action</a>
<a href="#" class="btn btn-secondary my-2">Secondary action</a>
</p>
</div>
</div>
</section>

<div class="album py-5 bg-light">
<div class="container">
<div class="row">
<div class="col">
<table>
<tr>
<th>Заголовок</th>
<th>Текст</th>
<th>Автор</th>
<th></th>
</tr>
<tr th:each="task : ${tasks}">
<td th:utext="${task.title}">...</td>
<td th:utext="${task.text}">...</td>
<td th:utext="${task.author}">...</td>
<td>
<button class="btn btn-danger btn-delete" th:id="${task.id}">Удалить</button>
</td>
</tr>
</table>
</div>
<div class="col">
<form th:action="@{/addTask}"
th:object="${taskForm}" method="POST">
<table>
<tr>
<th>Заголовок</th>
<td>
<input class="form-control" type="text" th:field="*{title}"/>
</td>
</tr>
<tr>
<th>Текст</th>
<td>
<input class="form-control" type="text" th:field="*{text}"/>
</td>
</tr>
<tr>
<th>Автор</th>
<td>
<input class="form-control" type="text" th:field="*{author}"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" class="btn btn-success" value="Добавить"/>
</td>
</tr>
</table>
</form>
</div>
</div>
</div>
</div>
</main>

Блок <section>...</section> отвечает за верхнюю часть тела страницы, второй блок <div class="album py-5 bg-light">...</div> - за нижнюю.

С помощью тега <div class="container">...</div> обозначается контейнер - просто область страницы с отступами, а внутри него мы создаём таблицу разметки бутстрапа.

Чтобы обозначить строку таблицы разметки, её необходимо обернуть в тег <div class="row">...</div>, а ячейку - в тег <div class="col">...</div>

У нас в таблице разметки одна строка с двумя ячейками: в первой список задач, во второй - форма добавления.

Также я поменял стили кнопок удаления за счёт того, что добавил им классы btn btn-danger, кнопке отправки формы я добавил классы btn btn-success, а каждому полю ввода form-control.

Перезапустим сервер:

cmd

Теперь нам осталось добавить подвал. Подвалов по умолчанию на сайте бутстрапа практически нет, лучше выбрать его из готовых на сторонних сайтах. Например, можно подобрать подвал здесь.

Я выбрал этот

cmd

Немного перепишем его и добавим сразу после блока <main>...</main>:

<footer class="bg-dark text-center text-white mt-auto">
<!-- Grid container -->
<div class="container p-4 pb-0">
<!-- Section: CTA -->
<section class="">
<p class="d-flex justify-content-center align-items-center">
<span class="me-3">Это демонстрационный сайт </span>
<button type="button" class="btn btn-outline-light btn-rounded">
Регистрация
</button>
</p>
</section>
<!-- Section: CTA -->
</div>
<!-- Grid container -->

<!-- Copyright -->
<div class="text-center p-3" style="background-color: rgba(0, 0, 0, 0.2);">
© 2022 Copyright:
<a class="text-white" href="https://ege.buran.rest/docs/project/web/view">Буран</a>
</div>
<!-- Copyright -->
</footer>

Чтобы подвал был закреплён в нижней части страницы, я добавил ему класс mt-auto, а также добавил классы d-flex flex-column min-vh-100 тегу <body>

Перезапустим сервер. Теперь подвал и подвал на странице рисуется, как надо.

cmd

Модальные окна

В вебе есть два вида всплывающих окон. Первый - это обычные всплывающие окна, они порождаются браузером и отображаются в его верхней части. Вторая - это модальные окна. Они представляют собой мини-страницы, которые отображаются поверх основной.

В бутстрап прописано готовое решение для модального окна. Подробнее здесь

Чтобы создать модальное окно, достаточно добавить этот код в конец <body>...</body>

<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Modal title</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
...
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>

Также поменяем кнопки в верхней части страницы

    <p>
<button type="button" class="btn btn-primary my-2" data-bs-toggle="modal" data-bs-target="#exampleModal">
Добавить задачу
</button>
<a href="/" class="btn btn-secondary my-2">На главную</a>
</p>

Теперь по клике на кнопку Добавить задачу появляется модально окно.

cmd

Оно тоже разделено на три блока: заголовок, тело и подвал.

Перенесём в модальное окно форму и немного поменяем заголовок и подвал.

Теперь весь код страницы списка задач будет таким:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body class="well d-flex flex-column min-vh-100">

<header class="p-3 bg-dark text-white">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
<svg class="bi me-2" width="40" height="32" role="img" aria-label="Bootstrap">
<use xlink:href="#bootstrap"></use>
</svg>
</a>

<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/" class="nav-link px-2 text-white">Главная</a></li>
<li><a href="/taskList" class="nav-link px-2 text-secondary">Список задач</a></li>
<li><a href="#" class="nav-link px-2 text-white">Контакты</a></li>
</ul>


<button type="button" class="btn btn-outline-light me-2">Вход</button>
<button type="button" class="btn btn-warning">Регистрация</button>
</div>
</div>
</header>

<main>

<section class="py-5 text-center container">
<div class="row py-lg-5">
<div class="col-lg-6 col-md-8 mx-auto">
<h1 th:utext="${title}">..!..</h1>
<p class="lead text-muted" th:utext="${message}">..!..</p>
<p>
<button type="button" class="btn btn-primary my-2" data-bs-toggle="modal"
data-bs-target="#exampleModal">
Добавить задачу
</button>
<a href="/" class="btn btn-secondary my-2">На главную</a>
</p>
</div>
</div>
</section>

<div class="album py-5 bg-light">
<div class="container">
<div class="row" >
<div class="col-8">
<table class="table table-striped">
<thead>
<tr>
<th>Заголовок</th>
<th>Текст</th>
<th>Автор</th>
<th>Удаление</th>
</tr>
</thead>
<tr th:each="task : ${tasks}">
<td th:utext="${task.title}">...</td>
<td th:utext="${task.text}">...</td>
<td th:utext="${task.author}">...</td>
<td>
<button class="btn btn-danger btn-delete" th:id="${task.id}">Удалить</button>
</td>
</tr>
</table>
</div>
<div class="col">
</div>
</div>
</div>
</div>

<div th:if="${errorMessage}" th:utext="${errorMessage}" class="alert alert-danger" role="alert">
...!...
</div>

</main>


<footer class="bg-dark text-center text-white mt-auto">
<!-- Grid container -->
<div class="container p-4 pb-0">
<!-- Section: CTA -->
<section class="">
<p class="d-flex justify-content-center align-items-center">
<span class="me-3">Это демонстрационный сайт </span>
<button type="button" class="btn btn-outline-light btn-rounded">
Регистрация
</button>
</p>
</section>
<!-- Section: CTA -->
</div>
<!-- Grid container -->

<!-- Copyright -->
<div class="text-center p-3" style="background-color: rgba(0, 0, 0, 0.2);">
© 2022 Copyright:
<a class="text-white" href="https://ege.buran.rest/docs/project/web/view">Буран</a>
</div>
<!-- Copyright -->

<div th:replace="fragments/footer :: footer"></div>
</footer>


<!-- Modal -->
<div class="modal fade" id="exampleModal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form th:action="@{/addTask}"
th:object="${taskForm}" method="POST">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">Новая задача</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<table align="center">
<tr>
<th>Заголовок</th>
<td>
<input class="form-control" type="text" th:field="*{title}"/>
</td>
</tr>
<tr>
<th>Текст</th>
<td>
<input class="form-control" type="text" th:field="*{text}"/>
</td>
</tr>
<tr>
<th>Автор</th>
<td>
<input class="form-control" type="text" th:field="*{author}"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
</td>
</tr>
</table>

</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<input type="submit" class="btn btn-primary" value="Добавить"/>
</div>

</form>
</div>
</div>
</div>


<script>
$(document).ready(function () {
// здесь код скрипта, где в $ будет находиться объект, предоставляющий доступ к функциям jQuery
$(".btn-delete").click(function (event) {
$.post("/tasks/delete/" + event.target.id, {}, function (data) {
// перезагружаем страницу
location.reload()
});
});
});
</script>

</body>
</html>

Теперь модальное окно будет выглядеть так

cmd

Также я немного переписал таблицу, чтобы она отображалась красивее

cmd

Изменение задачи

Теперь нам осталось только написать редактирование задачи. Для этого создадим ещё одну страницу, которая по id будет отображать страницу с формой, заполненной её данными.

Для начала добавим кнопку редактирования в таблицу задач:

    <td>
<a th:href="@{/tasks/{id}(id=${task.id})}">
<button class="btn btn-info">Редактировать</button>
</a>
</td>

Здесь используется построение строки на основе маски. В первой части указывается выражение, в фигурных скобках указываются переменные, которые необходимо поставить, а во второй - сами переменны.

cmd

Теперь пропишем GET-запрос в веб-контроллере WebController.java

    /**
* Посмотреть задачу
*
* @param model - модель
* @return - возвращает путь к шаблону
*/
@GetMapping(value = {"/tasks/{id}"})
public String showTask(Model model, @PathVariable(name = "id") int id) {
// читаем все задачи
Tasks task = tasksService.read(id);
// задаём сообщение
model.addAttribute("message", "Задача: " + id);
// задаём заголовок
model.addAttribute("title", EDIT_PAGE_TITLE);
// добавляем фому
TaskForm taskForm = new TaskForm();
taskForm.setText(task.getText());
taskForm.setAuthor(task.getAuthor());
taskForm.setTitle(task.getTitle());
model.addAttribute("taskForm", taskForm);
model.addAttribute("taskId", id);
return "taskEdit";
}

Также нам понадобится обработать POST-запрос для обработки формы редактирования

    /**
* Изменить задачу
*
* @param model - модель
* @param taskForm - форма с задачей
* @return - возвращает путь к шаблону
*/
@PostMapping(value = {"/tasks/{id}"})
public String saveTask(Model model, @ModelAttribute("taskForm") TaskForm taskForm,
@PathVariable(name = "id") int id) {
// получаем значения из формы
String author = taskForm.getAuthor();
String text = taskForm.getText();
String title = taskForm.getTitle();

// если все элементы формы получены и непустые
if (author != null && author.length() > 0 && text != null && text.length() > 0 && title != null && title.length() > 0) {
// создаём новую задачу
Tasks task = new Tasks();
task.setAuthor(author);
task.setText(text);
task.setTitle(title);
// добавляем задачу в БД
tasksService.update(task, id);
// переходим к списку задач
return "redirect:/taskList";
}

model.addAttribute("errorMessage", "Форма заполнена некорректно");
return "redirect:/taskList";
}

Теперь создадим в папке resources/templates новую страницу taskEdit.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/head :: head"></head>
<body class="well d-flex flex-column min-vh-100">

<header class="p-3 bg-dark text-white">
<div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mb-2 mb-lg-0 text-white text-decoration-none">
<svg class="bi me-2" width="40" height="32" role="img" aria-label="Bootstrap">
<use xlink:href="#bootstrap"></use>
</svg>
</a>

<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
<li><a href="/" class="nav-link px-2 text-white">Главная</a></li>
<li><a href="/taskList" class="nav-link px-2 text-white">Список задач</a></li>
<li><a href="#" class="nav-link px-2 text-white">Контакты</a></li>
</ul>

<button type="button" class="btn btn-outline-light me-2">Вход</button>
<button type="button" class="btn btn-warning">Регистрация</button>
</div>
</div>
</header>

<main>
<section class="py-5 text-center container">
<div class="row py-lg-5">
<div class="col-lg-6 col-md-8 mx-auto">
<h1 th:utext="${title}">..!..</h1>
<p class="lead text-muted" th:utext="${message}">..!..</p>
<form th:action="@{/tasks/{id}(id=${taskId})}"
th:object="${taskForm}" method="POST">
<table align="center">
<tr>
<th>Заголовок</th>
<td>
<input class="form-control" type="text" th:field="*{title}"/>
</td>
</tr>
<tr>
<th>Текст</th>
<td>
<input class="form-control" type="text" th:field="*{text}"/>
</td>
</tr>
<tr>
<th>Автор</th>
<td>
<input class="form-control" type="text" th:field="*{author}"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
</td>
</tr>
</table>
<br>
<input type="submit" class="btn btn-primary" value="Изменить"/>
</form>
</div>
</div>
</section>


<div th:if="${errorMessage}" th:utext="${errorMessage}" class="alert alert-danger" role="alert">
...!...
</div>

</main>


<footer class="bg-dark text-center text-white mt-auto">
<!-- Grid container -->
<div class="container p-4 pb-0">
<!-- Section: CTA -->
<section class="">
<p class="d-flex justify-content-center align-items-center">
<span class="me-3">Это демонстрационный сайт </span>
<button type="button" class="btn btn-outline-light btn-rounded">
Регистрация
</button>
</p>
</section>
<!-- Section: CTA -->
</div>
<!-- Grid container -->

<!-- Copyright -->
<div class="text-center p-3" style="background-color: rgba(0, 0, 0, 0.2);">
© 2022 Copyright:
<a class="text-white" href="https://ege.buran.rest/docs/project/web/view">Буран</a>
</div>
<!-- Copyright -->

<div th:replace="fragments/footer :: footer"></div>
</footer>

</body>
</html>

Перезапустим сервер, теперь можно из списка задач перейти к редактированию выбранной

cmd

Изменим пользователя и отправим форму. После ответа сервера нас автоматически перенаправит к списку задач.

cmd

Всё работает так, как задумывалось. Пользователь изменился. Все операции CRUD реализованы.

Исходники этого проекта можно скачать здесь.