Skip to content

ability to mark a book as finished #4

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

Open
wants to merge 1 commit into
base: chore/code-review
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
41 changes: 41 additions & 0 deletions apps/okreads-e2e/src/integration/reading-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,45 @@ describe('When: I use the reading list feature', () => {
'My Reading List'
);
});

it('Then: I should be able to mark a book as finished', () => {
cy.get('tmo-root').should('contain.text', 'okreads');

cy.get('#searchInput').type('jquery');
cy.get('form').submit();

cy.get('[data-testing="book-item"]')
.find('button:not(:disabled)')
.its('length')
.should('be.gt', 0)
.then(() => {
cy.get('button[id^="wantToRead-"]:not(:disabled)')
.invoke('attr', 'id')
.then(id => {
const bookId = id.split(/-(.+)/)[1];

cy.get(`#wantToRead-${bookId}`).click();

cy.get('[data-testing="toggle-reading-list"]').click();
cy.get('[data-testing="reading-list-container"]')
.should(
'contain.text',
'My Reading List'
);

cy.get(`#markAsRead-${bookId}`)
.click()
.then(() => {
cy.get(`#markAsRead-${bookId}`)
.should('be.disabled');
});

cy.get(`#btnToggleListClose`).click();

cy.get(`#wantToRead-${bookId}`)
.should('be.disabled')
.should('include.text', 'Finished');
});
});
});
});
8 changes: 5 additions & 3 deletions apps/okreads/browser/src/app/app.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ <h1>okreads</h1>
<button
data-testing="toggle-reading-list"
mat-button
(click)="drawer.toggle()"
>
(click)="drawer.toggle()">
Reading List
<tmo-total-count></tmo-total-count>
</button>
Expand All @@ -23,7 +22,10 @@ <h1>okreads</h1>
<div class="reading-list-container" data-testing="reading-list-container">
<h2>
My Reading List
<button mat-icon-button (click)="drawer.close()">
<button
id="btnToggleListClose"
mat-icon-button
(click)="drawer.close()">
<mat-icon>close</mat-icon>
</button>
</h2>
Expand Down
10 changes: 8 additions & 2 deletions libs/api/books/src/lib/reading-list.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { Book } from '@tmo/shared/models';
import { ReadingListService } from './reading-list.service';

@Controller()
export class ReadingListController {
constructor(private readonly readingList: ReadingListService) {}
constructor(private readonly readingList: ReadingListService) { }

@Get('/reading-list/')
async getReadingList() {
Expand All @@ -20,4 +20,10 @@ export class ReadingListController {
async removeFromReadingList(@Param() params) {
return await this.readingList.removeBook(params.id);
}

@Put('/reading-list/:id/finished')
async finished(@Param() params) {
const id = params.id;
return await this.readingList.markAsfinished(id);
}
}
16 changes: 16 additions & 0 deletions libs/api/books/src/lib/reading-list.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,20 @@ export class ReadingListService {
return list.filter(x => x.bookId !== id);
});
}

async markAsfinished(id: string): Promise<ReadingListItem> {
this.storage.update(list => {
return list.map(item => {
if (item.bookId === id) {
item = {
...item,
finished: true,
finishedDate: new Date().toISOString(),
};
}
return item;
});
});
return await this.storage.read().find(item => item.bookId === id);
}
}
15 changes: 15 additions & 0 deletions libs/books/data-access/src/lib/+state/reading-list.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,18 @@ export const confirmedRemoveFromReadingList = createAction(
'[Reading List API] Confirmed remove from list',
props<{ item: ReadingListItem }>()
);

export const finishReadingBook = createAction(
'[Reading List API] Finish reading book',
props<{ item: ReadingListItem }>()
);

export const finishReadingBookSuccess = createAction(
'[Reading List API] Finish reading book success',
props<{ item: ReadingListItem }>()
);

export const finishReadingBookError = createAction(
'[Reading List API] Finish reading book error',
props<{ error: string }>()
);
81 changes: 57 additions & 24 deletions libs/books/data-access/src/lib/+state/reading-list.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,10 @@ describe('ToReadEffects', () => {
actions.next(ReadingListActions.addToReadingList({ book: book }));

effects.addBook$
.subscribe(action => {
expect(action).toEqual(
ReadingListActions.confirmedAddToReadingList({ book })
);
done();
});
.subscribe(action => {
expect(action).toEqual(ReadingListActions.confirmedAddToReadingList({ book }));
done();
});

httpMock.expectOne({ url: '/api/reading-list', method: 'post' }).flush([]);
});
Expand All @@ -93,12 +91,10 @@ describe('ToReadEffects', () => {
actions.next(ReadingListActions.addToReadingList({ book: book }));

effects.addBook$
.subscribe(action => {
expect(action).toEqual(
ReadingListActions.failedAddToReadingList({ book })
);
done();
});
.subscribe(action => {
expect(action).toEqual(ReadingListActions.failedAddToReadingList({ book }));
done();
});

httpMock.expectOne({ url: '/api/reading-list', method: 'post' }).error(null);
});
Expand All @@ -111,12 +107,10 @@ describe('ToReadEffects', () => {
actions.next(ReadingListActions.removeFromReadingList({ item }));

effects.removeBook$
.subscribe(action => {
expect(action).toEqual(
ReadingListActions.confirmedRemoveFromReadingList({ item })
);
done();
});
.subscribe(action => {
expect(action).toEqual(ReadingListActions.confirmedRemoveFromReadingList({ item }));
done();
});

httpMock.expectOne({
url: `/api/reading-list/${item.bookId}`,
Expand All @@ -130,17 +124,56 @@ describe('ToReadEffects', () => {
actions.next(ReadingListActions.removeFromReadingList({ item }));

effects.removeBook$
.subscribe(action => {
expect(action).toEqual(
ReadingListActions.failedRemoveFromReadingList({ item })
);
done();
});
.subscribe(action => {
expect(action).toEqual(ReadingListActions.failedRemoveFromReadingList({ item }));
done();
});

httpMock.expectOne({
url: `/api/reading-list/${item.bookId}`,
method: 'delete'
}).error(null);
});
});

describe('markBookAsFinished$', () => {
it('should change the finished status to true on finishReadingBookSuccess', done => {
const item: ReadingListItem = createReadingListItem('A');
actions = new ReplaySubject();
actions.next(ReadingListActions.finishReadingBook({ item }));

const finishedBook = {
...item,
finished: true,
finishedDate: new Date().toISOString()
};

effects.markBookAsFinished$
.subscribe(action => {
expect(action).toEqual(ReadingListActions.finishReadingBookSuccess({ item: finishedBook }));
done();
});

httpMock.expectOne({ url: `/api/reading-list/${item.bookId}/finished`, method: 'put' }).flush(finishedBook);
});

it('should handle error if finishReadingBook api faild', done => {
const item: ReadingListItem = createReadingListItem('A');
actions = new ReplaySubject();
actions.next(ReadingListActions.finishReadingBook({ item }));

const readingListAction = ReadingListActions.finishReadingBookError({
error: 'Internal Server Error'
});

effects.markBookAsFinished$
.subscribe(action => {
expect(action.type).toEqual(readingListAction.type);
done();
});

httpMock.expectOne({ url: `/api/reading-list/${item.bookId}/finished`, method: 'put' }).error(null);
});
});

});
15 changes: 15 additions & 0 deletions libs/books/data-access/src/lib/+state/reading-list.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export class ReadingListEffects implements OnInitEffects {
)
);

markBookAsFinished$ = createEffect(() =>
this.actions$.pipe(
ofType(ReadingListActions.finishReadingBook),
concatMap(({ item }) =>
this.http.put(`/api/reading-list/${item.bookId}/finished`, null)
.pipe(map((finishedbook: ReadingListItem) =>
ReadingListActions.finishReadingBookSuccess({ item: finishedbook })
), catchError((error) =>
of(ReadingListActions.finishReadingBookError({ error }))
))

)
)
);

ngrxOnInitEffects() {
return ReadingListActions.init();
}
Expand Down
24 changes: 24 additions & 0 deletions libs/books/data-access/src/lib/+state/reading-list.reducer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,30 @@ describe('ReadingList Reducer', () => {

expect(result.error).toEqual('Internal server error');
});

it('should show mark as finishied icon in the reading list on finishReadingBookSuccess', () => {
const action = ReadingListActions.finishReadingBookSuccess({
item: {
...createReadingListItem('A'),
finished: true,
finishedDate: new Date().toISOString()
}
});

const result: State = reducer(state, action);

expect(result.entities['A'].finished).toBeTruthy();
});

it('should show error message on finishReadingBookFailure', () => {
const action = ReadingListActions.finishReadingBookError({
error: 'Internal server error'
});

const result: State = reducer(state, action);

expect(result.error).toEqual('Internal server error');
});
});

describe('unknown action', () => {
Expand Down
11 changes: 10 additions & 1 deletion libs/books/data-access/src/lib/+state/reading-list.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,16 @@ const readingListReducer = createReducer(
{ bookId: action.item.bookId, ...action.item },
state
)
)
),
on(ReadingListActions.finishReadingBookSuccess, (state, action) =>
readingListAdapter.upsertOne(action.item, state)
),
on(ReadingListActions.finishReadingBookError, (state, action) => {
return {
...state,
error: action.error
};
})
);

export function reducer(state: State | undefined, action: Action) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ export const getAllBooks = createSelector<
Record<string, ReadingListItem>,
ReadingListBook[]
>(getBooks, getReadingListEntities, (books, entities) => {
return books.map(book => ({ ...book, isAdded: Boolean(entities[book.id]) }));
return books.map(book => ({
...book,
isAdded: Boolean(entities[book.id]),
finished: Boolean(entities[book.id] && entities[book.id]['finished'])
}));
});

export const getReadingList = createSelector(getReadingListState, selectAll);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
[attr.aria-label]="'Add ' + book?.title + ' to reading list'"
[attr.aria-disabled]="book?.isAdded"
[disabled]="book?.isAdded">
Want to Read
{{book?.finished ? 'Finished': 'Want to Read'}}
</button>
</mat-card-actions>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Object.defineProperty(window, 'matchMedia', {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions libs/books/feature/src/lib/books-feature.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatBadgeModule } from '@angular/material/badge';
import { MatSnackBarModule } from '@angular/material/snack-bar';

import { BooksDataAccessModule } from '@tmo/books/data-access';

import { BookSearchComponent } from './book-search/book-search.component';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<ng-container *ngIf="(readingList$ | async)?.length> 0; else empty">
<div class="reading-list-item" *ngFor="let book of readingList$ | async">
<div>
<div class="reading-list-item--cover-container">
<img
class="reading-list-item--cover"
[attr.aria-label]="book?.title"
Expand All @@ -14,8 +14,13 @@ <h4 class="reading-list-item--details--title">
<div class="reading-list-item--details--author">
{{ book?.authors?.join(',') }}
</div>
<div *ngIf="book?.finishedDate"
class="reading-list-item--details--finishedon">
Finished on: {{ book?.finishedDate | date: 'dd/MM/yyyy' }}
</div>
</div>
<div>

<div class="reading-list-item--actions">
<button
mat-icon-button
color="warn"
Expand All @@ -24,6 +29,17 @@ <h4 class="reading-list-item--details--title">
(click)="removeFromReadingList(book)">
<mat-icon aria-hidden="true">remove_circle</mat-icon>
</button>

<button
id="markAsRead-{{book?.bookId}}"
[ngClass]="!(book?.finished) ? 'mark-as-finish' : 'finished'"
class="mark-as-read"
[disabled]="book.finished"
(click)="markAsFinish(book)"
[attr.aria-label]="'Mark ' + book?.title + ' as done reading'">
<mat-icon aria-hidden="true">done_all</mat-icon>
<span>{{book?.finished ? 'Finished' : 'Mark finished'}}</span>
</button>
</div>
</div>
</ng-container>
Expand Down
Loading