task crud finished

issue #46
This commit is contained in:
Ricardo Campos
2024-09-19 20:16:50 -03:00
parent 02d1f3878e
commit 8e52b07dc3
19 changed files with 535 additions and 60 deletions
+2
View File
@@ -40,6 +40,8 @@ const ApiConfig = {
refreshTokenUrl: `${server}/rest/user-sessions/refresh`,
tasksUrl: `${server}/rest/tasks`,
login: async (email: string, password: string): Promise<SigninResponse | Error> => {
try {
const response = await fetch(ApiConfig.signInUrl, {
+127
View File
@@ -0,0 +1,127 @@
import { API_TOKEN } from '../app-constants/app-constants';
import TaskRequest from '../types/TaskRequest';
import { TaskResponse } from '../types/TaskResponse';
import ApiConfig from './apiConfig';
async function addTaskRequest(task: TaskRequest): Promise<TaskResponse | Error> {
try {
const tokenState = localStorage.getItem(API_TOKEN);
const response = await fetch(ApiConfig.tasksUrl, {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenState}`
},
body: JSON.stringify(task)
});
if (response.ok) {
const data = await response.json();
return data;
}
if (response.status === 400) {
return new Error('Wrong or missing information!');
}
if (response.status === 403) {
return new Error('Forbidden! Access denied');
}
if (response.status === 500) {
return new Error('Internal Server Error!');
}
} catch (e) {
if (typeof e === 'string') {
return new Error(e as string);
}
}
return new Error('Unknown error');
}
async function getTasksRequest(): Promise<TaskResponse[] | Error> {
try {
const tokenState = localStorage.getItem(API_TOKEN);
const response = await fetch(ApiConfig.tasksUrl, {
method: 'GET',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenState}`
}
});
if (response.ok) {
const data = await response.json();
return data;
}
if (response.status === 403) {
return new Error('Forbidden! Access denied');
}
if (response.status === 500) {
return new Error('Internal Server Error!');
}
} catch (e) {
if (typeof e === 'string') {
return new Error(e as string);
}
}
return new Error('Unknown error');
}
async function updateTaskDoneRequest(id: number, done: boolean): Promise<Error | undefined> {
try {
const tokenState = localStorage.getItem(API_TOKEN);
const response = await fetch(`${ApiConfig.tasksUrl}/${id}`, {
method: 'PATCH',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenState}`
},
body: JSON.stringify({ done })
});
if (response.ok) {
await response.json();
return;
}
if (response.status === 403) {
return new Error('Forbidden! Access denied');
}
if (response.status === 500) {
return new Error('Internal Server Error!');
}
} catch (e) {
if (typeof e === 'string') {
return new Error(e as string);
}
}
return new Error('Unknown error');
}
async function deleteTaskRequest(id: number): Promise<Error | undefined> {
try {
const tokenState = localStorage.getItem(API_TOKEN);
const response = await fetch(`${ApiConfig.tasksUrl}/${id}`, {
method: 'DELETE',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenState}`
}
});
if (response.status === 204) {
return;
}
if (response.status === 403) {
return new Error('Forbidden! Access denied');
}
if (response.status === 500) {
return new Error('Internal Server Error!');
}
} catch (e) {
console.log('aha!');
if (typeof e === 'string') {
return new Error(e as string);
}
}
return new Error('Unknown error');
}
export { addTaskRequest, getTasksRequest, updateTaskDoneRequest, deleteTaskRequest };
-13
View File
@@ -1,13 +0,0 @@
.footer {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background-color: #282c34;
color: #fff;
}
.footer span {
font-weight: bold;
color: #61dafb;
}
-29
View File
@@ -1,29 +0,0 @@
import React, { useContext } from 'react';
import { Button } from 'react-bootstrap';
import style from './Footer.module.css';
import { env } from '../env';
import AuthContext from '../context/AuthContext';
const Footer = () => {
const { signOut } = useContext(AuthContext);
const build = env.VITE_BUILD;
return (
<footer className={style.footer}>
<span>React + TS Todoo</span>
{' '}
@ 2024
{` Build: ${build}`}
<Button
type="button"
onClick={() => signOut()}
>
Sair
</Button>
</footer>
);
};
export default Footer;
+42
View File
@@ -0,0 +1,42 @@
import React, { useContext } from 'react';
import { Button, Col, Container, Row } from 'react-bootstrap';
import { env } from '../../env';
import AuthContext from '../../context/AuthContext';
import './style.css';
/**
* Footer component.
*
* This component provides the footer section of the application,
* providing navigation to logout.
* It also includes the build version.
*
* @returns The Footer component.
*/
function Footer(): JSX.Element {
const { signOut, user } = useContext(AuthContext);
const build = env.VITE_BUILD;
const currentYear = new Date().getFullYear();
return (
<footer className="footer">
<Container>
<Row className="align-items-center">
<Col xs={12} sm={4} className="text-center text-sm-start">
<span>TaskNote App &copy; {currentYear} ({build})</span>
</Col>
<Col xs={12} sm={4} className="text-center text-sm-end">
{user?.email}
</Col>
<Col xs={12} sm={4} className="text-center text-sm-end">
<Button type="button" variant="link" onClick={signOut} className="logout-button">
Logout
</Button>
</Col>
</Row>
</Container>
</footer>
);
};
export default Footer;
+19
View File
@@ -0,0 +1,19 @@
.footer {
background-color: #f8f9fa;
padding: 20px 0;
margin-top: 50px;
}
.link {
color: #007bff;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.logout-button {
color: #dc3545!important;
text-decoration: none!important;
}
+6
View File
@@ -24,6 +24,12 @@ function Header() {
<LinkContainer to="/home">
<Nav.Link>Home</Nav.Link>
</LinkContainer>
<LinkContainer to="/tasks">
<Nav.Link>Tasks</Nav.Link>
</LinkContainer>
<LinkContainer to="/notes">
<Nav.Link>Notes</Nav.Link>
</LinkContainer>
<LinkContainer to="/about">
<Nav.Link>About</Nav.Link>
</LinkContainer>
+18 -5
View File
@@ -39,17 +39,29 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
return undefined;
};
const updateUserSession = (userPriv: User | null, bearerToken: string) => {
const updateUserSession = (userPriv: User | null, bearerToken: string): User => {
if (userPriv) {
localStorage.setItem(USER_DATA, JSON.stringify(userPriv));
}
localStorage.setItem(API_TOKEN, bearerToken);
if (userPriv) {
return userPriv;
}
const savedUser = localStorage.getItem(USER_DATA);
if (savedUser) {
return JSON.parse(savedUser);
}
return { email: 'undefined' };
};
const checkCurrentAuthUser = async (pathname: string): Promise<void> => {
const bearerToken: SigninResponse | undefined = await fetchCurrentSession(pathname);
if (bearerToken && bearerToken.token) {
updateUserSession(null, bearerToken.token);
const userLocal = updateUserSession(null, bearerToken.token);
setUser(userLocal);
}
};
@@ -61,7 +73,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
}
const currentUser: User = {
email
email: email
};
setSigned(true);
@@ -78,7 +90,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
}
const currentUser: User = {
email
email: email
};
setSigned(true);
@@ -102,7 +114,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
const refreshTokenPvt = async (): Promise<void> => {
const bearerToken: SigninResponse | undefined = await fetchCurrentSession('/');
if (bearerToken) {
updateUserSession(null, bearerToken.token);
const userLocal = updateUserSession(null, bearerToken.token);
setUser(userLocal);
}
return Promise.resolve();
};
+11 -4
View File
@@ -3,21 +3,28 @@ import { Outlet } from 'react-router-dom';
import { Container } from 'react-bootstrap';
import Header from '../../components/Header';
import Footer from '../../components/Footer';
import './style.css';
/**
* Layout component.
*
* This component provides the layout of the application,
* including the outlet responsible for the main content.
* It also includes the header and the footer.
*
* @returns The Layout component.
*/
function Layout() {
function Layout(): JSX.Element {
return (
<>
<div className="page-container">
<Header />
<Container>
<Container className="content-container">
<Outlet />
</Container>
<Footer />
</>
</div>
);
}
@@ -0,0 +1,9 @@
.page-container {
display: flex;
flex-direction: column;
min-height: 100vh; /* Ensure the container takes up full viewport height */
}
.content-container {
flex-grow: 1; /* Allow the content area to grow and fill the remaining space */
}
+12 -6
View File
@@ -2,6 +2,8 @@ import { Navigate, RouteObject } from 'react-router-dom';
import getStoredPath from '../utils/PathUtils';
import Home from '../views/Home';
import About from '../views/About';
import Task from '../views/Task';
import Note from '../views/Note';
const BrowserRoutes: RouteObject[] = [
{
@@ -24,15 +26,19 @@ const BrowserRoutes: RouteObject[] = [
},
{
path: '/home',
element: (
<Home />
)
element: <Home />
},
{
path: '/about',
element: (
<About />
)
element: <About />
},
{
path: '/tasks',
element: <Task />
},
{
path: '/notes',
element: <Note />
}
];
+6
View File
@@ -0,0 +1,6 @@
type TaskRequest = {
description: string;
urls?: string[];
}
export default TaskRequest;
+13
View File
@@ -0,0 +1,13 @@
type TaskUrlResponse = {
id: number,
url: string
}
type TaskResponse = {
id: number,
description: string,
done: boolean,
urls: TaskUrlResponse[]
}
export type { TaskUrlResponse, TaskResponse };
+46 -2
View File
@@ -1,11 +1,55 @@
import React from 'react';
import { Button, Card, Col, Container, Row } from 'react-bootstrap';
import './style.css';
/**
*
*/
function Home() {
function Home(): JSX.Element {
return (
<h1>This is home!</h1>
<Container>
<Row className="mt-3">
<Col xs={12}>
<Card>
<Card.Body>
<Card.Title>Search</Card.Title>
<Card.Text>
10
</Card.Text>
</Card.Body>
<Card.Footer>3 record(s) found!</Card.Footer>
</Card>
</Col>
</Row>
<Row className="mt-3">
<Col xs={12} sm={6}>
<Card className="text-center">
<Card.Header>Tasks</Card.Header>
<Card.Body>
<Card.Title>Pending tasks</Card.Title>
<Card.Text>
10
</Card.Text>
<Button variant="primary" type="button">Go to Tasks</Button>
</Card.Body>
<Card.Footer>Test?</Card.Footer>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card className="text-center">
<Card.Header>Notes</Card.Header>
<Card.Body>
<Card.Title>Notes summary</Card.Title>
<Card.Text>
10
</Card.Text>
<Button variant="primary" type="button">Go to Notes</Button>
</Card.Body>
<Card.Footer>Test?</Card.Footer>
</Card>
</Col>
</Row>
</Container>
);
}
+3
View File
@@ -0,0 +1,3 @@
.home-card {
padding-top: 100px;
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
/**
*
*/
function Note() {
return (
<h1>This is task!</h1>
);
}
export default Note;
+201
View File
@@ -0,0 +1,201 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, Button, Card, Col, Container, Form, InputGroup, Row, Table } from 'react-bootstrap';
import {
addTaskRequest,
deleteTaskRequest,
getTasksRequest,
updateTaskDoneRequest
} from '../../api-service/taskService';
import TaskRequest from '../../types/TaskRequest';
import { TaskResponse } from '../../types/TaskResponse';
import './style.css';
function Task(): JSX.Element {
const [validated, setValidated] = useState<boolean>(true);
const [formInvalid, setFormInvalid] = useState<boolean>(false);
const [errorMessage, setErrorMessage] = useState<string>('');
const [tasks, setTasks] = useState<TaskResponse[]>([]);
const handleError = (e: unknown): void => {
if (typeof e === 'string') {
setErrorMessage(e);
setFormInvalid(true);
} else if (e instanceof Error) {
setErrorMessage(e.message);
setFormInvalid(true);
}
}
const addTask = async (desc: string, url?: string): Promise<boolean> => {
const payload: TaskRequest = {
description: desc,
urls: url? [url] : []
};
const response = await addTaskRequest(payload);
if ('id' in response) {
const task: TaskResponse = response;
loadTasks();
return true;
} else {
handleError(response);
}
return false;
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
setValidated(true);
const form = event.currentTarget;
if (form.checkValidity() === false) {
setFormInvalid(true);
return;
}
setFormInvalid(false);
const added: boolean = await addTask(form.description.value, form.url.value);
if (added) {
form.reset();
}
};
const loadTasks = async() => {
const tasks: TaskResponse[] | Error = await getTasksRequest();
if (tasks instanceof Error) {
handleError(tasks);
} else {
setTasks(tasks);
}
};
const markAsDone = async (task: TaskResponse) => {
const tasks: Error | undefined = await updateTaskDoneRequest(task.id, !task.done);
if (tasks instanceof Error) {
handleError(tasks);
} else {
loadTasks();
}
};
const deleteTask = async (taskId: number) => {
const response: Error | undefined = await deleteTaskRequest(taskId);
if (response instanceof Error) {
handleError(tasks);
} else {
loadTasks();
}
};
useEffect(() => {
loadTasks();
}, []);
return (
<Container>
<Row className="mt-3">
<Col xs={12}>
<Card>
<Card.Body>
<Card.Title>Add task</Card.Title>
{formInvalid ? (
<Alert variant={"danger"}>
{ errorMessage }
</Alert>
) : null}
<Form noValidate validated={validated} onSubmit={handleSubmit}>
<Form.Group className="mb-3" controlId="formBasicEmail">
<Form.Label>Description</Form.Label>
<Form.Control
required
type="test"
name="description"
placeholder="Enter description"
/>
</Form.Group>
<Form.Group className="mb-3" controlId="input-url">
<Form.Label>Additional URL</Form.Label>
<Form.Control
required={false}
type="text"
name="url"
placeholder="Additional URL (Optional)"
/>
</Form.Group>
<Button
variant="primary"
type="submit"
className="w-100"
>
Save
</Button>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<Row className="mt-3">
<Col xs={12}>
<Card>
<Card.Body>
<Card.Title>Task list</Card.Title>
<Table striped bordered hover>
<thead>
<tr>
<th>#</th>
<th>Description</th>
<th>Done</th>
<th>URL</th>
<th>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>
<td className={task.done ? 'text-done' : ''}>{task.done? 'Yes' : 'No'}</td>
<td className={task.done ? 'text-done' : ''}>
{task.urls.length > 0? (
<a href={`${task.urls[0].url}`}>Link</a>
) : '-'}
</td>
<td className={task.done ? 'text-done' : ''}>
<Button
type="button"
variant="link"
onClick={() => markAsDone(task)}
>
{task.done ? 'Undone' : 'Done'}
</Button>
<Button
type="button"
variant="link"
onClick={() => deleteTask(task.id)}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</Table>
</Card.Body>
<Card.Footer>
{tasks.length === 0? 'No tasks' : `${tasks.length} pending task(s)`}
</Card.Footer>
</Card>
</Col>
</Row>
</Container>
);
}
export default Task;
+7
View File
@@ -0,0 +1,7 @@
.text-done {
color: #ccc!important;
}
.text-done .btn.btn-link {
color: #ccc!important;
}
@@ -60,7 +60,7 @@ class TaskServiceImpl implements TaskService {
task.setUser(user);
TaskEntity created = taskRepository.save(task);
if (Objects.isNull(taskRequest.urls())) {
if (!Objects.isNull(taskRequest.urls()) && !taskRequest.urls().isEmpty()) {
List<TaskUrlEntity> urls = saveUrls(task, taskRequest.urls());
task.setUrls(urls);
}