티스토리 뷰
본 포스팅은 백기선님의 스프링과 JPA 기반 웹 애플리케이션 개발 강의를 참고하여 작성하였습니다.
소스 코드는 여기 있습니다. (commit hash: c5c6507)> git clone https://github.com/lcalmsky/spring-boot-app.git > git checkout c5c6507
ℹ️ squash merge를 사용해 기존 branch를 삭제하기로 하여 앞으로는 commit hash로 포스팅 시점의 소스 코드를 공유할 예정입니다.
Overview
관심 주제 등록을 위한 뷰를 구현합니다.
뷰 구현에 앞서 구현에서 누락된 부분을 먼저 수정하겠습니다.
Tag Entity 수정
Tag
Entity에 컬럼 정보를 추가해줍니다.
/src/main/java/io/lcalmsky/app/account/domain/entity/Tag.java
package io.lcalmsky.app.account.domain.entity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString
public class Tag {
@Id
@GeneratedValue
private Long id;
@Column(unique = true, nullable = false)
private String title;
}
Tag
는 각각 유니크해야하고 값이 꼭 존재해야 합니다.
설정 수정
DB 쿼리를 추적하기 위해 설정을 추가합니다.
spring:
datasource:
username: sa
password:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:test
h2.console:
enabled: true
jpa:
hibernate:
ddl-auto: create-drop # 매 번 테이블 application을 종료할 때 drop, 시작할 때 create
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate:
SQL: debug
type.descriptor.sql.BasicBinder: trace # 로그 레벨 설정 추가
라이브러리 설치
태그를 표현하고 사용하기위해 적합한 라이브러리를 설치합니다.
프로젝트 루트 디렉토리에서 static 리소스 디렉토리로 이동한 뒤 npm
을 이용해 라이브러리를 추가합니다.
> cd /src/main/resources/static
> npm install @yaireo/tagify
엔드포인트 수정
관심 주제 뷰로 이동할 수 있도록 SettingsController
를 수정해줍니다.
/src/main/java/io/lcalmsky/app/settings/controller/SettingsController.java
// 생략
public class SettingsController {
// 생략
static final String SETTINGS_TAGS_VIEW_NAME = "settings/tags";
static final String SETTINGS_TAGS_URL = "/" + SETTINGS_TAGS_VIEW_NAME;
// 생략
@GetMapping(SETTINGS_TAGS_URL)
public String updateTags(@CurrentUser Account account, Model model) {
model.addAttribute(account);
return SETTINGS_TAGS_VIEW_NAME;
}
}
이전에 작성했던 것들과 동일하게 뷰 이름과 URL을 상수로 지정해놓고, 뷰로 이동하는 요청을 다룰 수 있는 메서드를 추가하였습니다.
SettingsController.java 전체 보기
package io.lcalmsky.app.settings.controller;
import io.lcalmsky.app.account.application.AccountService;
import io.lcalmsky.app.account.domain.entity.Account;
import io.lcalmsky.app.account.support.CurrentUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.validation.Valid;
@Controller
@RequiredArgsConstructor
public class SettingsController {
static final String SETTINGS_PROFILE_VIEW_NAME = "settings/profile";
static final String SETTINGS_PROFILE_URL = "/" + SETTINGS_PROFILE_VIEW_NAME;
static final String SETTINGS_PASSWORD_VIEW_NAME = "settings/password";
static final String SETTINGS_PASSWORD_URL = "/" + SETTINGS_PASSWORD_VIEW_NAME;
static final String SETTINGS_NOTIFICATION_VIEW_NAME = "settings/notification";
static final String SETTINGS_NOTIFICATION_URL = "/" + SETTINGS_NOTIFICATION_VIEW_NAME;
static final String SETTINGS_ACCOUNT_VIEW_NAME = "settings/account";
static final String SETTINGS_ACCOUNT_URL = "/" + SETTINGS_ACCOUNT_VIEW_NAME;
static final String SETTINGS_TAGS_VIEW_NAME = "settings/tags";
static final String SETTINGS_TAGS_URL = "/" + SETTINGS_TAGS_VIEW_NAME;
private final AccountService accountService;
private final PasswordFormValidator passwordFormValidator;
private final NicknameFormValidator nicknameFormValidator;
@InitBinder("passwordForm")
public void passwordFormValidator(WebDataBinder webDataBinder) {
webDataBinder.addValidators(passwordFormValidator);
}
@InitBinder("nicknameForm")
public void nicknameFormInitBinder(WebDataBinder webDataBinder) {
webDataBinder.addValidators(nicknameFormValidator);
}
@GetMapping(SETTINGS_PROFILE_URL)
public String profileUpdateForm(@CurrentUser Account account, Model model) {
model.addAttribute(account);
model.addAttribute(Profile.from(account));
return SETTINGS_PROFILE_VIEW_NAME;
}
@PostMapping(SETTINGS_PROFILE_URL)
public String updateProfile(@CurrentUser Account account, @Valid Profile profile, Errors errors, Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
return SETTINGS_PROFILE_VIEW_NAME;
}
accountService.updateProfile(account, profile);
attributes.addFlashAttribute("message", "프로필을 수정하였습니다.");
return "redirect:" + SETTINGS_PROFILE_URL;
}
@GetMapping(SETTINGS_PASSWORD_URL)
public String passwordUpdateForm(@CurrentUser Account account, Model model) {
model.addAttribute(account);
model.addAttribute(new PasswordForm());
return SETTINGS_PASSWORD_VIEW_NAME;
}
@PostMapping(SETTINGS_PASSWORD_URL)
public String updatePassword(@CurrentUser Account account, @Valid PasswordForm passwordForm, Errors errors, Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
return SETTINGS_PASSWORD_VIEW_NAME;
}
accountService.updatePassword(account, passwordForm.getNewPassword());
attributes.addFlashAttribute("message", "패스워드를 변경했습니다.");
return "redirect:" + SETTINGS_PASSWORD_URL;
}
@GetMapping(SETTINGS_NOTIFICATION_URL)
public String notificationForm(@CurrentUser Account account, Model model) {
model.addAttribute(account);
model.addAttribute(NotificationForm.from(account));
return SETTINGS_NOTIFICATION_VIEW_NAME;
}
@PostMapping(SETTINGS_NOTIFICATION_URL)
public String updateNotification(@CurrentUser Account account, @Valid NotificationForm notificationForm, Errors errors, Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
return SETTINGS_NOTIFICATION_URL;
}
accountService.updateNotification(account, notificationForm);
attributes.addFlashAttribute("message", "알림설정을 수정하였습니다.");
return "redirect:" + SETTINGS_NOTIFICATION_URL;
}
@GetMapping(SETTINGS_ACCOUNT_URL)
public String nicknameForm(@CurrentUser Account account, Model model) {
model.addAttribute(account);
model.addAttribute(new NicknameForm(account.getNickname()));
return SETTINGS_ACCOUNT_VIEW_NAME;
}
@PostMapping(SETTINGS_ACCOUNT_URL)
public String updateNickname(@CurrentUser Account account, @Valid NicknameForm nicknameForm, Errors errors, Model model, RedirectAttributes attributes) {
if (errors.hasErrors()) {
model.addAttribute(account);
return SETTINGS_ACCOUNT_VIEW_NAME;
}
accountService.updateNickname(account, nicknameForm.getNickname());
attributes.addFlashAttribute("message", "닉네임을 수정하였습니다.");
return "redirect:" + SETTINGS_ACCOUNT_URL;
}
@GetMapping(SETTINGS_TAGS_URL)
public String updateTags(@CurrentUser Account account, Model model) {
model.addAttribute(account);
return SETTINGS_TAGS_VIEW_NAME;
}
}
fragments 수정
fragments에 위에서 설치한 라이브러리의 css
파일을 읽어올 수 있게 stylesheet
를 추가합니다.
그리고 라이브러리를 그냥 사용해도 되지만 border
, padding
, margin
이 없는 게 더 이쁘기 때문에 .tagify-outisde 클래스를 만들어 해당 값을 수정합니다.
추가로 info
레벨을 나타낼 수 있는 symbol
이 누락되어 추가해주었습니다.
/src/main/resources/templates/fragments.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:fragment="head">
<meta charset="UTF-8">
<title>Webluxible</title>
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="/node_modules/font-awesome/css/font-awesome.min.css"/>
<link rel="stylesheet" href="/node_modules/@yaireo/tagify/dist/tagify.css"/> <!--추가-->
<script src="/node_modules/jdenticon/dist/jdenticon.min.js"></script>
<script src="/node_modules/jquery/dist/jquery.min.js"></script>
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<style>
.container {
max-width: 100%;
}
/*클래스 생성 및 값 설정*/
.tagify-outside {
border: 0;
padding: 0;
margin: 0;
}
</style>
</head>
<!--생략-->
<svg th:fragment="svg-symbols" xmlns="http://www.w3.org/2000/svg" style="display: none;">
<!--info 레벨 추가-->
<symbol id="info-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</symbol>
<symbol id="check-circle-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</symbol>
<symbol id="exclamation-triangle-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</symbol>
<symbol id="exclamation-triangle-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</symbol>
</svg>
<!--생략-->
</html>
fragments.html 전체 보기
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head th:fragment="head">
<meta charset="UTF-8">
<title>Webluxible</title>
<link rel="stylesheet" href="/node_modules/bootstrap/dist/css/bootstrap.min.css"/>
<link rel="stylesheet" href="/node_modules/font-awesome/css/font-awesome.min.css"/> <!--font-awesome 추가-->
<link rel="stylesheet" href="/node_modules/@yaireo/tagify/dist/tagify.css"/>
<script src="/node_modules/jdenticon/dist/jdenticon.min.js"></script> <!--jdenticon script 추가-->
<script src="/node_modules/jquery/dist/jquery.min.js"></script> <!--index.html에서 옮김-->
<script src="/node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <!--index.html에서 옮김-->
<style>
.container {
max-width: 100%;
}
.tagify-outside {
border: 0;
padding: 0;
margin: 0;
}
</style>
</head>
<footer th:fragment="footer">
<div class="row justify-content-center">
<small class="d-flex mb-3 text-muted" style="justify-content: center">Webluxible © 2021</small>
</div>
</footer>
<div th:fragment="settings-menu (currentMenu)" class="list-group">
<a class="list-group-item list-group-item-action" th:classappend="${currentMenu == 'profile'} ? active" href="#"
th:href="@{/settings/profile}">프로필</a>
<a class="list-group-item list-group-item-action" th:classappend="${currentMenu == 'password'} ? active" href="#"
th:href="@{/settings/password}">패스워드</a>
<a class="list-group-item list-group-item-action" th:classappend="${currentMenu == 'notification'} ? active"
href="#" th:href="@{/settings/notification}">알림 설정</a>
<a class="list-group-item list-group-item-action" th:classappend="${currentMenu == 'tags'} ? active" href="#"
th:href="@{/settings/tags}">관심 주제</a>
<a class="list-group-item list-group-item-action" th:classappend="${currentMenu == 'zones'} ? active" href="#"
th:href="@{/settings/zones}">활동 지역</a>
<a class="list-group-item list-group-item-action list-group-item-danger"
th:classappend="${currentMenu == 'account'}? active" href="#" th:href="@{/settings/account}">계정</a>
</div>
<nav th:fragment="navigation-bar" class="navbar navbar-expand-sm navbar-dark bg-dark">
<a class="navbar-brand" href="/" th:href="@{/}">
<img src="/images/logo.png" width="30" height="30">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<form th:action="@{/search/study}" class="form-inline" method="get">
<input class="form-control mr-sm-2" name="keyword" type="search" placeholder="스터디 찾기"
aria-label="Search"/>
</form>
</li>
</ul>
<ul class="navbar-nav justify-content-end">
<li class="nav-item" sec:authorize="!isAuthenticated()">
<a class="nav-link" th:href="@{/login}">로그인</a>
</li>
<li class="nav-item" sec:authorize="!isAuthenticated()">
<a class="nav-link" th:href="@{/sign-up}">가입</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" th:href="@{/notifications}">
<i class="fa fa-bell-o" aria-hidden="true"></i> <!--"알림" 문자열을 종 모양 아이콘으로 수정-->
</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link btn btn-outline-primary" th:href="@{/notifications}">
<i class="fa fa-plus" aria-hidden="true"></i> 스터디 개설 <!--"스터디 개설" 문자열 앞에 플러스 아이콘 추가-->
</a>
</li>
<li class="nav-item dropdown" sec:authorize="isAuthenticated()">
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<!-- 이미지가 존재하면 이미지를, 그렇지 않으면 아바타를 보여주도록 수정(시작) -->
<svg th:if="${#strings.isEmpty(account?.profile?.image)}"
th:data-jdenticon-value="${#authentication.name}" width="24" height="24"
class="rounded border bg-light"></svg><!--"프로필" 대신 아바타 이미지를 보여줌-->
<img th:if="${!#strings.isEmpty(account?.profile?.image)}"
th:src="${account.profile.image}" width="24" height="24" class="rounded border"/>
<!-- 이미지가 존재하면 이미지를, 그렇지 않으면 아바타를 보여주도록 수정(끝) -->
</a>
<div class="dropdown-menu dropdown-menu-sm-right" aria-labelledby="userDropdown">
<h6 class="dropdown-header">
<span sec:authentication="name">Username</span>
</h6>
<a class="dropdown-item" th:href="@{'/profile/' + ${#authentication.name}}">프로필</a>
<a class="dropdown-item">스터디</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#" th:href="@{'/settings/profile'}">설정</a>
<form class="form-inline my-2 my-lg-0" action="#" th:action="@{/logout}" method="post">
<button class="dropdown-item" type="submit">로그아웃</button>
</form>
</div>
</li>
</ul>
</div>
</nav>
<script type="application/javascript" th:fragment="form-validation">
(function () {
'use strict';
window.addEventListener('load', function () {
// Fetch all the forms we want to apply custom Bootstrap validation styles to
const forms = document.getElementsByClassName('needs-validation');
// Loop over them and prevent submission
Array.prototype.filter.call(forms, function (form) {
form.addEventListener('submit', function (event) {
if (form.checkValidity() === false) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated')
}, false)
})
}, false)
}())
</script>
<svg th:fragment="svg-symbols" xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="info-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</symbol>
<symbol id="check-circle-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
</symbol>
<symbol id="exclamation-triangle-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</symbol>
<symbol id="exclamation-triangle-fill" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</symbol>
</svg>
<svg th:fragment="symbol-info" class="bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Info:">
<use xlink:href="#info-fill"/>
</svg>
<svg th:fragment="symbol-success" class="bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Success:">
<use xlink:href="#check-circle-fill"/>
</svg>
<svg th:fragment="symbol-warning" class="bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Warning:">
<use xlink:href="#exclamation-triangle-fill"/>
</svg>
<svg th:fragment="symbol-danger" class="bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Danger:">
<use xlink:href="#exclamation-triangle-fill"/>
</svg>
</html>
뷰 작성
password.html
파일을 복사하여 tags.html
파일을 생성합니다.
password.html 파일 내에 9번 째 라인에 currentMenu를 표시하는 부분에 오타가 있었습니다.
<div th:replace="fragments.html::settings-menu (currentMenu='password')"></div>
이렇게 수정해야 합니다.
기존에 구현했던 것과 유사하게 구현하고 아래 스크립트 부분을 수정합니다.
/src/main/resources/templates/settings/tags.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head th:replace="fragments.html :: head"></head>
<body class="bg-light">
<div th:replace="fragments.html :: navigation-bar"></div>
<svg th:replace="fragments.html::svg-symbols"/>
<div class="container">
<div class="row mt-5 justify-content-center">
<div class="col-2">
<div th:replace="fragments.html::settings-menu (currentMenu='tags')"></div>
</div>
<div class="col-8">
<div class="row">
<h2 class="col-12">관심있는 스터디 주제</h2>
</div>
<div class="row">
<div class="col-12">
<div class="alert alert-info" role="alert">
<svg th:replace="fragments.html::symbol-info"/>
참여하고 싶은 스터디 주제를 입력해 주세요. 해당 주제의 스터디가 생기면 알림을 받을 수 있습니다. 태그를 입력하고 쉼표 또는 엔터를 입력하세요.
</div>
<input id="tags" type="text" name="tags" class="tagify-outside" aria-describedby="tagHelp"/>
</div>
</div>
</div>
</div>
</div>
<script src="/node_modules/@yaireo/tagify/dist/tagify.min.js"></script>
<script type="application/javascript">
$(function () {
function tagRequest(url, tagTitle) {
$.ajax({
dataType: "json",
autocomplete: {
enabled: true,
rightKey: true
},
contentType: "application/json; charset=utf-8",
method: "POST",
url: "/settings/tags" + url,
data: JSON.stringify({'tagTitle': tagTitle})
}).done(function (data, status) {
console.log("${data} and status is #{status}")
})
}
function onAdd(e) {
tagRequest("/add", e.detail.data.value);
}
function onRemove(e) {
tagRequest("/remove", e.detail.data.value);
}
let tagInput = document.querySelector("#tags");
let tagify = new Tagify(tagInput, {
pattern: /^.{0,20}$/,
dropdown: {
enabled: 1
}
});
tagify.on("add", onAdd);
tagify.on("remove", onRemove);
tagify.DOM.input.classList.add('form-control');
tagify.DOM.scope.parentNode.insertBefore(tagify.DOM.input, tagify.DOM.scope);
});
</script>
</body>
</html>
스크립트의 경우 css를 적용하는 스크립트와, tagify 라이브러리를 사용하는 스크립트 두 가지가 있습니다.
자세한 설명은 여기를 참조하시면 됩니다.
간단히 설명하자면, tagify
가 적용된 컴포넌트에 액션이 발생할 때 실행될 function
을 각각 구현했고, ajax
요청으로 서버와 통신하도록 하였습니다.
테스트
애플리케이션을 실행하고 [가입] - [프로필] - [프로필 수정] - [관심 주제]로 진입하면 아래와 같은 뷰를 확인할 수 있습니다.
빈 칸에 원하는 주제를 입력하고 엔터나 콤마를 입력하면 아래 그림처럼 태그가 추가되는 것을 확인할 수 있습니다.
이미 추가된 태그의 x 버튼에 마우스를 가져다대면 아래 처럼 활성화되고
클릭시에는 태그가 사라지는 것을 확인할 수 있습니다.
개발자 도구의 Console
창을 보면 실제 태그 추가/삭제 액션이 일어날 때마다 서버로 통신을 시도한 것을 확인할 수 있는데요, 아직 구현되어있지 않아 403 에러가 발생하는 것 또한 확인할 수 있습니다.
다음 포스팅에선 추가/삭제시 서버 기능을 구현해보도록 하겠습니다.
'SpringBoot > Web Application 만들기' 카테고리의 다른 글
스프링 부트 웹 애플리케이션 제작(30): 관심 주제 자동완성 기능 구현 (0) | 2022.03.28 |
---|---|
스프링 부트 웹 애플리케이션 제작(29): 관심 주제 추가, 조회, 삭제 기능 구현 (0) | 2022.03.27 |
스프링 부트 웹 애플리케이션 제작(27): 관심 주제 도메인 설계 (0) | 2022.03.16 |
스프링 부트 웹 애플리케이션 제작(26): 비밀번호를 잊어버렸어요(이메일 로그인) (0) | 2022.03.10 |
스프링 부트 웹 애플리케이션 제작(25): 닉네임 변경 기능 구현 (0) | 2022.03.09 |
- Total
- Today
- Yesterday
- 스프링 부트
- JSON
- 스프링 데이터 jpa
- Java
- gRPC
- r
- JPA
- proto3
- QueryDSL
- 스프링부트
- 클린 아키텍처
- 함께 자라기 후기
- 알고리즘
- 함께 자라기
- spring boot application
- Spring Boot Tutorial
- Jackson
- spring boot jwt
- 스프링 부트 애플리케이션
- 헥사고날 아키텍처
- spring boot app
- leetcode
- Linux
- intellij
- Spring Data JPA
- @ManyToOne
- 스프링 부트 회원 가입
- Spring Boot
- 스프링 부트 튜토리얼
- Spring Boot JPA
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |