diff --git a/README.md b/README.md index 24d274b..a4b3b17 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,21 @@ mysql> grant all on todoapp.* to todouser; - ToDoV1.war を手元のPCの tomcatの webappsにコピー - ブラウザから,http://localhost:8080/ToDoV1/ にアクセス +### :whale: `docker-compose`での実行 + + * 起動 + * `docker-compose up -d` + * `-d` を付けるとバックグラウンドで実行. + * `docker-compose logs -f` でバックグラウンドで出力されるログを確認できる. + * 終了 + * `docker-compose stop` で終了. + * `docker-compose down` でコンテナも削除する. + * `docker-compose` で起動している `mysql` に接続する. + * `mysql -u todouser -p -h 127.0.0.1 -P 13306` もしくは + * `docker exec -it todo_mysql bash` でコンテナに接続し, + * `todo_mysql` というコンテナ上で `bash` を起動する(`-it`はインタラクティブモード). + * その後,`bash`上で `mysql -u todouser -p` で接続する. + ## 設計 - メンバー,および,ToDo項目をCRUDする典型的なアプリケーション diff --git a/build.gradle b/build.gradle index 56ba0d2..8414531 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'com.github.mfornos:humanize-slim:1.2.2' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'mysql:mysql-connector-java' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..21672b3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3" +services: + todoapp: + image: tomcat:9-jdk11-corretto + container_name: tomcat_todoapp + ports: + - 8080:8080 + volumes: # ホストOSのディレクトリ:コンテナOSでのマウント先 + - ./build/libs:/usr/local/tomcat/webapps + environment: # Docker-compose で起動した時の接続先.application.propertiesも参照のこと. + DATASTORE: jdbc:mysql://db:3306/todoapp + db: + image: mysql:8.0 + container_name: todo_mysql + restart: always + ports: # ホストOSのポート番号:コンテナOSのポート番号 + - 13306:3306 + # ホストOSで mysql を起動しっぱなしにしておくため,ポート番号を分ける. + # 上の DATASTORE のポート番号は db のポート番号であるため,3306 で良い. + volumes: + - ./build/mysql/data:/var/lib/mysql # データ置き場 + - ./src/docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf # 設定ファイル + environment: + MYSQL_ROOT_PASSWORD: 'rootroot' + MYSQL_DATABASE: 'todoapp' + MYSQL_USER: 'todouser' + MYSQL_PASSWORD: 'todotodo' + TZ: 'Asia/Tokyo' \ No newline at end of file diff --git a/src/docker/mysql/my.cnf b/src/docker/mysql/my.cnf new file mode 100644 index 0000000..d17f22f --- /dev/null +++ b/src/docker/mysql/my.cnf @@ -0,0 +1,9 @@ +[mysqld] +character-set-server=utf8 + +[mysql] +default-character-set=utf8 + +[client] +default-character-set=utf8 + diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/ToDoConfig.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/ToDoConfig.java new file mode 100644 index 0000000..677f5fa --- /dev/null +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/ToDoConfig.java @@ -0,0 +1,22 @@ +package jp.ac.kobe_u.cs.itspecialist.todoapp; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class ToDoConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) { + PageableHandlerMethodArgumentResolver resolver = new PageableHandlerMethodArgumentResolver(); + //ページ単位に表示する件数 + resolver.setFallbackPageable(PageRequest.of(0, 5)); + argumentResolvers.add(resolver); + } + +} \ No newline at end of file diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/TriFunction.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/TriFunction.java new file mode 100644 index 0000000..c3f94fa --- /dev/null +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/TriFunction.java @@ -0,0 +1,6 @@ +package jp.ac.kobe_u.cs.itspecialist.todoapp; + +@FunctionalInterface +public interface TriFunction<A1, A2, A3, R> { + R apply(A1 a1, A2 a2, A3 a3); +} diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoController.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoController.java index 6b20956..ca3e42d 100644 --- a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoController.java +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoController.java @@ -1,16 +1,16 @@ package jp.ac.kobe_u.cs.itspecialist.todoapp.controller; import java.util.List; +import java.util.Objects; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.*; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.LoginForm; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.ToDoForm; @@ -54,27 +54,48 @@ public class ToDoController { * ユーザのToDoリストのページ */ @GetMapping("/{mid}/todos") - String showToDoList(@PathVariable String mid, @ModelAttribute(name = "ToDoForm") ToDoForm form, Model model) { + String showToDoList(@PathVariable String mid, + @RequestParam(name="sort_by", required = false) String sortBy, + @RequestParam(name="order", required = false) String order, + @ModelAttribute(name = "ToDoForm") ToDoForm form, Model model, + Pageable pageable) { Member m = mService.getMember(mid); + // デフォルト値を入れておく. + sortBy = getDefault(sortBy, "seq"); + order = getDefault(order, "asc"); + model.addAttribute("member", m); model.addAttribute("ToDoForm", form); - List<ToDo> todos = tService.getToDoList(mid); + Page<ToDo> todos = tService.getToDoList(mid, sortBy, order, pageable); model.addAttribute("todos", todos); - List<ToDo> dones = tService.getDoneList(mid); + Page<ToDo> dones = tService.getDoneList(mid, sortBy, order, pageable); model.addAttribute("dones", dones); return "list"; } + private String getDefault(String value, String defaultValue) { + if(value == null || Objects.equals(value.trim(), "")) { + return defaultValue; + } + return value; + } + /** * 全員のToDoリストのページ */ @GetMapping("/{mid}/todos/all") - String showAllToDoList(@PathVariable String mid, Model model) { + String showAllToDoList(@PathVariable String mid, + @RequestParam(name="sort_by", required = false) String sortBy, + @RequestParam(name="order", required = false) String order, + Model model, Pageable pageable) { Member m = mService.getMember(mid); + // デフォルト値を入れておく. + sortBy = getDefault(sortBy, "seq"); + order = getDefault(order, "asc"); model.addAttribute("member", m); - List<ToDo> todos = tService.getToDoList(); + Page<ToDo> todos = tService.getToDoList(sortBy, order, pageable); model.addAttribute("todos", todos); - List<ToDo> dones = tService.getDoneList(); + Page<ToDo> dones = tService.getDoneList(sortBy, order, pageable); model.addAttribute("dones", dones); return "alllist"; } @@ -85,10 +106,19 @@ public class ToDoController { @PostMapping("/{mid}/todos") String createToDo(@PathVariable String mid, @Validated @ModelAttribute(name = "ToDoForm") ToDoForm form, BindingResult bindingResult, Model model) { - if (bindingResult.hasErrors()) { - return showToDoList(mid, form, model); + if (!bindingResult.hasErrors()) { + tService.createToDo(mid, form); } - tService.createToDo(mid, form); + return "redirect:/" + mid + "/todos"; + } + + /** + * ToDoの更新.期限を追加,削除する.その後,ユーザページへリダイレクトする. + */ + @PutMapping("/{mid}/todos/{seq}/due") + String updateDueDate(@PathVariable String mid, @PathVariable Long seq, + @Validated @ModelAttribute(name = "ToDoForm") ToDoForm form, Model model) { + tService.updateDueDate(mid, seq, form.getDueDate()); return "redirect:/" + mid + "/todos"; } @@ -100,4 +130,23 @@ public class ToDoController { tService.done(mid, seq); return "redirect:/" + mid + "/todos"; } + + /** + * ToDoの完了のキャンセル.キャンセル終了後,ユーザページへリダイレクト + */ + @GetMapping("/{mid}/todos/{seq}/cancel") + String cancelToDo(@PathVariable String mid, @PathVariable Long seq, Model model) { + tService.cancel(mid, seq); + return "redirect:/" + mid + "/todos"; + } + + /* + * 背景色の更新.更新終了後,ユーザページへリダイレクト. + */ + @PutMapping("/{mid}/todos/{seq}/background") + String updateBackground(@PathVariable String mid, @PathVariable Long seq, + @Validated @ModelAttribute(name="ToDoForm") ToDoForm form, Model model) { + tService.updateBackground(mid, seq, form.getBackground()); + return "redirect:/" + mid + "/todos"; + } } diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoRestController.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoRestController.java index 1862695..cb88b04 100644 --- a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoRestController.java +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/controller/ToDoRestController.java @@ -3,6 +3,8 @@ package jp.ac.kobe_u.cs.itspecialist.todoapp.controller; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -48,14 +50,14 @@ public class ToDoRestController { /* --- R: ToDoを取得する (リスト) --- */ @GetMapping("/{mid}/todos") - List<ToDo> getToDoList(@PathVariable String mid) { - return todoService.getToDoList(mid); + Page<ToDo> getToDoList(@PathVariable String mid) { + return todoService.getToDoList(mid, "seq", "asc", Pageable.ofSize(10)); } /* --- R: Doneを取得する (リスト) --- */ @GetMapping("/{mid}/dones") - List<ToDo> getDoneList(@PathVariable String mid) { - return todoService.getDoneList(mid); + Page<ToDo> getDoneList(@PathVariable String mid) { + return todoService.getDoneList(mid, "seq", "asc", Pageable.ofSize(10)); } /* --- U: ToDoを完了する --- */ diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/dto/ToDoForm.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/dto/ToDoForm.java index b2f6fc4..01d1b41 100644 --- a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/dto/ToDoForm.java +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/dto/ToDoForm.java @@ -1,5 +1,9 @@ package jp.ac.kobe_u.cs.itspecialist.todoapp.dto; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Date; import javax.validation.constraints.NotBlank; @@ -7,6 +11,7 @@ import javax.validation.constraints.Size; import jp.ac.kobe_u.cs.itspecialist.todoapp.entity.ToDo; import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; /** * ToDoの入力フォーム @@ -16,12 +21,34 @@ public class ToDoForm { @NotBlank @Size(min=1, max=64) String title; //ToDo題目 + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime due; // 期限 + + @NotBlank + @Size(min=6, max=8) + String background; // 背景色 public ToDo toEntity() { ToDo t = new ToDo(); t.setTitle(title); t.setCreatedAt(new Date()); t.setDone(false); + t.setDueAt(getDueDate()); + t.setBackground(background); return t; } + + public Date getDueDate() { + if (due == null) { + return null; + } + return toDate(due); + } + + private Date toDate(LocalDateTime ldt) { + ZoneId id = ZoneId.systemDefault(); + ZonedDateTime zdt = ZonedDateTime.of(ldt, id); + Instant instant = zdt.toInstant(); + return Date.from(instant); + } } diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/entity/ToDo.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/entity/ToDo.java index c0f901f..fe5184a 100644 --- a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/entity/ToDo.java +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/entity/ToDo.java @@ -1,5 +1,7 @@ package jp.ac.kobe_u.cs.itspecialist.todoapp.entity; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.Date; import javax.persistence.Entity; @@ -9,6 +11,7 @@ import javax.persistence.Id; import javax.persistence.Temporal; import javax.persistence.TemporalType; +import humanize.Humanize; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -31,4 +34,46 @@ public class ToDo { Date createdAt; //作成日時 @Temporal(TemporalType.TIMESTAMP) Date doneAt; //完了日時 + + @Temporal(TemporalType.TIMESTAMP) + Date dueAt; //期限 + String background; //背景色 + + public boolean isValidDueDate() { + if (dueAt == null) { // dueが設定されていなければ無視する. + return true; + } + if (done) { // 完了していれば,更新不可. + return false; + } + // 期限は作成日よりも後でなければならない. + return dueAt.after(createdAt); + } + + private static final DateFormat FORMATTER = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); + + public String getDueString() { + if (dueAt == null) { + return ""; + } + return FORMATTER.format(dueAt); + } + + public String humanizeCreatedAt() { + return Humanize.naturalDay(createdAt); + } + + public String humanizeDueAt() { + if(dueAt == null) { + return ""; + } + return Humanize.naturalDay(dueAt); + } + + public String humanizeDoneAt() { + if(doneAt == null) { + return ""; + } + return Humanize.naturalDay(doneAt); + } } diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/repository/ToDoRepository.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/repository/ToDoRepository.java index 4d7767c..32a5f8b 100644 --- a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/repository/ToDoRepository.java +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/repository/ToDoRepository.java @@ -2,6 +2,8 @@ package jp.ac.kobe_u.cs.itspecialist.todoapp.repository; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; @@ -9,8 +11,33 @@ import jp.ac.kobe_u.cs.itspecialist.todoapp.entity.ToDo; @Repository public interface ToDoRepository extends CrudRepository<ToDo, Long> { - List<ToDo> findAll(); - List<ToDo> findByDone(boolean done); - List<ToDo> findByMid(String mid); - List<ToDo> findByMidAndDone(String mid, boolean done); + Page<ToDo> findAll(Pageable pageable); + Page<ToDo> findByDone(boolean done, Pageable pageable); + Page<ToDo> findByMid(String mid, Pageable pageable); + Page<ToDo> findByMidAndDone(String mid, boolean done, Pageable pageable); + + // ソート機能を追加する. + Page<ToDo> findByDoneOrderBySeqAsc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderBySeqDesc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByTitleAsc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByTitleDesc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByMidAsc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByMidDesc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByCreatedAtAsc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByCreatedAtDesc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByDoneAtAsc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByDoneAtDesc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByDueAtAsc(boolean done, Pageable pageable); + Page<ToDo> findByDoneOrderByDueAtDesc(boolean done, Pageable pageable); + + Page<ToDo> findByMidAndDoneOrderBySeqAsc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderBySeqDesc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByTitleAsc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByTitleDesc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByCreatedAtAsc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByCreatedAtDesc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByDoneAtAsc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByDoneAtDesc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByDueAtAsc(String mid, boolean done, Pageable pageable); + Page<ToDo> findByMidAndDoneOrderByDueAtDesc(String mid, boolean done, Pageable pageable); } diff --git a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/service/ToDoService.java b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/service/ToDoService.java index 57a6371..25bccb2 100644 --- a/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/service/ToDoService.java +++ b/src/main/java/jp/ac/kobe_u/cs/itspecialist/todoapp/service/ToDoService.java @@ -1,9 +1,15 @@ package jp.ac.kobe_u.cs.itspecialist.todoapp.service; -import java.util.Date; -import java.util.List; +import java.util.*; +import java.util.function.BiFunction; +import java.util.function.Function; + +import jp.ac.kobe_u.cs.itspecialist.todoapp.TriFunction; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.util.Pair; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import jp.ac.kobe_u.cs.itspecialist.todoapp.dto.ToDoForm; @@ -26,6 +32,10 @@ public class ToDoService { public ToDo createToDo(String mid, ToDoForm form) { mService.getMember(mid); //実在メンバーか確認 ToDo todo = form.toEntity(); + if(!todo.isValidDueDate()) { + throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, + todo.getDueAt() + ": should be after created at"); + } todo.setMid(mid); return tRepo.save(todo); } @@ -43,39 +53,109 @@ public class ToDoService { return todo; } + public Page<ToDo> getToDoAllList(String mid, Pageable pageable) { + return tRepo.findByMidAndDone(mid, false, pageable); + } + /** * あるメンバーのToDoリストを取得する (R) * @param mid * @return */ - public List<ToDo> getToDoList(String mid) { - return tRepo.findByMidAndDone(mid, false); + public Page<ToDo> getToDoList(String mid, String sortBy, String order, Pageable pageable) { + TriFunction<String, Boolean, Pageable, Page<ToDo>> finder = midAndDoneFinder.getOrDefault(Pair.of(sortBy, order), + (memberId, doneFlag, pageable2) -> tRepo.findByMidAndDone(memberId, doneFlag, pageable2)); + return finder.apply(mid, false, pageable); + } + + public Page<ToDo> getDoneAllList(String mid, Pageable pageable) { + return tRepo.findByMidAndDone(mid, true, pageable); } + /** * あるメンバーのDoneリストを取得する (R) * @param mid * @return */ - public List<ToDo> getDoneList(String mid) { - return tRepo.findByMidAndDone(mid, true); + public Page<ToDo> getDoneList(String mid, String sortBy, String order, Pageable pageable) { + TriFunction<String, Boolean, Pageable, Page<ToDo>> finder = midAndDoneFinder.getOrDefault(Pair.of(sortBy, order), + (memberId, doneFlag, pageable2) -> tRepo.findByMidAndDone(memberId, doneFlag, pageable2)); + return finder.apply(mid, true, pageable); + } + + private final Map<Pair<String, String>, TriFunction<String, Boolean, Pageable, Page<ToDo>>> midAndDoneFinder = generateMidAndDoneFinder(); + private Map<Pair<String, String>, TriFunction<String, Boolean, Pageable, Page<ToDo>>> generateMidAndDoneFinder() { + Map<Pair<String, String>, TriFunction<String, Boolean, Pageable, Page<ToDo>>> map = new HashMap<>(); + map.put(Pair.of("seq", "asc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderBySeqAsc(mid, done, pageable)); + map.put(Pair.of("seq", "desc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderBySeqDesc(mid, done, pageable)); + map.put(Pair.of("title", "asc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByTitleAsc(mid, done, pageable)); + map.put(Pair.of("title", "desc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByTitleDesc(mid, done, pageable)); + map.put(Pair.of("created_at", "asc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByCreatedAtAsc(mid, done, pageable)); + map.put(Pair.of("created_at", "desc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByCreatedAtDesc(mid, done, pageable)); + map.put(Pair.of("done_at", "asc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByDoneAtAsc(mid, done, pageable)); + map.put(Pair.of("done_at", "desc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByDoneAtDesc(mid, done, pageable)); + map.put(Pair.of("due_at", "asc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByDueAtAsc(mid, done, pageable)); + map.put(Pair.of("due_at", "desc"), (mid, done, pageable) -> tRepo.findByMidAndDoneOrderByDueAtDesc(mid, done, pageable)); + return map; } /** * 全員のToDoリストを取得する (R) * @return */ - public List<ToDo> getToDoList() { - return tRepo.findByDone(false); + public Page<ToDo> getToDoList(String sortBy, String order, Pageable pageable) { + BiFunction<Boolean, Pageable, Page<ToDo>> finder = doneFinder.getOrDefault(Pair.of(sortBy, order), + (doneFlag, pageable2) -> tRepo.findByDone(doneFlag, pageable2)); + return finder.apply(false, pageable); } /** * 全員のDoneリストを取得する (R) * @return */ - public List<ToDo> getDoneList() { - return tRepo.findByDone(true); + public Page<ToDo> getDoneList(String sortBy, String order, Pageable pageable) { + BiFunction<Boolean, Pageable, Page<ToDo>> finder = doneFinder.getOrDefault(Pair.of(sortBy, order), + (doneFlag, pageable2) -> tRepo.findByDone(doneFlag, pageable2)); + return finder.apply(true, pageable); + } + + /** + * 〆切を更新する. + * @param mid + * @param seq + * @param due + */ + public ToDo updateDueDate(String mid, Long seq, Date due) { + ToDo todo = getToDo(seq); + if (!Objects.equals(mid, todo.getMid())) { + throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, mid + + ": Cannot done other's todo of " + todo.getMid()); + } + if (due != null && due.before(todo.getCreatedAt())) { + throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, + due + ": should be after created at."); + } + todo.setDueAt(due); + return tRepo.save(todo); } + private final Map<Pair<String, String>, BiFunction<Boolean, Pageable, Page<ToDo>>> doneFinder = generateDoneFinder(); + private Map<Pair<String, String>, BiFunction<Boolean, Pageable, Page<ToDo>>> generateDoneFinder() { + Map<Pair<String, String>, BiFunction<Boolean, Pageable, Page<ToDo>>> map = new HashMap<>(); + map.put(Pair.of("seq", "asc"), (doneFlag, pageable) -> tRepo.findByDoneOrderBySeqAsc(doneFlag, pageable)); + map.put(Pair.of("seq", "desc"), (doneFlag, pageable) -> tRepo.findByDoneOrderBySeqDesc(doneFlag, pageable)); + map.put(Pair.of("title", "asc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByTitleAsc(doneFlag, pageable)); + map.put(Pair.of("title", "desc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByTitleDesc(doneFlag, pageable)); + map.put(Pair.of("mid", "asc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByMidAsc(doneFlag, pageable)); + map.put(Pair.of("mid", "desc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByMidDesc(doneFlag, pageable)); + map.put(Pair.of("created_at", "asc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByCreatedAtAsc(doneFlag, pageable)); + map.put(Pair.of("created_at", "desc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByCreatedAtDesc(doneFlag, pageable)); + map.put(Pair.of("done_at", "asc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByDoneAtAsc(doneFlag, pageable)); + map.put(Pair.of("done_at", "desc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByDoneAtDesc(doneFlag, pageable)); + map.put(Pair.of("due_at", "asc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByDueAtAsc(doneFlag, pageable)); + map.put(Pair.of("due_at", "desc"), (doneFlag, pageable) -> tRepo.findByDoneOrderByDueAtDesc(doneFlag, pageable)); + return map; + } /** * ToDoを完了する @@ -87,14 +167,32 @@ public class ToDoService { ToDo todo = getToDo(seq); //Doneの認可を確認する.他人のToDoを閉めたらダメ. if (!mid.equals(todo.getMid())) { - throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, mid - + ": Cannot done other's todo of " + todo.getMid()); + throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, mid + + ": Cannot done other's todo of " + todo.getMid()); } todo.setDone(true); todo.setDoneAt(new Date()); return tRepo.save(todo); } + /** + * ToDo の完了をキャンセルする. + * @param mid 完了者 + * @param seq 完了をキャンセルするToDoの番号 + * @return + */ + public ToDo cancel(String mid, Long seq) { + ToDo todo = getToDo(seq); + // Doneの認可を確認する.他人のToDoを閉めたらダメ. + if (!mid.equals(todo.getMid())) { + throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, + mid + ": Cannot cancel other's todo of " + todo.getMid()); + } + todo.setDone(false); + todo.setDoneAt(null); + return tRepo.save(todo); + } + /** * ToDoを更新する * @param mid 更新者 @@ -106,13 +204,31 @@ public class ToDoService { ToDo todo = getToDo(seq); //Doneの認可を確認する.他人のToDoを更新したらダメ. if (!mid.equals(todo.getMid())) { - throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, mid - + ": Cannot update other's todo of " + todo.getMid()); + throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, mid + + ": Cannot update other's todo of " + todo.getMid()); } todo.setTitle(form.getTitle()); //タイトルを更新 return tRepo.save(todo); } + /** + * 背景色を更新する. + * @param mid 更新者 + * @param seq 更新するToDo番号 + * @param background 新しい背景色 + * @return + */ + public ToDo updateBackground(String mid, Long seq, String background) { + ToDo todo = getToDo(seq); + //Doneの認可を確認する.他人のToDoを更新したらダメ. + if (!mid.equals(todo.getMid())) { + throw new ToDoAppException(ToDoAppException.INVALID_TODO_OPERATION, mid + + ": Cannot update other's todo of " + todo.getMid()); + } + todo.setBackground(background); + return tRepo.save(todo); + } + /** * ToDoを削除する * @param mid 削除者 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e07c446..afe672e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,10 +2,16 @@ server.port = 18080 # MySQLデータベース接続設定 -spring.datasource.url=jdbc:mysql://localhost:3306/todoapp spring.datasource.username=todouser spring.datasource.password=todotodo +## ${環境変数名:デフォルト値} で,ローカルで起動した場合(テスト時), +## Docker経由で起動した場合の接続先をそれぞれ設定する. +spring.datasource.url=${DATASTORE:jdbc:mysql://localhost:3306/todoapp} # Spring-JPA: DBのテーブルを自動作成してくれる機能 # create: 新規作成, update: なければ新規作成, create-drop: 新規作成し終了時に削除 spring.jpa.hibernate.ddl-auto=update +# HTML から PUT, DELETE リクエスを投げるために必要. +# ただし,MultipartFileがリクエストに含まれると時間がかかるらしい. +# https://qiita.com/kazuhiro1982/items/b8b9965fddf9c5507517 +spring.mvc.hiddenmethod.filter.enabled=true diff --git a/src/main/resources/templates/alllist.html b/src/main/resources/templates/alllist.html index 8580517..ccd7478 100644 --- a/src/main/resources/templates/alllist.html +++ b/src/main/resources/templates/alllist.html @@ -15,35 +15,114 @@ <h2>ToDo</h2> <table border="1"> <tr> - <th>#</th> - <th>タイトル</th> - <th>作成者</th> - <th>作成日時</th> + <th> + # + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'seq'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'seq'}, order=${'desc'})}">↑</a> + </th> + <th> + タイトル + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'title'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'title'}, order=${'desc'})}">↑</a> + </th> + <th> + 作成者 + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'mid'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'mid'}, order=${'desc'})}">↑</a> + </th> + <th> + 作成日時 + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'created_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'created_at'}, order=${'desc'})}">↑</a> + </th> + <th> + 期日 + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'due_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'due_at'}, order=${'desc'})}">↑</a> + </th> </tr> <tr th:each="todo: ${todos}"> - <td>[[${todo.seq}]]</td> + <td th:style="'background-color: ' + ${todo.background}">[[${todo.seq}]]</td> <td>[[${todo.title}]]</td> <td>[[${todo.mid}]]</td> - <td>[[${todo.createdAt}]]</td> + <td th:title="${todo.createdAt}">[[${todo.humanizeCreatedAt()}]]</td> + <td th:title="${todo.dueAt}">[[${todo.humanizeDueAt()}]]</td> </tr> </table> + <div th:fragment="paginationbar"> + <ul> + <li th:class="${todos.first}? 'disabled': ''" style="display:inline"> + <span th:if="${todos.first}"><<</span> + <a th:if="${not todos.first}" th:href="@{${url}(page=0)}"><<</a> + </li> + <li th:if="${todos.totalPages} > 0" th:each="i: ${#numbers.sequence(0, todos.totalPages-1)}" th:class="(${i}==${todos.number})?'active': ''" style="display:inline"> + <span th:if="${i} == ${todos.number}" th:text="${i+1}">1</span> + <a th:if="${i}!=${todos.number}" th:href="@{${url}(page=${i})}"> + <span th:text="${i+1}">1</span> + </a> + </li> + <li th:class="${todos.last}? 'disabled': ''" style="display:inline"> + <span th:if="${todos.last}">>></span> + <a th:if="${not todos.last}" th:href="@{${url}(page=(${todos.totalPages}-1))}">>></a> + </li> + </ul> + </div> <h2>Done</h2> <table border="1"> <tr> - <th>#</th> - <th>タイトル</th> - <th>作成者</th> - <th>完了日時</th> + <th> + # + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'seq'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'seq'}, order=${'desc'})}">↑</a> + </th> + <th> + タイトル + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'title'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'title'}, order=${'desc'})}">↑</a> + </th> + <th> + 作成者 + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'mid'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'mid'}, order=${'desc'})}">↑</a> + </th> + <th> + 期日 + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'due_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'due_at'}, order=${'desc'})}">↑</a> + </th> + <th> + 完了日時 + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'done_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos/all(mid=${member.mid}, sort_by=${'done_at'}, order=${'desc'})}">↑</a> + </th> </tr> <tr th:each="done: ${dones}"> - <td>[[${done.seq}]]</td> + <td th:style="'background-color: ' + ${done.background}">[[${done.seq}]]</td> <td>[[${done.title}]]</td> <td>[[${done.mid}]]</td> - <td>[[${done.doneAt}]]</td> + <td th:title="${done.dueAt}">[[${done.humanizeDueAt()}]]</td> + <td th:title="${done.doneAt}">[[${done.humanizeDoneAt()}]]</td> </tr> </table> - + <div th:fragment="paginationbar"> + <ul> + <li th:class="${dones.first}? 'disabled': ''" style="display:inline"> + <span th:if="${dones.first}"><<</span> + <a th:if="${not dones.first}" th:href="@{${url}(page=0)}"><<</a> + </li> + <li th:if="${dones.totalPages} > 0" th:each="i: ${#numbers.sequence(0, dones.totalPages-1)}" th:class="(${i}==${dones.number})?'active': ''" style="display:inline"> + <span th:if="${i} == ${dones.number}" th:text="${i+1}">1</span> + <a th:if="${i}!=${dones.number}" th:href="@{${url}(page=${i})}"> + <span th:text="${i+1}">1</span> + </a> + </li> + <li th:class="${dones.last}? 'disabled': ''" style="display:inline"> + <span th:if="${dones.last}">>></span> + <a th:if="${not dones.last}" th:href="@{${url}(page=(${dones.totalPages}-1))}">>></a> + </li> + </ul> + </div> </body> </html> \ No newline at end of file diff --git a/src/main/resources/templates/list.html b/src/main/resources/templates/list.html index a2939b9..9e91cbc 100644 --- a/src/main/resources/templates/list.html +++ b/src/main/resources/templates/list.html @@ -15,16 +15,45 @@ <h2>ToDo</h2> <table border="1"> <tr> - <th>#</th> - <th>タイトル</th> - <th>作成日時</th> + <th> + # + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'seq'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'seq'}, order=${'desc'})}">↑</a> + </th> + <th> + タイトル + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'title'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'title'}, order=${'desc'})}">↑</a> + </th> + <th> + 作成日時 + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'created_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'created_at'}, order=${'desc'})}">↑</a> + </th> + <th> + 期日 + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'due_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'due_at'}, order=${'desc'})}">↑</a> + </th> <th>コマンド</th> </tr> <tr th:each="todo: ${todos}"> - <td>[[${todo.seq}]]</td> + <td th:style="'background-color: ' + ${todo.background}">[[${todo.seq}]]</td> <td>[[${todo.title}]]</td> - <td>[[${todo.createdAt}]]</td> + <td th:title="${todo.createdAt}">[[${todo.humanizeCreatedAt()}]]</td> + <td th:title="${todo.dueAt}">[[${todo.humanizeDueAt()}]]</td> <td> + <form th:action="@{/{mid}/todos/{seq}/due(mid=${member.mid},seq=${todo.seq})}" th:method="put" th:object="${ToDoForm}"> + <input type="hidden" name="title" th:value="${todo.title}" /> + <input type="hidden" name="background" th:value="${todo.background}" /> + <input type="datetime-local" name="due" th:value="${todo.getDueString()}" /> + <input type="submit" value="期日を設定" /> + </form> + <form th:object="${ToDoForm}" th:action="@{/{mid}/todos/{seq}/background(mid=${member.mid},seq=${todo.seq})}" th:method="put"> + <input type="hidden" name="title" th:value="${todo.title}"/> + <input type="color" name="background" th:value="${todo.background}" /> + <input type="submit" value="更新" /> + </form> <a th:href="@{/{mid}/todos/{seq}/done(mid=${member.mid},seq=${todo.seq})}">完了</a> </td> </tr> @@ -32,32 +61,93 @@ <td> * </td> - <td colspan="3"> + <td colspan="4"> <form role="form" th:action="@{/{mid}/todos(mid=${member.mid})}" th:object="${ToDoForm}" method="post"> <input type="text" required th:field="*{title}" /> + <input type="datetime-local" th:field="*{due}" /> + <input type="color" required name="background" th:value="*{background == null ? '#ffffff': background}" /> <input type="submit" value="新規作成" /> <div th:if="${#fields.hasErrors('title')}" th:errors="*{title}" style="color: red"></div> </form> </td> </tr> </table> + <div th:fragment="paginationbar"> + <ul> + <li th:class="${todos.first}? 'disabled': ''" style="display:inline"> + <span th:if="${todos.first}"><<</span> + <a th:if="${not todos.first}" th:href="@{${url}(page=0)}"><<</a> + </li> + <li th:if="${todos.totalPages} > 0" th:each="i: ${#numbers.sequence(0, todos.totalPages-1)}" th:class="(${i}==${todos.number})?'active': ''" style="display:inline"> + <span th:if="${i} == ${todos.number}" th:text="${i+1}">1</span> + <a th:if="${i}!=${todos.number}" th:href="@{${url}(page=${i})}"> + <span th:text="${i+1}">1</span> + </a> + </li> + <li th:class="${todos.last}? 'disabled': ''" style="display:inline"> + <span th:if="${todos.last}">>></span> + <a th:if="${not todos.last}" th:href="@{${url}(page=(${todos.totalPages}-1))}">>></a> + </li> + </ul> + </div> <h2>Done</h2> <table border="1"> <tr> - <th>#</th> - <th>タイトル</th> - <th>作成日時</th> - <th>完了日時</th> + <th> + # + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'seq'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'seq'}, order=${'desc'})}">↑</a> + </th> + <th> + タイトル + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'title'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'title'}, order=${'desc'})}">↑</a> + </th> + <th> + 作成日時 + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'created_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'created_at'}, order=${'desc'})}">↑</a> + </th> + <th> + 期日 + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'due_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'due_at'}, order=${'desc'})}">↑</a> + </th> + <th> + 完了日時 + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'done_at'}, order=${'asc'})}">↓</a> + <a th:href="@{/{mid}/todos(mid=${member.mid}, sort_by=${'done_at'}, order=${'desc'})}">↑</a> + </th> + <th>コマンド</th> </tr> <tr th:each="done: ${dones}"> - <td>[[${done.seq}]]</td> + <td th:style="'background-color: ' + ${done.background}">[[${done.seq}]]</td> <td>[[${done.title}]]</td> - <td>[[${done.createdAt}]]</td> - <td>[[${done.doneAt}]]</td> + <td th:title="${done.createdAt}">[[${done.humanizeCreatedAt()}]]</td> + <td th:title="${done.dueAt}">[[${done.humanizeDueAt()}]]</td> + <td th:title="${done.doneAt}">[[${done.humanizeDoneAt()}]]</td> + <td><a th:href="@{/{mid}/todos/{seq}/cancel(mid=${member.mid},seq=${done.seq})}">キャンセル</a></td> </tr> </table> - + <div th:fragment="paginationbar"> + <ul> + <li th:class="${dones.first}? 'disabled': ''" style="display:inline"> + <span th:if="${dones.first}"><<</span> + <a th:if="${not dones.first}" th:href="@{${url}(page=0)}"><<</a> + </li> + <li th:if="${dones.totalPages} > 0" th:each="i: ${#numbers.sequence(0, dones.totalPages-1)}" th:class="(${i}==${dones.number})?'active': ''" style="display:inline"> + <span th:if="${i} == ${dones.number}" th:text="${i+1}">1</span> + <a th:if="${i}!=${dones.number}" th:href="@{${url}(page=${i})}"> + <span th:text="${i+1}">1</span> + </a> + </li> + <li th:class="${dones.last}? 'disabled': ''" style="display:inline"> + <span th:if="${dones.last}">>></span> + <a th:if="${not dones.last}" th:href="@{${url}(page=(${dones.totalPages}-1))}">>></a> + </li> + </ul> + </div> </body> </html> \ No newline at end of file