Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement and test core todo functionalities #2

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
15,269 changes: 14,860 additions & 409 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
"eject": "react-scripts eject"
},
"eslintConfig": {
"plugins": ["prettier"],
"plugins": [
"prettier"
],
"extends": [
"react-app",
"plugin:prettier/recommended"
Expand All @@ -36,20 +38,26 @@
]
},
"dependencies": {
"bm-react-handson": "file:",
"json-server": "0.17.4",
"lodash": "4.17.21",
"react": "17.0.2",
"react-dom": "17.0.2",
"styled-components": "5.3.11"
"styled-components": "5.3.11",
"uuid": "10.0.0",
"zustand": "4.5.5"
},
"devDependencies": {
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"@testing-library/user-event": "14.5.2",
"@types/jest": "28.1.8",
"@types/lodash": "4.17.12",
"@types/node": "16.18.114",
"@types/react": "17.0.83",
"@types/react-dom": "17.0.25",
"@types/styled-components": "5.1.34",
"@types/uuid": "10.0.0",
"eslint-config-prettier": "8.10.0",
"eslint-plugin-prettier": "4.2.1",
"prettier": "2.8.8",
Expand Down
131 changes: 126 additions & 5 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,130 @@
import React from 'react';
import {render} from '@testing-library/react';
import {render, screen, act} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {App} from './App';
import {useTodoStore} from './stores/todoStore';
import {waitFor} from '@testing-library/react';
import {fireEvent} from '@testing-library/dom';

test('renders learn react link', () => {
const {getByText} = render(<App />);
const linkElement = getByText(/TODO APP/);
expect(linkElement).toBeInTheDocument();
jest.mock('./stores/todoStore', () => ({
useTodoStore: jest.fn(),
}));

const mockTodos = [
{id: '1', text: 'Test Todo 1', done: false, createdTimestamp: Date.now()},
{id: '2', text: 'Test Todo 2', done: true, createdTimestamp: Date.now()},
];

const todoStoreMock = {
todos: mockTodos,
fetch: jest.fn(),
create: jest.fn(),
toggle: jest.fn(),
delete: jest.fn(),
allDone: false,
checkIfAllDone: jest.fn(),
};

beforeEach(() => {
(useTodoStore as unknown as jest.Mock).mockReturnValue(todoStoreMock);
});

describe('App Component', () => {
it('renders the app with initial todos', async () => {
await act(async () => {
render(<App />);
});

const todoItems = screen.getAllByRole('listitem');
expect(todoItems).toHaveLength(mockTodos.length);
});

it('creates a new todo', async () => {
const user = userEvent.setup();
await act(async () => {
render(<App />);
});

const input = screen.getByPlaceholderText('todo title');
await user.type(input, 'New Todo');
const addButton = screen.getByText('+');
await act(async () => {
await user.click(addButton);
});

expect(todoStoreMock.create).toHaveBeenCalledWith('New Todo');
});

it('toggles a todo', async () => {
const user = userEvent.setup();
await act(async () => {
render(<App />);
});

const checkboxes = screen.getAllByRole('checkbox');
await act(async () => {
await user.click(checkboxes[0]);
});

expect(todoStoreMock.toggle).toHaveBeenCalledWith(mockTodos[0].id);
});

it('deletes a todo', async () => {
const user = userEvent.setup();
await act(async () => {
render(<App />);
});

const deleteButtons = screen.getAllByText('🗑️');
await act(async () => {
await user.click(deleteButtons[0]);
});

expect(todoStoreMock.delete).toHaveBeenCalledWith(mockTodos[0].id);
});

it('shows alert when all todos are done', async () => {
window.alert = jest.fn();
todoStoreMock.allDone = true;

await act(async () => {
render(<App />);
});

expect(window.alert).toHaveBeenCalledWith(
`Congratulations, you're all set! You've done everything on your list.`
);
});

it('filters todos based on filter text', async () => {
render(<App />);

const filterInput = screen.getByPlaceholderText('Filter todos');
fireEvent.change(filterInput, {target: {value: 'Test Todo 1'}});

await waitFor(() => {
const todoItems = screen.getAllByRole('listitem');
expect(todoItems).toHaveLength(1);
});

expect(screen.getByText('Test Todo 1')).toBeInTheDocument();
});

it('clears the filter when clear button is clicked', async () => {
const user = userEvent.setup();
await act(async () => {
render(<App />);
});

const filterInput = screen.getByPlaceholderText('Filter todos');
await user.type(filterInput, 'Test Todo 1');

const clearButton = screen.getByText('❌');
await act(async () => {
await user.click(clearButton);
});

const todoItems = screen.getAllByRole('listitem');
expect(todoItems).toHaveLength(mockTodos.length);
});
});
101 changes: 64 additions & 37 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import React from 'react';
import styled from 'styled-components';
import {TodosFooter} from './components/TodosFooter';
import {TodosHeader} from './components/TodosHeader';
import {OnSubmit, TodoInput} from './components/TodoInput';
import {TodoList} from './components/TodoList';
import {Todo} from './types';
import {TodoStatusBar} from './components/TodoStatusBar';
import {TodosFooter} from './components/TodosFooter/TodosFooter';
import {TodosHeader} from './components/TodosHeader/TodosHeader';
import {OnSubmit, TodoInput} from './components/TodoInput/TodoInput';
import {TodoList} from './components/TodoList/TodoList';
import {TodoStatusBar} from './components/TodoStatusBar/TodoStatusBar';
import {useTodoStore} from './stores/todoStore';
import {Todo} from './models/types';
import {OnToggle} from './components/TodoItem/TodoItem';
import {TodoFilter} from './components/TodoFilter/TodoFilter';

export const AppContainer = styled.div`
display: flex;
Expand All @@ -16,53 +19,77 @@
height: 100vh;
`;

export interface AppState {
todos: Array<Todo>;
}

export const App: React.FC = () => {
const [todos, setTodos] = React.useState<Todo[]>([]);
const [filteredTodos, setFilteredTodos] = React.useState<Todo[]>([]);
const [filterText, setFilterText] = React.useState('');

const todoStore = useTodoStore();

React.useEffect(() => {
(async () => {
const response = await fetch('http://localhost:3001/todos');
setTodos(await response.json());
})();
todoStore.fetch();
}, []);

Check warning on line 30 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build (16)

React Hook React.useEffect has a missing dependency: 'todoStore'. Either include it or remove the dependency array

const createTodo: OnSubmit = async text => {
const newTodo = {
text,
done: false,
createdTimestamp: Date.now(),
};
const filterTodos = React.useCallback(() => {
let _filteredTodos = todoStore.todos;
if (filterText.length > 0) {
_filteredTodos = _filteredTodos.filter(todo =>
todo.text.toLowerCase().includes(filterText.toLowerCase())
);
}
setFilteredTodos(_filteredTodos);
}, [todoStore.todos, filterText]);

React.useEffect(() => {
filterTodos();
}, [todoStore.todos, filterText]);

Check warning on line 44 in src/App.tsx

View workflow job for this annotation

GitHub Actions / build (16)

React Hook React.useEffect has a missing dependency: 'filterTodos'. Either include it or remove the dependency array

const response = await fetch('http://localhost:3001/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newTodo),
});
if (!response.ok) {
window.alert(
`Unexpected error ${response.status}: ${response.statusText}`
React.useEffect(() => {
if (todoStore.allDone) {
alert(
`Congratulations, you're all set! You've done everything on your list.`
);
return text;
}
setTodos([...todos, await response.json()]);
return '';
}, [todoStore.allDone]);

const createTodo: OnSubmit = async text => {
return await todoStore.create(text);
};

const toggleTodo: OnToggle = async (id: string | number) => {
await todoStore.toggle(id);
};

const deleteTodo = async (id: string | number) => {
await todoStore.delete(id);
};

const handleFilterChange = (filterText: string) => {
setFilterText(filterText);
};

return (
<AppContainer className='App'>
<TodosHeader>
<TodoStatusBar total={todos.length} />
<TodoFilter
filterText={filterText}
onFilterChange={handleFilterChange}
></TodoFilter>
<TodoStatusBar
total={todoStore.todos.length}
done={todoStore.todos.filter(x => x.done).length}
/>
</TodosHeader>
<TodoInput onSubmit={createTodo} />
<TodoList todos={todos} />
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodosFooter>
<TodoStatusBar total={todos.length} />
<TodoStatusBar
total={todoStore.todos.length}
done={todoStore.todos.filter(x => x.done).length}
/>
</TodosFooter>
</AppContainer>
);
Expand Down
90 changes: 90 additions & 0 deletions src/components/InputWithDebounce/InputWithDebounce.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import {render, screen, fireEvent, act} from '@testing-library/react';
import {InputWithDebounce} from './InputWithDebounce';
import {useDebounce} from '../../hooks/useDebounce';

jest.mock('../../hooks/useDebounce');

describe('InputWithDebounce', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the input with the correct placeholder and value', () => {
render(
<InputWithDebounce
value='test'
placeholder='Filter todos'
onChange={jest.fn()}
/>
);

const input = screen.getByPlaceholderText('Filter todos');
expect(input).toBeInTheDocument();
expect(input).toHaveValue('test');
});

it('calls onChange with the debounced value', async () => {
jest.useFakeTimers();
const onChange = jest.fn();
(useDebounce as jest.Mock).mockImplementation(value => value);

render(
<InputWithDebounce
value=''
placeholder='Filter todos'
onChange={onChange}
debounceTime={500}
/>
);

const input = screen.getByPlaceholderText('Filter todos');
fireEvent.change(input, {target: {value: 'test'}});

act(() => {
jest.advanceTimersByTime(500);
});

expect(onChange).toHaveBeenCalledWith('test');
jest.useRealTimers();
});

it('updates the input value when the value prop changes', () => {
const {rerender} = render(
<InputWithDebounce
value='initial'
placeholder='Filter todos'
onChange={jest.fn()}
/>
);

const input = screen.getByPlaceholderText('Filter todos');
expect(input).toHaveValue('initial');

rerender(
<InputWithDebounce
value='updated'
placeholder='Filter todos'
onChange={jest.fn()}
/>
);

expect(input).toHaveValue('updated');
});

it('updates the internal state when the input value changes', () => {
const onChange = jest.fn();
render(
<InputWithDebounce
value=''
placeholder='Filter todos'
onChange={onChange}
/>
);

const input = screen.getByPlaceholderText('Filter todos');
fireEvent.change(input, {target: {value: 'test'}});

expect(input).toHaveValue('test');
});
});
Loading
Loading