diff --git a/.eslintrc.base.json b/.eslintrc.base.json index 9ca2e83..0f32ddc 100644 --- a/.eslintrc.base.json +++ b/.eslintrc.base.json @@ -18,6 +18,12 @@ } ] } + ], + "no-console": [ + "error", + { + "allow": ["warn", "error"] + } ] } }, diff --git a/.prettierrc b/.prettierrc index 544138b..9472f59 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,11 @@ { - "singleQuote": true + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "semi": true, + "bracketSpacing": true, + "arrowParens": "always", + "trailingComma": "es5", + "bracketSameLine": true, + "printWidth": 80 } diff --git a/jest.config.ts b/jest.config.ts index fd6ffc5..d0dbd1b 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,26 +1,5 @@ -/* eslint-disable */ +import { getJestProjects } from '@nx/jest'; + export default { - displayName: 'series-workspace', - preset: './jest.preset.js', - setupFilesAfterEnv: ['/src/test-setup.ts'], - coverageDirectory: './coverage/series-workspace', - transform: { - '^.+\\.(ts|mjs|js|html)$': [ - 'jest-preset-angular', - { - tsconfig: '/tsconfig.spec.json', - stringifyContentPathRegex: '\\.(html|svg)$', - }, - ], - }, - transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], - snapshotSerializers: [ - 'jest-preset-angular/build/serializers/no-ng-attributes', - 'jest-preset-angular/build/serializers/ng-snapshot', - 'jest-preset-angular/build/serializers/html-comment', - ], - testMatch: [ - '/src/**/__tests__/**/*.[jt]s?(x)', - '/src/**/*(*.)@(spec|test).[jt]s?(x)', - ], + projects: getJestProjects(), }; diff --git a/nx.json b/nx.json index 3f965ec..39c6cf7 100644 --- a/nx.json +++ b/nx.json @@ -31,6 +31,11 @@ "{workspaceRoot}/eslint.config.js" ], "cache": true + }, + "@nx/angular:ng-packagr-lite": { + "cache": true, + "dependsOn": ["^build"], + "inputs": ["production", "^production"] } }, "namedInputs": { @@ -63,5 +68,13 @@ } }, "defaultProject": "series-workspace", - "nxCloudAccessToken": "YzFkMzY0ZjItY2IzZi00ZDc5LTk4MzctMGZmZjNjZmE3ZGNlfHJlYWQtd3JpdGU=" + "nxCloudAccessToken": "YzFkMzY0ZjItY2IzZi00ZDc5LTk4MzctMGZmZjNjZmE3ZGNlfHJlYWQtd3JpdGU=", + "plugins": [ + { + "plugin": "@nx/eslint/plugin", + "options": { + "targetName": "lint" + } + } + ] } diff --git a/project.json b/project.json index 7dbd153..b97aab8 100644 --- a/project.json +++ b/project.json @@ -82,7 +82,7 @@ "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectName}"], "options": { - "jestConfig": "jest.config.ts" + "jestConfig": "jest.config.app.ts" } } } diff --git a/src/app/app.component.html b/src/app/app.component.html index 8d7dca5..820e8cb 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,14 +1,9 @@ - -
    -
  • nav 1
  • -
  • nav 2
  • -
  • nav 3
  • -
+
-
+
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 32303b5..eed0cc3 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -3,11 +3,8 @@ } .logo { - width: 120px; - height: 31px; - background: rgba(255, 255, 255, 0.2); - margin: 16px 24px 16px 0; - float: left; + color: white; + font-size: 1rem; } nz-header { @@ -25,14 +22,8 @@ nz-content { margin-top: 64px; } -nz-breadcrumb { - margin: 16px 0; -} - -.inner-content { - background: #fff; - padding: 24px; - min-height: 380px; +.content { + display: flex; } nz-footer { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index a3e0274..5b3d86d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -6,20 +6,15 @@ import { NzHeaderComponent, NzLayoutComponent, } from 'ng-zorro-antd/layout'; -import { NzBreadCrumbComponent } from 'ng-zorro-antd/breadcrumb'; -import { NzMenuDirective, NzMenuItemComponent } from 'ng-zorro-antd/menu'; @Component({ standalone: true, imports: [ RouterModule, + NzLayoutComponent, + NzHeaderComponent, NzContentComponent, - NzBreadCrumbComponent, NzFooterComponent, - NzHeaderComponent, - NzLayoutComponent, - NzMenuDirective, - NzMenuItemComponent, ], selector: 'app-root', templateUrl: './app.component.html', diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 75377e9..ea34e60 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,8 +2,21 @@ import { Route } from '@angular/router'; export const appRoutes: Route[] = [ { - path: '', + path: 'search', loadComponent: () => - import('./series/series.component').then((m) => m.SeriesComponent), + import('./series/search/search-container.component').then( + (m) => m.SearchContainerComponent + ), + }, + // All empty paths should redirect to the search page + { + path: '', + pathMatch: 'full', + redirectTo: 'search', + }, + // All other paths should redirect to the search page too + { + path: '**', + redirectTo: 'search', }, ]; diff --git a/src/app/series/search/components/results/results.component.html b/src/app/series/search/components/results/results.component.html new file mode 100644 index 0000000..6cc22cb --- /dev/null +++ b/src/app/series/search/components/results/results.component.html @@ -0,0 +1,42 @@ +@if (state() === 'loaded'){ +{{ series().length }} items found + +
+ @for (item of series(); track item.show.id; ) { + + + + + + + + @if (item.show.image){ + + } @else { + No Poster Found + } + + + + + {{ item.show.name }} + + + } @empty { + + } +
+}@else { + +} diff --git a/src/app/series/search/components/results/results.component.scss b/src/app/series/search/components/results/results.component.scss new file mode 100644 index 0000000..3bd4324 --- /dev/null +++ b/src/app/series/search/components/results/results.component.scss @@ -0,0 +1,32 @@ +:host { + display: flex; + flex-direction: column; + gap: 24px; + + .series-card__poster-not-found { + width: 210px; + height: 295px; + display: flex; + justify-content: center; + align-items: center; + } + + .series-card__wrapper { + display: flex; + flex-wrap: wrap; + width: 100%; + justify-content: space-around; + gap: 2rem; + } + + ::ng-deep { + [nz-card] { + width: 210px; + flex: 0 0 auto; + } + + [nz-typography] { + text-align: right; + } + } +} diff --git a/src/app/series/search/components/results/results.component.spec.ts b/src/app/series/search/components/results/results.component.spec.ts new file mode 100644 index 0000000..67e84ad --- /dev/null +++ b/src/app/series/search/components/results/results.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ResultsComponent } from './results.component'; +import { By } from '@angular/platform-browser'; +import { NzEmptyComponent } from 'ng-zorro-antd/empty'; +import { DebugElement } from '@angular/core'; +import { SeriesMock } from '../../../tests/series.mocks'; + +describe('ResultsComponent', () => { + let component: ResultsComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let cardsElements: DebugElement[]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ResultsComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ResultsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + fixture.componentRef.setInput('series', SeriesMock); + fixture.componentRef.setInput('state', 'loaded'); + fixture.detectChanges(); + cardsElements = fixture.debugElement.queryAll( + By.css('nz-card[data-testId="series-card"]') + ); + }); + + it('should create nz-card with the correct number of items', () => { + expect(cardsElements.length).toBe(SeriesMock.length); + }); + + it('should show the correct title in nz-card-meta', () => { + const posterTitle = cardsElements[0].query( + By.css('[data-testId="poster-title"]') + ); + expect(posterTitle).toBeTruthy(); + expect(posterTitle.nativeElement.innerHTML).toContain( + SeriesMock[0].show.name + ); + }); + + it('should show the correct medium image in nz-card', () => { + const posterImage = cardsElements[0].query( + By.css('[data-testId="poster-image"]') + ); + expect(posterImage).toBeTruthy(); + expect(posterImage.properties['src']).toEqual( + SeriesMock[0].show.image.medium + ); + }); + + it('should show empty component when `series` input is empty array', () => { + fixture.componentRef.setInput('series', []); + fixture.detectChanges(); + const emptyComponent = fixture.debugElement.query( + By.directive(NzEmptyComponent) + ); + expect(emptyComponent).toBeTruthy(); + }); +}); diff --git a/src/app/series/search/components/results/results.component.ts b/src/app/series/search/components/results/results.component.ts new file mode 100644 index 0000000..ead0bff --- /dev/null +++ b/src/app/series/search/components/results/results.component.ts @@ -0,0 +1,36 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { CommonModule, NgOptimizedImage } from '@angular/common'; +import { NzEmptyComponent } from 'ng-zorro-antd/empty'; +import { NzCardComponent, NzCardMetaComponent } from 'ng-zorro-antd/card'; +import { NzPaginationComponent } from 'ng-zorro-antd/pagination'; +import { ComponentState, Serie } from '../../../../shared/models'; +import { NzTypographyComponent } from 'ng-zorro-antd/typography'; + +@Component({ + selector: 'app-results', + standalone: true, + imports: [ + CommonModule, + NzEmptyComponent, + NzCardComponent, + NzCardMetaComponent, + NzPaginationComponent, + NgOptimizedImage, + NzTypographyComponent, + ], + templateUrl: './results.component.html', + styleUrl: './results.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ResultsComponent { + series = input([]); + state = input('idle'); + isLoading = computed(() => this.state() === 'loading'); + selected = output(); +} diff --git a/src/app/series/search/components/search/search.component.html b/src/app/series/search/components/search/search.component.html new file mode 100644 index 0000000..8aca638 --- /dev/null +++ b/src/app/series/search/components/search/search.component.html @@ -0,0 +1,40 @@ +

Find a TV Serie

+ +
+ + + + + + + + + + + + + +
+ + + @if (queryValue) { + + } + diff --git a/src/app/series/search/components/search/search.component.scss b/src/app/series/search/components/search/search.component.scss new file mode 100644 index 0000000..05246ee --- /dev/null +++ b/src/app/series/search/components/search/search.component.scss @@ -0,0 +1,23 @@ +:host { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: center; + min-height: 175px; + justify-content: center; + + h1 { + text-align: center; + } + + button { + min-width: 80px; + margin: 0; + } + + ::ng-deep { + .ant-form-item:first-child .ant-form-item-control-input { + width: 350px; + } + } +} diff --git a/src/app/series/search/components/search/search.component.spec.ts b/src/app/series/search/components/search/search.component.spec.ts new file mode 100644 index 0000000..3828027 --- /dev/null +++ b/src/app/series/search/components/search/search.component.spec.ts @@ -0,0 +1,122 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SearchComponent } from './search.component'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { NzButtonComponent } from 'ng-zorro-antd/button'; + +describe('SearchComponent', () => { + let component: SearchComponent; + let fixture: ComponentFixture; + let de: DebugElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SearchComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SearchComponent); + de = fixture.debugElement; + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + describe(`SearchComponent`, () => { + it('should exist a input for the query control', () => { + const queryInput = de.nativeElement.querySelector( + '[data-testId="query-input"]' + ); + expect(queryInput).not.toBeNull(); + }); + + it('should show loading button when input is loading `true`', () => { + // arrange + const button = fixture.debugElement.query( + By.directive(NzButtonComponent) + ); + expect(button).not.toBeNull(); + + // act + fixture.componentRef.setInput('state', 'idle'); + fixture.detectChanges(); + + // assert + expect(button.injector.get(NzButtonComponent).nzLoading).toBe(false); + + // arrange + fixture.componentRef.setInput('state', 'loading'); + fixture.detectChanges(); + + // assert + expect(button.injector.get(NzButtonComponent).nzLoading).toBe(true); + }); + + it('should emit form value when the user clicks the button and form is valid', async () => { + const expectedEmittedQueryValue = 'test'; // Mock data + component.form.setValue({ query: expectedEmittedQueryValue }); + component.form.markAsTouched(); + + // Espiamos 2 métodos + const submitSpy = jest.spyOn(component, 'submitForm'); + const outputSpy = jest.spyOn(component.query, 'emit'); + + // Buscamos el botón + const button = de.nativeElement.querySelector( + '[data-testId="submit-button"]' + ); + + // Verificamos que no sea null + expect(button).not.toBeNull(); + + // Simulamos la interacción + button.click(); + + // Asserts + expect(component.form.valid).toBe(true); // El formulario es válido? + expect(submitSpy).toHaveBeenCalled(); // el método fue llamado con el click del botón? + expect(outputSpy).toHaveBeenCalledWith(expectedEmittedQueryValue); // Si es válido, emitió el valor de query? + }); + + describe('Clear icon', () => { + it('clear icon should not be present if query is empty', () => { + // Assert + const clearIcon = de.nativeElement.querySelector( + '[data-testId="reset-icon"]' + ); + expect(clearIcon).toBeNull(); + }); + it('clear icon should be present if query has value', async () => { + // Act + component.form.setValue({ query: 'test' }); + fixture.detectChanges(); + + // Assert + await fixture.whenStable(); + const clearIcon = de.nativeElement.querySelector( + '[data-testId="reset-icon"]' + ); + expect(clearIcon).not.toBeNull(); + }); + it('form should be reset when click clear icon', async () => { + // Arrange + component.form.setValue({ query: 'test' }); + fixture.detectChanges(); + + // Assert + await fixture.whenStable(); + const clearIcon = de.nativeElement.querySelector( + '[data-testId="reset-icon"]' + ); + expect(clearIcon).not.toBeNull(); + + // Act + jest.spyOn(component, 'resetForm'); + clearIcon.click(); + + // Assert + expect(component.resetForm).toHaveBeenCalled(); + expect(component.form.controls['query'].value).toBeNull(); + }); + }); + }); +}); diff --git a/src/app/series/search/components/search/search.component.ts b/src/app/series/search/components/search/search.component.ts new file mode 100644 index 0000000..dc8e4d4 --- /dev/null +++ b/src/app/series/search/components/search/search.component.ts @@ -0,0 +1,78 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + output, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + NzFormControlComponent, + NzFormDirective, + NzFormItemComponent, + NzFormLabelComponent, +} from 'ng-zorro-antd/form'; +import { + FormBuilder, + FormControl, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { NzColDirective, NzRowDirective } from 'ng-zorro-antd/grid'; +import { NzInputDirective, NzInputGroupComponent } from 'ng-zorro-antd/input'; +import { NzButtonComponent } from 'ng-zorro-antd/button'; +import { NzIconDirective } from 'ng-zorro-antd/icon'; +import { ComponentState } from '../../../../shared/models'; + +@Component({ + selector: 'app-search', + standalone: true, + imports: [ + CommonModule, + NzFormDirective, + ReactiveFormsModule, + NzFormItemComponent, + NzFormLabelComponent, + NzFormControlComponent, + NzColDirective, + NzInputDirective, + NzButtonComponent, + NzRowDirective, + NzInputGroupComponent, + NzIconDirective, + ], + templateUrl: './search.component.html', + styleUrl: './search.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + // eslint-disable-next-line @angular-eslint/no-host-metadata-property + host: { class: 'inner-content' }, +}) +export class SearchComponent { + // Signal Input + state = input('idle'); + // computed state + isLoading = computed(() => this.state() === 'loading'); + + // Signal Output + query = output(); + + form = inject(FormBuilder).group({ + query: new FormControl('', Validators.required), + }); + + get queryValue(): string { + const { value } = this.form.controls['query']; + return value ? value : ''; + } + + submitForm() { + if (this.form.valid) { + this.query.emit(this.queryValue); + } + } + + resetForm() { + this.form.reset(); + } +} diff --git a/src/app/series/search/search-container.component.html b/src/app/series/search/search-container.component.html new file mode 100644 index 0000000..ce1a211 --- /dev/null +++ b/src/app/series/search/search-container.component.html @@ -0,0 +1,2 @@ + + diff --git a/src/app/series/search/search-container.component.scss b/src/app/series/search/search-container.component.scss new file mode 100644 index 0000000..2289204 --- /dev/null +++ b/src/app/series/search/search-container.component.scss @@ -0,0 +1,18 @@ +:host { + width: 100%; + padding-top: 24px; + display: flex; + flex-direction: column; + gap: 24px; + + ::ng-deep { + nz-empty { + min-height: 300px; + display: flex; + flex-wrap: wrap; + align-content: center; + flex-direction: column; + justify-content: center; + } + } +} diff --git a/src/app/series/series.component.spec.ts b/src/app/series/search/search-container.component.spec.ts similarity index 53% rename from src/app/series/series.component.spec.ts rename to src/app/series/search/search-container.component.spec.ts index 1d2f50d..eae6e86 100644 --- a/src/app/series/series.component.spec.ts +++ b/src/app/series/search/search-container.component.spec.ts @@ -1,17 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { SeriesComponent } from './series.component'; +import { SearchContainerComponent } from './search-container.component'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -describe('SeriesComponent', () => { - let component: SeriesComponent; - let fixture: ComponentFixture; +describe('SearchComponent', () => { + let component: SearchContainerComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [SeriesComponent, HttpClientTestingModule], + imports: [SearchContainerComponent, HttpClientTestingModule], }).compileComponents(); - fixture = TestBed.createComponent(SeriesComponent); + fixture = TestBed.createComponent(SearchContainerComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/src/app/series/search/search-container.component.ts b/src/app/series/search/search-container.component.ts new file mode 100644 index 0000000..69774f0 --- /dev/null +++ b/src/app/series/search/search-container.component.ts @@ -0,0 +1,30 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + Signal, +} from '@angular/core'; +import { ResultsComponent } from './components/results/results.component'; +import { SearchComponent } from './components/search/search.component'; + +import { SeriesService } from '../services/series.service'; +import { SeriesStore } from '../series.store'; +import { ViewModelComponent } from '../../shared/models'; + +@Component({ + selector: 'app-search-container', + standalone: true, + imports: [ResultsComponent, SearchComponent], + templateUrl: './search-container.component.html', + styleUrl: './search-container.component.scss', + providers: [SeriesService, SeriesStore], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SearchContainerComponent { + private readonly store = inject(SeriesStore); + readonly vm: Signal = this.store.vm; + + searchSeries(formValue: string) { + this.store.searchSeries(formValue); + } +} diff --git a/src/app/series/series.component.html b/src/app/series/series.component.html deleted file mode 100644 index a302c91..0000000 --- a/src/app/series/series.component.html +++ /dev/null @@ -1,28 +0,0 @@ -

Series

-@if (vm().isLoading) { -

Loading...

-} @else { -
- @for (item of vm().series; track item.id; ) { - - - - - - - - - - } @empty { - - } - - -
-} diff --git a/src/app/series/series.component.scss b/src/app/series/series.component.scss deleted file mode 100644 index a1ea207..0000000 --- a/src/app/series/series.component.scss +++ /dev/null @@ -1,14 +0,0 @@ -.series-card__wrapper { - display: flex; - flex-wrap: wrap; - width: 100%; - justify-content: space-around; - gap: 2rem; -} - -:host ::ng-deep { - nz-card { - width: 210px; - flex: 0 0 auto; - } -} diff --git a/src/app/series/series.component.ts b/src/app/series/series.component.ts deleted file mode 100644 index 7c4adb3..0000000 --- a/src/app/series/series.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - OnInit, - Signal, -} from '@angular/core'; -import { SeriesStore } from './store/series.store'; -import { AsyncPipe, NgForOf, NgIf, NgOptimizedImage } from '@angular/common'; -import { SeriesService } from './services/series.service'; -import { ViewModelComponent } from './shared/series.models'; -import { NzCardComponent, NzCardMetaComponent } from 'ng-zorro-antd/card'; -import { NzColDirective, NzRowDirective } from 'ng-zorro-antd/grid'; -import { NzPaginationComponent } from 'ng-zorro-antd/pagination'; -import { NzSpaceItemDirective } from 'ng-zorro-antd/space'; -import { NzImageDirective } from 'ng-zorro-antd/image'; -import { NzEmptyComponent } from 'ng-zorro-antd/empty'; - -@Component({ - selector: 'app-series', - standalone: true, - imports: [ - AsyncPipe, - NgForOf, - NgIf, - NzCardComponent, - NzCardMetaComponent, - NgOptimizedImage, - NzRowDirective, - NzColDirective, - NzPaginationComponent, - NzSpaceItemDirective, - NzImageDirective, - NzEmptyComponent, - ], - templateUrl: './series.component.html', - styleUrls: ['./series.component.scss'], - providers: [SeriesStore, SeriesService], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class SeriesComponent implements OnInit { - private readonly store = inject(SeriesStore); - readonly vm: Signal = this.store.vm; // Our ViewModel exposed to the template - - ngOnInit(): void { - this.store.getAllSeries(); - } -} diff --git a/src/app/series/store/series.store.ts b/src/app/series/series.store.ts similarity index 62% rename from src/app/series/store/series.store.ts rename to src/app/series/series.store.ts index 22eb4aa..14ae01c 100644 --- a/src/app/series/store/series.store.ts +++ b/src/app/series/series.store.ts @@ -1,14 +1,17 @@ import { Injectable, Signal } from '@angular/core'; import { ComponentStore, tapResponse } from '@ngrx/component-store'; -import { switchMap, tap } from 'rxjs'; -import { SeriesService } from '../services/series.service'; -import { HttpErrorResponse } from '@angular/common/http'; import { + ComponentState, Serie, SeriesState, ViewModelComponent, -} from '../shared/series.models'; -import { initialSeriesState } from '../shared/series.constants'; +} from '../shared/models'; +import { debounceTime, switchMap, tap } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; +import { SeriesService } from './services/series.service'; +import { initialSeriesState } from '../shared/constants'; + +const DEBOUNCE_TIME_DEFAULT = 300; @Injectable() export class SeriesStore extends ComponentStore { @@ -16,16 +19,22 @@ export class SeriesStore extends ComponentStore { readonly addSeries = this.updater((state, series: Serie[]) => { return { ...state, series, state: 'loaded' }; }); - // Effects - readonly getAllSeries = this.effect((trigger$) => - trigger$.pipe( + readonly setQuery = this.updater((state, query: string) => { + return { ...state, query }; + }); + + readonly searchSeries = this.effect((query$) => + query$.pipe( + debounceTime(DEBOUNCE_TIME_DEFAULT), // Define the state of the component as loading tap(() => this.patchState({ state: 'loading' })), - switchMap(() => - this.seriesService.getSeries().pipe( + switchMap((query) => + this.seriesService.searchSeries(query).pipe( tapResponse({ // When the request is successful, update the store - next: (movies) => this.addSeries(movies), + next: (series) => { + return this.addSeries(series); + }, error: (e: HttpErrorResponse) => { // When the request fails, update the store with the error state this.patchState({ state: 'error' }); @@ -36,28 +45,27 @@ export class SeriesStore extends ComponentStore { ) ) ); + // Selectors private readonly series: Signal = this.selectSignal( (state) => state.series ); - private readonly isLoading: Signal = this.selectSignal( - (state) => state.state === 'loading' + private readonly componentState: Signal = this.selectSignal( + ({ state }) => state ); + // This is the ViewModel exposed to the component readonly vm: Signal = this.selectSignal( this.series, - this.isLoading, - (series, isLoading) => ({ series, isLoading }) + this.componentState, + (series, state) => ({ series, state }) ); constructor(private readonly seriesService: SeriesService) { super(initialSeriesState); // <--- Initialization when the store is created - - // Only for debugging purposes - this.state$.subscribe((state) => console.log(state)); } private handleError(e: HttpErrorResponse) { - console.log(e); + console.warn(e); } } diff --git a/src/app/series/services/series.service.spec.ts b/src/app/series/services/series.service.spec.ts index dcef8c6..ec3f0be 100644 --- a/src/app/series/services/series.service.spec.ts +++ b/src/app/series/services/series.service.spec.ts @@ -1,20 +1,58 @@ import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { SeriesService, TVMAZE_ENDPOINT } from './series.service'; -import { SeriesService } from './series.service'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { SeriesMock } from '../tests/series.mocks'; +import { Serie } from '../../shared/models'; describe('SeriesService', () => { let service: SeriesService; + let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [SeriesService], }); + service = TestBed.inject(SeriesService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); }); - it('should be created', () => { - expect(service).toBeTruthy(); + xdescribe('getSeries', () => { + it('should return an Observable', () => { + const dummySeries: Serie[] = SeriesMock; + + service.getSeries().subscribe((series) => { + expect(series.length).toBe(2); + expect(series).toEqual(dummySeries); + }); + + const req = httpMock.expectOne(`${TVMAZE_ENDPOINT}/shows`); + expect(req.request.method).toBe('GET'); + req.flush(dummySeries); + }); + }); + + describe('searchSeries', () => { + it('should return an Observable when searching series', () => { + const dummySeries: Serie[] = SeriesMock; + + service.searchSeries('query').subscribe((series) => { + expect(series.length).toBe(1); + expect(series).toEqual(dummySeries); + }); + + const req = httpMock.expectOne(`${TVMAZE_ENDPOINT}/search/shows?q=query`); + expect(req.request.method).toBe('GET'); + req.flush(dummySeries); + }); }); }); diff --git a/src/app/series/services/series.service.ts b/src/app/series/services/series.service.ts index 55e1229..7b98e12 100644 --- a/src/app/series/services/series.service.ts +++ b/src/app/series/services/series.service.ts @@ -1,17 +1,37 @@ import { inject, Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable, tap } from 'rxjs'; -import { Serie } from '../shared/series.models'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Serie } from '../../shared/models'; -const TVMAZE_ENDPOINT = 'https://api.tvmaze.com/'; +export const TVMAZE_ENDPOINT = 'https://api.tvmaze.com'; +/** + * Service class for managing TV series data. + * @injectable + */ @Injectable() export class SeriesService { private httpClient = inject(HttpClient); + /** + * Fetches the series from the TVMaze API. + * + * @return {Observable} An Observable that emits an array of Serie objects. + */ getSeries(): Observable { - return this.httpClient - .get(`${TVMAZE_ENDPOINT}shows`) - .pipe(tap((s) => console.log(s))); + return this.httpClient.get(`${TVMAZE_ENDPOINT}/shows`); + } + + /** + * Searches for TV series based on the provided query. + * + * @param {string} query - The query string used to search for TV series. + * @return {Observable} - An observable that emits an array of Serie objects matching the search query. + */ + searchSeries(query: string): Observable { + const params = new HttpParams().set('q', query); + return this.httpClient.get(`${TVMAZE_ENDPOINT}/search/shows`, { + params, + }); } } diff --git a/src/app/series/store/series.store.spec.ts b/src/app/series/store/series.store.spec.ts deleted file mode 100644 index 850a492..0000000 --- a/src/app/series/store/series.store.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { SeriesStore } from './series.store'; -import { SeriesService } from '../services/series.service'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; - -describe('SeriesStoreService', () => { - let service: SeriesStore; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [SeriesStore, SeriesService], - }); - service = TestBed.inject(SeriesStore); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); -}); diff --git a/src/app/series/tests/series.mocks.ts b/src/app/series/tests/series.mocks.ts new file mode 100644 index 0000000..32ae106 --- /dev/null +++ b/src/app/series/tests/series.mocks.ts @@ -0,0 +1,19 @@ +import { Serie } from '../../shared/models'; + +export const SeriesMock: Serie[] = [ + { + score: 0.70492816, + show: { + id: 169, + name: 'Breaking Bad', + image: { + medium: + 'https://static.tvmaze.com/uploads/images/medium_portrait/501/1253519.jpg', + original: + 'https://static.tvmaze.com/uploads/images/original_untouched/501/1253519.jpg', + }, + summary: + "

Breaking Bad follows protagonist Walter White, a chemistry teacher who lives in New Mexico with his wife and teenage son who has cerebral palsy. White is diagnosed with Stage III cancer and given a prognosis of two years left to live. With a new sense of fearlessness based on his medical prognosis, and a desire to secure his family's financial security, White chooses to enter a dangerous world of drugs and crime and ascends to power in this world. The series explores how a fatal diagnosis such as White's releases a typical man from the daily concerns and constraints of normal society and follows his transformation from mild family man to a kingpin of the drug trade.

", + }, + }, +]; diff --git a/src/app/series/shared/series.constants.ts b/src/app/shared/constants.ts similarity index 66% rename from src/app/series/shared/series.constants.ts rename to src/app/shared/constants.ts index 111f755..5fbf213 100644 --- a/src/app/series/shared/series.constants.ts +++ b/src/app/shared/constants.ts @@ -1,7 +1,8 @@ -import { SeriesState } from './series.models'; +import { SeriesState } from './models'; export const initialSeriesState: SeriesState = { series: [], selectedId: null, state: 'idle', + query: '', }; diff --git a/src/app/series/shared/series.models.ts b/src/app/shared/models.ts similarity index 79% rename from src/app/series/shared/series.models.ts rename to src/app/shared/models.ts index c617061..5059ac4 100644 --- a/src/app/series/shared/series.models.ts +++ b/src/app/shared/models.ts @@ -2,6 +2,10 @@ export type ComponentState = 'idle' | 'loading' | 'loaded' | 'error'; // We will add more here export interface Serie { + score: number; + show: SerieDetail; +} +interface SerieDetail { id: number; name: string; summary: string; @@ -13,9 +17,10 @@ export interface SeriesState { series: Serie[]; selectedId: number | null; state: ComponentState; + query: string; } export interface ViewModelComponent { series: Serie[]; - isLoading: boolean; + state: ComponentState; } diff --git a/src/assets/no-poster.png b/src/assets/no-poster.png new file mode 100644 index 0000000..2fae58c Binary files /dev/null and b/src/assets/no-poster.png differ diff --git a/src/styles.scss b/src/styles.scss index 48da018..dac0007 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1 +1,5 @@ //@import "~ng-zorro-antd/ng-zorro-antd.css"; +.inner-content { + background: #fff; + padding: 24px; +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 888ae37..e644e06 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,7 +13,10 @@ "lib": ["es2020", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, - "baseUrl": "." + "baseUrl": ".", + "paths": { + "search": ["src/libs/search/src/index.ts"] + } }, "exclude": ["node_modules", "tmp"] }