지우너
[CRUD 연습 프로젝트] Todo(1) 본문
영한쌤의 강의를 듣고 포트폴리오를 만들다가 좌절하고, 기초 중 기초라는 todo 리스트를 먼저 만들어보기로 했습니다.
todo리스트를 만드는 이유는 아래와 같습니다.
- 엔티티 관계가 복잡하지 않다(이전에 만들고자 한 구독형 블로그는 사용자, 글, 좋아요, 구독(팔로우/팔로잉) 등 여러 엔티티의 관계를 고민하는 것부터 큰 산이었다...ㅎㅎ)
- CRUD의 기초! 걸음마도 떼기 전에 뛰려고 하면 당연히 잘 될리 없다.
사실 시작할 때는 무슨 todo 리스트를 만들라고 하나 라는 마음이 있었습니다(너무 만만해 보였다...).
막상 만들기 시작하니 영한 쌤과 함께 짰던 코드를 참고하지 않고서는 코드를 짤 수 없었고, controller, repository, service, controller를 다 만든 후에도 화면을 어떻게 해야 할지 몰라 조금 헤맸습니다.
아래 글은 편의상 "-다" 체를 사용하여 작성했습니다! 양해 바랍니다:)
1. 우선은 화면부터
html 부분은 부트스트랩 문서를 많이 참고했고, js는 gpt에게 어떻게 짜는지 물어봤다.
home.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments/header :: header">
<title>Todo List</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader" />
<h1>Todo</h1>
<!-- 할 일 목록 컨테이너 -->
<div id="todo-container"></div>
<!-- 할 일 입력 -->
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="할 일을 입력하세요." aria-label="todo">
<div class="input-group-append">
<button id="button-addon2" class="btn btn-outline-secondary" type="button">추가</button>
</div>
</div>
<div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
<!-- JavaScript 파일 불러오기 -->
<script src="/js/todo.js"></script>
</body>
</html>
todo.js
document.addEventListener("DOMContentLoaded", function () {
document.getElementById("button-addon2").addEventListener("click", function () {
const todoContainer = document.getElementById("todo-container"); // 할 일 목록 컨테이너
const todoInput = document.querySelector(".form-control"); // 입력 필드
const todoText = todoInput.value.trim(); // 입력값 가져오기 (공백 제거)
if (todoText === "") {
alert("할 일을 입력하세요!"); // 빈 값 방지
return;
}
// 새로운 할 일 요소 생성
const newTodo = document.createElement("div");
newTodo.className = "form-check"; // Bootstrap 스타일 적용
newTodo.innerHTML = `
<div class="input-group mb-3">
<input class="form-check-input todo-checkbox" type="checkbox">
<div class="col input-group-append">
<label class="form-check-label todo-text">${todoText}</label>
</div>
<div class="input-group-append">
<button class="btn btn-sm btn-outline-danger remove-btn">삭제</button>
</div>
</div>
`;
// 컨테이너에 추가
todoContainer.appendChild(newTodo);
// 입력 필드 초기화
todoInput.value = "";
// 체크박스 클릭 시 취소선 적용
const checkbox = newTodo.querySelector(".todo-checkbox");
const label = newTodo.querySelector(".todo-text");
checkbox.addEventListener("change", function () {
if (checkbox.checked) {
label.style.textDecoration = "line-through";
label.style.color = "gray";
} else {
label.style.textDecoration = "none";
label.style.color = "black";
}
});
// 삭제 버튼 이벤트 추가
newTodo.querySelector(".remove-btn").addEventListener("click", function () {
newTodo.remove();
});
});
});
HomeController.java
package jiwon.todo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class HomeController {
@RequestMapping("/")
public String home(){
return "home";
}
}
2. 이제 DB에서 불러오도록 바꿔보자(Create, Read, Delete)
지금은 그냥 할 일을 입력해서 추가를 누르면 화면에 추가되는 형식으로 동작하고 있다.
- todo list는 db에서 전체 todo list 목록을 불러오도록(조회) 변경
- 할일을 입력하고 추가 버튼을 누르면 post 요청으로 db에 할 일 등록
문제
나는 home 화면에서 전체 데이터 조회, 할 일 추가 작업을 하고 싶었다. 새로운 html 페이지를 생성하여 추가하는 방식을 원하지 않았음.
gpt의 답변은 아래와 같았다.
@PostMapping("/")을 사용하려고 했는데(이 방식도 동작은 한다), 홈 화면("/")은 원래 조회 역할
홈 화면에서 실행되지만 RESTful하게 유지하기 위해 @PostMapping("/todos")로 설정하고 반환값을 홈화면으로 설정한다→"redirect:/" → home.html이 실행
RESTful한 경로(/todos)를 사용하지만 뷰는 home.html 유지한다.
버튼에 함수를 잘 연결해준 거 같은데 db에 데이터가 저장되지 않았다.
왜인가 했는데 TodoForm.java에 Setter가 없어서 문제가 생긴 거였다.
home.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/header :: header}">
<title>Todo List</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div class="container">
<div th:replace="fragments/bodyHeader :: bodyHeader" />
<h1>Todo</h1>
<!-- 할 일 목록 컨테이너 -->
<div id="todo-container">
<div th:each="todo : ${todos}" class="input-group mb-3 todo-item">
<input class="form-check-input todo-checkbox" type="checkbox" th:checked="${todo.isDone}" th:data-id="${todo.id}">
<div class="col input-group-append">
<label th:text="${todo.name}" class="form-check-label todo-text"></label>
</div>
<div class="input-group-append">
<button class="btn btn-sm btn-outline-danger remove-btn" th:data-id="${todo.id}" onclick="deleteTodo(this)">삭제</button>
</div>
</div>
</div>
<!-- 할 일 입력 -->
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="할 일을 입력하세요."
aria-label="todo" name="name"
th:value="${todoForm != null ? todoForm.name : ''}">
<div class="input-group-append">
<button id="button-addon2" class="btn btn-outline-secondary" type="button" onclick="addTodo(this)">추가</button>
</div>
</div>
<div th:replace="fragments/footer :: footer"/>
</div> <!-- /container -->
</body>
<script>
function addTodo() {
const inputField = document.querySelector("input[aria-label='todo']");
const todoText = inputField.value.trim();
if (!todoText) {
alert("할 일을 입력하세요.");
return;
}
// form 요소 생성
const form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", "/todos");
// hidden input 추가 (사용자가 입력한 값 포함)
const input = document.createElement("input");
input.setAttribute("type", "hidden");
input.setAttribute("name", "name");
input.setAttribute("value", todoText);
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
function deleteTodo(button) {
const todoId = button.getAttribute("data-id");
const form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", "/todos/" + todoId + "/delete");
document.body.appendChild(form);
form.submit();
}
</script>
</html>
HomeController.java
package jiwon.todo.controller;
import jakarta.validation.Valid;
import jiwon.todo.domain.Todo;
import jiwon.todo.service.TodoService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class HomeController {
private final TodoService todoService;
@GetMapping("/")
public String home(Model model){
List<Todo> todos = todoService.findTodos();
model.addAttribute("todos", todos);
model.addAttribute("form", new TodoForm()); // 입력 폼 추가 (검증 실패 시 데이터 유지)
return "home";
}
@PostMapping("/todos")
public String create(@Valid TodoForm form, BindingResult result, Model model) {
//️ form 데이터 검증
if (result.hasErrors()) {
model.addAttribute("todos", todoService.findTodos());
model.addAttribute("todoForm", form);
System.out.println("!!!!!! 검증 실패: " + result.getAllErrors());
return "home";
}
// DB에 저장
Todo todo = new Todo(form.getName());
todoService.saveTodo(todo);
System.out.println("!!!!!! 새로운 할 일 저장: " + todo.getName());
return "redirect:/";
}
@PostMapping("/todos/{todoId}/delete")
public String deleteTodo(@PathVariable("todoId") Long todoId) {
todoService.deleteTodo(todoId);
return "redirect:/";
}
}
TodoForm.java
package jiwon.todo.controller;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class TodoForm {
@NotEmpty(message = "할 일을 입력해주세요")
private String name;
}
3. 수정기능을 추가(Update)
할 일을 잘못 입력했을 때 삭제 후 추가해야하는 불편함
check 상태가 저장되지 않음.
- 삭제 버튼을 더보기 버튼(드롭다운)으로 변경
- 드롭다운에 수정 삭제 옵션
- check박스가 checked로 변경될 경우 status 수정
삭제버튼 → 드롭다운으로 변경
https://getbootstrap.kr/docs/5.3/components/dropdowns/#이벤트
예제 코드
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Dropdown button
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</div>
드롭다운이 보여지기는 하는데 클릭해도 작동하지 않는다...
<!-- Popper JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>
위의 이 부분을 아래와 같이 변경했더니 작동한다!
<!-- Popper JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
bootstrap.bundle.min.js는 Popper js를 포함한다고 한다.
이제 잘 작동한다!
부트 스트랩 아이콘(https://icons.getbootstrap.kr/icons/three-dots/)을 이제 사용하고 싶은데, 어떻게 사용해야 할지 모르겠다.
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-three-dots" viewBox="0 0 16 16">
<path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
</svg>
f12로 예제부분을 뜯어봤다...ㅎㅎ 그냥 넣고 싶은 부분에 넣으면 되는 것 같아서 드롭다운의 버튼 부분에 html을 넣었다.
더보기 아이콘 옆에 화살표가 보기 싫어서 어떻게 없애려나 생각했다. button 태그 class = "dropdown-toggle" 이 부분을 없애니까 드롭다운 기능은 되면서 화살표만 없어졌다.
수정 버튼을 누르면
- 아래 왼쪽 사진에서 블록으로 표시된 todo.name을 표시하는 label 부분이 할 일 입력 부분처럼 input 태그로 바뀌어야 한다.
- 현재 할 일이 form 안에 입력되어 있도록 한다.
- 더보기 버튼 대신 수정 버튼으로 변경되어야 한다.
수정버튼을 만들기에 앞서 check box가 뭔가 div로 묶여있지 않은 느낌? 따로 노는 느낌이 들어서 이 부분을 해결해줬다.
Home.HTML
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/header :: header}">
<title>Todo List</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>
<div class="container">
<div th:replace="~{fragments/bodyHeader :: bodyHeader}"/>
<h1 class="mb-4">Todo</h1>
<!-- 할 일 목록 컨테이너 -->
<div id="todo-container">
<div th:each="todo : ${todos}" class="form-check input-group mb-3 todo-item">
<!--체크박스-->
<input class="form-check-input todo-checkbox" type="checkbox" th:checked="${todo.isDone}"
th:data-id="${todo.id}">
<!--할일 이름-->
<div class="col input-group-append">
<label th:text="${todo.name}" class="form-check-label todo-text"></label>
</div>
<!--더보기 드롭다운-->
<div class="input-group-append">
<div class="dropdown">
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-three-dots" viewBox="0 0 16 16">
<path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/>
</svg>
</button>
<ul class="dropdown-menu">
<li>
<button class="dropdown-item btn update-btn" th:data-id="${todo.id}"
onclick="updateTodo(this)">수정
</button>
</li>
<li>
<button class="dropdown-item btn remove-btn" th:data-id="${todo.id}"
onclick="deleteTodo(this)">삭제
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 할 일 입력 -->
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="할 일을 입력하세요."
aria-label="todo" name="name"
th:value="${todoForm != null ? todoForm.name : ''}">
<div class="input-group-append">
<button id="button-addon2" class="btn btn-outline-secondary" type="button" onclick="addTodo(this)">추가
</button>
</div>
</div>
<div th:replace="~{fragments/footer :: footer}"/>
</div> <!-- /container -->
</body>
<script>
function addTodo() {
const inputField = document.querySelector("input[aria-label='todo']");
const todoText = inputField.value.trim();
if (!todoText) {
alert("할 일을 입력하세요.");
return;
}
// form 요소 생성
const form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", "/todos");
// hidden input 추가 (사용자가 입력한 값 포함)
const input = document.createElement("input");
input.setAttribute("type", "hidden");
input.setAttribute("name", "name");
input.setAttribute("value", todoText);
form.appendChild(input);
document.body.appendChild(form);
form.submit();
}
function updateTodo(button) {
const todoId = button.getAttribute("data-id"); // 버튼에서 직접 가져오기
const todoItem = button.closest(".todo-item"); // 가장 가까운 todo-item 컨테이너 찾기
const label = todoItem.querySelector(".todo-text"); // 기존 label 요소
const text = label.innerText; // 기존 할 일 텍스트 값 저장
// 기존 label 숨기기
label.style.display = "none";
// 새로운 input 요소 생성
const input = document.createElement("input");
input.setAttribute("type", "text");
input.setAttribute("class", "form-control todo-edit-input");
input.setAttribute("value", text);
// label 자리에 input 추가
const colDiv = todoItem.querySelector(".col");
colDiv.appendChild(input);
// 기존 더보기 드롭다운 숨기기
const dropdownContainer = todoItem.querySelector(".dropdown");
dropdownContainer.style.display = "none"; // 드롭다운 버튼 숨기기
// 저장 버튼 추가
const saveButton = document.createElement("button");
saveButton.setAttribute("type", "button");
saveButton.setAttribute("class", "btn btn-outline-primary");
saveButton.innerText = "저장";
saveButton.setAttribute("onclick", `saveUpdatedTodo(this, '${todoId}')`); // todoId 전달
// 드롭다운 대신 저장 버튼 추가
todoItem.appendChild(saveButton);
}
function saveUpdatedTodo(button, todoId) {
const todoItem = button.closest(".todo-item");
const input = todoItem.querySelector(".todo-edit-input");
const updatedText = input.value.trim();
if (!updatedText) {
alert("할 일을 입력하세요.");
return;
}
// form 요소 생성 (POST 요청)
const form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", `/todos/${todoId}/update`);
const inputField = document.createElement("input");
inputField.setAttribute("type", "hidden");
inputField.setAttribute("name", "name");
inputField.setAttribute("value", updatedText);
form.appendChild(inputField);
document.body.appendChild(form);
form.submit();
}
function deleteTodo(button) {
const todoId = button.getAttribute("data-id");
const form = document.createElement("form");
form.setAttribute("method", "post");
form.setAttribute("action", "/todos/" + todoId + "/delete");
document.body.appendChild(form);
form.submit();
}
</script>
</html>
HomeController.java
package jiwon.todo.controller;
import jakarta.validation.Valid;
import jiwon.todo.domain.Todo;
import jiwon.todo.service.TodoService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.List;
@Controller
@RequiredArgsConstructor
public class HomeController {
private final TodoService todoService;
@GetMapping("/")
public String home(Model model){
List<Todo> todos = todoService.findTodos();
model.addAttribute("todos", todos);
model.addAttribute("form", new TodoForm()); // 입력 폼 추가 (검증 실패 시 데이터 유지)
return "home";
}
@PostMapping("/todos")
public String create(@Valid TodoForm form, BindingResult result, Model model) {
//️ form 데이터 검증
if (result.hasErrors()) {
model.addAttribute("todos", todoService.findTodos());
model.addAttribute("todoForm", form);
System.out.println("!!!!!! 검증 실패: " + result.getAllErrors());
return "home";
}
// DB에 저장
Todo todo = new Todo(form.getName());
todoService.saveTodo(todo);
System.out.println("!!!!!! 새로운 할 일 저장: " + todo.getName());
return "redirect:/";
}
@PostMapping("/todos/{todoId}/delete")
public String deleteTodo(@PathVariable("todoId") Long todoId) {
todoService.deleteTodo(todoId);
return "redirect:/";
}
@PostMapping("/todos/{id}/update")
public String updateTodo(@PathVariable Long id, @RequestParam String name) {
todoService.updateTodo(id, name, false);
return "redirect:/";
}
}
참고 사이트
[HTML] form 태그 사용하는 다양한 방법 / 개념잡기
[Thymeleaf] th:each 사용해서 동적 html 생성하기 (feat.select 태그)
'Project' 카테고리의 다른 글
[Unity 3D Run] (아직 못함) 캐릭터를 가리는 오브젝트의 투명도 처리 (0) | 2023.05.27 |
---|---|
[Unity 3D Run] 해상도 설정 (1) | 2023.04.08 |
[Unity 3D Run] 게임오버/클리어 UI (3) (1) | 2023.03.25 |
[Unity 3D Run] 게임오버 UI (2) 공부+약간 수정 (0) | 2023.03.21 |
[Unity 3D Run] 게임 오버 UI(1) (0) | 2023.03.16 |