feat: add notes archival option before deleting
This commit is contained in:
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## 2026-07-28
|
||||
|
||||
### Added
|
||||
- Option to archive notes.
|
||||
|
||||
### Changed
|
||||
- Notes should be archived before deleting.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/tasknote-app:app-v2026.07.28.000
|
||||
docker pull rmcampos/tasknote-api:api-v2026.07.28.000
|
||||
```
|
||||
|
||||
## 2026-07-23
|
||||
|
||||
### Added
|
||||
@@ -21,6 +35,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Delete my account buttons layout to match the system design.
|
||||
- Loaded tasks now has a light yellow styling.
|
||||
|
||||
```bash
|
||||
# Docker images
|
||||
docker pull rmcampos/tasknote-app:app-v2026.07.23.190
|
||||
docker pull rmcampos/tasknote-api:api-v2026.07.23.189
|
||||
```
|
||||
|
||||
### Fixed
|
||||
- Dropped the untagged tag from loading in the add notes and tasks form.
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@ vi.mock('react-i18next', () => ({
|
||||
vi.mock('../../api-service/api', () => ({
|
||||
default: {
|
||||
getJSON: vi.fn(),
|
||||
postJSON: vi.fn(),
|
||||
patchJSON: vi.fn(),
|
||||
deleteNoContent: vi.fn()
|
||||
putJSON: vi.fn(),
|
||||
deleteNoContent: vi.fn(),
|
||||
getJSONNoAuth: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -131,7 +134,8 @@ const mockNotes: NoteResponse[] = [
|
||||
lastUpdate: '2023-10-10',
|
||||
url: 'http://example.com',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
shareToken: null,
|
||||
archived: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
@@ -141,7 +145,8 @@ const mockNotes: NoteResponse[] = [
|
||||
lastUpdate: '2023-10-09',
|
||||
url: null,
|
||||
shared: false,
|
||||
shareToken: null
|
||||
shareToken: null,
|
||||
archived: false
|
||||
}
|
||||
];
|
||||
|
||||
@@ -192,6 +197,7 @@ describe('Home Component', () => {
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
(api.deleteNoContent as any).mockResolvedValue(undefined);
|
||||
(api.putJSON as any).mockResolvedValue(undefined);
|
||||
|
||||
// Mock window.innerWidth for the cleanText function
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -366,7 +372,9 @@ describe('Home Component', () => {
|
||||
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('tasks'));
|
||||
});
|
||||
|
||||
test('deletes note', async () => {
|
||||
test('archives note', async () => {
|
||||
(api.putJSON as any).mockResolvedValue(undefined);
|
||||
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
@@ -379,33 +387,26 @@ describe('Home Component', () => {
|
||||
const noteDropdownToggles = screen.getAllByTestId('three-dots-icon');
|
||||
// Note dropdowns start after task dropdowns
|
||||
const firstNoteDropdown = noteDropdownToggles[mockTasks.length];
|
||||
|
||||
|
||||
// Click the dropdown toggle
|
||||
await act(async () => {
|
||||
fireEvent.click(firstNoteDropdown);
|
||||
});
|
||||
|
||||
// Find and click the "Delete" option by testId
|
||||
const deleteButtons = screen.getAllByRole('button');
|
||||
const deleteButton = deleteButtons.find(
|
||||
button => button.textContent === 'task_table_action_delete'
|
||||
);
|
||||
// Find and click the "Archive" option by testId
|
||||
const archiveButton = screen.getByTestId('note-dropdown-archive-item-1');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton!);
|
||||
fireEvent.click(archiveButton);
|
||||
});
|
||||
|
||||
// Modal should appear; confirm deletion
|
||||
const confirmButton = screen.getByText('delete_modal_confirm');
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton);
|
||||
// Should call archive API immediately
|
||||
await waitFor(() => {
|
||||
expect(api.putJSON).toHaveBeenCalledWith(expect.stringContaining('/notes/1/archive'), {});
|
||||
});
|
||||
|
||||
// Should call deleteNoContent API
|
||||
expect(api.deleteNoContent).toHaveBeenCalledWith(expect.stringContaining('/1'));
|
||||
|
||||
// Should reload notes
|
||||
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes'))
|
||||
expect(api.getJSON).toHaveBeenCalledWith(expect.stringContaining('notes'));
|
||||
});
|
||||
|
||||
test('opens markdown modal when clicking on note tag', async () => {
|
||||
@@ -492,7 +493,7 @@ describe('Home Component', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps filter selection after deleting a note', async () => {
|
||||
test('keeps filter selection after archiving a note', async () => {
|
||||
await act(async () => {
|
||||
renderHome();
|
||||
});
|
||||
@@ -524,11 +525,11 @@ describe('Home Component', () => {
|
||||
});
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button');
|
||||
const deleteButton = deleteButtons.find(
|
||||
button => button.textContent === 'task_table_action_delete'
|
||||
const archiveButton = deleteButtons.find(
|
||||
button => button.textContent === 'note_action_archive'
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(deleteButton!);
|
||||
fireEvent.click(archiveButton!);
|
||||
});
|
||||
|
||||
// After reload, filter should still be applied - tasks should remain hidden
|
||||
|
||||
@@ -155,7 +155,8 @@ describe('NoteAdd Component', () => {
|
||||
tags: [],
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
shareToken: null,
|
||||
archived: false
|
||||
}
|
||||
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, newNote);
|
||||
});
|
||||
|
||||
@@ -77,6 +77,7 @@ const enTranslations = {
|
||||
home_card_task_empty: 'No pending tasks',
|
||||
home_card_task_done: 'done tasks!',
|
||||
home_completed_tasks_title: 'Completed tasks',
|
||||
home_archived_notes_title: 'Archived notes',
|
||||
home_card_task_done_empty: 'No done tasks!',
|
||||
home_card_task_btn: 'Go to Tasks',
|
||||
home_card_note_title: 'Notes Summary',
|
||||
@@ -122,6 +123,9 @@ const enTranslations = {
|
||||
note_action_share: 'Share',
|
||||
note_action_unshare: 'Unshare',
|
||||
note_action_copy_link: 'Copy link',
|
||||
note_action_archive: 'Archive',
|
||||
note_action_restore: 'Restore',
|
||||
note_action_delete_permanently: 'Delete permanently',
|
||||
|
||||
about_page_title_one: 'About the',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -77,6 +77,7 @@ const ptBrTranslations = {
|
||||
home_card_task_empty: 'Nenhuma tarefa pendente',
|
||||
home_card_task_done: 'tarefa(s) concluída(s)',
|
||||
home_completed_tasks_title: 'Tarefas concluídas',
|
||||
home_archived_notes_title: 'Notas arquivadas',
|
||||
home_card_task_done_empty: 'Nenhuma tarefa condluída',
|
||||
home_card_task_btn: 'Ir para Tarefas',
|
||||
home_card_note_title: 'Resumo de Notas',
|
||||
@@ -122,6 +123,9 @@ const ptBrTranslations = {
|
||||
note_action_share: 'Compartilhar',
|
||||
note_action_unshare: 'Parar de compartilhar',
|
||||
note_action_copy_link: 'Copiar link',
|
||||
note_action_archive: 'Arquivar',
|
||||
note_action_restore: 'Restaurar',
|
||||
note_action_delete_permanently: 'Excluir permanentemente',
|
||||
|
||||
about_page_title_one: 'Sobre o',
|
||||
about_page_title_two: 'App TaskNote',
|
||||
|
||||
@@ -77,6 +77,7 @@ const ruTranslations = {
|
||||
home_card_task_empty: 'Нет незавершённых задач',
|
||||
home_card_task_done: 'выполненные задачи!',
|
||||
home_completed_tasks_title: 'Выполненные задачи',
|
||||
home_archived_notes_title: 'Архивированные заметки',
|
||||
home_card_task_done_empty: 'Нет выполненных задач!',
|
||||
home_card_task_btn: 'Перейти к задачам',
|
||||
home_card_note_title: 'Обзор заметок',
|
||||
@@ -122,6 +123,9 @@ const ruTranslations = {
|
||||
note_action_share: 'Поделиться',
|
||||
note_action_unshare: 'Закрыть доступ',
|
||||
note_action_copy_link: 'Копировать ссылку',
|
||||
note_action_archive: 'Архивировать',
|
||||
note_action_restore: 'Восстановить',
|
||||
note_action_delete_permanently: 'Удалить навсегда',
|
||||
|
||||
about_page_title_one: 'около',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -77,6 +77,7 @@ const esTranslations = {
|
||||
home_card_task_empty: 'No tienes tareas pendientes',
|
||||
home_card_task_done: 'tarea(s) completada(s)',
|
||||
home_completed_tasks_title: 'Tareas completadas',
|
||||
home_archived_notes_title: 'Notas archivadas',
|
||||
home_card_task_done_empty: 'No tareas completadas',
|
||||
home_card_task_btn: 'Ir a Tareas',
|
||||
home_card_note_title: 'Resumen de Notas',
|
||||
@@ -122,6 +123,9 @@ const esTranslations = {
|
||||
note_action_share: 'Compartir',
|
||||
note_action_unshare: 'Dejar de compartir',
|
||||
note_action_copy_link: 'Copiar enlace',
|
||||
note_action_archive: 'Archivar',
|
||||
note_action_restore: 'Restaurar',
|
||||
note_action_delete_permanently: 'Eliminar permanentemente',
|
||||
|
||||
about_page_title_one: 'Acerca de',
|
||||
about_page_title_two: 'TaskNote App',
|
||||
|
||||
@@ -7,6 +7,7 @@ type NoteResponse = {
|
||||
lastUpdate: string;
|
||||
shared: boolean;
|
||||
shareToken: string | null;
|
||||
archived: boolean;
|
||||
};
|
||||
|
||||
export type { NoteResponse };
|
||||
|
||||
+197
-24
@@ -52,6 +52,7 @@ function Home(): React.ReactNode {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>([]);
|
||||
const [completedTasks, setCompletedTasks] = useState<TaskResponse[]>([]);
|
||||
const [notes, setNotes] = useState<NoteResponse[]>([]);
|
||||
const [archivedNotes, setArchivedNotes] = useState<NoteResponse[]>([]);
|
||||
const [savedNotes, setSavedNotes] = useState<NoteResponse[]>([]);
|
||||
const [savedTasks, setSavedTasks] = useState<TaskResponse[]>([]);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
|
||||
@@ -126,6 +127,27 @@ function Home(): React.ReactNode {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter notes into active and archived sets.
|
||||
*
|
||||
* @param {NoteResponse[]} allNotes The full list of notes to partition.
|
||||
* @returns {{ active: NoteResponse[]; archived: NoteResponse[] }} Active and archived notes.
|
||||
*/
|
||||
const partitionNotes = (allNotes: NoteResponse[]): { active: NoteResponse[]; archived: NoteResponse[] } => {
|
||||
return allNotes.reduce<{ active: NoteResponse[]; archived: NoteResponse[] }>(
|
||||
(acc, note) => {
|
||||
if (note.archived) {
|
||||
acc.archived.push(note);
|
||||
}
|
||||
else {
|
||||
acc.active.push(note);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ active: [], archived: [] }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the delete confirmation modal for a task or note.
|
||||
*
|
||||
@@ -155,6 +177,40 @@ function Home(): React.ReactNode {
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive a note, moving it to the archived notes section.
|
||||
*
|
||||
* @param {number} noteId The note ID to be archived.
|
||||
*/
|
||||
const archiveNote = async (noteId: number): Promise<void> => {
|
||||
try {
|
||||
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/archive`, {});
|
||||
await loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore an archived note back to active notes.
|
||||
*
|
||||
* @param {number} noteId The note ID to be restored.
|
||||
*/
|
||||
const restoreNote = async (noteId: number): Promise<void> => {
|
||||
try {
|
||||
await api.putJSON(`${ApiConfig.notesUrl}/${noteId}/restore`, {});
|
||||
await loadAllNotes();
|
||||
}
|
||||
catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveNote = (noteId: number): void => {
|
||||
void archiveNote(noteId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Share or unshare a note.
|
||||
*
|
||||
@@ -194,9 +250,11 @@ function Home(): React.ReactNode {
|
||||
const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => {
|
||||
const activeTasks = allTasks.filter((task: TaskResponse) => !task.completed);
|
||||
const doneTasks = allTasks.filter((task: TaskResponse) => task.completed);
|
||||
const { active: activeNotes, archived: archivedNoteList } = partitionNotes(allNotes);
|
||||
|
||||
if (!text && (!radioFilter || radioFilter === 'everything')) {
|
||||
setNotes([...allNotes]);
|
||||
setNotes([...activeNotes]);
|
||||
setArchivedNotes([...archivedNoteList]);
|
||||
setTasks([...activeTasks]);
|
||||
setCompletedTasks([...doneTasks]);
|
||||
return;
|
||||
@@ -206,9 +264,10 @@ function Home(): React.ReactNode {
|
||||
|
||||
if (radioFilter && radioFilter === 'onlyTasks') {
|
||||
setNotes([]);
|
||||
setArchivedNotes([]);
|
||||
}
|
||||
else {
|
||||
let filteredNotes = allNotes.filter((note: NoteResponse) => {
|
||||
let filteredNotes = activeNotes.filter((note: NoteResponse) => {
|
||||
const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase());
|
||||
const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase());
|
||||
const anyUrlMatch = note.url?.includes(text.toLowerCase());
|
||||
@@ -223,7 +282,23 @@ function Home(): React.ReactNode {
|
||||
filteredNotes = filteredNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
|
||||
}
|
||||
|
||||
let filteredArchivedNotes = archivedNoteList.filter((note: NoteResponse) => {
|
||||
const anyTitleMatch = note.title.toLowerCase().includes(text.toLowerCase());
|
||||
const anyContentMatch = note.description.toLowerCase().includes(text.toLowerCase());
|
||||
const anyUrlMatch = note.url?.includes(text.toLowerCase());
|
||||
const anyTagMatch = note.tags?.some(tag => tag.toLowerCase().includes(text.toLowerCase()));
|
||||
return anyTitleMatch || anyContentMatch || anyUrlMatch || anyTagMatch;
|
||||
});
|
||||
|
||||
if (tagToFilter === 'untagged') {
|
||||
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => !note.tags || note.tags.length === 0);
|
||||
}
|
||||
else if (tagToFilter) {
|
||||
filteredArchivedNotes = filteredArchivedNotes.filter((note: NoteResponse) => note.tags && note.tags.includes(tagToFilter));
|
||||
}
|
||||
|
||||
setNotes([...filteredNotes]);
|
||||
setArchivedNotes([...filteredArchivedNotes]);
|
||||
}
|
||||
|
||||
if (radioFilter && radioFilter === 'onlyNotes') {
|
||||
@@ -478,7 +553,9 @@ function Home(): React.ReactNode {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dropdown onSelect={eventKey => eventKey && handleOptionChange(eventKey)}>
|
||||
<Dropdown
|
||||
onSelect={eventKey => eventKey && handleOptionChange(eventKey)}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
id="filter-dropdown"
|
||||
@@ -501,7 +578,10 @@ function Home(): React.ReactNode {
|
||||
</Badge>
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu className="shadow-lg border-0" style={{ minWidth: '200px' }}>
|
||||
<Dropdown.Menu
|
||||
className="shadow-lg border-0"
|
||||
style={{ minWidth: '200px' }}
|
||||
>
|
||||
<Dropdown.Header className="text-muted small">
|
||||
<i className="bi bi-funnel me-2"></i>
|
||||
Filter Options
|
||||
@@ -568,7 +648,10 @@ function Home(): React.ReactNode {
|
||||
<Row className="mt-3">
|
||||
{tasks.map((task: TaskResponse) => (
|
||||
<Col xs={12} key={task.id.toString()}>
|
||||
<Card key={task.id.toString()} className={`task-card ${task.highPriority ? 'high-importance' : ''}`}>
|
||||
<Card
|
||||
key={task.id.toString()}
|
||||
className={`task-card ${task.highPriority ? 'high-importance' : ''}`}
|
||||
>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
@@ -585,7 +668,10 @@ function Home(): React.ReactNode {
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="success" data-testid={`task-dropdown-menu-${task.id}`}>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`task-dropdown-menu-${task.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
@@ -601,11 +687,14 @@ function Home(): React.ReactNode {
|
||||
onClick={() => toggleTaskCompleted(task)}
|
||||
data-testid={`task-dropdown-done-item-${task.id}`}
|
||||
>
|
||||
{task.completed ? t('task_table_action_undone') : t('task_table_action_done')}
|
||||
{task.completed
|
||||
? t('task_table_action_undone')
|
||||
: t('task_table_action_done')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => confirmDelete({ type: 'task', id: task.id })}
|
||||
onClick={() =>
|
||||
confirmDelete({ type: 'task', id: task.id })}
|
||||
data-testid={`task-dropdown-delete-item-${task.id}`}
|
||||
>
|
||||
{t('task_table_action_delete')}
|
||||
@@ -646,15 +735,15 @@ function Home(): React.ReactNode {
|
||||
<span className="home-item-icon">
|
||||
<JournalText />
|
||||
</span>
|
||||
<NoteTitle
|
||||
title={note.title}
|
||||
noteUrl={note.url}
|
||||
/>
|
||||
<NoteTitle title={note.title} noteUrl={note.url} />
|
||||
</Card.Title>
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="success" data-testid={`note-dropdown-menu-${note.id}`}>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`note-dropdown-menu-${note.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
@@ -673,7 +762,9 @@ function Home(): React.ReactNode {
|
||||
onClick={() => toggleShareNote(note)}
|
||||
data-testid={`note-dropdown-share-item-${note.id}`}
|
||||
>
|
||||
{note.shared ? t('note_action_unshare') : t('note_action_share')}
|
||||
{note.shared
|
||||
? t('note_action_unshare')
|
||||
: t('note_action_share')}
|
||||
</Dropdown.Item>
|
||||
{note.shared && note.shareToken && (
|
||||
<Dropdown.Item
|
||||
@@ -686,10 +777,10 @@ function Home(): React.ReactNode {
|
||||
)}
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => confirmDelete({ type: 'note', id: note.id })}
|
||||
data-testid={`note-dropdown-delete-item-${note.id}`}
|
||||
onClick={() => handleArchiveNote(note.id)}
|
||||
data-testid={`note-dropdown-archive-item-${note.id}`}
|
||||
>
|
||||
{t('task_table_action_delete')}
|
||||
{t('note_action_archive')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
@@ -727,7 +818,9 @@ function Home(): React.ReactNode {
|
||||
</Col>
|
||||
{completedTasks.map((task: TaskResponse) => (
|
||||
<Col xs={12} key={`completed-${task.id.toString()}`}>
|
||||
<Card className={`task-card task-completed ${task.highPriority ? 'high-importance' : ''}`}>
|
||||
<Card
|
||||
className={`task-card task-completed ${task.highPriority ? 'high-importance' : ''}`}
|
||||
>
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
@@ -744,7 +837,10 @@ function Home(): React.ReactNode {
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="success" data-testid={`completed-task-dropdown-menu-${task.id}`}>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`completed-task-dropdown-menu-${task.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
@@ -757,7 +853,8 @@ function Home(): React.ReactNode {
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => confirmDelete({ type: 'task', id: task.id })}
|
||||
onClick={() =>
|
||||
confirmDelete({ type: 'task', id: task.id })}
|
||||
data-testid={`completed-task-dropdown-delete-item-${task.id}`}
|
||||
>
|
||||
{t('task_table_action_delete')}
|
||||
@@ -780,6 +877,81 @@ function Home(): React.ReactNode {
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{archivedNotes.length > 0 && (
|
||||
<Row className="mt-4">
|
||||
<Col xs={12}>
|
||||
<h5 className="text-muted">{t('home_archived_notes_title')}</h5>
|
||||
</Col>
|
||||
{archivedNotes.map((note: NoteResponse) => (
|
||||
<Col xs={12} key={`archived-${note.id.toString()}`}>
|
||||
<Card className="task-card task-completed mb-3">
|
||||
<Card.Body>
|
||||
<Row>
|
||||
<Col xs={10}>
|
||||
<Card.Title>
|
||||
<span className="home-item-icon">
|
||||
<JournalText />
|
||||
</span>
|
||||
<NoteTitle title={note.title} noteUrl={note.url} />
|
||||
</Card.Title>
|
||||
</Col>
|
||||
<Col xs={2} className="text-end">
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle
|
||||
variant="success"
|
||||
data-testid={`archived-note-dropdown-menu-${note.id}`}
|
||||
>
|
||||
<ThreeDotsVertical />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() => restoreNote(note.id)}
|
||||
data-testid={`archived-note-dropdown-restore-item-${note.id}`}
|
||||
>
|
||||
{t('note_action_restore')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
as="button"
|
||||
onClick={() =>
|
||||
confirmDelete({ type: 'note', id: note.id })}
|
||||
data-testid={`archived-note-dropdown-delete-item-${note.id}`}
|
||||
>
|
||||
{t('note_action_delete_permanently')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<span className="text-muted span-line-break font-size-14">
|
||||
{getFirstRows(note.description)}
|
||||
</span>
|
||||
</Card.Body>
|
||||
<Card.Footer className="task-card-footer">
|
||||
<TaskTag
|
||||
tags={note.tags}
|
||||
lastUpdate={note.lastUpdate}
|
||||
taskOrNote="note"
|
||||
onClick={(e: React.MouseEvent<Element, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setModalTitle(note.title);
|
||||
setModalContent(note.description);
|
||||
setShowMarkdownView(true);
|
||||
localStorage.setItem(
|
||||
OPEN_NOTE_ID_KEY,
|
||||
note.id.toString()
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<ModalMarkdown
|
||||
show={showMarkdownView}
|
||||
onHide={handleCloseModal}
|
||||
@@ -799,13 +971,13 @@ function Home(): React.ReactNode {
|
||||
{t('delete_modal_title')}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{t('delete_modal_body')}
|
||||
</Modal.Body>
|
||||
<Modal.Body>{t('delete_modal_body')}</Modal.Body>
|
||||
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
}}
|
||||
className="task-note-btn"
|
||||
>
|
||||
{t('delete_modal_cancel')}
|
||||
@@ -814,6 +986,7 @@ function Home(): React.ReactNode {
|
||||
variant="danger"
|
||||
onClick={handleConfirmDelete}
|
||||
className="task-note-btn"
|
||||
data-testid="confirm-delete-button"
|
||||
>
|
||||
{t('delete_modal_confirm')}
|
||||
</Button>
|
||||
|
||||
@@ -230,7 +230,8 @@ function NoteAdd(): React.ReactNode {
|
||||
tags: finalTags,
|
||||
lastUpdate: '',
|
||||
shared: false,
|
||||
shareToken: null
|
||||
shareToken: null,
|
||||
archived: false
|
||||
};
|
||||
|
||||
const saved = action === 'add'
|
||||
|
||||
@@ -115,4 +115,28 @@ public class NoteController {
|
||||
public ResponseEntity<NoteResponse> unshareNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.unshareNote(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a note, disabling edits and revoking public sharing.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse with the archived note.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/archive")
|
||||
public ResponseEntity<NoteResponse> archiveNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.archiveNote(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an archived note back to active state.
|
||||
*
|
||||
* @param id Note identification.
|
||||
* @return NoteResponse with the restored note.
|
||||
* @throws NoteNotFoundException when note not found.
|
||||
*/
|
||||
@PutMapping("/{id}/restore")
|
||||
public ResponseEntity<NoteResponse> restoreNote(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(noteService.restoreNote(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ public class NoteEntity {
|
||||
@Column(name = "share_token", length = 36)
|
||||
private String shareToken;
|
||||
|
||||
@Column(name = "archived", nullable = false)
|
||||
private boolean archived = false;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
@@ -113,6 +116,14 @@ public class NoteEntity {
|
||||
this.shareToken = shareToken;
|
||||
}
|
||||
|
||||
public boolean isArchived() {
|
||||
return archived;
|
||||
}
|
||||
|
||||
public void setArchived(boolean archived) {
|
||||
this.archived = archived;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
@@ -145,6 +156,8 @@ public class NoteEntity {
|
||||
+ tags
|
||||
+ ", lastUpdate="
|
||||
+ lastUpdate
|
||||
+ ", archived="
|
||||
+ archived
|
||||
+ '}';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package br.com.tasknoteapp.server.exception;
|
||||
|
||||
/** This class represents a conflict when an note is archived and cannot be modified. */
|
||||
public class NoteArchivedException extends BaseBadRequestException {
|
||||
|
||||
public NoteArchivedException() {
|
||||
super("note", "Note is archived");
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ public record NoteResponse(
|
||||
String lastUpdate,
|
||||
List<String> tags,
|
||||
boolean shared,
|
||||
String shareToken) {
|
||||
String shareToken,
|
||||
boolean archived) {
|
||||
|
||||
/**
|
||||
* Creates a NoteResponse given a NoteEntity and its Urals.
|
||||
@@ -34,6 +35,7 @@ public record NoteResponse(
|
||||
timeAgoFmt,
|
||||
entity.getTags().stream().map(TagEntity::getName).toList(),
|
||||
entity.isShared(),
|
||||
entity.getShareToken());
|
||||
entity.getShareToken(),
|
||||
entity.isArchived());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import br.com.tasknoteapp.server.entity.NoteEntity;
|
||||
import br.com.tasknoteapp.server.entity.NoteUrlEntity;
|
||||
import br.com.tasknoteapp.server.entity.TagEntity;
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.exception.NoteArchivedException;
|
||||
import br.com.tasknoteapp.server.exception.NoteNotFoundException;
|
||||
import br.com.tasknoteapp.server.repository.NoteRepository;
|
||||
import br.com.tasknoteapp.server.repository.NoteUrlRepository;
|
||||
@@ -160,6 +161,10 @@ public class NoteService {
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = note.get();
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
if (!Objects.isNull(patch.title()) && !patch.title().isBlank()) {
|
||||
noteEntity.setTitle(patch.title().trim());
|
||||
}
|
||||
@@ -208,6 +213,9 @@ public class NoteService {
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = note.get();
|
||||
if (!noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteUrlRepository.deleteByNote_id(noteId);
|
||||
logger.info("URL deleted from note ID {}", noteId);
|
||||
@@ -255,6 +263,10 @@ public class NoteService {
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
if (!noteEntity.isShared()) {
|
||||
noteEntity.setShared(true);
|
||||
noteEntity.setShareToken(UUID.randomUUID().toString());
|
||||
@@ -282,6 +294,11 @@ public class NoteService {
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteEntity.setShared(false);
|
||||
noteEntity.setShareToken(null);
|
||||
noteRepository.save(noteEntity);
|
||||
@@ -301,7 +318,7 @@ public class NoteService {
|
||||
logger.info("Fetching shared note with token {}", shareToken);
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByShareToken(shareToken);
|
||||
if (noteOpt.isEmpty() || !noteOpt.get().isShared()) {
|
||||
if (noteOpt.isEmpty() || !noteOpt.get().isShared() || noteOpt.get().isArchived()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
@@ -352,6 +369,66 @@ public class NoteService {
|
||||
return notes.stream().map(n -> NoteResponse.fromEntity(n, noteUrls.get(n.getId()))).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a note, disabling edits and revoking public sharing.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the archived note.
|
||||
*/
|
||||
@Transactional
|
||||
public NoteResponse archiveNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Archiving note ID {} for user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
if (noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteEntity.setArchived(true);
|
||||
noteEntity.setShared(false);
|
||||
noteEntity.setShareToken(null);
|
||||
noteEntity.setLastUpdate(LocalDateTime.now());
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note ID {} archived", noteId);
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an archived note back to active state.
|
||||
*
|
||||
* @param noteId The note id from the database.
|
||||
* @return {@link NoteResponse} containing the restored note.
|
||||
*/
|
||||
@Transactional
|
||||
public NoteResponse restoreNote(Long noteId) {
|
||||
UserEntity user = getCurrentUser();
|
||||
logger.info("Restoring note ID {} for user ID {}", noteId, user.getId());
|
||||
|
||||
Optional<NoteEntity> noteOpt = noteRepository.findByIdAndUser_id(noteId, user.getId());
|
||||
if (noteOpt.isEmpty()) {
|
||||
throw new NoteNotFoundException();
|
||||
}
|
||||
|
||||
NoteEntity noteEntity = noteOpt.get();
|
||||
if (!noteEntity.isArchived()) {
|
||||
throw new NoteArchivedException();
|
||||
}
|
||||
|
||||
noteEntity.setArchived(false);
|
||||
noteEntity.setLastUpdate(LocalDateTime.now());
|
||||
noteRepository.save(noteEntity);
|
||||
logger.info("Note ID {} restored", noteId);
|
||||
|
||||
return NoteResponse.fromEntity(noteEntity, getNoteUrl(noteEntity.getId()));
|
||||
}
|
||||
|
||||
private NoteUrlEntity saveUrl(NoteEntity noteEntity, String url) {
|
||||
NoteUrlEntity noteUrl = new NoteUrlEntity();
|
||||
noteUrl.setUrl(url);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE tasknote.notes
|
||||
ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_archived ON tasknote.notes (archived);
|
||||
@@ -44,7 +44,7 @@ class NoteControllerTest {
|
||||
void getAllNotes_notesFound_shouldSucceed() throws Exception {
|
||||
NoteUrlResponse noteUrl = new NoteUrlResponse(111L, "https://test.com");
|
||||
NoteResponse note =
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null);
|
||||
new NoteResponse(111L, "title", "description", "https://test.com", null, List.of("tag"), false, null, false);
|
||||
|
||||
when(noteService.getAllNotes()).thenReturn(List.of(note));
|
||||
|
||||
@@ -109,7 +109,8 @@ class NoteControllerTest {
|
||||
null,
|
||||
List.of("tag"),
|
||||
false,
|
||||
null);
|
||||
null,
|
||||
false);
|
||||
|
||||
when(noteService.patchNote(noteId, patchRequest)).thenReturn(response);
|
||||
|
||||
@@ -201,7 +202,7 @@ class NoteControllerTest {
|
||||
NoteRequest request = new NoteRequest("Title", "Description", null, List.of("tag"));
|
||||
|
||||
NoteResponse entity = new NoteResponse(1L, request.title(), request.description(),
|
||||
null, null, List.of("tag"), false, null);
|
||||
null, null, List.of("tag"), false, null, false);
|
||||
|
||||
when(noteService.createNote(request)).thenReturn(entity);
|
||||
|
||||
@@ -331,7 +332,8 @@ class NoteControllerTest {
|
||||
final Long noteId = 1L;
|
||||
final String token = "test-token-uuid";
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), true, token);
|
||||
new NoteResponse(
|
||||
noteId, "title", "description", null, null, List.of("tag"), true, token, false);
|
||||
|
||||
when(noteService.shareNote(noteId)).thenReturn(response);
|
||||
|
||||
@@ -366,7 +368,8 @@ class NoteControllerTest {
|
||||
void unshareNote_happyPath_shouldSucceed() throws Exception {
|
||||
final Long noteId = 1L;
|
||||
NoteResponse response =
|
||||
new NoteResponse(noteId, "title", "description", null, null, List.of("tag"), false, null);
|
||||
new NoteResponse(
|
||||
noteId, "title", "description", null, null, List.of("tag"), false, null, false);
|
||||
|
||||
when(noteService.unshareNote(noteId)).thenReturn(response);
|
||||
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ class PublicNoteControllerTest {
|
||||
void getSharedNote_happyPath_shouldSucceed() throws Exception {
|
||||
final String token = "test-share-token";
|
||||
NoteResponse response =
|
||||
new NoteResponse(1L, "title", "description", null, null, List.of("tag"), true, token);
|
||||
new NoteResponse(
|
||||
1L, "title", "description", null, null, List.of("tag"), true, token, false);
|
||||
|
||||
when(noteService.getSharedNote(token)).thenReturn(response);
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ class NoteServiceTest {
|
||||
|
||||
@Test
|
||||
void deleteNote() {
|
||||
note.setArchived(true);
|
||||
when(authUtil.getCurrentUserEmail()).thenReturn(Optional.of(user.getEmail()));
|
||||
when(authService.findByEmail(user.getEmail())).thenReturn(Optional.of(user));
|
||||
when(noteRepository.findByIdAndUser_id(note.getId(), user.getId()))
|
||||
|
||||
@@ -44,7 +44,7 @@ class UserSessionServiceTest {
|
||||
TaskResponse task =
|
||||
new TaskResponse(1L, false, "Task 1", true, null, null, null, null, List.of());
|
||||
NoteResponse note =
|
||||
new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null);
|
||||
new NoteResponse(1L, "Note 1", "Description", null, null, null, false, null, false);
|
||||
|
||||
when(authService.getCurrentUser()).thenReturn(Optional.of(user));
|
||||
when(taskService.getAllTasks()).thenReturn(List.of(task));
|
||||
|
||||
Reference in New Issue
Block a user