diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d9dd54..12c0d404 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.? ``` --- diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx index dbe6cbf0..04db8a7c 100644 --- a/client/src/__test__/views/Home.test.tsx +++ b/client/src/__test__/views/Home.test.tsx @@ -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(); @@ -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 () => { diff --git a/client/src/__test__/views/NoteAdd.test.tsx b/client/src/__test__/views/NoteAdd.test.tsx index 34d464c3..2550de5d 100644 --- a/client/src/__test__/views/NoteAdd.test.tsx +++ b/client/src/__test__/views/NoteAdd.test.tsx @@ -105,6 +105,7 @@ describe('NoteAdd Component', () => { // Reset mock between tests mockedUseSearchParams.mockReturnValue([new URLSearchParams(), vi.fn()]); mockedUseParams.mockReturnValue({}); + localStorage.clear(); vi.clearAllMocks(); }); @@ -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`); }); }); @@ -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`); }); }); @@ -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'); + }); + }); }); diff --git a/client/src/components/ModalMarkdown/index.tsx b/client/src/components/ModalMarkdown/index.tsx index 4a5cf1f7..128d0291 100644 --- a/client/src/components/ModalMarkdown/index.tsx +++ b/client/src/components/ModalMarkdown/index.tsx @@ -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'; @@ -11,6 +12,7 @@ type Props = { onHide: () => void; onSave?: () => Promise; saveButtonLabel?: string; + tags?: string[]; }; /** @@ -72,6 +74,16 @@ const ModalMarkdown: React.FC = (props: Props): React.ReactNode => { : ( {props.markdownText} )} + {props.tags && props.tags.length > 0 && ( +
+ {props.tags.map(tag => ( + + # + {tag} + + ))} +
+ )}