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 <RMCampos@users.noreply.github.com> * 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 <RMCampos@users.noreply.github.com>
This commit is contained in:
co-authored by
claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Ricardo Campos
parent
ab34a310c4
commit
b423082b1f
@@ -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
|
||||
@@ -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(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<AuthContext.Provider value={authContextValue}>
|
||||
<BrowserRouter>
|
||||
<Home />
|
||||
</BrowserRouter>
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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 (
|
||||
<Container fluid>
|
||||
<ContentHeader
|
||||
|
||||
Reference in New Issue
Block a user