Feat/321 improve search and results (#419)

* bugfix: when reloading the page logged it doesn't go to home

* feat: make the alert error dismissible

* feat: improve dashboard page. Issue #321

* feat: improve home search tasks. Issue #321

* feat: improve home search. issue #412

* test: fix test cases

* chore: improve url - copilot review

* test: add unit tests. Issue #321

* chore: fix checkstyle issue

* test: add more tests to backend. Issue #321

* chore: fix sonarcloud issues. Issue #321

* test: add frontend tests. Issue #321

* test: add more tests to frontend. Issue #321

* test: add more tests to frontend. Issue #321

* test: add more tests to frontend. Issue #321
This commit is contained in:
2025-04-21 18:59:55 -03:00
committed by GitHub
parent 570f5ebb14
commit c6dd3dd9d5
35 changed files with 1606 additions and 161 deletions
@@ -0,0 +1,84 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { SearchResults } from '../../components/SearchResults';
import { TaskResponse } from '../../types/TaskResponse';
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('SearchResults Component', () => {
const mockTaskAction = vi.fn();
const sampleTasks: TaskResponse[] = [
{
id: 1,
description: 'Task 1',
done: false,
highPriority: false,
dueDate: '2023-10-01',
dueDateFmt: '2023-10-01',
lastUpdate: 'Some time ago',
tag: 'tag1',
urls: ['https://example.com'],
},
{
id: 2,
description: 'Task 2',
done: false,
highPriority: false,
urls: [],
dueDate: '',
dueDateFmt: '',
lastUpdate: 'Some time ago',
tag: 'tag2'
},
];
afterEach(() => {
vi.clearAllMocks();
});
it('renders "No tasks found" when results array is empty', () => {
render(<SearchResults results={[]} taskAction={mockTaskAction} />);
expect(screen.getByText(/No tasks found/i)).toBeDefined();
});
it('renders the correct number of tasks', () => {
render(<SearchResults results={sampleTasks} taskAction={mockTaskAction} />);
expect(screen.getByText(/2 task\(s\) found/i)).toBeDefined();
expect(screen.getByText('Task 1')).toBeDefined();
expect(screen.getByText('Task 2')).toBeDefined();
});
it('triggers taskAction with "done" action when done icon is clicked', () => {
render(<SearchResults results={sampleTasks} taskAction={mockTaskAction} />);
const doneButton = screen.getByTestId('task-home-result-done-1');
fireEvent.click(doneButton);
expect(mockTaskAction).toHaveBeenCalledWith('done', sampleTasks[0]);
});
it('triggers taskAction with "edit" action when edit icon is clicked', () => {
render(<SearchResults results={sampleTasks} taskAction={mockTaskAction} />);
const editButton = screen.getByTestId('task-home-result-edit-1');
fireEvent.click(editButton);
expect(mockTaskAction).toHaveBeenCalledWith('edit', sampleTasks[0]);
});
it('triggers taskAction with "delete" action when delete icon is clicked', () => {
render(<SearchResults results={sampleTasks} taskAction={mockTaskAction} />);
const deleteButton = screen.getByTestId('task-home-result-delete-1');
fireEvent.click(deleteButton);
expect(mockTaskAction).toHaveBeenCalledWith('delete', sampleTasks[0]);
});
it('renders external link icon when task has URLs', () => {
render(<SearchResults results={sampleTasks} taskAction={mockTaskAction} />);
const externalLink = screen.getByAltText('external link');
expect(externalLink).toBeDefined();
// expect(externalLink.closest('a')).toHaveAttribute('href', 'https://example.com');
});
it('renders due date icon when task has a due date', () => {
render(<SearchResults results={sampleTasks} taskAction={mockTaskAction} />);
const dueDateIcon = screen.getByTitle('2023-10-01');
expect(dueDateIcon).toBeDefined();
});
});
@@ -38,7 +38,7 @@ describe('Sidebar Component', () => {
<AuthContext.Provider value={authContextMock}>
<I18nextProvider i18n={i18n}>
<SidebarContext.Provider value={sidebarContextMock}>
<Sidebar />
<Sidebar isMobileOpen={false} setIsMobileOpen={vi.fn()} />
</SidebarContext.Provider>
</I18nextProvider>
</AuthContext.Provider>
+25 -6
View File
@@ -7,6 +7,7 @@ import api from '../../api-service/api';
import Home from '../../views/Home';
import '../../i18n';
import AuthContext from '../../context/AuthContext';
import { TasksChartResponse } from '../../types/TasksChartResponse';
// Mock the Chart component
vi.mock('react-charts', () => ({
@@ -33,14 +34,32 @@ const authContextMock = {
updateUser: vi.fn()
};
const mockData: SummaryResponse = {
pendingTaskCount: 354,
doneTaskCount: 555,
notesCount: 2222
};
const mockTags: string[] = ['tag1', 'tag2'];
const mockChart: TasksChartResponse[] = [
{ day: 'S', count: 5, date: new Date() },
{ day: 'M', count: 10, date: new Date() },
];
describe('Renders the home view', () => {
it('should render text based on new contentHeader component', async () => {
const mockData: SummaryResponse = {
pendingTaskCount: 354,
doneTaskCount: 555,
notesCount: 2222
};
const mockedGetJSON = vi.spyOn(api, 'getJSON').mockResolvedValue(mockData);
vi.spyOn(api, "getJSON").mockImplementation((url: string) => {
if (url === 'http://localhost:8585/rest/home/tasks/tags') {
return Promise.resolve(mockTags);
} else if (url === 'http://localhost:8585/rest/home/summary') {
return Promise.resolve(mockData);
} else if (url === 'http://localhost:8585/rest/home/completed-tasks-chart') {
return Promise.resolve(mockChart);
}
return Promise.reject(new Error("Unknown endpoint: " + url));
});
await act(async () => {
render(
+197
View File
@@ -0,0 +1,197 @@
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, it, vi, expect, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router';
import AuthContext from '../../context/AuthContext';
import Home from '../../views/Home';
import api from '../../api-service/api';
import '../../i18n';
import { TasksChartResponse } from '../../types/TasksChartResponse';
import { SummaryResponse } from '../../types/SummaryResponse';
import { TaskResponse } from '../../types/TaskResponse';
import { NoteResponse } from '../../types/NoteResponse';
vi.mock('react-charts', () => ({
Chart: ({ options }) => <div data-testid="mocked-chart">Mocked Chart</div>
}));
vi.mock('../../api-service/api');
const mockAuthContext = {
signed: true,
user: {
userId: 1,
name: 'Ricardo',
email: 'ricardo@campos.com',
admin: false,
createdAt: new Date(),
gravatarImageUrl: 'http://image.com'
},
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
signOut: vi.fn(),
register: vi.fn(),
isAdmin: false,
updateUser: vi.fn()
};
const mockTags = ['work', 'personal'];
const mockTasks: TaskResponse[] = [
{ id: 1, description: 'Task 1', done: false, highPriority: false, dueDate: '', dueDateFmt: '', tag: 'tag1', urls: [], lastUpdate: 'Moments ago' },
{ id: 2, description: 'Task 2', done: false, highPriority: false, dueDate: '', dueDateFmt: '', tag: 'tag2', urls: [], lastUpdate: 'Moments ago' },
];
const mockNotes: NoteResponse[] = [
{ id: 1, title: 'Note 1', description: 'Description 1', url: '' },
{ id: 2, title: 'Note 2', description: 'Description 2', url: '' },
];
const mockChart: TasksChartResponse[] = [
{ day: 'S', count: 5, date: new Date() },
{ day: 'M', count: 10, date: new Date() },
];
const mockSummary: SummaryResponse = {
pendingTaskCount: 354,
doneTaskCount: 555,
notesCount: 2222
};
describe('Home Component', () => {
beforeEach(() => {
vi.spyOn(api, 'getJSON').mockImplementation((url) => {
if (url.includes('/tasks/tags')) return Promise.resolve(mockTags);
if (url.includes('/tasks/filter')) return Promise.resolve(mockTasks);
if (url.includes('/summary')) return Promise.resolve(mockSummary);
if (url.includes('/completed-tasks-chart')) return Promise.resolve(mockChart);
if (url.includes('/search')) return Promise.resolve({ tasks: mockTasks, notes: mockNotes });
return Promise.reject(new Error('Unknown endpoint'));
});
vi.spyOn(api, 'patchJSON').mockResolvedValue({});
vi.spyOn(api, 'deleteNoContent').mockResolvedValue({});
});
const renderComponent = () => {
return render(
<MemoryRouter>
<AuthContext.Provider value={mockAuthContext}>
<Home />
</AuthContext.Provider>
</MemoryRouter>
);
}
it('renders the component with initial data', async () => {
renderComponent();
await waitFor(() => {
expect(screen.getByText('Welcome to TaskNote! Get ready to complete your pending tasks')).toBeDefined();
expect(screen.getByText('🔥 High Priority')).toBeDefined();
expect(screen.getByText('#work')).toBeDefined();
expect(screen.getByText('#personal')).toBeDefined();
});
});
it('handles search functionality', async () => {
renderComponent();
const searchInput = screen.getByPlaceholderText('Search tasks & notes');
const form = searchInput.closest('form') as HTMLFormElement;
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'Task' } });
fireEvent.submit(form);
});
expect(screen.getByText('Task 1')).toBeDefined();
expect(screen.getByText('Task 2')).toBeDefined();
expect(screen.getByText('Note 1')).toBeDefined();
expect(screen.getByText('Note 2')).toBeDefined();
});
it('handles search functionality with error', async () => {
renderComponent();
const searchInput = screen.getByPlaceholderText('Search tasks & notes');
const form = searchInput.closest('form') as HTMLFormElement;
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'a' } });
fireEvent.submit(form);
});
expect(screen.getByText('Please type at least 3 characters')).toBeDefined();
});
it('loads tasks based on filter', async () => {
renderComponent();
const highPriorityButton = screen.getByText('🔥 High Priority');
await act(async () => {
fireEvent.click(highPriorityButton);
});
expect(screen.getByText('Task 1')).toBeDefined();
expect(screen.getByText('Task 2')).toBeDefined();
});
it('marks a task as done', async () => {
renderComponent();
const searchInput = screen.getByPlaceholderText('Search tasks & notes');
const form = searchInput.closest('form') as HTMLFormElement;
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'Task' } });
fireEvent.submit(form);
});
const markAsDoneButton = screen.getByTestId('task-home-result-done-1');
await act(async () => {
fireEvent.click(markAsDoneButton!);
});
expect(api.patchJSON).toHaveBeenCalledWith(expect.stringContaining('/tasks/1'), expect.objectContaining({ done: true }));
});
it('deletes a task', async () => {
renderComponent();
const searchInput = screen.getByPlaceholderText('Search tasks & notes');
const form = searchInput.closest('form') as HTMLFormElement;
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'Task' } });
fireEvent.submit(form);
});
const deleteButton = screen.getByTestId('task-home-result-delete-1');
await act(async () => {
fireEvent.click(deleteButton!);
});
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/tasks/1'));
});
it('opens a note in the modal', async () => {
renderComponent();
const searchInput = screen.getByPlaceholderText('Search tasks & notes');
const form = searchInput.closest('form') as HTMLFormElement;
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'Task' } });
fireEvent.submit(form);
});
const openNoteButton = screen.getByTestId('note-home-result-open-1');
await act(async () => {
fireEvent.click(openNoteButton!);
});
expect(screen.getByText('Description 1')).toBeDefined();
});
});
+30 -1
View File
@@ -9,6 +9,8 @@ import i18n from '../../i18n';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
import { NoteResponse } from '../../types/NoteResponse';
import SidebarContext from '../../context/SidebarContext';
import { beforeEach } from 'node:test';
vi.mock('../../api-service/api');
@@ -27,6 +29,17 @@ vi.mock('react-i18next', () => ({
I18nextProvider: ({ children }: any) => children,
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<any>("react-router-dom");
return {
...actual,
useSearchParams: vi.fn(),
};
});
import { useSearchParams } from "react-router-dom";
const authContextMock = {
signed: true,
user: {
@@ -45,19 +58,31 @@ const authContextMock = {
updateUser: vi.fn(),
};
const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
};
describe('NoteAdd Component', () => {
const renderNoteAdd = () => {
return render(
<MemoryRouter>
<AuthContext.Provider value={authContextMock}>
<I18nextProvider i18n={i18n}>
<NoteAdd />
<SidebarContext.Provider value={sidebarContextMock}>
<NoteAdd />
</SidebarContext.Provider>
</I18nextProvider>
</AuthContext.Provider>
</MemoryRouter>
);
};
beforeEach(() => {
// Reset mock between tests
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReset();
});
it('should render the NoteAdd component', () => {
const { getByText } = renderNoteAdd();
expect(getByText('note_form_title_label')).toBeDefined();
@@ -76,6 +101,10 @@ describe('NoteAdd Component', () => {
});
it('should add a new note when form is valid', async () => {
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue([
new URLSearchParams("backTo=home"),
]);
const { getByLabelText, getByTestId, getByRole } = renderNoteAdd();
const descriptionInput = getByLabelText('note_form_title_label') as HTMLInputElement;
const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement;
+34 -2
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router';
import { I18nextProvider } from 'react-i18next';
import TaskAdd from '../../views/TaskAdd';
@@ -9,6 +9,7 @@ import i18n from '../../i18n';
import api from '../../api-service/api';
import ApiConfig from '../../api-service/apiConfig';
import TaskNoteRequest from '../../types/TaskNoteRequest';
import SidebarContext from '../../context/SidebarContext';
vi.mock('../../api-service/api');
@@ -27,6 +28,17 @@ vi.mock('react-i18next', () => ({
I18nextProvider: ({ children }: any) => children,
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<any>("react-router-dom");
return {
...actual,
useSearchParams: vi.fn(),
};
});
import { useSearchParams } from "react-router-dom";
const authContextMock = {
signed: true,
user: {
@@ -45,19 +57,31 @@ const authContextMock = {
updateUser: vi.fn(),
};
const sidebarContextMock = {
currentPage: '/home',
setNewPage: vi.fn()
};
describe('TaskAdd Component', () => {
const renderTaskAdd = () => {
return render(
<MemoryRouter>
<AuthContext.Provider value={authContextMock}>
<I18nextProvider i18n={i18n}>
<TaskAdd />
<SidebarContext.Provider value={sidebarContextMock}>
<TaskAdd />
</SidebarContext.Provider>
</I18nextProvider>
</AuthContext.Provider>
</MemoryRouter>
);
};
beforeEach(() => {
// Reset mock between tests
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReset();
});
it('should render the TaskAdd component', () => {
const { getByText } = renderTaskAdd();
expect(getByText('task_form_title')).toBeDefined();
@@ -77,6 +101,10 @@ describe('TaskAdd Component', () => {
});
it('should add a new task when form is valid', async () => {
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue([
new URLSearchParams("backTo=home"),
]);
const { getByLabelText, getByRole } = renderTaskAdd();
const descriptionInput = getByLabelText('task_form_desc_label') as HTMLInputElement;
const submitButton = getByRole('button', { name: 'task_form_submit' });
@@ -98,6 +126,10 @@ describe('TaskAdd Component', () => {
});
it('should render text based on new contentHeader component', () => {
(useSearchParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue([
new URLSearchParams("backTo=home"),
]);
const { getByText } = renderTaskAdd();
expect(getByText('Add')).toBeDefined();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 443 B

+17 -12
View File
@@ -1,9 +1,10 @@
import React from 'react';
import { Alert, Col, Row } from 'react-bootstrap';
import { Alert } from 'react-bootstrap';
type Props = {
errorMessage?: string;
dataTestid?: string;
onClose?: () => void;
};
/**
@@ -12,20 +13,24 @@ type Props = {
* @param {Props} props the AlertError props with the message to be displayed.
* @param {string} [props.errorMessage] Optional error message.
* @param {string} [props.dataTestid] Optional data-testid property.
* @param {Function} [props.onClose] OnClose function to be called.
* @returns {React.ReactNode} the AlertError rendered component.
*/
const AlertError: React.FC<Props> = (props: Props): React.ReactNode => {
return props.errorMessage && props.errorMessage.length > 0
? (
<Row className="main-margin">
<Col xs={12}>
<Alert variant="danger" data-testid={props.dataTestid}>
{ props.errorMessage }
</Alert>
</Col>
</Row>
)
: null;
if (!props.errorMessage || props.errorMessage.length === 0) {
return null;
}
return (
<Alert
variant="danger"
dismissible
data-testid={props.dataTestid}
onClose={props.onClose}
>
{ props.errorMessage }
</Alert>
);
};
export default AlertError;
@@ -49,7 +49,7 @@ const ContentHeader: React.FC<Props> = (props: Props): React.ReactNode => {
</Col>
{props.isHomeComponent && (
<Col xs={12} sm={4} className="text-sm-end">
<NavLink to="/tasks/new" onClick={() => setNewPage('/tasks/new')}>
<NavLink to="/tasks/new?backTo=home" onClick={() => setNewPage('/tasks/new')}>
<button
type="button"
className="home-new-item w-45 mb-2"
@@ -57,7 +57,7 @@ const ContentHeader: React.FC<Props> = (props: Props): React.ReactNode => {
New task
</button>
</NavLink>
<NavLink to="/notes/new" onClick={() => setNewPage('/notes/new')}>
<NavLink to="/notes/new?backTo=home" onClick={() => setNewPage('/notes/new')}>
<button
type="button"
className="home-new-item w-45 ms-2"
@@ -0,0 +1,26 @@
import React from 'react';
type Props = {
text: string;
onClick: () => void;
type: string;
};
const HomeFilterButton: React.FC<Props> = (props: Props) => {
const homeHigh = props.type === 'high' ? 'home-high' : '';
const homeAll = props.type === 'all' ? 'home-all ms-2' : '';
const homeTag = props.type === 'tag' ? 'home-tag ms-2' : '';
const filterClasses = [homeHigh, homeAll, homeTag].join(' ');
return (
<button
className={`home-filter ${filterClasses}`}
type="button"
onClick={props.onClick}
>
{props.text}
</button>
);
};
export default HomeFilterButton;
@@ -0,0 +1,80 @@
import React from 'react';
import { ListGroup } from 'react-bootstrap';
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
import { PencilSquare, Trash } from 'react-bootstrap-icons';
import { NoteResponse } from '../../types/NoteResponse';
interface SearchResultsProps {
results: NoteResponse[];
noteAction: (action: string, task: NoteResponse) => void;
}
export const SearchNoteResults: React.FC<SearchResultsProps> = ({ results, noteAction }) => {
return (
<>
{results.length === 0
? (
<p className="text-muted fst-italic search-result-item-title">No notes found</p>
)
: (
<>
<p className="text-muted fst-italic search-result-item-title">
{results.length}
{' '}
notes(s) found
</p>
<ListGroup className="mt-2 d-flex flex-column">
{results.map((note: NoteResponse) => (
<ListGroup.Item key={note.id} className="search-result-item d-flex justify-content-between align-items-center">
{note.title}
<small>
<a
href="#"
className="ms-2"
title="Open note"
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
noteAction('open', note);
}}
data-testid={`note-home-result-open-${note.id}`}
>
Open
</a>
</small>
{note.url && note.url.length > 0 && (
<a href={note.url} target="_blank" rel="noreferrer" className="task-note-external-link">
<img src={ExternalLinkIcon} width={20} alt="external link" />
</a>
)}
<div className="d-flex gap-2 ms-auto">
<a
href="#"
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
noteAction('edit', note);
}}
>
<PencilSquare />
</a>
<a
href="#"
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
noteAction('delete', note);
}}
>
<Trash />
</a>
</div>
</ListGroup.Item>
))}
</ListGroup>
</>
)}
</>
);
};
@@ -0,0 +1,81 @@
import React from 'react';
import { TaskResponse } from '../../types/TaskResponse';
import { ListGroup } from 'react-bootstrap';
import ExternalLinkIcon from '../../assets/icons8-external-link-30.png';
import { Check2Square, PencilSquare, Trash } from 'react-bootstrap-icons';
interface SearchResultsProps {
results: TaskResponse[];
taskAction: (action: string, task: TaskResponse) => void;
}
export const SearchResults: React.FC<SearchResultsProps> = ({ results, taskAction }) => {
return (
<>
{results.length === 0
? (
<p className="text-muted fst-italic search-result-item-title">No tasks found</p>
)
: (
<>
<p className="text-muted fst-italic search-result-item-title">
{results.length}
{' '}
task(s) found
</p>
<ListGroup className="mt-2 d-flex flex-column">
{results.map((task: TaskResponse) => (
<ListGroup.Item key={task.id} className="search-result-item d-flex justify-content-between align-items-center">
{task.description}
{task.urls && task.urls.length > 0 && (
<a href={task.urls[0]} target="_blank" rel="noreferrer" className="task-note-external-link">
<img src={ExternalLinkIcon} width={20} alt="external link" />
</a>
)}
{task.dueDateFmt && (
<span className="ms-1" title={task.dueDateFmt}>📅</span>
)}
<div className="d-flex gap-2 ms-auto">
<a
href="#"
data-testid={`task-home-result-done-${task.id}`}
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
taskAction('done', task);
}}
>
<Check2Square />
</a>
<a
href="#"
data-testid={`task-home-result-edit-${task.id}`}
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
taskAction('edit', task);
}}
>
<PencilSquare />
</a>
<a
href="#"
data-testid={`task-home-result-delete-${task.id}`}
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();
taskAction('delete', task);
}}
>
<Trash />
</a>
</div>
</ListGroup.Item>
))}
</ListGroup>
</>
)}
</>
);
};
+2 -2
View File
@@ -36,7 +36,7 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
return (
<>
<button
className="d-md-none position-fixed top-0 start-0 btn btn-light m-2 z-3"
className="d-lg-none position-fixed top-0 start-0 btn btn-light m-2 z-3"
onClick={() => props.setIsMobileOpen(!props.isMobileOpen)}
aria-label="Toggle sidebar"
>
@@ -124,7 +124,7 @@ function Sidebar(props: React.PropsWithChildren<Props>): React.ReactNode {
{props.isMobileOpen && (
<button
className="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50 d-md-none"
className="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-50 d-lg-none"
style={{ zIndex: 1 }}
onClick={() => props.setIsMobileOpen(false)}
aria-label="Close mobile menu"
+2 -2
View File
@@ -14,7 +14,7 @@
}
/* Mobile sidebar positioning - hidden by default */
@media (max-width: 767.98px) {
@media (max-width: 991.98px) {
.sidebar {
transform: translateX(-100%);
}
@@ -36,7 +36,7 @@
/* Adjust header spacing on smaller screens */
@media (max-width: 991.98px) {
.sidebar-header {
margin-top: 40px;
margin-top: 60px;
}
}
+2 -2
View File
@@ -10,14 +10,14 @@
}
/* On medium screens and larger, push content to make room for sidebar */
@media (min-width: 768px) {
@media (min-width: 992px) {
.main-content {
margin-left: 276px; /* Same as sidebar width */
}
}
/* On small screens, content takes full width by default */
@media (max-width: 767.98px) {
@media (max-width: 991.98px) {
.main-content {
margin-left: 0;
}
+45 -4
View File
@@ -155,16 +155,16 @@ a:hover, .btn-link:hover {
}
/* On medium screens and larger */
@media (min-width: 768px) {
@media (min-width: 992px) {
.main-margin {
margin-top: 2rem;
}
}
/* On small screens, content takes full width by default */
@media (max-width: 767.98px) {
@media (max-width: 991.98px) {
.main-margin {
margin-top: 3rem;
margin-top: 5rem;
}
}
@@ -266,7 +266,7 @@ code {
.input-group-text {
border-right: none !important;
padding: 0.375rem 0.05rem 0.375rem 0.75rem;
padding: 0.375rem 0.10rem 0.375rem 0.75rem;
}
.input-group > .form-control,
@@ -279,3 +279,44 @@ code {
color: var(--bs-body-color);
border: none;
}
.form-control:focus {
box-shadow: none;
outline: none;
border-color: var(--bs-border-color); /* optional prevents border from changing on focus */
}
.form-control:invalid {
box-shadow: none;
outline: none;
}
.home-filter {
all: unset;
font-size: 12px;
padding: 5px 12px;
border-radius: 4px;
}
.home-filter.home-high {
background-color: #d63031;
color: #FFFFFF;
}
.home-filter.home-all {
background-color: #feca57;
color: #000;
}
.home-filter.home-tag {
background-color: #E0E0E0;
color: #212121;
}
p.search-result-item-title {
font-size: 16px;
margin-bottom: 0;
margin-left: 2px;
}
.search-result-item {
font-size: 16px;
}
+30
View File
@@ -32,6 +32,7 @@ $input-border-radius-lg: 0.3rem;
--bs-border-radius-lg: 4px;
--bs-border-new-btn-item-color: #6c757d;
--bs-border-color-translucent: rgba(248, 249, 250, 1);
--bs-form-invalid-border-color: #dee2e6;
/* Buttons */
--bs-btn-color: #2EB745;
@@ -43,9 +44,23 @@ $input-border-radius-lg: 0.3rem;
/* Color palette */
--bs-primary: #2EB745;
--bs-info: #2EB745;
--bs-success: #2EB745;
/* sidebar header */
--bs-header-sidebar-color: #DCDCDC;
/* Override outline-success specific variables */
.btn-outline-success {
--bs-btn-color: #2EB745;
--bs-btn-border-color: #2EB745;
--bs-btn-hover-bg: #2EB745;
--bs-btn-hover-border-color: #2EB745;
--bs-btn-hover-color: #fff;
--bs-btn-active-bg: #249a3a;
--bs-btn-active-border-color: #249a3a;
--bs-btn-disabled-color: #2EB745;
--bs-btn-disabled-border-color: #2EB745;
}
}
// card bg color: #161a1d
@@ -72,6 +87,7 @@ $input-border-radius-lg: 0.3rem;
--bs-border-radius-lg: 4px;
--bs-border-new-btn-item-color: #6c757d;
--bs-border-color-translucent: #161a1d;
--bs-form-invalid-border-color: #444;
/* Buttons */
--bs-btn-color: #2EB745;
@@ -86,7 +102,21 @@ $input-border-radius-lg: 0.3rem;
/* Color palette */
--bs-primary: #2EB745;
--bs-info: #2EB745;
--bs-success: #2EB745;
/* sidebar header */
--bs-header-sidebar-color: #444;
/* Override outline-success specific variables */
.btn-outline-success {
--bs-btn-color: #2EB745;
--bs-btn-border-color: #2EB745;
--bs-btn-hover-bg: #2EB745;
--bs-btn-hover-border-color: #2EB745;
--bs-btn-hover-color: #fff;
--bs-btn-active-bg: #249a3a;
--bs-btn-active-border-color: #249a3a;
--bs-btn-disabled-color: #2EB745;
--bs-btn-disabled-border-color: #2EB745;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ function About(): React.ReactNode {
const { t } = useTranslation();
return (
<Container>
<Container fluid>
<ContentHeader
h1TextRegular="About the"
h1TextBold="TaskNote App"
+5 -2
View File
@@ -116,7 +116,7 @@ function Account(): React.ReactNode {
useEffect(() => {}, [user]);
return (
<Container>
<Container fluid>
<ContentHeader
h1TextRegular="My"
h1TextBold="Account"
@@ -133,7 +133,10 @@ function Account(): React.ReactNode {
Update only what you need. Blank fields will not be updated
</Card.Title>
<AlertError errorMessage={errorMessage} />
<AlertError
errorMessage={errorMessage}
onClose={() => setErrorMessage('')}
/>
<Form noValidate validated={validated} onSubmit={handleSubmit} className="mt-4">
{/* User name */}
+231 -83
View File
@@ -1,8 +1,6 @@
import React, { useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Accordion,
Button,
Card,
Col,
Container,
@@ -23,6 +21,11 @@ import AuthContext from '../../context/AuthContext';
import ContentHeader from '../../components/ContentHeader';
import AlertError from '../../components/AlertError';
import { Search } from 'react-bootstrap-icons';
import HomeFilterButton from '../../components/HomeFilterButton';
import { SearchResults } from '../../components/SearchResults';
import { useNavigate } from 'react-router';
import { SearchNoteResults } from '../../components/SearchNoteResults';
import ModalMarkdown from '../../components/ModalMarkdown';
/**
* Home page component.
@@ -34,10 +37,18 @@ import { Search } from 'react-bootstrap-icons';
function Home(): React.ReactNode {
const { user } = useContext(AuthContext);
const { i18n, t } = useTranslation();
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const [validated, setValidated] = useState<boolean>(false);
const [hasError, setHasError] = useState<boolean>(false);
const [searchResults, setSearchResults] = useState<HomeSearchResponse | null>(null);
const [name, setName] = useState<string>(user?.name ? user?.name : 'User');
const [lastSearch, setLastSearch] = useState<string>('');
const [showMarkdownView, setShowMarkdownView] = useState<boolean>(false);
const [modalTitle, setModalTitle] = useState<string>('');
const [modalContent, setModalContent] = useState<string>('');
const [searchTermValue, setSearchTermValue] = useState<string>('');
/**
* Handles the error by setting the error message.
@@ -63,6 +74,7 @@ function Home(): React.ReactNode {
try {
const response: HomeSearchResponse = await api.getJSON(`${ApiConfig.homeUrl}/search?term=${term}`);
setSearchResults(response);
setLastSearch(`textSearch#${term}`);
return true;
}
catch (e) {
@@ -79,27 +91,124 @@ function Home(): React.ReactNode {
const handleSearch = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
setValidated(true);
setValidated(false);
setHasError(false);
const form = event.currentTarget;
if (form.checkValidity() === false) {
setErrorMessage(translateServerResponse('Please type at least 3 characters', i18n.language));
if (searchTermValue.length === 0) {
form.reset();
setHasError(false);
setSearchResults(null);
return;
}
const searched: boolean = await searchTerm(form.search_term.value);
if (searchTermValue.length < 3) {
setErrorMessage(translateServerResponse('Please type at least 3 characters', i18n.language));
setHasError(true);
return;
}
const searched: boolean = await searchTerm(searchTermValue);
if (searched) {
form.reset();
}
};
const loadTasks = async (filter: string): Promise<void> => {
try {
const response: TaskResponse[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/filter/${filter}`);
const result: HomeSearchResponse = {
tasks: response,
notes: []
};
setSearchResults(result);
setLastSearch(`tagClick#${filter}`);
}
catch (e) {
handleError(e);
}
};
const loadTags = async (): Promise<void> => {
try {
const response: string[] = await api.getJSON(`${ApiConfig.homeUrl}/tasks/tags`);
setTags(response);
}
catch (e) {
handleError(e);
}
};
const reDoLastSearch = () => {
if (lastSearch.startsWith('textSearch#')) {
searchTerm(lastSearch.substring(11));
}
else if (lastSearch.startsWith('tagClick#')) {
loadTasks(lastSearch.substring(9));
}
};
/**
* Mark a task as done or undone.
*
* @param {TaskResponse} task The task to be marked as done or undone.
*/
const markAsDone = async (task: TaskResponse): Promise<void> => {
try {
const updatedTask = {
...task,
done: !task.done
};
await api.patchJSON(`${ApiConfig.tasksUrl}/${task.id}`, updatedTask);
reDoLastSearch();
}
catch (e) {
handleError(e);
}
};
/**
* Delete a task.
*
* @param {number} taskIdParam The task ID to be deleted.
*/
const deleteTask = async (taskIdParam: number) => {
try {
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`);
reDoLastSearch();
}
catch (e) {
handleError(e);
}
};
/**
* Delete a note.
*
* @param {number} noteIdParam The note ID to be deleted.
*/
const deleteNote = async (noteIdParam: number) => {
try {
await api.deleteNoContent(`${ApiConfig.notesUrl}/${noteIdParam}`);
reDoLastSearch();
}
catch (e) {
handleError(e);
}
};
const handleCloseModal = () => setShowMarkdownView(false);
useEffect(() => {
handleDefaultLang();
setName(user?.name ? user?.name : 'User');
loadTags();
}, [user]);
useEffect(() => {}, [searchResults]);
return (
<Container>
<Container fluid>
<ContentHeader
h1TextRegular={t('home_welcome_title')}
h1TextBold={name}
@@ -109,6 +218,115 @@ function Home(): React.ReactNode {
isHomeComponent
/>
<Row className="mb-4">
<Col xs={12}>
<Card>
<Card.Body>
<AlertError
errorMessage={errorMessage}
onClose={() => {
setErrorMessage('');
setHasError(false);
setValidated(false);
}}
/>
<Form noValidate validated={validated} onSubmit={handleSearch}>
<InputGroup className={`me-auto ${hasError ? 'is-invalid border border-danger rounded' : ''}`}>
<InputGroup.Text>
<Search />
</InputGroup.Text>
<Form.Control
type="text"
id="search_term"
name="search_term"
placeholder="Search tasks & notes"
value={searchTermValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchTermValue(e.target.value);
}}
/>
</InputGroup>
<Row className="mt-2">
<Col xs={12}>
<HomeFilterButton
text="🔥 High Priority"
type="high"
onClick={() => loadTasks('high')}
/>
<HomeFilterButton
text="All tasks"
type="all"
onClick={() => loadTasks('all')}
/>
{tags.map((tag: string) => (
<HomeFilterButton
key={tag}
text={`#${tag}`}
type="tag"
onClick={() => loadTasks(tag)}
/>
))}
<HomeFilterButton
text="Clear result"
type="tag"
onClick={() => setSearchResults(null)}
/>
</Col>
</Row>
</Form>
</Card.Body>
</Card>
</Col>
{searchResults && (
<Col xs={12}>
<Card className="mt-3">
<Card.Body>
<SearchResults
results={searchResults.tasks}
taskAction={(action: string, task: TaskResponse) => {
if (action === 'done') {
markAsDone(task);
}
else if (action === 'edit') {
navigate(`/tasks/edit/${task.id}?backTo=home`);
}
else if (action === 'delete') {
deleteTask(task.id);
}
}}
/>
</Card.Body>
</Card>
<Card className="mt-3">
<Card.Body>
<SearchNoteResults
results={searchResults.notes}
noteAction={(action: string, note: NoteResponse) => {
if (action === 'edit') {
navigate(`/notes/edit/${note.id}?backTo=home`);
}
else if (action === 'delete') {
deleteNote(note.id);
}
else if (action === 'open') {
setModalTitle(note.title);
setModalContent(note.description);
setShowMarkdownView(true);
}
}}
/>
</Card.Body>
</Card>
</Col>
)}
</Row>
<Row className="mb-4">
<Col xs={12} lg={6} className="mb-4">
<CompletedTasks />
@@ -118,82 +336,12 @@ function Home(): React.ReactNode {
</Col>
</Row>
<Row className="mb-4">
<Col xs={12}>
<Card>
<Card.Body>
<Card.Title>{t('home_card_search_label')}</Card.Title>
<AlertError errorMessage={errorMessage} />
<Form noValidate validated={validated} onSubmit={handleSearch}>
<InputGroup className="mb-3">
<InputGroup.Text>
<Search />
</InputGroup.Text>
<Form.Control
pattern=".{3,}"
required
type="text"
id="search_term"
name="search_term"
placeholder={t('home_card_search_placeholder')}
/>
<Button type="submit" variant="outline-secondary" id="button-search">
{t('home_card_search_btn')}
</Button>
</InputGroup>
</Form>
</Card.Body>
</Card>
</Col>
</Row>
<Row>
<Col xs={12}>
<h2>{t('home_card_search_result_label')}</h2>
<Accordion defaultActiveKey="0">
{searchResults && searchResults.tasks.length > 0 && (
searchResults.tasks.map((task: TaskResponse) => (
<Accordion.Item key={task.description} eventKey={task.description}>
<Accordion.Header>
[Task]
{' '}
{task.description}
</Accordion.Header>
<Accordion.Body>
{task.urls.length > 0
? (
<a href={`${task.urls[0]}`}>{task.urls[0]}</a>
)
: 'No URL!'}
</Accordion.Body>
</Accordion.Item>
))
)}
{searchResults && searchResults.notes.length > 0 && (
searchResults.notes.map((note: NoteResponse) => (
<Accordion.Item key={note.title} eventKey={note.title}>
<Accordion.Header>
[Note]
{' '}
{note.title}
</Accordion.Header>
<Accordion.Body>
<span className="span-line-break">
{ note.description }
</span>
</Accordion.Body>
</Accordion.Item>
))
)}
{searchResults?.tasks.length === 0 && searchResults?.notes.length === 0 && (
<h3>{t('home_card_search_empty_result')}</h3>
)}
</Accordion>
</Col>
</Row>
<ModalMarkdown
show={showMarkdownView}
onHide={handleCloseModal}
title={modalTitle}
markdownText={modalContent}
/>
</Container>
);
}
+7 -3
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect } from 'react';
import { Button, Container } from 'react-bootstrap';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { handleDefaultLang } from '../../lang-service/LangHandler';
import { setDefaultLang } from '../../storage-service/storage';
@@ -18,7 +18,8 @@ import './styles.scss';
* @returns {React.ReactNode} The Landing page component.
*/
function Landing(): React.ReactNode {
const { checkCurrentAuthUser } = useContext(AuthContext);
const { signed, checkCurrentAuthUser } = useContext(AuthContext);
const navigate = useNavigate();
const { i18n, t } = useTranslation();
const handleLanguage = (lang: string): void => {
@@ -29,7 +30,10 @@ function Landing(): React.ReactNode {
useEffect(() => {
checkCurrentAuthUser(window.location.pathname);
handleDefaultLang();
}, []);
if (signed) {
navigate('/home');
}
}, [signed]);
return (
<Container fluid className="vh-100 d-flex justify-content-center align-items-center landing-page">
+14 -2
View File
@@ -1,6 +1,8 @@
import React from 'react';
import './styles.scss';
import React, { useContext, useEffect } from 'react';
import LoginForm from '../../components/LoginForm';
import AuthContext from '../../context/AuthContext';
import './styles.scss';
import { useNavigate } from 'react-router';
/**
* Login page component.
@@ -11,6 +13,16 @@ import LoginForm from '../../components/LoginForm';
* @returns {React.ReactNode} The Login page component.
*/
function Login(): React.ReactNode {
const { signed, checkCurrentAuthUser } = useContext(AuthContext);
const navigate = useNavigate();
useEffect(() => {
checkCurrentAuthUser(window.location.pathname);
if (signed) {
navigate('/home');
}
}, [signed]);
return <LoginForm prefix="login" />;
}
+6 -3
View File
@@ -167,7 +167,7 @@ function Note(): React.ReactNode {
}, []);
return (
<Container>
<Container fluid>
<ContentHeader
h1TextRegular="All"
h1TextBold="Notes"
@@ -176,7 +176,10 @@ function Note(): React.ReactNode {
h2GreenText="Them"
/>
<AlertError errorMessage={errorMessage} />
<AlertError
errorMessage={errorMessage}
onClose={() => setErrorMessage('')}
/>
<Row>
<Col xs={12} sm={8} lg={9}>
@@ -191,7 +194,7 @@ function Note(): React.ReactNode {
/>
</Col>
<Col xs={12} sm={4} lg={3} className="mt-3 mt-sm-0">
<NavLink to="/notes/new">
<NavLink to="/notes/new?backTo=notes">
<div className="d-grid">
<button
type="button"
+18 -7
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import {
Card,
Col,
@@ -6,7 +6,7 @@ import {
Form,
Row
} from 'react-bootstrap';
import { useNavigate, useParams } from 'react-router';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
import { NoteResponse } from '../../types/NoteResponse';
import api from '../../api-service/api';
@@ -16,6 +16,7 @@ import FormInput from '../../components/FormInput';
import ModalMarkdown from '../../components/ModalMarkdown';
import AlertError from '../../components/AlertError';
import ContentHeader from '../../components/ContentHeader';
import SidebarContext from '../../context/SidebarContext';
type NoteAction = 'add' | 'edit';
@@ -34,7 +35,9 @@ function NoteAdd(): React.ReactNode {
const [action, setAction] = useState<NoteAction>('add');
const [showPreviewMd, setShowPreviewMd] = useState<boolean>(false);
const { i18n, t } = useTranslation();
const { setNewPage } = useContext(SidebarContext);
const params = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
/**
@@ -127,7 +130,8 @@ function NoteAdd(): React.ReactNode {
if (added) {
form.reset();
resetInputs();
navigate('/notes');
setNewPage(`/${searchParams.get('backTo')}`);
navigate(`/${searchParams.get('backTo')}`);
}
}
else if (action === 'edit') {
@@ -142,7 +146,8 @@ function NoteAdd(): React.ReactNode {
if (edited) {
form.reset();
resetInputs();
navigate('/notes');
setNewPage(`/${searchParams.get('backTo')}`);
navigate(`/${searchParams.get('backTo')}`);
}
}
};
@@ -189,7 +194,7 @@ function NoteAdd(): React.ReactNode {
}, []);
return (
<Container>
<Container fluid>
<ContentHeader
h1TextRegular="Add"
h1TextBold="Note"
@@ -204,7 +209,10 @@ function NoteAdd(): React.ReactNode {
<Card.Body>
<Card.Title>{t('note_form_title')}</Card.Title>
<AlertError errorMessage={errorMessage} />
<AlertError
errorMessage={errorMessage}
onClose={() => setErrorMessage('')}
/>
<Form
noValidate
@@ -277,7 +285,10 @@ function NoteAdd(): React.ReactNode {
<button
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
onClick={() => navigate('/notes')}
onClick={() => {
setNewPage(`/${searchParams.get('backTo')}`);
navigate(`/${searchParams.get('backTo')}`);
}}
>
Cancel
</button>
+7 -4
View File
@@ -160,7 +160,7 @@ function Task(): React.ReactNode {
}, []);
return (
<Container>
<Container fluid>
<ContentHeader
h1TextRegular="All"
h1TextBold="Tasks"
@@ -169,7 +169,10 @@ function Task(): React.ReactNode {
h2GreenText="Them"
/>
<AlertError errorMessage={errorMessage} />
<AlertError
errorMessage={errorMessage}
onClose={() => setErrorMessage('')}
/>
<Row>
<Col xs={12} sm={8} lg={9}>
@@ -184,7 +187,7 @@ function Task(): React.ReactNode {
/>
</Col>
<Col xs={12} sm={4} lg={3} className="mt-3 mt-sm-0">
<NavLink to="/tasks/new">
<NavLink to="/tasks/new?backTo=tasks">
<div className="d-grid">
<button
type="button"
@@ -264,7 +267,7 @@ function Task(): React.ReactNode {
</Dropdown.Toggle>
<Dropdown.Menu>
{!task.done && (
<NavLink to={`/tasks/edit/${task.id}`}>
<NavLink to={`/tasks/edit/${task.id}?backTo=tasks`}>
<Dropdown.Item as="span">
{t('task_table_action_edit')}
</Dropdown.Item>
+18 -7
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import {
Card,
Col,
@@ -6,7 +6,7 @@ import {
Form,
Row
} from 'react-bootstrap';
import { useNavigate, useParams } from 'react-router';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import TaskNoteRequest from '../../types/TaskNoteRequest';
import { TaskResponse } from '../../types/TaskResponse';
import { useTranslation } from 'react-i18next';
@@ -16,6 +16,7 @@ import { translateServerResponse } from '../../utils/TranslatorUtils';
import FormInput from '../../components/FormInput';
import ContentHeader from '../../components/ContentHeader';
import AlertError from '../../components/AlertError';
import SidebarContext from '../../context/SidebarContext';
type TaskAction = 'add' | 'edit';
@@ -36,7 +37,9 @@ function TaskAdd(): React.ReactNode {
const [highPriority, setHighPriority] = useState<boolean>(false);
const [tag, setTag] = useState<string>('');
const { i18n, t } = useTranslation();
const { setNewPage } = useContext(SidebarContext);
const params = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
/**
@@ -138,7 +141,8 @@ function TaskAdd(): React.ReactNode {
if (added) {
form.reset();
resetInputs();
navigate('/tasks');
setNewPage(`/${searchParams.get('backTo')}`);
navigate(`/${searchParams.get('backTo')}`);
}
}
else if (action === 'edit') {
@@ -158,7 +162,8 @@ function TaskAdd(): React.ReactNode {
if (edited) {
form.reset();
resetInputs();
navigate('/tasks');
setNewPage(`/${searchParams.get('backTo')}`);
navigate(`/${searchParams.get('backTo')}`);
}
}
};
@@ -194,7 +199,7 @@ function TaskAdd(): React.ReactNode {
}, []);
return (
<Container>
<Container fluid>
<ContentHeader
h1TextRegular="Add"
h1TextBold="Task"
@@ -210,7 +215,10 @@ function TaskAdd(): React.ReactNode {
<Card.Body>
<Card.Title>{t('task_form_title')}</Card.Title>
<AlertError errorMessage={errorMessage} data-testid="add-task-error-message" />
<AlertError
errorMessage={errorMessage}
onClose={() => setErrorMessage('')}
/>
<Form
noValidate
@@ -294,7 +302,10 @@ function TaskAdd(): React.ReactNode {
<button
type="button"
className="ms-2 home-new-item-secondary task-note-btn"
onClick={() => navigate('/tasks')}
onClick={() => {
setNewPage(`/${searchParams.get('backTo')}`);
navigate(`/${searchParams.get('backTo')}`);
}}
>
Cancel
</button>
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.controller;
import br.com.tasknoteapp.server.response.SearchResponse;
import br.com.tasknoteapp.server.response.SummaryResponse;
import br.com.tasknoteapp.server.response.TaskResponse;
import br.com.tasknoteapp.server.response.TasksChartResponse;
import br.com.tasknoteapp.server.service.HomeService;
import io.swagger.v3.oas.annotations.Operation;
@@ -14,6 +15,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -106,4 +108,63 @@ public class HomeController {
public List<TasksChartResponse> getTasksChart() {
return homeService.getTasksChartData();
}
/**
* Get the tasks given a filter.
*
* @returns List of TaskResponse with found tasks.
*/
@GetMapping("/tasks/filter/{filter}")
@Operation(
summary = "Get the tasks given a filter",
description = "Get the tasks given a filter which can be high | all | tag",
responses = {
@ApiResponse(
responseCode = "200",
description = "Found tasks or empty array",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = TaskResponse.class, type = "array"))),
@ApiResponse(
responseCode = "401",
description = "Unauthorized. Access Denied",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public List<TaskResponse> tasksByFilter(
@Parameter(
name = "filter",
in = ParameterIn.PATH,
description = "Task filter key. One of high, all, tag",
required = true)
@PathVariable
String filter) {
return homeService.getTasksByFilter(filter);
}
/**
* Get the top 5 tags.
*
* @returns List of String with the tags.
*/
@GetMapping("/tasks/tags")
@Operation(
summary = "Get the top 5 tags",
description = "Get the top 5 tags or the ones in use",
responses = {
@ApiResponse(
responseCode = "200",
description = "List of tags or an empty list",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = String.class, type = "array"))),
@ApiResponse(
responseCode = "401",
description = "Unauthorized. Access Denied",
content = @Content(schema = @Schema(implementation = Void.class)))
})
public List<String> getTasksTags() {
return homeService.getTopTasksTag();
}
}
@@ -4,6 +4,7 @@ import br.com.tasknoteapp.server.entity.TaskEntity;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/** This interface represents a task repository, for database access. */
public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
@@ -11,6 +12,16 @@ public interface TaskRepository extends JpaRepository<TaskEntity, Long> {
List<TaskEntity> findAllByUser_id(Long userId);
@Query(
"select t from TaskEntity t where upper(t.description) like upper(%?1%) and t.user.id = ?2")
List<TaskEntity> findAllBySearchTerm(String searchTerm, Long userId);
"""
select distinct t
from TaskEntity t
left join TaskUrlEntity tu on tu.id.taskId = t.id
where (
upper(t.description) like upper(concat('%', :searchTerm, '%')) or
upper(t.tag) like upper(concat('%', :searchTerm, '%')) or
upper(tu.id.url) like upper(concat('%', :searchTerm, '%'))
) and t.user.id = :userId and t.done = false
""")
List<TaskEntity> findAllBySearchTerm(
@Param("searchTerm") String searchTerm, @Param("userId") Long userId);
}
@@ -16,9 +16,11 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -41,6 +43,8 @@ public class HomeService {
private final NotesCreatedRepository notesCreatedRepository;
private static final String N_TASKS_FOUND = "{} tasks found!";
/**
* Get summary for the home page.
*
@@ -78,7 +82,7 @@ public class HomeService {
log.info("Searching for {}", term);
List<TaskResponse> tasks = taskService.searchTasks(term);
log.info("{} tasks found!", tasks.size());
log.info(N_TASKS_FOUND, tasks.size());
List<NoteResponse> notes = noteService.searchNotes(term);
log.info("{} notes found!", notes.size());
@@ -136,6 +140,56 @@ public class HomeService {
return chartData;
}
/**
* Get tasks by a given filter.
*
* @param filter The filter to get the tasks.
* @return {@link List} of {@link TaskResponse} with found records or an empty list.
*/
public List<TaskResponse> getTasksByFilter(String filter) {
log.info("Getting tasks by filter for filter: {}", filter);
List<TaskResponse> tasks = taskService.getTasksByFilter(filter);
log.info(N_TASKS_FOUND, tasks.size());
return tasks;
}
/**
* Get up to 5 most used tags.
*
* @return List of String with the tags.
*/
public List<String> getTopTasksTag() {
log.info("Getting top tags for the tasks");
List<TaskResponse> tasks = taskService.getTasksByFilter("all");
log.info(N_TASKS_FOUND, tasks.size());
Map<String, Integer> tagsCount = new HashMap<>();
for (TaskResponse task : tasks) {
if (tagsCount.size() == 5) {
break;
}
String tag = task.tag();
if (tag.isBlank()) {
tag = "untagged";
}
tagsCount.putIfAbsent(tag, 0);
tagsCount.put(tag, tagsCount.get(tag) + 1);
}
Map<String, Integer> sortedDesc =
tagsCount.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.collect(
Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue, (e1, e2) -> e1, LinkedHashMap::new));
return sortedDesc.keySet().stream().toList();
}
private List<TasksChartResponse> createListFromDate(LocalDateTime date) {
List<TasksChartResponse> list = new ArrayList<>();
for (int i = 0; i < 7; i++) {
@@ -133,9 +133,9 @@ public class TaskService {
if (!Objects.isNull(patch.done())) {
taskEntity.setDone(patch.done());
}
patchDueDate(taskEntity, patch);
taskEntity.setHighPriority(false);
if (!Objects.isNull(patch.highPriority())) {
taskEntity.setHighPriority(patch.highPriority());
@@ -212,6 +212,10 @@ public class TaskService {
log.info("Searching tasks to user {}", user.getId());
if (Objects.isNull(searchTerm) || searchTerm.isBlank()) {
return List.of();
}
List<TaskEntity> tasks =
taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), user.getId());
log.info("{} tasks found!", tasks.size());
@@ -221,6 +225,49 @@ public class TaskService {
.toList();
}
/**
* Get tasks by a given filter.
*
* @param filter The filter to get the tasks.
* @return {@link List} of {@link TaskResponse} with found records or an empty list.
*/
public List<TaskResponse> getTasksByFilter(String filter) {
UserEntity user = getCurrentUser();
List<TaskEntity> allTasks =
taskRepository.findAllByUser_id(user.getId()).stream()
.filter(t -> t.getDone().equals(Boolean.FALSE))
.toList();
if (allTasks.isEmpty()) {
return List.of();
}
if (filter.equals("all")) {
return allTasks.stream()
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
.toList();
}
if (filter.equals("high")) {
return allTasks.stream()
.filter(TaskEntity::getHighPriority)
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
.toList();
}
if (filter.equals("untagged")) {
return allTasks.stream()
.filter(t -> t.getTag() == null || t.getTag().isBlank())
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
.toList();
}
return allTasks.stream()
.filter(t -> t.getTag().equals(filter))
.map((TaskEntity tr) -> TaskResponse.fromEntity(tr, getAllTasksUrls(tr.getId())))
.toList();
}
private UserEntity getCurrentUser() {
Optional<String> currentUserEmail = authUtil.getCurrentUserEmail();
String email = currentUserEmail.orElseThrow();
@@ -4,6 +4,8 @@ import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.format.TextStyle;
import java.util.Locale;
import java.util.Objects;
/** This class contains util methods to format local date time. */
@@ -60,23 +62,47 @@ public class TimeAgoUtil {
return null;
}
StringBuilder sb = new StringBuilder();
// Format should be: yyyy-MM-dd
Period period = Period.between(LocalDate.now(), futureDate);
if (period.getYears() > 1) {
return String.format("%d years left", period.getYears());
sb.append(String.format("%d years left", period.getYears()));
} else if (period.getYears() > 0) {
return String.format("%d year left", period.getYears());
sb.append(String.format("%d year left", period.getYears()));
} else if (period.getMonths() > 1) {
return String.format("%d months left", period.getMonths());
sb.append(String.format("%d months left", period.getMonths()));
} else if (period.getMonths() > 0) {
return String.format("%d month left", period.getMonths());
sb.append(String.format("%d month left", period.getMonths()));
} else if (period.getDays() > 1) {
return String.format("%d days left", period.getDays());
sb.append(String.format("%d days left", period.getDays()));
} else if (period.getDays() > 0) {
return String.format("%d day left", period.getDays());
sb.append(String.format("%d day left", period.getDays()));
} else if (period.getDays() == 0) {
return String.format("0 days left", period.getDays());
sb.append("0 days left");
} else {
sb.append("Due");
}
return "Due date";
String dayOfWeek = futureDate.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.ENGLISH);
int dayOfMonth = futureDate.getDayOfMonth();
String suffix = getDaySuffix(dayOfMonth);
String month = futureDate.getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH);
int year = futureDate.getYear();
String dateFmt = String.format(" (%s %d%s, %s %d)", dayOfWeek, dayOfMonth, suffix, month, year);
return sb.toString() + dateFmt;
}
private static String getDaySuffix(int day) {
if (day >= 11 && day <= 13) {
return "th";
}
return switch (day % 10) {
case 1 -> "st";
case 2 -> "nd";
case 3 -> "rd";
default -> "th";
};
}
}
@@ -139,4 +139,78 @@ class HomeControllerTest {
.andExpect(status().isUnauthorized())
.andReturn();
}
@Test
@DisplayName("Get tasks by filter using the filter all it should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void tasksByFilter_allTasks_shouldSucceed() throws Exception {
String filter = "all";
TaskResponse taskResponse =
new TaskResponse(
1L, "Desc", false, true, null, null, "Moments ago", "tag", List.of("http://test.com"));
when(homeService.getTasksByFilter(filter)).thenReturn(List.of(taskResponse));
mockMvc
.perform(
get("/rest/home/tasks/filter/{filter}", filter)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(taskResponse.id()))
.andExpect(jsonPath("$[0].description").value(taskResponse.description()))
.andExpect(jsonPath("$[0].done", Matchers.is(false)))
.andExpect(jsonPath("$[0].highPriority", Matchers.is(true)))
.andExpect(jsonPath("$[0].dueDate", Matchers.nullValue()))
.andExpect(jsonPath("$[0].dueDateFmt", Matchers.nullValue()))
.andExpect(jsonPath("$[0].lastUpdate").value("Moments ago"))
.andExpect(jsonPath("$[0].urls[0]").value(taskResponse.urls().get(0)))
.andReturn();
}
@Test
@DisplayName("Get tasks by filter not authorized it should fail")
void tasksByFilter_notAuthorized_shouldFail() throws Exception {
String filter = "all";
mockMvc
.perform(
get("/rest/home/tasks/filter/{filter}", filter)
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andReturn();
}
@Test
@DisplayName("Get task tags following the happy path it should succeed")
@WithMockUser(username = "user@domain.com", password = "abcde123456A@")
void getTasksTags_happyPath_shouldSucceed() throws Exception {
when(homeService.getTopTasksTag()).thenReturn(List.of("tag1", "tag2"));
mockMvc
.perform(
get("/rest/home/tasks/tags")
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0]").value("tag1"))
.andExpect(jsonPath("$[1]").value("tag2"))
.andReturn();
}
@Test
@DisplayName("Get task tags not authorized it should fail")
void getTasksTags_notAuthorized_shouldSucceed() throws Exception {
mockMvc
.perform(
get("/rest/home/tasks/tags")
.with(csrf().asHeader())
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized())
.andReturn();
}
}
@@ -156,4 +156,85 @@ class HomeServiceTest {
Assertions.assertEquals(7, chartData.size());
Assertions.assertEquals(firstDay, chartData.get(0).day());
}
@Test
@DisplayName("Get tasks by filter high priority tasks it should succeed")
void getTasksByFilter_highTasks_shouldSucceed() {
String filter = "high";
TaskResponse highTask1 =
new TaskResponse(2L, "Task 1", false, true, null, null, null, "tag", List.of());
when(taskService.getTasksByFilter(filter)).thenReturn(List.of(highTask1));
List<TaskResponse> list = homeService.getTasksByFilter(filter);
Assertions.assertNotNull(list);
Assertions.assertEquals(1, list.size());
Assertions.assertTrue(list.get(0).highPriority());
}
@Test
@DisplayName("Get top tasks tag should return up to 5 most used tags")
void getTopTasksTag_shouldReturnTopTags() {
TaskResponse task1 =
new TaskResponse(1L, "Task 1", false, false, null, null, null, "tag1", List.of());
TaskResponse task2 =
new TaskResponse(2L, "Task 2", false, false, null, null, null, "tag2", List.of());
TaskResponse task3 =
new TaskResponse(3L, "Task 3", false, false, null, null, null, "tag1", List.of());
TaskResponse task4 =
new TaskResponse(4L, "Task 4", false, false, null, null, null, "tag3", List.of());
TaskResponse task5 =
new TaskResponse(5L, "Task 5", false, false, null, null, null, "tag2", List.of());
TaskResponse task6 =
new TaskResponse(6L, "Task 6", false, false, null, null, null, "tag4", List.of());
TaskResponse task7 =
new TaskResponse(7L, "Task 7", false, false, null, null, null, "tag5", List.of());
TaskResponse task8 =
new TaskResponse(8L, "Task 8", false, false, null, null, null, "tag6", List.of());
when(taskService.getTasksByFilter("all"))
.thenReturn(List.of(task1, task2, task3, task4, task5, task6, task7, task8));
List<String> topTags = homeService.getTopTasksTag();
Assertions.assertNotNull(topTags);
Assertions.assertEquals(5, topTags.size());
Assertions.assertTrue(topTags.contains("tag1"));
Assertions.assertTrue(topTags.contains("tag2"));
Assertions.assertTrue(topTags.contains("tag3"));
Assertions.assertTrue(topTags.contains("tag4"));
Assertions.assertTrue(topTags.contains("tag5"));
}
@Test
@DisplayName("Get top tasks tag with no tags should return empty list")
void getTopTasksTag_noTags_shouldReturnEmptyList() {
when(taskService.getTasksByFilter("all")).thenReturn(List.of());
List<String> topTags = homeService.getTopTasksTag();
Assertions.assertNotNull(topTags);
Assertions.assertTrue(topTags.isEmpty());
}
@Test
@DisplayName("Get top tasks tag with blank tags should handle untagged tasks")
void getTopTasksTag_blankTags_shouldHandleUntagged() {
TaskResponse task1 =
new TaskResponse(1L, "Task 1", false, false, null, null, null, "", List.of());
TaskResponse task2 =
new TaskResponse(2L, "Task 2", false, false, null, null, null, " ", List.of());
TaskResponse task3 =
new TaskResponse(3L, "Task 3", false, false, null, null, null, "tag1", List.of());
when(taskService.getTasksByFilter("all")).thenReturn(List.of(task1, task2, task3));
List<String> topTags = homeService.getTopTasksTag();
Assertions.assertNotNull(topTags);
Assertions.assertEquals(2, topTags.size());
Assertions.assertTrue(topTags.contains("untagged"));
Assertions.assertTrue(topTags.contains("tag1"));
}
}
@@ -485,4 +485,220 @@ class TaskServiceTest {
assertEquals("test", patched.tag());
assertTrue(patched.urls().isEmpty());
}
@Test
@DisplayName("Search tasks with matching term should succeed")
void searchTasks_matchingTerm_shouldSucceed() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
TaskEntity taskEntity = new TaskEntity();
taskEntity.setId(1L);
taskEntity.setDescription("Write unit tests");
taskEntity.setHighPriority(false);
taskEntity.setTag("development");
String searchTerm = "unit";
when(taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), USER_ID))
.thenReturn(List.of(taskEntity));
List<TaskResponse> responses = taskService.searchTasks(searchTerm);
assertNotNull(responses);
assertFalse(responses.isEmpty());
assertEquals(1, responses.size());
assertEquals(taskEntity.getDescription(), responses.get(0).description());
}
@Test
@DisplayName("Search tasks with no matching term should return empty list")
void searchTasks_noMatchingTerm_shouldReturnEmptyList() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
String searchTerm = "nonexistent";
when(taskRepository.findAllBySearchTerm(searchTerm.toUpperCase(), USER_ID))
.thenReturn(List.of());
List<TaskResponse> responses = taskService.searchTasks(searchTerm);
assertNotNull(responses);
assertTrue(responses.isEmpty());
}
@Test
@DisplayName("Search tasks with null search term should return empty list")
void searchTasks_nullSearchTerm_shouldReturnEmptyList() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
String searchTerm = null;
when(taskRepository.findAllBySearchTerm(null, USER_ID)).thenReturn(List.of());
List<TaskResponse> responses = taskService.searchTasks(searchTerm);
assertNotNull(responses);
assertTrue(responses.isEmpty());
}
@Test
@DisplayName("Get tasks by filter 'all' should succeed")
void getTasksByFilter_all_shouldSucceed() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
TaskEntity task1 = new TaskEntity();
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setTag("tag1");
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setTag("tag2");
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
List<TaskResponse> responses = taskService.getTasksByFilter("all");
assertEquals(2, responses.size());
assertEquals("tag1", responses.get(0).tag());
assertEquals("tag2", responses.get(1).tag());
}
@Test
@DisplayName("Get tasks by filter 'high' should succeed")
void getTasksByFilter_high_shouldSucceed() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
TaskEntity task1 = new TaskEntity();
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setTag("tag1");
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setTag("tag2");
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
List<TaskResponse> responses = taskService.getTasksByFilter("high");
assertEquals(1, responses.size());
assertEquals("tag2", responses.get(0).tag());
}
@Test
@DisplayName("Get tasks by filter 'untagged' should succeed")
void getTasksByFilter_untagged_shouldSucceed() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
TaskEntity task1 = new TaskEntity();
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setTag(null);
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setTag("");
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
List<TaskResponse> responses = taskService.getTasksByFilter("untagged");
assertEquals(2, responses.size());
assertNull(responses.get(0).tag());
assertTrue(responses.get(1).tag().isBlank());
}
@Test
@DisplayName("Get tasks by specific tag filter should succeed")
void getTasksByFilter_specificTag_shouldSucceed() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
TaskEntity task1 = new TaskEntity();
task1.setId(1L);
task1.setDescription("Task 1");
task1.setHighPriority(false);
task1.setDone(false);
task1.setTag("tag1");
TaskEntity task2 = new TaskEntity();
task2.setId(2L);
task2.setDescription("Task 2");
task2.setHighPriority(true);
task2.setDone(false);
task2.setTag("tag2");
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of(task1, task2));
List<TaskResponse> responses = taskService.getTasksByFilter("tag1");
assertEquals(1, responses.size());
assertEquals("tag1", responses.get(0).tag());
}
@Test
@DisplayName("Get tasks by filter with no matching tasks should return empty list")
void getTasksByFilter_noMatchingTasks_shouldReturnEmptyList() {
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(USER_EMAIL));
UserEntity userEntity = new UserEntity();
userEntity.setId(USER_ID);
userEntity.setEmail(USER_EMAIL);
when(authService.findByEmail(USER_EMAIL)).thenReturn(Optional.of(userEntity));
when(taskRepository.findAllByUser_id(USER_ID)).thenReturn(List.of());
List<TaskResponse> responses = taskService.getTasksByFilter("nonexistent");
assertTrue(responses.isEmpty());
}
}
@@ -2,6 +2,9 @@ package br.com.tasknoteapp.server.util;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Period;
import java.time.format.TextStyle;
import java.util.Locale;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -21,8 +24,61 @@ class TimeAgoUtilTest {
@Test
void formatDueDateTest() {
Assertions.assertNull(TimeAgoUtil.formatDueDate(null));
Assertions.assertEquals("1 day left", TimeAgoUtil.formatDueDate(LocalDate.now().plusDays(1L)));
LocalDate localDate1 = LocalDate.now().plusDays(1L);
String expected1 = "1 day left" + getFormattedSuffix(localDate1);
Assertions.assertEquals(expected1, TimeAgoUtil.formatDueDate(localDate1));
LocalDate localDate2 = LocalDate.now().plusDays(12L);
String expected2 = "12 days left" + getFormattedSuffix(localDate2);
Assertions.assertEquals(expected2, TimeAgoUtil.formatDueDate(localDate2));
}
private String getFormattedSuffix(LocalDate futureDate) {
String dayOfWeek = futureDate.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.ENGLISH);
int dayOfMonth = futureDate.getDayOfMonth();
String suffix = getDaySuffix(dayOfMonth);
String month = futureDate.getMonth().getDisplayName(TextStyle.FULL, Locale.ENGLISH);
int year = futureDate.getYear();
return String.format(" (%s %d%s, %s %d)", dayOfWeek, dayOfMonth, suffix, month, year);
}
private static String getDaySuffix(int day) {
if (day >= 11 && day <= 13) {
return "th";
}
return switch (day % 10) {
case 1 -> "st";
case 2 -> "nd";
case 3 -> "rd";
default -> "th";
};
}
@Test
void formatDueDateEdgeCasesTest() {
// Test for today
LocalDate today = LocalDate.now();
String expectedToday = "0 days left" + getFormattedSuffix(today);
Assertions.assertEquals(expectedToday, TimeAgoUtil.formatDueDate(today));
// Test for a past date
LocalDate pastDate = LocalDate.now().minusDays(9L);
Assertions.assertEquals(
"12 days left", TimeAgoUtil.formatDueDate(LocalDate.now().plusDays(12L)));
"Due" + getFormattedSuffix(pastDate), TimeAgoUtil.formatDueDate(pastDate));
// Test for a far future date
LocalDate farFutureDate = LocalDate.now().plusYears(5L);
String expectedFarFuture = "5 years left" + getFormattedSuffix(farFutureDate);
Assertions.assertEquals(expectedFarFuture, TimeAgoUtil.formatDueDate(farFutureDate));
// Test for a leap year date
LocalDate leapYearDate = LocalDate.of(2024, 2, 29);
if (LocalDate.now().isBefore(leapYearDate)) {
Period period = Period.between(LocalDate.now(), leapYearDate);
String expectedLeapYear =
String.format("%d days left", period.getDays()) + getFormattedSuffix(leapYearDate);
Assertions.assertEquals(expectedLeapYear, TimeAgoUtil.formatDueDate(leapYearDate));
}
}
}