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:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ function About(): React.ReactNode {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container fluid>
|
||||
<ContentHeader
|
||||
h1TextRegular="About the"
|
||||
h1TextBold="TaskNote App"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user