From b423082b1fb97e15e70eb7405b9e04bf6a9da763 Mon Sep 17 00:00:00 2001 From: Ricardo Campos Date: Tue, 10 Feb 2026 16:57:44 -0300 Subject: [PATCH] fix: Keep filter between actions in Home view (#9) * fix: keep filter selection between actions in Home view When a user selects a filter (Tasks, Notes, or a tag) and performs an action like marking a task as done or deleting a note, the filter is now preserved after the data reload. - Extract applyFilter() helper that accepts explicit task/note arrays to avoid stale state closure issues - loadAllTasks() now re-applies the current selectedOption and filterText after fetching fresh data instead of resetting to 'everything' - loadAllNotes() now re-applies the current filter after fetching fresh data - Add 3 new tests verifying filter persistence after actions Fixes #8 Co-authored-by: Ricardo Campos * feat: fix race condition as per Claude review * ci: add CI to client --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Ricardo Campos --- .github/workflows/client-ci.yml | 51 ++++++++ client/src/__test__/views/Home.test.tsx | 151 ++++++++++++++++++++++++ client/src/views/Home/index.tsx | 36 ++++-- 3 files changed, 226 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/client-ci.yml diff --git a/.github/workflows/client-ci.yml b/.github/workflows/client-ci.yml new file mode 100644 index 0000000..1b79e52 --- /dev/null +++ b/.github/workflows/client-ci.yml @@ -0,0 +1,51 @@ +name: React App CI + +on: + workflow_dispatch: + push: + branches: + - '**' + paths: + - 'client/**/*.html' + - 'client/**/*.png' + - 'client/**/*.json' + - 'client/**/*.txt' + - 'client/**/*.ts' + - 'client/**/*.tsx' + - 'client/**/*.js' + - 'client/Dockerfile' + - 'client/Caddyfile' + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm install + working-directory: ./client + + - name: Run lint + run: npm run lint + working-directory: ./client + + - name: Run build + run: npm run build + working-directory: ./client + + - name: Run tests + run: npm test -- --watchAll=false + working-directory: ./client diff --git a/client/src/__test__/views/Home.test.tsx b/client/src/__test__/views/Home.test.tsx index 054ac0c..16957a7 100644 --- a/client/src/__test__/views/Home.test.tsx +++ b/client/src/__test__/views/Home.test.tsx @@ -444,6 +444,157 @@ describe('Home Component', () => { expect(screen.getByTestId('alert-error')).toBeDefined(); expect(screen.getByTestId('alert-error').textContent).toBe('API Error'); }); + + test('keeps filter selection after marking task as done', async () => { + await act(async () => { + render( + + + + + + ); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(2); + }); + + // Select "Only Tasks" filter + const dropdownToggle = screen.getByTestId('main-label-selector'); + await act(async () => { + fireEvent.click(dropdownToggle); + }); + + const onlyTasksOption = screen.getByRole('button', { name: /home_radio_tasks/i }); + await act(async () => { + fireEvent.click(onlyTasksOption); + }); + + // Verify filter is applied - notes should be hidden + await waitFor(() => { + expect(screen.queryAllByTestId('note-title').length).toBe(0); + }); + + // Mark a task as done + const dropdownToggles = screen.getAllByTestId('three-dots-icon'); + await act(async () => { + fireEvent.click(dropdownToggles[0]); + }); + + const dropdownItems = screen.getAllByRole('button'); + const markAsDoneButton = dropdownItems.find( + item => item.textContent === 'task_table_action_done' + ); + await act(async () => { + fireEvent.click(markAsDoneButton!); + }); + + // After reload, filter should still be applied - notes should remain hidden + await waitFor(() => { + expect(screen.queryAllByTestId('note-title').length).toBe(0); + expect(screen.getAllByTestId('task-title').length).toBe(2); + }); + }); + + test('keeps filter selection after deleting a note', async () => { + await act(async () => { + render( + + + + + + ); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('note-title').length).toBe(2); + }); + + // Select "Only Notes" filter + const dropdownToggle = screen.getByTestId('main-label-selector'); + await act(async () => { + fireEvent.click(dropdownToggle); + }); + + const onlyNotesOption = screen.getByRole('button', { name: /home_radio_notes/i }); + await act(async () => { + fireEvent.click(onlyNotesOption); + }); + + // Verify filter is applied - tasks should be hidden + await waitFor(() => { + expect(screen.queryAllByTestId('task-title').length).toBe(0); + }); + + // Delete a note + const noteDropdownToggles = screen.getAllByTestId('three-dots-icon'); + await act(async () => { + fireEvent.click(noteDropdownToggles[0]); + }); + + const deleteButtons = screen.getAllByRole('button'); + const deleteButton = deleteButtons.find( + button => button.textContent === 'task_table_action_delete' + ); + await act(async () => { + fireEvent.click(deleteButton!); + }); + + // After reload, filter should still be applied - tasks should remain hidden + await waitFor(() => { + expect(screen.queryAllByTestId('task-title').length).toBe(0); + expect(screen.getAllByTestId('note-title').length).toBe(2); + }); + }); + + test('keeps text search after marking task as done', async () => { + await act(async () => { + render( + + + + + + ); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(2); + }); + + // Enter search text to filter to only 'Task 1' + const searchInput = screen.getByPlaceholderText('home_input_filter'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'Task 1' } }); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(1); + expect(screen.getAllByTestId('task-title')[0].textContent).toBe('Task 1'); + }); + + // Mark the task as done + const dropdownToggles = screen.getAllByTestId('three-dots-icon'); + await act(async () => { + fireEvent.click(dropdownToggles[0]); + }); + + const dropdownItems = screen.getAllByRole('button'); + const markAsDoneButton = dropdownItems.find( + item => item.textContent === 'task_table_action_done' + ); + await act(async () => { + fireEvent.click(markAsDoneButton!); + }); + + // After reload, text filter should still be applied + await waitFor(() => { + expect(screen.getAllByTestId('task-title').length).toBe(1); + expect(screen.getAllByTestId('task-title')[0].textContent).toBe('Task 1'); + }); + }); /* test('getFirstRows properly formats note preview', async () => { await act(async () => { diff --git a/client/src/views/Home/index.tsx b/client/src/views/Home/index.tsx index d2befee..def9016 100644 --- a/client/src/views/Home/index.tsx +++ b/client/src/views/Home/index.tsx @@ -105,14 +105,17 @@ function Home(): React.ReactNode { }; /** - * Filter notes by a given text. + * Apply filters to a given set of tasks and notes, updating displayed state. + * + * @param {string} text - The text to filter by. + * @param {string | undefined} radioFilter - The radio filter option. + * @param {TaskResponse[]} allTasks - The full list of tasks to filter from. + * @param {NoteResponse[]} allNotes - The full list of notes to filter from. */ - const filterTasksAndNotes = (text: string, radioFilter?: string): void => { - setFilterText(text); - - if (!text && !radioFilter) { - setNotes([...savedNotes]); - setTasks([...savedTasks]); + const applyFilter = (text: string, radioFilter: string | undefined, allTasks: TaskResponse[], allNotes: NoteResponse[]): void => { + if (!text && (!radioFilter || radioFilter === 'everything')) { + setNotes([...allNotes]); + setTasks([...allTasks]); return; } @@ -122,7 +125,7 @@ function Home(): React.ReactNode { setNotes([]); } else { - let filteredNotes = savedNotes.filter((note: NoteResponse) => { + let filteredNotes = allNotes.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()); @@ -143,7 +146,7 @@ function Home(): React.ReactNode { setTasks([]); } else { - let filteredTasks = savedTasks.filter((task: TaskResponse) => { + let filteredTasks = allTasks.filter((task: TaskResponse) => { const shouldFilter = task.description.toLowerCase().includes(text.toLowerCase()) || task.tag.toLowerCase().includes(text.toLowerCase()) || task.urls.filter((url: string) => url.includes(text.toLowerCase())).length > 0; @@ -161,6 +164,14 @@ function Home(): React.ReactNode { } }; + /** + * Filter notes by a given text. + */ + const filterTasksAndNotes = (text: string, radioFilter?: string): void => { + setFilterText(text); + applyFilter(text, radioFilter, savedTasks, savedNotes); + }; + /** * Load tasks from the server. */ @@ -178,8 +189,6 @@ function Home(): React.ReactNode { return 1; }); setSavedTasks([...translated]); - setTasks([...translated]); - setSelectedOption('everything'); } catch (e) { handleError(e); @@ -193,7 +202,6 @@ function Home(): React.ReactNode { try { const notesFetched: NoteResponse[] = await api.getJSON(ApiConfig.notesUrl); notesFetched.sort((n1, n2) => (n1.id > n2.id) ? -1 : 1); - setNotes([...notesFetched]); setSavedNotes([...notesFetched]); } catch (e) { @@ -305,6 +313,10 @@ function Home(): React.ReactNode { loadAllNotes(); }, [user]); + useEffect(() => { + applyFilter(filterText, selectedOption, savedTasks, savedNotes); + }, [savedTasks, savedNotes, filterText, selectedOption]); + return (