diff --git a/apps/okreads-e2e/src/integration/reading-list.spec.ts b/apps/okreads-e2e/src/integration/reading-list.spec.ts
index dd8668a..421c6b5 100644
--- a/apps/okreads-e2e/src/integration/reading-list.spec.ts
+++ b/apps/okreads-e2e/src/integration/reading-list.spec.ts
@@ -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');
+ });
+ });
+ });
});
diff --git a/apps/okreads/browser/src/app/app.component.html b/apps/okreads/browser/src/app/app.component.html
index 31e82c9..b9279f7 100644
--- a/apps/okreads/browser/src/app/app.component.html
+++ b/apps/okreads/browser/src/app/app.component.html
@@ -7,8 +7,7 @@
okreads
@@ -23,7 +22,10 @@ okreads
My Reading List
-
diff --git a/libs/api/books/src/lib/reading-list.controller.ts b/libs/api/books/src/lib/reading-list.controller.ts
index 0c2671e..84a00d1 100644
--- a/libs/api/books/src/lib/reading-list.controller.ts
+++ b/libs/api/books/src/lib/reading-list.controller.ts
@@ -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() {
@@ -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);
+ }
}
diff --git a/libs/api/books/src/lib/reading-list.service.ts b/libs/api/books/src/lib/reading-list.service.ts
index 63d1f22..c4b6923 100644
--- a/libs/api/books/src/lib/reading-list.service.ts
+++ b/libs/api/books/src/lib/reading-list.service.ts
@@ -28,4 +28,20 @@ export class ReadingListService {
return list.filter(x => x.bookId !== id);
});
}
+
+ async markAsfinished(id: string): Promise {
+ 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);
+ }
}
diff --git a/libs/books/data-access/src/lib/+state/reading-list.actions.ts b/libs/books/data-access/src/lib/+state/reading-list.actions.ts
index 1fbf1e4..f69bc00 100644
--- a/libs/books/data-access/src/lib/+state/reading-list.actions.ts
+++ b/libs/books/data-access/src/lib/+state/reading-list.actions.ts
@@ -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 }>()
+);
\ No newline at end of file
diff --git a/libs/books/data-access/src/lib/+state/reading-list.effects.spec.ts b/libs/books/data-access/src/lib/+state/reading-list.effects.spec.ts
index 0c11d30..e6357ca 100644
--- a/libs/books/data-access/src/lib/+state/reading-list.effects.spec.ts
+++ b/libs/books/data-access/src/lib/+state/reading-list.effects.spec.ts
@@ -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([]);
});
@@ -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);
});
@@ -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}`,
@@ -130,12 +124,10 @@ 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}`,
@@ -143,4 +135,45 @@ describe('ToReadEffects', () => {
}).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);
+ });
+ });
+
});
diff --git a/libs/books/data-access/src/lib/+state/reading-list.effects.ts b/libs/books/data-access/src/lib/+state/reading-list.effects.ts
index b965da7..bbc2ccf 100644
--- a/libs/books/data-access/src/lib/+state/reading-list.effects.ts
+++ b/libs/books/data-access/src/lib/+state/reading-list.effects.ts
@@ -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();
}
diff --git a/libs/books/data-access/src/lib/+state/reading-list.reducer.spec.ts b/libs/books/data-access/src/lib/+state/reading-list.reducer.spec.ts
index f5dea53..1efccc9 100644
--- a/libs/books/data-access/src/lib/+state/reading-list.reducer.spec.ts
+++ b/libs/books/data-access/src/lib/+state/reading-list.reducer.spec.ts
@@ -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', () => {
diff --git a/libs/books/data-access/src/lib/+state/reading-list.reducer.ts b/libs/books/data-access/src/lib/+state/reading-list.reducer.ts
index d75d02a..c372674 100644
--- a/libs/books/data-access/src/lib/+state/reading-list.reducer.ts
+++ b/libs/books/data-access/src/lib/+state/reading-list.reducer.ts
@@ -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) {
diff --git a/libs/books/data-access/src/lib/+state/reading-list.selectors.ts b/libs/books/data-access/src/lib/+state/reading-list.selectors.ts
index e6c933d..f255417 100644
--- a/libs/books/data-access/src/lib/+state/reading-list.selectors.ts
+++ b/libs/books/data-access/src/lib/+state/reading-list.selectors.ts
@@ -35,7 +35,11 @@ export const getAllBooks = createSelector<
Record,
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);
diff --git a/libs/books/feature/src/lib/book-search/book-search.component.html b/libs/books/feature/src/lib/book-search/book-search.component.html
index 631ddda..836b296 100644
--- a/libs/books/feature/src/lib/book-search/book-search.component.html
+++ b/libs/books/feature/src/lib/book-search/book-search.component.html
@@ -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'}}
diff --git a/libs/books/feature/src/lib/book-search/book-search.component.spec.ts b/libs/books/feature/src/lib/book-search/book-search.component.spec.ts
index 292a608..ffe6222 100644
--- a/libs/books/feature/src/lib/book-search/book-search.component.spec.ts
+++ b/libs/books/feature/src/lib/book-search/book-search.component.spec.ts
@@ -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(),
diff --git a/libs/books/feature/src/lib/books-feature.module.ts b/libs/books/feature/src/lib/books-feature.module.ts
index 513e02e..8649cbb 100644
--- a/libs/books/feature/src/lib/books-feature.module.ts
+++ b/libs/books/feature/src/lib/books-feature.module.ts
@@ -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';
diff --git a/libs/books/feature/src/lib/reading-list/reading-list.component.html b/libs/books/feature/src/lib/reading-list/reading-list.component.html
index 78b436d..437b74c 100644
--- a/libs/books/feature/src/lib/reading-list/reading-list.component.html
+++ b/libs/books/feature/src/lib/reading-list/reading-list.component.html
@@ -1,6 +1,6 @@
0; else empty">
-
+
{{ book?.authors?.join(',') }}
+
+ Finished on: {{ book?.finishedDate | date: 'dd/MM/yyyy' }}
+
-
+
+
(click)="removeFromReadingList(book)">
remove_circle
+
+
+ done_all
+ {{book?.finished ? 'Finished' : 'Mark finished'}}
+
diff --git a/libs/books/feature/src/lib/reading-list/reading-list.component.scss b/libs/books/feature/src/lib/reading-list/reading-list.component.scss
index 7b70bb3..942db0e 100644
--- a/libs/books/feature/src/lib/reading-list/reading-list.component.scss
+++ b/libs/books/feature/src/lib/reading-list/reading-list.component.scss
@@ -1,27 +1,66 @@
@import 'variables.scss';
-h4{
- margin:0;
+h4 {
+ margin: 0;
}
:host {
display: block;
font-size: 0.75rem;
}
+
.reading-list {
padding: $spacing-xxs;
}
.reading-list-item {
display: flex;
- justify-content: center;
+ justify-content: left;
margin-bottom: $spacing-xs;
width: 100%;
- > * {
+ >* {
display: flex;
flex-direction: column;
- justify-content: center;
+ justify-content: left;
+ align-items: flex-start;
+ }
+
+ .reading-list-item--actions {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+
+ .mark-as-read {
+ border: none;
+ padding: 0px;
+ display: flex;
+ align-items: center;
+ color: #3a7241;
+ font-size: smaller;
+ font-weight: bold;
+ width: 70px;
+ cursor: pointer;
+
+ &.mark-as-finish {
+ box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
+ background-color: #bdddc1;
+
+ &:hover {
+ box-shadow: 0 12px 16px 0 rgba(0, 0, 0, 0.24), 0 17px 50px 0 rgba(0, 0, 0, 0.19);
+ }
+ }
+
+ &.finished {
+ cursor: default;
+ }
+
+ .mat-icon {
+ font-size: 16px;
+ position: relative;
+ top: 3px;
+ }
+ }
}
}
@@ -34,6 +73,7 @@ h4{
.reading-list-item--details {
flex: 1;
min-width: 0;
+ max-width: 15rem;
margin-right: $spacing-xxs;
}
@@ -48,4 +88,4 @@ h4{
font-weight: 100;
font-size: 1rem;
color: $gray60;
-}
+}
\ No newline at end of file
diff --git a/libs/books/feature/src/lib/reading-list/reading-list.component.spec.ts b/libs/books/feature/src/lib/reading-list/reading-list.component.spec.ts
index 20cd221..ab65669 100644
--- a/libs/books/feature/src/lib/reading-list/reading-list.component.spec.ts
+++ b/libs/books/feature/src/lib/reading-list/reading-list.component.spec.ts
@@ -5,10 +5,23 @@ import { provideMockStore, MockStore } from '@ngrx/store/testing';
import { createReadingListItem, SharedTestingModule } from '@tmo/shared/testing';
import { BooksFeatureModule } from '@tmo/books/feature';
-import { getReadingList, removeFromReadingList } from '@tmo/books/data-access';
-
+import { finishReadingBook, getReadingList, removeFromReadingList } from '@tmo/books/data-access';
import { ReadingListComponent } from './reading-list.component';
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // deprecated
+ removeListener: jest.fn(), // deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+});
+
describe('ReadingList Component', () => {
let component: ReadingListComponent;
let fixture: ComponentFixture
;
@@ -46,4 +59,14 @@ describe('ReadingList Component', () => {
expect(store.dispatch).toHaveBeenCalledWith(removeFromReadingList({ item: readingListItem }));
});
+
+ it('removeFromReadingList', () => {
+ const readingListItem = createReadingListItem('9U5I_tskq9MC');
+
+ const finishButton = fixture.debugElement.query(By.css('#markAsRead-9U5I_tskq9MC'));
+ finishButton.nativeElement.click();
+
+ expect(store.dispatch).toHaveBeenCalledWith(finishReadingBook({ item: readingListItem })
+ );
+ });
});
diff --git a/libs/books/feature/src/lib/reading-list/reading-list.component.ts b/libs/books/feature/src/lib/reading-list/reading-list.component.ts
index 0174904..fe07f6f 100644
--- a/libs/books/feature/src/lib/reading-list/reading-list.component.ts
+++ b/libs/books/feature/src/lib/reading-list/reading-list.component.ts
@@ -4,7 +4,11 @@ import { Observable } from 'rxjs';
import { Store } from '@ngrx/store';
-import { getReadingList, removeFromReadingList } from '@tmo/books/data-access';
+import {
+ finishReadingBook,
+ getReadingList,
+ removeFromReadingList
+} from '@tmo/books/data-access';
import { ReadingListItem } from '@tmo/shared/models';
@Component({
@@ -21,4 +25,8 @@ export class ReadingListComponent {
removeFromReadingList(item: ReadingListItem) {
this.store.dispatch(removeFromReadingList({ item }));
}
+
+ markAsFinish(item: ReadingListItem): void {
+ this.store.dispatch(finishReadingBook({ item }));
+ }
}