feat: sort by undone first

feat: add time ago format for the last updated task time

issue #54
This commit is contained in:
Ricardo Campos
2024-10-08 15:48:51 -03:00
parent 52fcd589e9
commit 508788dbf2
11 changed files with 158 additions and 23 deletions
+2 -1
View File
@@ -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[]
}
+102 -15
View File
@@ -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<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [tasks, setTasks] = useState<TaskResponse[]>([]);
const [taskId, setTaskId] = useState<number>(0);
const [taskDescription, setTaskDescription] = useState<string>('');
const [taskUrl, setTaskUrl] = useState<string>('');
const [taskUrlId, setTaskUrlId] = useState<number>(0);
const [taskDone, setTaskDone] = useState<boolean>(false);
const [action, setAction] = useState<TaskAction>('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<boolean> => {
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<HTMLFormElement>): Promise<void> => {
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<HTMLInputElement>) => {
setTaskDescription(e.target.value);
}}
/>
</Form.Group>
@@ -131,6 +198,10 @@ function Task(): JSX.Element {
type="text"
name="url"
placeholder="Additional URL (Optional)"
value={taskUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setTaskUrl(e.target.value);
}}
/>
</Form.Group>
@@ -149,21 +220,25 @@ function Task(): JSX.Element {
</Row>
<Row className="mt-3">
<Col xs={12}>
<Table striped bordered hover>
<Table striped bordered hover responsive>
<thead>
<tr>
<th>#</th>
<th>Description</th>
<th>Done</th>
<th>URL</th>
<th>Actions</th>
<th scope="col" style={{ width: '5%' }}>#</th>
<th scope="col" style={{ width: '50%' }}>Description</th>
<th scope="col" style={{ width: '10%' }}>Done</th>
<th scope="col" style={{ width: '10%' }}>URL</th>
<th scope="col" style={{ width: '25%' }}>Actions</th>
</tr>
</thead>
<tbody>
{tasks.map((task: TaskResponse) => (
<tr key={`task-${task.id}`}>
<td className={task.done ? 'text-done' : ''}>{task.id}</td>
<td className={task.done ? 'text-done' : ''}>{task.description}</td>
<th scope="row" className={task.done ? 'text-done' : ''}>{task.id}</th>
<td className={task.done ? 'text-done' : ''}>
{task.description}
<br />
<small className={task.done ? '' : 'time-ago'}>{task.lastUpdate}</small>
</td>
<td className={task.done ? 'text-done' : ''}>{task.done ? 'Yes' : 'No'}</td>
<td className={task.done ? 'text-done' : ''}>
{task.urls.length > 0 ? (
@@ -173,15 +248,27 @@ function Task(): JSX.Element {
<td className={task.done ? 'text-done' : ''}>
<Button
type="button"
variant="link"
variant="primary"
onClick={() => markAsDone(task)}
className="btn-action"
>
{task.done ? 'Undone' : 'Done'}
</Button>
<Button
type="button"
variant="link"
variant="outline-primary"
onClick={() => editTask(task)}
className="btn-action"
>
Edit
</Button>
<Button
type="button"
variant="danger"
onClick={() => deleteTask(task.id)}
className="btn-action"
>
Delete
</Button>
+9
View File
@@ -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;
}
+7
View File
@@ -182,6 +182,13 @@
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
</dependency>
<!-- Pretty time - Time ago format -->
<dependency>
<groupId>org.ocpsoft.prettytime</groupId>
<artifactId>prettytime</artifactId>
<version>5.0.9.Final</version>
</dependency>
</dependencies>
<build>
@@ -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 {}
@@ -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));
}
/**
@@ -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<TaskUrlEntity> urls;
@Column(name = "last_update")
private LocalDateTime lastUpdate;
}
@@ -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<TaskUrlResponse> 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<TaskUrlEntity> urls = entity.getUrls();
List<TaskUrlResponse> 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);
}
}
@@ -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<TaskEntity> 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<Long> 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() {
@@ -0,0 +1,2 @@
alter table tasknote.tasks
add last_update timestamp;
@@ -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));