From 508788dbf23a862b16535ee8c6c01881ecbbc938 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Tue, 8 Oct 2024 15:48:51 -0300 Subject: [PATCH] feat: sort by undone first feat: add time ago format for the last updated task time issue #54 --- client/src/types/TaskResponse.ts | 3 +- client/src/views/Task/index.tsx | 117 +++++++++++++++--- client/src/views/Task/style.css | 9 ++ server/pom.xml | 7 ++ .../server/config/CloudNativeConfig.java | 1 + .../server/controller/TaskController.java | 3 +- .../tasknoteapp/server/entity/TaskEntity.java | 6 + .../server/response/TaskResponse.java | 17 ++- .../server/service/impl/TaskServiceImpl.java | 14 ++- .../V202410081427__add_last_update_task.sql | 2 + .../server/controller/HomeControllerTest.java | 2 +- 11 files changed, 158 insertions(+), 23 deletions(-) create mode 100644 server/src/main/resources/db/migration/V202410081427__add_last_update_task.sql diff --git a/client/src/types/TaskResponse.ts b/client/src/types/TaskResponse.ts index 0afea14..b2cd721 100644 --- a/client/src/types/TaskResponse.ts +++ b/client/src/types/TaskResponse.ts @@ -1,5 +1,5 @@ type TaskUrlResponse = { - id: number, + id: number | null, url: string } @@ -7,6 +7,7 @@ type TaskResponse = { id: number, description: string, done: boolean, + lastUpdate: string, urls: TaskUrlResponse[] } diff --git a/client/src/views/Task/index.tsx b/client/src/views/Task/index.tsx index 265754a..1e26e1b 100644 --- a/client/src/views/Task/index.tsx +++ b/client/src/views/Task/index.tsx @@ -3,11 +3,13 @@ import { Alert, Button, Card, Col, Container, Form, Row, Table } from 'react-bootstrap'; import TaskNoteRequest from '../../types/TaskNoteRequest'; -import { TaskResponse } from '../../types/TaskResponse'; +import { TaskResponse, TaskUrlResponse } from '../../types/TaskResponse'; import api from '../../api-service/api'; import ApiConfig from '../../api-service/apiConfig'; import './style.css'; +type TaskAction = 'add' | 'edit'; + /** * */ @@ -16,6 +18,12 @@ function Task(): JSX.Element { const [formInvalid, setFormInvalid] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [tasks, setTasks] = useState([]); + const [taskId, setTaskId] = useState(0); + const [taskDescription, setTaskDescription] = useState(''); + const [taskUrl, setTaskUrl] = useState(''); + const [taskUrlId, setTaskUrlId] = useState(0); + const [taskDone, setTaskDone] = useState(false); + const [action, setAction] = useState('add'); const handleError = (e: unknown): void => { if (typeof e === 'string') { @@ -30,6 +38,7 @@ function Task(): JSX.Element { const loadTasks = async () => { try { const tasksFetched: TaskResponse[] = await api.getJSON(ApiConfig.tasksUrl); + tasksFetched.sort((t1, t2) => t1.done === t2.done? 0 : t1.done? 1 : -1); setTasks(tasksFetched); } catch (e) { handleError(e); @@ -53,6 +62,26 @@ function Task(): JSX.Element { return false; }; + const submitEditTask = async (payload: TaskResponse): Promise => { + try { + await api.patchJSON(`${ApiConfig.tasksUrl}/${payload.id}`, payload); + loadTasks(); + return true; + } catch (e) { + handleError(e); + } + return false; + }; + + const resetInputs = () => { + setTaskId(0); + setTaskDescription(''); + setTaskDone(false); + setTaskUrl(''); + setTaskUrlId(0); + setAction('add'); + }; + const handleSubmit = async (event: React.FormEvent): Promise => { event.preventDefault(); event.stopPropagation(); @@ -66,9 +95,35 @@ function Task(): JSX.Element { } setFormInvalid(false); - const added: boolean = await addTask(form.description.value, form.url.value); - if (added) { - form.reset(); + + if (action === 'add') { + const added: boolean = await addTask(form.description.value, form.url.value); + if (added) { + form.reset(); + resetInputs(); + } + } else if (action === 'edit') { + const urls: TaskUrlResponse[] = []; + if (taskUrlId) { + urls.push({url: '', id: taskUrlId}); + } + if (taskUrl) { + urls.push({url: taskUrl, id: null}); + } + + const payload: TaskResponse = { + id: taskId, + description: taskDescription, + done: taskDone, + lastUpdate: '', + urls: urls + }; + + const edited: boolean = await submitEditTask(payload); + if (edited) { + form.reset(); + resetInputs(); + } } }; @@ -85,6 +140,15 @@ function Task(): JSX.Element { } }; + const editTask = async (task: TaskResponse) => { + setTaskId(task.id) + setTaskDescription(task.description); + setTaskUrl(task.urls.length? task.urls[0].url : ''); + setTaskUrlId(task.urls.length? task.urls[0].id || 0 : 0); + setTaskDone(task.done); + setAction('edit'); + }; + const deleteTask = async (taskId: number) => { try { await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskId}`); @@ -96,7 +160,6 @@ function Task(): JSX.Element { useEffect(() => { loadTasks(); - // getCsrfToken(); }, []); return ( @@ -121,6 +184,10 @@ function Task(): JSX.Element { type="test" name="description" placeholder="Enter description" + value={taskDescription} + onChange={(e: React.ChangeEvent) => { + setTaskDescription(e.target.value); + }} /> @@ -131,6 +198,10 @@ function Task(): JSX.Element { type="text" name="url" placeholder="Additional URL (Optional)" + value={taskUrl} + onChange={(e: React.ChangeEvent) => { + setTaskUrl(e.target.value); + }} /> @@ -149,21 +220,25 @@ function Task(): JSX.Element { - +
- - - - - + + + + + {tasks.map((task: TaskResponse) => ( - - + +
#DescriptionDoneURLActions#DescriptionDoneURLActions
{task.id}{task.description}{task.id} + {task.description} +
+ {task.lastUpdate} +
{task.done ? 'Yes' : 'No'} {task.urls.length > 0 ? ( @@ -173,15 +248,27 @@ function Task(): JSX.Element { + + + diff --git a/client/src/views/Task/style.css b/client/src/views/Task/style.css index af5d9df..3160534 100644 --- a/client/src/views/Task/style.css +++ b/client/src/views/Task/style.css @@ -4,4 +4,13 @@ .text-done .btn.btn-link { color: #ccc!important; +} + +.time-ago { + font-size: 0.775em; + color: #868181; +} + +.btn-action { + margin-right: 0.5rem; } \ No newline at end of file diff --git a/server/pom.xml b/server/pom.xml index a5c29d9..c113032 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -182,6 +182,13 @@ jjwt-jackson 0.12.6 + + + + org.ocpsoft.prettytime + prettytime + 5.0.9.Final + diff --git a/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java b/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java index d061c7d..3edeed3 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java +++ b/server/src/main/java/br/com/tasknoteapp/server/config/CloudNativeConfig.java @@ -48,6 +48,7 @@ import org.springframework.context.annotation.ImportRuntimeHints; io.jsonwebtoken.impl.io.StandardCompressionAlgorithms.class, io.jsonwebtoken.impl.DefaultClaimsBuilder.class, io.jsonwebtoken.impl.DefaultJwtParserBuilder.class, + org.ocpsoft.prettytime.PrettyTime.class, }) @ImportRuntimeHints(value = {HttpServletRequestRuntimeHint.class}) public class CloudNativeConfig {} diff --git a/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java b/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java index edac400..e2755ac 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java +++ b/server/src/main/java/br/com/tasknoteapp/server/controller/TaskController.java @@ -145,7 +145,8 @@ public class TaskController { @Valid TaskRequest taskRequest) { TaskEntity createdTask = taskService.createTask(taskRequest); - return ResponseEntity.status(HttpStatus.CREATED).body(TaskResponse.fromEntity(createdTask)); + return ResponseEntity.status(HttpStatus.CREATED) + .body(TaskResponse.fromEntity(createdTask, null)); } /** diff --git a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java index 55ab76b..dcc7cbe 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java +++ b/server/src/main/java/br/com/tasknoteapp/server/entity/TaskEntity.java @@ -1,5 +1,6 @@ package br.com.tasknoteapp.server.entity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -9,6 +10,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; + +import java.time.LocalDateTime; import java.util.List; import lombok.Data; import lombok.EqualsAndHashCode; @@ -35,4 +38,7 @@ public class TaskEntity { @OneToMany(mappedBy = "task", fetch = FetchType.LAZY) private List urls; + + @Column(name = "last_update") + private LocalDateTime lastUpdate; } diff --git a/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java b/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java index f32c41b..881dc7b 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java +++ b/server/src/main/java/br/com/tasknoteapp/server/response/TaskResponse.java @@ -7,12 +7,15 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import org.ocpsoft.prettytime.PrettyTime; + /** This record represents a task and its urls object to be returned. */ @Schema(description = "This record represents a task and its urls object to be returned.") public record TaskResponse( @Schema(description = "The id of the task", example = "1") Long id, @Schema(description = "The description of the task", example = "Task 1") String description, @Schema(description = "The done status of the task", example = "false") Boolean done, + @Schema(description = "When was the last update time of the task") String lastUpdate, @Schema(description = "The urls of the task, zero, one or more.", example = "[]") List urls) { @@ -22,7 +25,7 @@ public record TaskResponse( * @param entity The TaskEntity source data. * @return TaskResponse instance with all task data and urls, if any. */ - public static TaskResponse fromEntity(TaskEntity entity) { + public static TaskResponse fromEntity(TaskEntity entity, PrettyTime prettyTime) { List urls = entity.getUrls(); List urlsResponse = new ArrayList<>(); if (Objects.isNull(urls)) { @@ -34,7 +37,17 @@ public record TaskResponse( } } + if (Objects.isNull(prettyTime)) { + prettyTime = new PrettyTime(); + } + + String timeAgoFmt = prettyTime.format(entity.getLastUpdate()); + return new TaskResponse( - entity.getId(), entity.getDescription(), entity.getDone(), urlsResponse); + entity.getId(), + entity.getDescription(), + entity.getDone(), + timeAgoFmt, + urlsResponse); } } diff --git a/server/src/main/java/br/com/tasknoteapp/server/service/impl/TaskServiceImpl.java b/server/src/main/java/br/com/tasknoteapp/server/service/impl/TaskServiceImpl.java index 00603ee..882efd9 100644 --- a/server/src/main/java/br/com/tasknoteapp/server/service/impl/TaskServiceImpl.java +++ b/server/src/main/java/br/com/tasknoteapp/server/service/impl/TaskServiceImpl.java @@ -14,12 +14,15 @@ import br.com.tasknoteapp.server.service.AuthService; import br.com.tasknoteapp.server.service.TaskService; import br.com.tasknoteapp.server.util.AuthUtil; import jakarta.transaction.Transactional; + +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.ocpsoft.prettytime.PrettyTime; import org.springframework.stereotype.Service; /** This class contains the implementation for the Task Service class. */ @@ -45,7 +48,8 @@ class TaskServiceImpl implements TaskService { List tasks = taskRepository.findAllByUser_id(user.getId()); log.info("{} tasks found!", tasks.size()); - return tasks.stream().map(TaskResponse::fromEntity).toList(); + PrettyTime time = new PrettyTime(); + return tasks.stream().map((TaskEntity tr) -> TaskResponse.fromEntity(tr, time)).toList(); } @Override @@ -58,6 +62,7 @@ class TaskServiceImpl implements TaskService { task.setDescription(taskRequest.description()); task.setDone(false); task.setUser(user); + task.setLastUpdate(LocalDateTime.now()); TaskEntity created = taskRepository.save(task); if (!Objects.isNull(taskRequest.urls()) && !taskRequest.urls().isEmpty()) { @@ -89,6 +94,8 @@ class TaskServiceImpl implements TaskService { taskEntity.setDone(patch.done()); } + taskEntity.setLastUpdate(LocalDateTime.now()); + if (!Objects.isNull(patch.urls())) { List urlIds = patch.urls().stream().filter(p -> p.id() != null).map(TaskUrlPatchRequest::id).toList(); @@ -110,7 +117,7 @@ class TaskServiceImpl implements TaskService { log.info("Task patched! Id {}", patchedTask.getId()); - return TaskResponse.fromEntity(patchedTask); + return TaskResponse.fromEntity(patchedTask, null); } @Override @@ -148,7 +155,8 @@ class TaskServiceImpl implements TaskService { taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), user.getId()); log.info("{} tasks found!", tasks.size()); - return tasks.stream().map(TaskResponse::fromEntity).toList(); + PrettyTime time = new PrettyTime(); + return tasks.stream().map((TaskEntity tr) -> TaskResponse.fromEntity(tr, time)).toList(); } private UserEntity getCurrentUser() { diff --git a/server/src/main/resources/db/migration/V202410081427__add_last_update_task.sql b/server/src/main/resources/db/migration/V202410081427__add_last_update_task.sql new file mode 100644 index 0000000..9350e0f --- /dev/null +++ b/server/src/main/resources/db/migration/V202410081427__add_last_update_task.sql @@ -0,0 +1,2 @@ +alter table tasknote.tasks + add last_update timestamp; diff --git a/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java b/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java index 6354b06..d514d5d 100644 --- a/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java +++ b/server/src/test/java/br/com/tasknoteapp/server/controller/HomeControllerTest.java @@ -73,7 +73,7 @@ class HomeControllerTest { @DisplayName("Search happy path should succeed") @WithMockUser(username = "user@domain.com", password = "abcde123456A@") void search_happyPath_shouldSucceed() throws Exception { - TaskResponse task = new TaskResponse(1L, "Task 1", false, List.of()); + TaskResponse task = new TaskResponse(1L, "Task 1", false, "now", List.of()); NoteResponse note = new NoteResponse(1L, "Note 1", "Note desc", List.of()); SearchResponse response = new SearchResponse(List.of(task), List.of(note));