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 (