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

With model view presenter v1 #6

Merged
merged 17 commits into from Feb 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 0 additions & 8 deletions src/App.test.js

This file was deleted.

51 changes: 29 additions & 22 deletions src/Todo/TodoList.js
@@ -1,42 +1,49 @@
import {useEffect, useMemo, useState} from 'react';
import {createTodo, getTodos} from './todo.service';
import {getTodos} from './todo.service';
import {TodoListPresenter} from './TodoListPresenter';

const TodoList = () => {
const [todos, setTodoList] = useState([]);
useEffect(function callApi() {
getTodos().then(list => setTodoList(list));
}, []);

const ongoingCount = useMemo(() => todos.filter(t => !t.done).length, [todos]);
const doneCount = useMemo(() => todos.filter(t => t.done).length, [todos]);

const [clickCount, setClickCount] = useState(0);

const addEmptyTodo = () => setTodoList([createTodo('Relax! Edition will come...', false), ...todos]);
const toggleDone = (index) => setTodoList([...todos.slice(0, index), {
...todos[index],
done: !todos[index].done,
}, ...todos.slice(index + 1)]);
const TodoList = ({presenter, viewModel}) => {
useEffect(() => {
presenter.loadTodos();
}, [presenter]);

return <>
<table>
<thead>
<tr>
<th rowSpan={2} align="left">My todos ({ongoingCount} ongoing /{doneCount} done/ {clickCount} clicks)
<button onClick={addEmptyTodo}>Add</button>
<th rowSpan={2} align="left">My todos ({viewModel.ongoingCount} ongoing
/{viewModel.doneCount} done/ {viewModel.clickCount} clicks)
<button onClick={() => presenter.addEmptyTodo()}>Add</button>
</th>
</tr>
</thead>
<tbody>
{todos.map((todo, index) => (
{viewModel.todos.map((todo, index) => (
<tr key={todo.id}>
<td onClick={() => setClickCount(() => clickCount + 1)}><label htmlFor={`done-${todo.id}`}>{todo.title}</label></td>
<td><input type="checkbox" value="1" id={`done-${todo.id}`} checked={todo.done} onChange={() => toggleDone(index)}/></td>
<td onClick={() => presenter.incrementClickCount()}><label htmlFor={`done-${todo.id}`}>{todo.title}</label>
</td>
<td><input type="checkbox" value="1" id={`done-${todo.id}`} checked={todo.done}
onChange={() => presenter.toggleDone(index)}/></td>
</tr>
))}
</tbody>
</table>
</>;
};

export default TodoList;
export const withMVP = (Wrapped) =>
function WithTodoPresenter() {
const [viewModel, setViewModel] = useState();

const presenter = useMemo(() => {
const presenter = new TodoListPresenter(getTodos);
presenter.onViewModelChange(setViewModel);
return presenter;
}, []);

return <Wrapped presenter={presenter} viewModel={viewModel || presenter.immutableViewModel()}/>;
};


export default withMVP(TodoList);
51 changes: 51 additions & 0 deletions src/Todo/TodoListPresenter.js
@@ -0,0 +1,51 @@
import {createTodo} from './todo.service';
import {Presenter} from '../sharedKernel/Presenter';

export class TodoListPresenter extends Presenter {
constructor(getTodos) {
super({
viewModel: {
todos: [],
doneCount: 0,
ongoingCount: 0,
clickCount: 0,
},
});

this.useCase = {
getTodos,
};
}

async loadTodos() {
try {
this._setTodoList(await this.useCase.getTodos());
} catch (e) {
//Do nothing for the moment
}
}

incrementClickCount() {
this.update({clickCount: this.viewModel.clickCount + 1});
}

addEmptyTodo() {
this._setTodoList([createTodo('Relax! Edition will come...', false), ...this.viewModel.todos]);
}

_setTodoList(todos) {
this.update({
todos,
doneCount: todos.filter(t => t.done).length,
ongoingCount: todos.filter(t => !t.done).length,
});
}


toggleDone(index) {
this._setTodoList([
...this.viewModel.todos.slice(0, index),
{...this.viewModel.todos[index], done: !this.viewModel.todos[index].done},
...this.viewModel.todos.slice(index + 1)]);
}
}
67 changes: 67 additions & 0 deletions src/Todo/TodoListPresenter.spec.js
@@ -0,0 +1,67 @@
import {createTodo, getTodos} from './todo.service';
import {TodoListPresenter} from './TodoListPresenter';

jest.mock('./todo.service', () => ({
...jest.requireActual('./todo.service'),
getTodos: jest.fn(),
}));

test('it fetches todos from api', async () => {
getTodos.mockResolvedValue([createTodo('My first todo', false), createTodo('My second todo', true)]);
const presenter = new TodoListPresenter(getTodos);
let viewModel = {};
presenter.onViewModelChange((vm) => viewModel = vm);

await presenter.loadTodos();

expect(viewModel.todos).toHaveLength(2);
expect(viewModel.todos.find(t => t.title === 'My first todo').done).toBe(false);
expect(viewModel.todos.find(t => t.title === 'My second todo').done).toBe(true);
});

test('a todo can be set as done', async () => {
getTodos.mockResolvedValue([createTodo('My first todo', false)]);
const presenter = new TodoListPresenter(getTodos);
let viewModel = {};
presenter.onViewModelChange((vm) => viewModel = vm);
await presenter.loadTodos();

expect(viewModel.ongoingCount).toBe(1);
expect(viewModel.doneCount).toBe(0);

presenter.toggleDone(0);

expect(presenter.immutableViewModel().ongoingCount).toBe(0);
expect(presenter.immutableViewModel().doneCount).toBe(1);
});

test('a todo can be set as ongoing', async () => {
getTodos.mockResolvedValue([createTodo('My first todo', true)]);
const presenter = new TodoListPresenter(getTodos);
let viewModel = {};
presenter.onViewModelChange((vm) => viewModel = vm);
await presenter.loadTodos();

expect(viewModel.ongoingCount).toBe(0);
expect(viewModel.doneCount).toBe(1);

presenter.toggleDone(0);

expect(viewModel.ongoingCount).toBe(1);
expect(viewModel.doneCount).toBe(0);
});

test('a todo can be added', async () => {
getTodos.mockResolvedValue([]);
const presenter = new TodoListPresenter(getTodos);
let viewModel = {};
presenter.onViewModelChange((vm) => viewModel = vm);
await presenter.loadTodos();
expect(viewModel.todos).toHaveLength(0);

presenter.addEmptyTodo();

expect(viewModel.todos).toHaveLength(1);
expect((viewModel.todos)[0].done).toBe(false);
});

23 changes: 23 additions & 0 deletions src/sharedKernel/Presenter.js
@@ -0,0 +1,23 @@
export class Presenter {
constructor({viewModel: defaultViewModel, listener: viewModelListener = (_viewModel) => null}) {
this.viewModel = defaultViewModel;
this.viewModelListener = viewModelListener;
}

onViewModelChange(callback) {
this.viewModelListener = callback;
}

update(newValues) {
this.viewModel = {...this.viewModel, ...newValues};
this._refreshUI();
}

_refreshUI() {
this.viewModelListener(this.immutableViewModel());
}

immutableViewModel() {
return {...this.viewModel};
}
}