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 @@
-
+
{{ book?.authors?.join(',') }}
+
+ Finished on: {{ book?.finishedDate | date: 'dd/MM/yyyy' }} +
-
+ +
+ +
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 })); + } }