From b282840e46de67f0926eb6fc88140f4a1e0ea012 Mon Sep 17 00:00:00 2001 From: Anthony Rey Date: Mon, 13 Nov 2023 16:49:09 +0100 Subject: [PATCH] Add isbn as parameter --- .github/workflows/main.yml | 44 ++++++++++++++ package.json | 3 +- src/library/application/LibraryRoutes.tsx | 12 +++- src/library/domain/Books.ts | 3 +- .../infrastructure/primary/BookComponent.tsx | 57 ++++++++++-------- src/library/infrastructure/primary/Loader.ts | 58 +++++++++++++++++++ src/library/infrastructure/primary/UseLoad.ts | 39 ------------- .../infrastructure/secondary/RestBooks.ts | 5 +- test/component/spec/library/library.spec.ts | 18 +++--- .../secondary/RestBooks.spec.ts | 6 +- 10 files changed, 165 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/main.yml create mode 100644 src/library/infrastructure/primary/Loader.ts delete mode 100644 src/library/infrastructure/primary/UseLoad.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..e5ff12f --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,44 @@ +name: Main + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js LTS + uses: actions/setup-node@v4 + with: + cache: 'npm' + node-version: 'lts/*' + - name: node check + run: | + npm ci + npm test + env: + CI: true + + status-checks: + name: status-checks + needs: [build] + permissions: + contents: none + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validation Status checks + run: | + echo 'Configuration for Status checks that are required' + echo '${{ toJSON(needs) }}' + if [[ ('skipped' == '${{ needs.build.result }}') || ('success' == '${{ needs.build.result }}') ]]; then + exit 0 + fi + exit 1 diff --git a/package.json b/package.json index ee95661..bba1de1 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,14 @@ "type": "module", "scripts": { "dev": "vite", + "component:serve": "NODE_ENV=production vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0 --ignore-path .gitignore", "lint:fix": "npm run lint -- --fix", "preview": "vite preview", "test": "vitest", "test:ci": "vitest run --coverage", - "test:component": "start-server-and-test dev http://localhost:3030 test:component:open", + "test:component": "start-server-and-test component:serve http://localhost:3030 test:component:open", "test:component:ci": "start-server-and-test dev http://localhost:3030 test:component:run", "test:component:open": "cypress open --config-file test/component/cypress.config.ts", "test:component:run": "cypress run --config-file test/component/cypress.config.ts" diff --git a/src/library/application/LibraryRoutes.tsx b/src/library/application/LibraryRoutes.tsx index 1cebe5c..1dbb45d 100644 --- a/src/library/application/LibraryRoutes.tsx +++ b/src/library/application/LibraryRoutes.tsx @@ -1,14 +1,20 @@ -import { RouteObject } from 'react-router-dom'; +import { RouteObject, useParams } from 'react-router-dom'; import { BookComponent } from '@/library/infrastructure/primary/BookComponent.tsx'; import { LibraryApp } from '@/library/application/LibraryApp.tsx'; +import { ISBN } from '@/library/domain/ISBN.ts'; + +const BooksPage = () => { + const { isbn } = useParams(); + return ; +}; export const libraryRoutes: RouteObject = { path: '/', element: , children: [ { - path: '', - element: , + path: 'book/:isbn', + element: , }, ], }; diff --git a/src/library/domain/Books.ts b/src/library/domain/Books.ts index b4a0784..196142b 100644 --- a/src/library/domain/Books.ts +++ b/src/library/domain/Books.ts @@ -1,6 +1,7 @@ import { Book } from '@/library/domain/Book'; import { Either } from '@/functional/Either'; +import { ISBN } from '@/library/domain/ISBN.ts'; export interface Books { - get(): Promise>; + get(isbn: ISBN): Promise>; } diff --git a/src/library/infrastructure/primary/BookComponent.tsx b/src/library/infrastructure/primary/BookComponent.tsx index 4c6d0ef..f58ef34 100644 --- a/src/library/infrastructure/primary/BookComponent.tsx +++ b/src/library/infrastructure/primary/BookComponent.tsx @@ -1,33 +1,44 @@ -import { useState } from 'react'; -import { useLoadEither } from '@/library/infrastructure/primary/UseLoad'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { inject } from '@/injections.ts'; import { BOOKS } from '@/library/application/LibraryKeys.ts'; import { Book } from '@/library/domain/Book.ts'; +import { ISBN } from '@/library/domain/ISBN.ts'; +import { Loader, loadError, loadFor, loadInProgress, loadSuccess } from '@/library/infrastructure/primary/Loader.ts'; -export const BookComponent = () => { - const books = inject(BOOKS); +const BookInfoComponent = ({ book }: { book: Book }) => { + const { t } = useTranslation(); + return ( +
    +
  • + {t('book.title')} + {book.title} +
  • +
  • {book.isbn.get()}
  • +
+ ); +}; +export const BookComponent = (props: { isbn: ISBN }) => { const { t } = useTranslation(); - const [book, setBook] = useState(); + const [bookLoader, setBookLoader] = useState>(loadInProgress()); - const { isInProgress, isFailing, isSuccessful, errorMessage } = useLoadEither(books.get(), book => { - setBook(book); - }); + useEffect(() => { + setBookLoader(loadInProgress()); + inject(BOOKS) + .get(props.isbn) + .then(either => + either.evaluate( + error => setBookLoader(loadError(error.message)), + content => setBookLoader(loadSuccess(content)) + ) + ) + .catch((error: Error) => setBookLoader(loadError(error.message))); + }, [props.isbn]); - return ( - <> - {isInProgress &&

{t('book.inProgress')}

} - {isFailing &&

{errorMessage}

} - {isSuccessful && ( -
    -
  • - {t('book.title')} - {book?.title} -
  • -
  • {book?.isbn.get()}
  • -
- )} - - ); + return loadFor(bookLoader)({ + progress: () =>

{t('book.inProgress')}

, + error: message =>

{message}

, + success: book => , + }); }; diff --git a/src/library/infrastructure/primary/Loader.ts b/src/library/infrastructure/primary/Loader.ts new file mode 100644 index 0000000..3f57c55 --- /dev/null +++ b/src/library/infrastructure/primary/Loader.ts @@ -0,0 +1,58 @@ +import { ReactElement } from 'react'; + +const LoadingInProgress = Symbol(); +const LoadingError = Symbol(); +const LoadingSuccess = Symbol(); + +type LoadingStatus = typeof LoadingError | typeof LoadingInProgress | typeof LoadingSuccess; + +interface LoadWithStatus { + status: LoadingStatus; +} + +interface LoadSuccess extends LoadWithStatus { + content: T; + status: typeof LoadingSuccess; +} + +interface LoadError extends LoadWithStatus { + errorMessage: string; + status: typeof LoadingError; +} + +interface LoadInProgress extends LoadWithStatus { + status: typeof LoadingInProgress; +} + +export type Loader = LoadSuccess | LoadError | LoadInProgress; + +export const loadInProgress = (): LoadInProgress => ({ status: LoadingInProgress }); + +export const loadSuccess = (content: T): LoadSuccess => ({ + content, + status: LoadingSuccess, +}); + +export const loadError = (errorMessage: string): LoadError => ({ + errorMessage, + status: LoadingError, +}); + +type LoaderCallback = { + success: (content: T) => ReactElement; + error: (message: string) => ReactElement; + progress: () => ReactElement; +}; + +export const loadFor = + (loader: Loader) => + ({ success, error, progress }: LoaderCallback): ReactElement => { + switch (loader.status) { + case LoadingInProgress: + return progress(); + case LoadingError: + return error(loader.errorMessage); + case LoadingSuccess: + return success(loader.content); + } + }; diff --git a/src/library/infrastructure/primary/UseLoad.ts b/src/library/infrastructure/primary/UseLoad.ts deleted file mode 100644 index 24b591c..0000000 --- a/src/library/infrastructure/primary/UseLoad.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Either } from '@/functional/Either'; -import { useEffect, useState } from 'react'; - -const IN_PROGRESS = Symbol(); -const SUCCESS = Symbol(); -const FAILURE = Symbol(); - -type LoadingStatus = typeof IN_PROGRESS | typeof SUCCESS | typeof FAILURE; - -interface Loaded { - errorMessage: string; - isInProgress: boolean; - isSuccessful: boolean; - isFailing: boolean; -} - -export const useLoadEither = (promise: Promise>, then: (value: T) => void): Loaded => { - const [errorMessage, setErrorMessage] = useState(''); - const [status, setStatus] = useState(IN_PROGRESS); - - useEffect(() => { - const errorLoad = (err: Error) => { - setErrorMessage(err.message); - setStatus(FAILURE); - }; - - const successLoad = (either: Either) => - either.evaluate(errorLoad, value => { - then(value); - setStatus(SUCCESS); - }); - - promise.then(successLoad).catch(errorLoad); - }, [promise, then]); - - const is = (to: LoadingStatus): boolean => status === to; - - return { isInProgress: is(IN_PROGRESS), isSuccessful: is(SUCCESS), isFailing: is(FAILURE), errorMessage }; -}; diff --git a/src/library/infrastructure/secondary/RestBooks.ts b/src/library/infrastructure/secondary/RestBooks.ts index 7ac70b9..562c0c2 100644 --- a/src/library/infrastructure/secondary/RestBooks.ts +++ b/src/library/infrastructure/secondary/RestBooks.ts @@ -3,13 +3,14 @@ import { Either } from '@/functional/Either'; import { Book } from '@/library/domain/Book'; import { AxiosInstance } from 'axios'; import { RestBook, toBook } from '@/library/infrastructure/secondary/RestBook'; +import { ISBN } from '@/library/domain/ISBN.ts'; export class RestBooks implements Books { constructor(private readonly axiosInstance: AxiosInstance) {} - get(): Promise> { + get(isbn: ISBN): Promise> { return this.axiosInstance - .get('/isbn/9780321125217.json') + .get(`/isbn/${isbn.get()}.json`) .then(response => response.data) .then(toBook) .catch(error => Promise.resolve(Either.err(error))); diff --git a/test/component/spec/library/library.spec.ts b/test/component/spec/library/library.spec.ts index f6ca60d..ea69a7a 100644 --- a/test/component/spec/library/library.spec.ts +++ b/test/component/spec/library/library.spec.ts @@ -6,7 +6,7 @@ const BOOK = { number_of_pages: 42, }; -const stubOpenLibraryIsbn = () => cy.intercept('https://openlibrary.org/isbn/9780321125217.json', BOOK); +const stubOpenLibraryIsbn = () => cy.intercept('https://openlibrary.org/isbn/9780321125217.json', BOOK).as('BOOK'); const stubOpenLibraryIsbnInvalid = () => cy.intercept('https://openlibrary.org/isbn/9780321125217.json', { @@ -34,13 +34,13 @@ describe('Library', () => { }); it('Should be loading before result', () => { - cy.visit('/', loadLanguage('en')); + cy.visit('/book/9780321125217', loadLanguage('en')); cy.contains(dataSelector('book.loading'), 'In progress'); }); it('Should be loading before result', () => { - cy.visit('/', loadLanguage('fr')); + cy.visit('/book/9780321125217', loadLanguage('fr')); cy.contains(dataSelector('book.loading'), 'En cours'); }); @@ -49,7 +49,7 @@ describe('Library', () => { it('Should not show book with network error', () => { stubOpenLibraryIsbnNetworkError(); - cy.visit('/'); + cy.visit('/book/9780321125217'); cy.contains(dataSelector('book.error'), 'Request failed'); }); @@ -57,7 +57,7 @@ describe('Library', () => { it('Should not show book with invalid ISBN', () => { stubOpenLibraryIsbnInvalid(); - cy.visit('/'); + cy.visit('/book/9780321125217'); cy.contains(dataSelector('book.error'), 'Non digits are not allowed for ISBN'); }); @@ -65,7 +65,7 @@ describe('Library', () => { it('Should get book', () => { stubOpenLibraryIsbn(); - cy.visit('/'); + cy.visit('/book/9780321125217'); cy.contains(dataSelector('book.title'), 'Domain-driven design'); cy.contains(dataSelector('book.isbn'), '9780321125217'); @@ -75,15 +75,15 @@ describe('Library', () => { it('Should have english labels', () => { stubOpenLibraryIsbn(); - cy.visit('/', loadLanguage('en')); + cy.visit('/book/9780321125217', loadLanguage('en')); cy.contains(dataSelector('book.label.title'), 'Title: '); }); - it('Should have french labels', () => { + it.only('Should have french labels', () => { stubOpenLibraryIsbn(); - cy.visit('/', loadLanguage('fr')); + cy.visit('/book/9780321125217', loadLanguage('fr')); cy.contains(dataSelector('book.label.title'), 'Titre : '); }); diff --git a/test/unit/library/infrastructure/secondary/RestBooks.spec.ts b/test/unit/library/infrastructure/secondary/RestBooks.spec.ts index 87364ba..6ea18d4 100644 --- a/test/unit/library/infrastructure/secondary/RestBooks.spec.ts +++ b/test/unit/library/infrastructure/secondary/RestBooks.spec.ts @@ -17,6 +17,8 @@ const stubAxiosInstance = (): AxiosInstanceStub => get: sinon.stub(), }) as AxiosInstanceStub; +const FAKE_ISBN = ISBN.of('9780321125217'); + describe('RestBooks', () => { it('Should get book', async () => { const axiosInstance = stubAxiosInstance(); @@ -26,7 +28,7 @@ describe('RestBooks', () => { axiosInstance.get.resolves(response); const repository = new RestBooks(axiosInstance); - const eitherBook = await repository.get(); + const eitherBook = await repository.get(FAKE_ISBN); expect(axiosInstance.get.getCall(0).args).toContain('/isbn/9780321125217.json'); expect(eitherBook).toEqual>( @@ -43,7 +45,7 @@ describe('RestBooks', () => { axiosInstance.get.rejects(new Error('Network error')); const repository = new RestBooks(axiosInstance); - const eitherBook = await repository.get(); + const eitherBook = await repository.get(FAKE_ISBN); expect(eitherBook).toEqual>(Err.of(new Error('Network error'))); });