Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed
- Home page now displays only the last 24 items. [Issue 79](https://github.com/thermcampos/tasknote/issues/79)
- Notes tags now lives in the note document body. [Issue 75](https://github.com/thermcampos/tasknote/issues/75)

### Fixed
- iOS screen zoomming in when typing notes. [PR 81](https://github.com/thermcampos/tasknote/pull/81)
- After deleting tags and notes tags could be outdated. [Squeezed in PR 82](https://github.com/thermcampos/tasknote/pull/82)

```
# Docker images
- ghcr.io/thermcampos/tasknote/server:api-v2026.09.24.11
- ghcr.io/thermcampos/tasknote/client:app-v2026.09.24.10
- ghcr.io/thermcampos/tasknote/client:app-v2026.09.24.?
```

---
Expand Down
77 changes: 77 additions & 0 deletions client/src/__test__/views/Home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ describe('Home Component', () => {
(api.getJSON as any).mock.calls.filter((call: any[]) =>
(call[0] as string).includes('home/items'));

const getTagsCalls = () =>
(api.getJSON as any).mock.calls.filter((call: any[]) =>
(call[0] as string).includes('tasks/tags'));

beforeEach(() => {
// Reset mocks and setup default responses
vi.clearAllMocks();
Expand Down Expand Up @@ -891,6 +895,79 @@ describe('Home Component', () => {
expect(screen.getByTestId('home-view-hint').textContent).toBe('home_window_hint');
});

test('re-fetches tags after deleting a task', async () => {
await act(async () => {
renderHome();
});

await waitFor(() => {
expect(screen.getAllByTestId('task-title').length).toBe(2);
});

expect(getTagsCalls().length).toBe(1);

const dropdownToggles = screen.getAllByTestId('three-dots-icon');
await act(async () => {
fireEvent.click(dropdownToggles[0]);
});

const dropdownItems = screen.getAllByRole('button');
const deleteButton = dropdownItems.find(
item => item.textContent === 'task_table_action_delete'
);
await act(async () => {
fireEvent.click(deleteButton!);
});

await act(async () => {
fireEvent.click(screen.getByTestId('confirm-delete-button'));
});

await waitFor(() => {
expect(api.deleteNoContent).toHaveBeenCalled();
expect(getTagsCalls().length).toBe(2);
});
});

test('re-fetches tags after deleting an archived note', async () => {
(api.getJSON as any).mockImplementation((url: string) => {
if (url.includes('tasks/tags')) {
return Promise.resolve(mockTags);
}
else if (url.includes('home/items')) {
return Promise.resolve({ tasks: [], notes: [{ ...mockNotes[0], archived: true }] });
}
return Promise.resolve([]);
});

await act(async () => {
renderHome();
});

await waitFor(() => {
expect(screen.getByTestId('archived-note-dropdown-menu-1')).toBeDefined();
});

expect(getTagsCalls().length).toBe(1);

await act(async () => {
fireEvent.click(screen.getByTestId('archived-note-dropdown-menu-1'));
});

await act(async () => {
fireEvent.click(screen.getByTestId('archived-note-dropdown-delete-item-1'));
});

await act(async () => {
fireEvent.click(screen.getByTestId('confirm-delete-button'));
});

await waitFor(() => {
expect(api.deleteNoContent).toHaveBeenCalled();
expect(getTagsCalls().length).toBe(2);
});
});

/*
test('getFirstRows properly formats note preview', async () => {
await act(async () => {
Expand Down
102 changes: 100 additions & 2 deletions client/src/__test__/views/NoteAdd.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ describe('NoteAdd Component', () => {
// Reset mock between tests
mockedUseSearchParams.mockReturnValue([new URLSearchParams(), vi.fn()]);
mockedUseParams.mockReturnValue({});
localStorage.clear();
vi.clearAllMocks();
});

Expand Down Expand Up @@ -200,7 +201,7 @@ describe('NoteAdd Component', () => {
const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement;
expect(noteTitle.value).toBe(toEdit.title);
expect(noteUrl.value).toBe(toEdit.url);
expect(noteContentInput.innerHTML).toBe(toEdit.description);
expect(noteContentInput.innerHTML).toBe(`${toEdit.description}\n\ntags: dev`);
});
});

Expand Down Expand Up @@ -228,7 +229,7 @@ describe('NoteAdd Component', () => {
const noteContentInput = getByTestId('note-content-input-area') as HTMLAreaElement;
expect(noteTitle.value).toBe(toClone.title);
expect(noteUrl.value).toBe(toClone.url);
expect(noteContentInput.innerHTML).toBe(toClone.description);
expect(noteContentInput.innerHTML).toBe(`${toClone.description}\n\ntags: dev`);
});
});

Expand All @@ -246,4 +247,101 @@ describe('NoteAdd Component', () => {
expect(noteContentInput.innerHTML).toBe('');
});
});

it('should parse the tags footer, strip it from the description and send tags on save', async () => {
const { getByLabelText, getByTestId, getByRole } = renderNoteAdd();
const titleInput = getByLabelText('note_form_title_label') as HTMLInputElement;
const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement;
const submitButton = getByRole('button', { name: 'note_form_submit' });

fireEvent.change(titleInput, { target: { value: 'Tagged Note' } });
fireEvent.change(noteContentInput, {
target: { value: 'Note content\n\nTags: Foo, bar , foo,,' }
});
fireEvent.click(submitButton);

await waitFor(() => {
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, {
id: 0,
title: 'Tagged Note',
description: 'Note content',
url: '',
tags: ['foo', 'bar'],
lastUpdate: '',
shared: false,
shareToken: null,
archived: false
});
});
});

it('should save an untagged note when the footer has an empty value', async () => {
const { getByLabelText, getByTestId, getByRole } = renderNoteAdd();
const titleInput = getByLabelText('note_form_title_label') as HTMLInputElement;
const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement;
const submitButton = getByRole('button', { name: 'note_form_submit' });

fireEvent.change(titleInput, { target: { value: 'Untagged Note' } });
fireEvent.change(noteContentInput, { target: { value: 'Note content\n\ntags:' } });
fireEvent.click(submitButton);

await waitFor(() => {
expect(api.postJSON).toHaveBeenCalledWith(ApiConfig.notesUrl, {
id: 0,
title: 'Untagged Note',
description: 'Note content',
url: '',
tags: [],
lastUpdate: '',
shared: false,
shareToken: null,
archived: false
});
});
});

it('should show live tag chips while typing the footer', async () => {
const { getByTestId, queryByTestId } = renderNoteAdd();
const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement;

expect(queryByTestId('note-tags-preview')).toBeNull();

fireEvent.change(noteContentInput, { target: { value: 'Content\n\ntags: dev, react' } });

await waitFor(() => {
const preview = getByTestId('note-tags-preview');
expect(preview.textContent).toContain('#dev');
expect(preview.textContent).toContain('#react');
});
});

it('should keep the server footer over note.tags when editing', async () => {
mockedUseParams.mockReturnValue({ id: '1' });

const toEdit: NoteResponse = {
id: 1,
title: 'Note one',
description: 'Description of note one\n\ntags: body-tag',
url: 'http://notes.domain.com',
tags: ['server-tag'],
lastUpdate: '3 minutes ago',
shared: false,
shareToken: null
};

vi.spyOn(api, 'getJSON').mockResolvedValue(toEdit);

const { getByTestId } = renderNoteAdd();

await waitFor(() => {
const noteContentInput = getByTestId('note-content-input-area') as HTMLTextAreaElement;
expect(noteContentInput.innerHTML).toBe('Description of note one\n\ntags: body-tag');
});

await waitFor(() => {
const preview = getByTestId('note-tags-preview');
expect(preview.textContent).toContain('#body-tag');
expect(preview.textContent).not.toContain('#server-tag');
});
});
});
12 changes: 12 additions & 0 deletions client/src/components/ModalMarkdown/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import Badge from 'react-bootstrap/Badge';
import Modal from 'react-bootstrap/Modal';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
Expand All @@ -11,6 +12,7 @@ type Props = {
onHide: () => void;
onSave?: () => Promise<boolean>;
saveButtonLabel?: string;
tags?: string[];
};

/**
Expand Down Expand Up @@ -72,6 +74,16 @@ const ModalMarkdown: React.FC<Props> = (props: Props): React.ReactNode => {
: (
<Markdown remarkPlugins={[remarkGfm]}>{props.markdownText}</Markdown>
)}
{props.tags && props.tags.length > 0 && (
<div className="d-flex flex-wrap gap-1 mt-3" data-testid="modal-tags-preview">
{props.tags.map(tag => (
<Badge key={tag} bg="warning" text="dark" className="p-2">
#
{tag}
</Badge>
))}
</div>
)}
</Modal.Body>
<Modal.Footer className="d-flex flex-wrap gap-2 justify-content-end">
<button
Expand Down
2 changes: 2 additions & 0 deletions client/src/views/Home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function Home(): React.ReactNode {
try {
await api.deleteNoContent(`${ApiConfig.tasksUrl}/${taskIdParam}`);
await loadItems(filterText, selectedOption);
await loadTags();
}
catch (e) {
handleError(e);
Expand All @@ -121,6 +122,7 @@ function Home(): React.ReactNode {
try {
await api.deleteNoContent(`${ApiConfig.notesUrl}/${noteIdParam}`);
await loadItems(filterText, selectedOption);
await loadTags();
}
catch (e) {
handleError(e);
Expand Down
Loading
Loading