├── src ├── assets │ └── .gitkeep ├── app │ ├── components │ │ ├── full-photo │ │ │ ├── full-photo.component.css │ │ │ ├── full-photo.component.ts │ │ │ ├── full-photo.component.html │ │ │ ├── full-photo.component.spectator.spec.ts │ │ │ └── full-photo.component.spec.ts │ │ ├── search-form │ │ │ ├── search-form.component.css │ │ │ ├── search-form.component.html │ │ │ ├── search-form.component.ts │ │ │ ├── search-form.component.spectator.spec.ts │ │ │ └── search-form.component.spec.ts │ │ ├── photo-item │ │ │ ├── photo-item.component.css │ │ │ ├── photo-item.component.html │ │ │ ├── photo-item.component.ts │ │ │ ├── photo-item.component.spectator.spec.ts │ │ │ └── photo-item.component.spec.ts │ │ ├── flickr-search │ │ │ ├── flickr-search.component.css │ │ │ ├── flickr-search.component.html │ │ │ ├── flickr-search.component.ts │ │ │ ├── flickr-search.component.spec.ts │ │ │ └── flickr-search.component.spectator.spec.ts │ │ ├── photo-list │ │ │ ├── photo-list.component.css │ │ │ ├── photo-list.component.html │ │ │ ├── photo-list.component.ts │ │ │ ├── photo-list.component.spectator.spec.ts │ │ │ └── photo-list.component.spec.ts │ │ └── flickr-search-ngrx │ │ │ ├── flickr-search-ngrx.component.css │ │ │ ├── flickr-search-ngrx.component.html │ │ │ ├── flickr-search-ngrx.component.ts │ │ │ ├── flickr-search-ngrx.component.spec.ts │ │ │ └── flickr-search-ngrx.component.spectator.spec.ts │ ├── app.component.ts │ ├── reducers │ │ ├── photos-state-slice.ts │ │ ├── index.ts │ │ ├── photos.reducer.ts │ │ └── photos.reducer.spec.ts │ ├── models │ │ └── photo.ts │ ├── app.component.html │ ├── actions │ │ └── photos.actions.ts │ ├── selectors │ │ └── photos.selectors.ts │ ├── effects │ │ ├── photos.effects.ts │ │ └── photos.effects.spec.ts │ ├── services │ │ ├── flickr.service.ts │ │ ├── flickr.service.spectator.spec.ts │ │ └── flickr.service.spec.ts │ ├── app.component.spec.ts │ ├── spec-helpers │ │ ├── photo.spec-helper.ts │ │ └── element.spec-helper.ts │ └── app.module.ts ├── favicon.ico ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── tslint.json ├── index.html ├── main.ts └── styles.css ├── .vscode └── settings.json ├── .prettierrc ├── cypress ├── tsconfig.json ├── support │ ├── e2e.ts │ └── commands.ts ├── e2e │ ├── flickr-search-with-po.cy.ts │ ├── flickr-search.cy.ts │ ├── flickr-search-stub-network.cy.ts │ └── flickr-search-stub-network-intercept.cy.ts └── pages │ └── flickr-search.page.ts ├── tsconfig.spec.json ├── tsconfig.app.json ├── cypress.config.ts ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── karma.conf.js ├── package.json ├── README.md └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/full-photo/full-photo.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/components/search-form/search-form.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9elements/angular-flickr-search/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/components/photo-item/photo-item.component.css: -------------------------------------------------------------------------------- 1 | .link, .image { 2 | display: block; 3 | width: 100%; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "printWidth": 90, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*.ts"], 4 | "compilerOptions": { 5 | "sourceMap": false, 6 | "types": ["cypress"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html' 6 | }) 7 | export class AppComponent {} 8 | -------------------------------------------------------------------------------- /src/app/reducers/photos-state-slice.ts: -------------------------------------------------------------------------------- 1 | import { Photo } from '../models/photo'; 2 | 3 | export interface PhotosStateSlice { 4 | searchTerm: string; 5 | photos: Photo[]; 6 | currentPhoto: Photo | null; 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": ["jasmine"] 6 | }, 7 | "include": ["**/*.spec.ts", "**/*.d.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [true, "attribute", "app", "camelCase"], 5 | "component-selector": [true, "element", "app", "kebab-case"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:4200', 6 | video: false, 7 | experimentalRunAllSpecs: true, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/models/photo.ts: -------------------------------------------------------------------------------- 1 | export interface Photo { 2 | id: string; 3 | title: string; 4 | tags: string; 5 | owner: string; 6 | ownername: string; 7 | datetaken: string; 8 | url_q: string; 9 | url_m: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/flickr-search/flickr-search.component.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 50rem) { 2 | .photo-list-and-full-photo { 3 | display: flex; 4 | } 5 | 6 | .photo-list, 7 | .full-photo { 8 | flex: 0 1 50%; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/components/photo-list/photo-list.component.css: -------------------------------------------------------------------------------- 1 | .photos { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | } 6 | 7 | .photo { 8 | padding: 0 10px 10px 0; 9 | width: 150px; 10 | height: 150px; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/flickr-search-ngrx/flickr-search-ngrx.component.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 50rem) { 2 | 3 | .photo-list-and-full-photo { 4 | display: flex; 5 | } 6 | 7 | .photo-list, .full-photo { 8 | flex: 0 1 50%; 9 | } 10 | 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/search-form/search-form.component.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | 5 |

6 |
7 | -------------------------------------------------------------------------------- /src/app/components/photo-list/photo-list.component.html: -------------------------------------------------------------------------------- 1 |

{{ title }}

2 | 3 |
4 | 10 |
11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{js,ts}] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /src/app/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducerMap } from '@ngrx/store'; 2 | 3 | import { PhotosStateSlice } from './photos-state-slice'; 4 | import { photosReducer } from './photos.reducer'; 5 | 6 | export interface AppState { 7 | photos: PhotosStateSlice; 8 | } 9 | 10 | export const reducers: ActionReducerMap = { 11 | photos: photosReducer 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

6 | 7 | This non-commercial example application uses the Flickr API but is not endorsed or certified by Flickr Inc. or SmugMug, Inc. See the Flickr API Terms of Use. 8 | 9 |

10 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Flickr Search 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/app/components/photo-item/photo-item.component.html: -------------------------------------------------------------------------------- 1 | 8 | {{ photo.title }} 14 | 15 | -------------------------------------------------------------------------------- /src/app/components/full-photo/full-photo.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { Photo } from '../../models/photo'; 4 | 5 | @Component({ 6 | selector: 'app-full-photo', 7 | templateUrl: './full-photo.component.html', 8 | styleUrls: ['./full-photo.component.css'], 9 | }) 10 | export class FullPhotoComponent { 11 | @Input() 12 | public photo: Photo | null = null; 13 | } 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(AppModule) 13 | .catch((err) => console.error(err)); 14 | -------------------------------------------------------------------------------- /src/app/actions/photos.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Photo } from '../models/photo'; 3 | 4 | export const search = createAction('[photos] Search', props<{ searchTerm: string }>()); 5 | export const searchResultsLoaded = createAction( 6 | '[photos] Search results loaded', 7 | props<{ photos: Photo[] }>(), 8 | ); 9 | export const focusPhoto = createAction( 10 | '[photos] Focus photo', 11 | props<{ photo: Photo | null }>(), 12 | ); 13 | -------------------------------------------------------------------------------- /src/app/components/search-form/search-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-search-form', 5 | templateUrl: './search-form.component.html', 6 | styleUrls: ['./search-form.component.css'] 7 | }) 8 | export class SearchFormComponent { 9 | @Output() 10 | public search = new EventEmitter(); 11 | 12 | public handleSearch(event: Event, searchTerm: string): void { 13 | event.preventDefault(); 14 | this.search.emit(searchTerm); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/components/flickr-search/flickr-search.component.html: -------------------------------------------------------------------------------- 1 |

Flickr Search

2 | 3 | 4 | 5 |
6 | 12 | 13 | 19 |
20 | -------------------------------------------------------------------------------- /src/app/components/flickr-search-ngrx/flickr-search-ngrx.component.html: -------------------------------------------------------------------------------- 1 |

Flickr Search with NgRx

2 | 3 | 4 | 5 |
6 | 12 | 13 | 19 |
20 | -------------------------------------------------------------------------------- /src/app/components/photo-list/photo-list.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output, Type } from '@angular/core'; 2 | 3 | import { Photo } from '../../models/photo'; 4 | 5 | @Component({ 6 | selector: 'app-photo-list', 7 | templateUrl: './photo-list.component.html', 8 | styleUrls: ['./photo-list.component.css'], 9 | }) 10 | export class PhotoListComponent { 11 | @Input() 12 | public title = ''; 13 | 14 | @Input() 15 | public photos: Photo[] = []; 16 | 17 | @Output() 18 | public focusPhoto = new EventEmitter(); 19 | 20 | public handleFocusPhoto(photo: Photo): void { 21 | this.focusPhoto.emit(photo); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/components/photo-item/photo-item.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | import { Photo } from '../../models/photo'; 4 | 5 | @Component({ 6 | selector: 'app-photo-item', 7 | templateUrl: './photo-item.component.html', 8 | styleUrls: ['./photo-item.component.css'], 9 | }) 10 | export class PhotoItemComponent { 11 | @Input() 12 | public photo: Photo | null = null; 13 | 14 | @Output() 15 | public focusPhoto = new EventEmitter(); 16 | 17 | public handleClick(event: MouseEvent): void { 18 | event.preventDefault(); 19 | if (this.photo) { 20 | this.focusPhoto.emit(this.photo); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // When a command from ./commands is ready to use, import with `import './commands'` syntax 17 | import './commands'; 18 | -------------------------------------------------------------------------------- /src/app/selectors/photos.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | 3 | import { PhotosStateSlice } from '../reducers/photos-state-slice'; 4 | 5 | const featureKey = 'photos'; 6 | 7 | export const selectPhotos = createFeatureSelector(featureKey); 8 | 9 | export const searchTermSelector = createSelector( 10 | selectPhotos, 11 | (photosStateSlice) => photosStateSlice.searchTerm, 12 | ); 13 | 14 | export const photosSelector = createSelector( 15 | selectPhotos, 16 | (photosStateSlice) => photosStateSlice.photos, 17 | ); 18 | 19 | export const currentPhotoSelector = createSelector( 20 | selectPhotos, 21 | (photosStateSlice) => photosStateSlice.currentPhoto, 22 | ); 23 | -------------------------------------------------------------------------------- /src/app/components/full-photo/full-photo.component.html: -------------------------------------------------------------------------------- 1 | 2 |

{{ photo.title }}

3 | 4 |

5 | {{ photo.title }} 6 |

7 | 8 |

{{ photo.ownername }}

9 | 10 |

{{ photo.datetaken }}

11 | 12 |

{{ photo.tags }}

13 | 14 |

15 | 20 | https://www.flickr.com/photos/{{ photo.owner }}/{{ photo.id }} 21 | 22 |

23 |
24 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | // This file can be replaced during build by using the `fileReplacements` array. 3 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 4 | // The list of file replacements can be found in `angular.json`. 5 | 6 | export const environment = { 7 | production: false, 8 | }; 9 | 10 | /* 11 | * For easier debugging in development mode, you can import the following file 12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 13 | * 14 | * This import should be commented out in production mode because it will have a negative impact 15 | * on performance if an error is thrown. 16 | */ 17 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 18 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | margin: 1rem; 9 | padding: 0; 10 | font-family: sans-serif; 11 | background-color: #fff; 12 | color: black; 13 | } 14 | 15 | h1 { 16 | font-size: 1.2rem; 17 | } 18 | 19 | h1, 20 | p { 21 | margin-top: 1rem; 22 | margin-bottom: 1rem; 23 | } 24 | 25 | button, 26 | input[type='text'], 27 | input[type='number'], 28 | input[type='email'] { 29 | border: 0; 30 | padding: 0 0.5rem; 31 | font-size: inherit; 32 | line-height: 1; 33 | height: 2rem; 34 | } 35 | 36 | button { 37 | background-color: #1976d2; 38 | color: #fff; 39 | } 40 | 41 | input[type='text'], 42 | input[type='number'], 43 | input[type='email'] { 44 | background-color: #fff; 45 | border: 1px solid #1976d2; 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.angular/cache 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # e2e 39 | /e2e/*.js 40 | /e2e/*.map 41 | /e2e/screenshots 42 | /cypress/screenshots 43 | /cypress/videos 44 | 45 | # System Files 46 | .DS_Store 47 | Thumbs.db 48 | -------------------------------------------------------------------------------- /src/app/effects/photos.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { EMPTY } from 'rxjs'; 4 | import { catchError, map, mergeMap } from 'rxjs/operators'; 5 | 6 | import { search, searchResultsLoaded } from '../actions/photos.actions'; 7 | import { FlickrService } from '../services/flickr.service'; 8 | 9 | @Injectable() 10 | export class PhotosEffects { 11 | constructor(private actions$: Actions, private flickrService: FlickrService) {} 12 | 13 | public search$ = createEffect(() => 14 | this.actions$.pipe( 15 | ofType(search), 16 | mergeMap((action) => 17 | this.flickrService.searchPublicPhotos(action.searchTerm).pipe( 18 | map((photos) => searchResultsLoaded({ photos })), 19 | catchError(() => EMPTY), 20 | ), 21 | ), 22 | ), 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/reducers/photos.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on, Action } from '@ngrx/store'; 2 | 3 | import { focusPhoto, search, searchResultsLoaded } from '../actions/photos.actions'; 4 | import { PhotosStateSlice } from './photos-state-slice'; 5 | 6 | export const initialState: PhotosStateSlice = { 7 | searchTerm: '', 8 | photos: [], 9 | currentPhoto: null, 10 | }; 11 | 12 | const reducer = createReducer( 13 | initialState, 14 | on(search, (state, { searchTerm }) => ({ 15 | ...state, 16 | searchTerm, 17 | currentPhoto: null, 18 | })), 19 | on(searchResultsLoaded, (state, { photos }) => ({ 20 | ...state, 21 | photos, 22 | })), 23 | on(focusPhoto, (state, { photo }) => ({ 24 | ...state, 25 | currentPhoto: photo, 26 | })), 27 | ); 28 | 29 | export function photosReducer( 30 | state: PhotosStateSlice | undefined, 31 | action: Action, 32 | ): PhotosStateSlice { 33 | return reducer(state, action); 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "downlevelIteration": true, 14 | "experimentalDecorators": true, 15 | "moduleResolution": "node", 16 | "importHelpers": true, 17 | "target": "ES2022", 18 | "module": "es2020", 19 | "lib": [ 20 | "es2018", 21 | "dom" 22 | ], 23 | "useDefineForClassFields": false 24 | }, 25 | "angularCompilerOptions": { 26 | "enableI18nLegacyMessageIdFormat": false, 27 | "strictInjectionParameters": true, 28 | "strictInputAccessModifiers": true, 29 | "strictTemplates": true 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/flickr-search/flickr-search.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { FlickrService } from 'src/app/services/flickr.service'; 3 | 4 | import { Photo } from '../../models/photo'; 5 | 6 | @Component({ 7 | selector: 'app-flickr-search', 8 | templateUrl: './flickr-search.component.html', 9 | styleUrls: ['./flickr-search.component.css'], 10 | }) 11 | export class FlickrSearchComponent { 12 | public searchTerm = ''; 13 | public photos: Photo[] = []; 14 | public currentPhoto: Photo | null = null; 15 | 16 | constructor(private flickrService: FlickrService) {} 17 | 18 | public handleSearch(searchTerm: string): void { 19 | this.flickrService.searchPublicPhotos(searchTerm).subscribe((photos) => { 20 | this.searchTerm = searchTerm; 21 | this.photos = photos; 22 | this.currentPhoto = null; 23 | }); 24 | } 25 | 26 | public handleFocusPhoto(photo: Photo): void { 27 | this.currentPhoto = photo; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/components/search-form/search-form.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Spectator, createComponentFactory, byTestId } from '@ngneat/spectator'; 2 | import { SearchFormComponent } from './search-form.component'; 3 | 4 | const searchTerm = 'flowers'; 5 | 6 | describe('SearchFormComponent with spectator', () => { 7 | let spectator: Spectator; 8 | 9 | const createComponent = createComponentFactory({ 10 | component: SearchFormComponent, 11 | shallow: true, 12 | }); 13 | 14 | beforeEach(() => { 15 | spectator = createComponent(); 16 | }); 17 | 18 | it('starts a search', () => { 19 | let actualSearchTerm: string | undefined; 20 | 21 | spectator.component.search.subscribe((otherSearchTerm: string) => { 22 | actualSearchTerm = otherSearchTerm; 23 | }); 24 | 25 | spectator.typeInElement(searchTerm, byTestId('search-term-input')); 26 | 27 | spectator.dispatchFakeEvent(byTestId('form'), 'submit'); 28 | 29 | expect(actualSearchTerm).toBe(searchTerm); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /cypress/e2e/flickr-search-with-po.cy.ts: -------------------------------------------------------------------------------- 1 | import { FlickrSearch } from '../pages/flickr-search.page'; 2 | 3 | describe('Flickr search (with page object)', () => { 4 | const searchTerm = 'flower'; 5 | 6 | let page: FlickrSearch; 7 | 8 | beforeEach(() => { 9 | page = new FlickrSearch(); 10 | page.visit(); 11 | }); 12 | 13 | it('searches for a term', () => { 14 | page.searchFor(searchTerm); 15 | page 16 | .photoItemLinks() 17 | .should('have.length', 15) 18 | .each((link) => { 19 | expect(link.attr('href')).to.contain('https://www.flickr.com/photos/'); 20 | }); 21 | page.photoItemImages().should('have.length', 15); 22 | }); 23 | 24 | it('shows the full photo', () => { 25 | page.searchFor(searchTerm); 26 | page.photoItemLinks().first().click(); 27 | page.fullPhoto().should('contain', searchTerm); 28 | page.fullPhotoTitle().should('not.have.text', ''); 29 | page.fullPhotoTags().should('not.have.text', ''); 30 | page.fullPhotoImage().should('exist'); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /cypress/e2e/flickr-search.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Flickr search', () => { 2 | const searchTerm = 'flower'; 3 | 4 | beforeEach(() => { 5 | cy.visit('/'); 6 | }); 7 | 8 | it('searches for a term', () => { 9 | cy.byTestId('search-term-input').first().clear().type(searchTerm); 10 | cy.byTestId('submit-search').first().click(); 11 | 12 | cy.byTestId('photo-item-link') 13 | .should('have.length', 15) 14 | .each((link) => { 15 | expect(link.attr('href')).to.contain('https://www.flickr.com/photos/'); 16 | }); 17 | cy.byTestId('photo-item-image').should('have.length', 15); 18 | }); 19 | 20 | it('shows the full photo', () => { 21 | cy.byTestId('search-term-input').first().clear().type(searchTerm); 22 | cy.byTestId('submit-search').first().click(); 23 | 24 | cy.byTestId('photo-item-link').first().click(); 25 | cy.byTestId('full-photo').should('contain', searchTerm); 26 | cy.byTestId('full-photo-title').should('not.have.text', ''); 27 | cy.byTestId('full-photo-tags').should('not.have.text', ''); 28 | cy.byTestId('full-photo-image').should('exist'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/app/reducers/photos.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { focusPhoto, search, searchResultsLoaded } from '../actions/photos.actions'; 2 | import { 3 | initialState, 4 | photo1, 5 | photos, 6 | searchTerm, 7 | stateWithCurrentPhoto, 8 | stateWithPhotos, 9 | stateWithSearchTerm 10 | } from '../spec-helpers/photo.spec-helper'; 11 | import { photosReducer } from './photos.reducer'; 12 | 13 | describe('photosReducer', () => { 14 | it('returns an initial state', () => { 15 | const state = photosReducer(undefined, { type: 'init' }); 16 | expect(state).toEqual(initialState); 17 | }); 18 | 19 | it('stores the search term', () => { 20 | const state = photosReducer(initialState, search({ searchTerm })); 21 | expect(state).toEqual(stateWithSearchTerm); 22 | }); 23 | 24 | it('stores the search results', () => { 25 | const state = photosReducer(stateWithSearchTerm, searchResultsLoaded({ photos })); 26 | expect(state).toEqual(stateWithPhotos); 27 | }); 28 | 29 | it('focusses a photo', () => { 30 | const state = photosReducer(stateWithPhotos, focusPhoto({ photo: photo1 })); 31 | expect(state).toEqual(stateWithCurrentPhoto); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/app/services/flickr.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | import { map } from 'rxjs/operators'; 5 | 6 | import { Photo } from '../models/photo'; 7 | 8 | // Flickr API Response (relevant parts) 9 | export interface FlickrAPIResponse { 10 | photos: { 11 | photo: Photo[]; 12 | }; 13 | } 14 | 15 | @Injectable() 16 | export class FlickrService { 17 | constructor(private http: HttpClient) {} 18 | 19 | public searchPublicPhotos(searchTerm: string): Observable { 20 | return this.http 21 | .get('https://www.flickr.com/services/rest/', { 22 | params: { 23 | tags: searchTerm, 24 | method: 'flickr.photos.search', 25 | format: 'json', 26 | nojsoncallback: '1', 27 | tag_mode: 'all', 28 | media: 'photos', 29 | per_page: '15', 30 | extras: 'tags,date_taken,owner_name,url_q,url_m', 31 | api_key: 'c3050d39a5bb308d9921bef0e15c437d', 32 | }, 33 | }) 34 | .pipe(map((response) => response.photos.photo)); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import { findComponent } from './spec-helpers/element.spec-helper'; 6 | 7 | describe('AppComponent', () => { 8 | let fixture: ComponentFixture; 9 | let component: AppComponent; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | declarations: [AppComponent], 14 | schemas: [NO_ERRORS_SCHEMA], 15 | }).compileComponents(); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(AppComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('renders without errors', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | it('renders the Flickr search', () => { 29 | const el = findComponent(fixture, 'app-flickr-search'); 30 | expect(el).toBeTruthy(); 31 | }); 32 | 33 | it('renders the Flickr search with NgRx', () => { 34 | const el = findComponent(fixture, 'app-flickr-search-ngrx'); 35 | expect(el).toBeTruthy(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "tsconfig.json", 14 | "e2e/tsconfig.json" 15 | ], 16 | "createDefaultProgram": true 17 | }, 18 | "extends": [ 19 | "plugin:@angular-eslint/recommended", 20 | "plugin:@angular-eslint/template/process-inline-templates" 21 | ], 22 | "rules": { 23 | "@angular-eslint/component-selector": [ 24 | "error", 25 | { 26 | "prefix": "app", 27 | "style": "kebab-case", 28 | "type": "element" 29 | } 30 | ], 31 | "@angular-eslint/directive-selector": [ 32 | "error", 33 | { 34 | "prefix": "app", 35 | "style": "camelCase", 36 | "type": "attribute" 37 | } 38 | ] 39 | } 40 | }, 41 | { 42 | "files": [ 43 | "*.html" 44 | ], 45 | "extends": [ 46 | "plugin:@angular-eslint/template/recommended" 47 | ], 48 | "rules": {} 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /cypress/pages/flickr-search.page.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: no-reference 2 | /// 3 | 4 | /** 5 | * Page object for the Flickr search 6 | */ 7 | export class FlickrSearch { 8 | public visit(): void { 9 | cy.visit('/'); 10 | } 11 | 12 | public searchFor(term: string): void { 13 | cy.byTestId('search-term-input').first().clear().type(term); 14 | cy.byTestId('submit-search').first().click(); 15 | } 16 | 17 | public photoItemLinks(): Cypress.Chainable> { 18 | return cy.byTestId('photo-item-link'); 19 | } 20 | 21 | public photoItemImages(): Cypress.Chainable> { 22 | return cy.byTestId('photo-item-image'); 23 | } 24 | 25 | public fullPhoto(): Cypress.Chainable> { 26 | return cy.byTestId('full-photo'); 27 | } 28 | 29 | public fullPhotoTitle(): Cypress.Chainable> { 30 | return cy.byTestId('full-photo-title'); 31 | } 32 | 33 | public fullPhotoTags(): Cypress.Chainable> { 34 | return cy.byTestId('full-photo-tags'); 35 | } 36 | 37 | public fullPhotoImage(): Cypress.Chainable> { 38 | return cy.byTestId('full-photo-image'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /src/app/components/search-form/search-form.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { findEl, setFieldValue } from '../../spec-helpers/element.spec-helper'; 4 | import { SearchFormComponent } from './search-form.component'; 5 | 6 | const searchTerm = 'flowers'; 7 | 8 | describe('SearchFormComponent', () => { 9 | let component: SearchFormComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async () => { 13 | await TestBed.configureTestingModule({ 14 | declarations: [SearchFormComponent], 15 | }).compileComponents(); 16 | 17 | fixture = TestBed.createComponent(SearchFormComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('starts a search', () => { 23 | const preventDefault = jasmine.createSpy('submit preventDefault'); 24 | 25 | let actualSearchTerm: string | undefined; 26 | 27 | component.search.subscribe((otherSearchTerm: string) => { 28 | actualSearchTerm = otherSearchTerm; 29 | }); 30 | 31 | setFieldValue(fixture, 'search-term-input', searchTerm); 32 | 33 | findEl(fixture, 'form').triggerEventHandler('submit', { preventDefault }); 34 | 35 | expect(actualSearchTerm).toBe(searchTerm); 36 | expect(preventDefault).toHaveBeenCalled(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/app/components/flickr-search-ngrx/flickr-search-ngrx.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { select, Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Photo } from '../../models/photo'; 6 | import { AppState } from '../../reducers'; 7 | import { 8 | searchTermSelector, 9 | photosSelector, 10 | currentPhotoSelector, 11 | } from '../../selectors/photos.selectors'; 12 | import { search, focusPhoto } from '../../actions/photos.actions'; 13 | 14 | @Component({ 15 | selector: 'app-flickr-search-ngrx', 16 | templateUrl: './flickr-search-ngrx.component.html', 17 | styleUrls: ['./flickr-search-ngrx.component.css'], 18 | }) 19 | export class FlickrSearchNgrxComponent { 20 | public searchTerm$: Observable; 21 | public photos$: Observable; 22 | public currentPhoto$: Observable; 23 | 24 | constructor(private store$: Store) { 25 | this.searchTerm$ = this.store$.pipe(select(searchTermSelector)); 26 | this.photos$ = this.store$.pipe(select(photosSelector)); 27 | this.currentPhoto$ = this.store$.pipe(select(currentPhotoSelector)); 28 | } 29 | 30 | public handleSearch(searchTerm: string): void { 31 | this.store$.dispatch(search({ searchTerm })); 32 | } 33 | 34 | public handleFocusPhoto(photo: Photo): void { 35 | this.store$.dispatch(focusPhoto({ photo })); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/components/full-photo/full-photo.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; 2 | 3 | import { photo1, photo1Link } from '../../spec-helpers/photo.spec-helper'; 4 | import { FullPhotoComponent } from './full-photo.component'; 5 | 6 | describe('FullPhotoComponent with spectator', () => { 7 | let spectator: Spectator; 8 | 9 | const createComponent = createComponentFactory({ 10 | component: FullPhotoComponent, 11 | shallow: true, 12 | }); 13 | 14 | beforeEach(() => { 15 | spectator = createComponent({ props: { photo: photo1 } }); 16 | }); 17 | 18 | it('renders the photo information', () => { 19 | expect(spectator.query(byTestId('full-photo-title'))).toHaveText(photo1.title); 20 | 21 | const img = spectator.query(byTestId('full-photo-image')); 22 | expect(img).toHaveAttribute('src', photo1.url_m); 23 | expect(img).toHaveAttribute('alt', photo1.title); 24 | 25 | expect(spectator.query(byTestId('full-photo-ownername'))).toHaveText( 26 | photo1.ownername, 27 | ); 28 | expect(spectator.query(byTestId('full-photo-datetaken'))).toHaveText( 29 | photo1.datetaken, 30 | ); 31 | expect(spectator.query(byTestId('full-photo-tags'))).toHaveText(photo1.tags); 32 | 33 | const link = spectator.query(byTestId('full-photo-link')); 34 | expect(link).toHaveAttribute('href', photo1Link); 35 | expect(link).toHaveText(photo1Link); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/app/components/photo-item/photo-item.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; 2 | 3 | import { Photo } from '../../models/photo'; 4 | import { photo1, photo2Link } from '../../spec-helpers/photo.spec-helper'; 5 | import { PhotoItemComponent } from './photo-item.component'; 6 | 7 | describe('PhotoItemComponent with spectator', () => { 8 | let spectator: Spectator; 9 | 10 | const createComponent = createComponentFactory({ 11 | component: PhotoItemComponent, 12 | shallow: true, 13 | }); 14 | 15 | beforeEach(() => { 16 | spectator = createComponent({ props: { photo: photo1 } }); 17 | }); 18 | 19 | it('renders a link and a thumbnail', () => { 20 | const link = spectator.query(byTestId('photo-item-link')); 21 | expect(link).toHaveAttribute('href', photo2Link); 22 | 23 | const img = spectator.query(byTestId('photo-item-image')); 24 | expect(img).toHaveAttribute('src', photo1.url_q); 25 | expect(img).toHaveAttribute('alt', photo1.title); 26 | }); 27 | 28 | it('focusses a photo on click', () => { 29 | let photo: Photo | undefined; 30 | 31 | spectator.component.focusPhoto.subscribe((otherPhoto: Photo) => { 32 | photo = otherPhoto; 33 | }); 34 | 35 | spectator.click(byTestId('photo-item-link')); 36 | 37 | expect(photo).toBe(photo1); 38 | }); 39 | 40 | it('does nothing when the photo is null', () => { 41 | spectator.component.photo = null; 42 | spectator.detectChanges(); 43 | 44 | expect(spectator.query(byTestId('photo-item-link'))).not.toExist(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/app/components/full-photo/full-photo.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { expectText, findEl } from '../../spec-helpers/element.spec-helper'; 5 | import { photo1, photo1Link } from '../../spec-helpers/photo.spec-helper'; 6 | import { FullPhotoComponent } from './full-photo.component'; 7 | 8 | describe('FullPhotoComponent', () => { 9 | let component: FullPhotoComponent; 10 | let fixture: ComponentFixture; 11 | 12 | beforeEach(async () => { 13 | await TestBed.configureTestingModule({ 14 | declarations: [FullPhotoComponent], 15 | schemas: [NO_ERRORS_SCHEMA], 16 | }).compileComponents(); 17 | 18 | fixture = TestBed.createComponent(FullPhotoComponent); 19 | component = fixture.componentInstance; 20 | component.photo = photo1; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('renders the photo information', () => { 25 | expectText(fixture, 'full-photo-title', photo1.title); 26 | 27 | const img = findEl(fixture, 'full-photo-image'); 28 | expect(img.properties.src).toBe(photo1.url_m); 29 | expect(img.properties.alt).toBe(photo1.title); 30 | 31 | expectText(fixture, 'full-photo-ownername', photo1.ownername); 32 | expectText(fixture, 'full-photo-datetaken', photo1.datetaken); 33 | expectText(fixture, 'full-photo-tags', photo1.tags); 34 | 35 | const link = findEl(fixture, 'full-photo-link'); 36 | expect(link.properties.href).toBe(photo1Link); 37 | expect(link.nativeElement.textContent.trim()).toBe(photo1Link); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-firefox-launcher'), 12 | require('karma-jasmine-html-reporter'), 13 | require('karma-coverage'), 14 | require('@angular-devkit/build-angular/plugins/karma') 15 | ], 16 | client: { 17 | jasmine: { 18 | // you can add configuration options for Jasmine here 19 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 20 | // for example, you can disable the random execution with `random: false` 21 | // or set a specific seed with `seed: 4321` 22 | failSpecWithNoExpectations: true, 23 | }, 24 | clearContext: false // leave Jasmine Spec Runner output visible in browser 25 | }, 26 | jasmineHtmlReporter: { 27 | suppressAll: true // removes the duplicated traces 28 | }, 29 | coverageReporter: { 30 | dir: require('path').join(__dirname, './coverage/angular-flickr-search'), 31 | subdir: '.', 32 | reporters: [ 33 | { type: 'html' }, 34 | { type: 'text-summary' } 35 | ] 36 | }, 37 | reporters: ['progress', 'kjhtml'], 38 | port: 9876, 39 | colors: true, 40 | logLevel: config.LOG_INFO, 41 | autoWatch: true, 42 | browsers: ['Chrome'], 43 | singleRun: false, 44 | restartOnFileChange: true 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /src/app/components/photo-list/photo-list.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; 2 | import { MockComponent } from 'ng-mocks'; 3 | 4 | import { photo1, photo2 } from '../../spec-helpers/photo.spec-helper'; 5 | import { PhotoItemComponent } from '../photo-item/photo-item.component'; 6 | import { PhotoListComponent } from './photo-list.component'; 7 | import { Photo } from 'src/app/models/photo'; 8 | 9 | const title = 'Hello World'; 10 | const photos = [photo1, photo2]; 11 | 12 | describe('PhotoListComponent with spectator', () => { 13 | let spectator: Spectator; 14 | 15 | const createComponent = createComponentFactory({ 16 | component: PhotoListComponent, 17 | declarations: [MockComponent(PhotoItemComponent)], 18 | shallow: true, 19 | }); 20 | 21 | beforeEach(() => { 22 | spectator = createComponent({ props: { title, photos } }); 23 | }); 24 | 25 | it('renders the title', () => { 26 | expect(spectator.query(byTestId('photo-list-title'))).toHaveText(title); 27 | }); 28 | 29 | it('renders photo items', () => { 30 | const photoItems = spectator.queryAll(PhotoItemComponent); 31 | expect(photoItems.length).toBe(photos.length); 32 | photoItems.forEach((photoItem, i) => { 33 | expect(photoItem.photo).toBe(photos[i]); 34 | }); 35 | }); 36 | 37 | it('focusses a photo', () => { 38 | const photoItem = spectator.query(PhotoItemComponent); 39 | if (!photoItem) { 40 | throw new Error('photoItem not found'); 41 | } 42 | 43 | let photo: Photo | undefined; 44 | 45 | spectator.component.focusPhoto.subscribe((otherPhoto: Photo) => { 46 | photo = otherPhoto; 47 | }); 48 | 49 | photoItem.focusPhoto.emit(photo1); 50 | 51 | expect(photo).toBe(photo1); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/app/components/photo-item/photo-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { Photo } from '../../models/photo'; 5 | import { click, findEl } from '../../spec-helpers/element.spec-helper'; 6 | import { photo1, photo1Link } from '../../spec-helpers/photo.spec-helper'; 7 | import { PhotoItemComponent } from './photo-item.component'; 8 | 9 | describe('PhotoItemComponent', () => { 10 | let component: PhotoItemComponent; 11 | let fixture: ComponentFixture; 12 | 13 | beforeEach(async () => { 14 | await TestBed.configureTestingModule({ 15 | declarations: [PhotoItemComponent], 16 | schemas: [NO_ERRORS_SCHEMA], 17 | }).compileComponents(); 18 | 19 | fixture = TestBed.createComponent(PhotoItemComponent); 20 | component = fixture.componentInstance; 21 | component.photo = photo1; 22 | fixture.detectChanges(); 23 | }); 24 | 25 | it('renders a link and a thumbnail', () => { 26 | const link = findEl(fixture, 'photo-item-link'); 27 | expect(link.properties.href).toBe(photo1Link); 28 | 29 | const img = findEl(fixture, 'photo-item-image'); 30 | expect(img.properties.src).toBe(photo1.url_q); 31 | expect(img.properties.alt).toBe(photo1.title); 32 | }); 33 | 34 | it('focusses a photo on click', () => { 35 | let photo: Photo | undefined; 36 | 37 | component.focusPhoto.subscribe((otherPhoto: Photo) => { 38 | photo = otherPhoto; 39 | }); 40 | 41 | click(fixture, 'photo-item-link'); 42 | 43 | expect(photo).toBe(photo1); 44 | }); 45 | 46 | it('does nothing when the photo is null', () => { 47 | component.photo = null; 48 | fixture.detectChanges(); 49 | 50 | expect(() => { 51 | findEl(fixture, 'photo-item-link'); 52 | }).toThrow(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app/spec-helpers/photo.spec-helper.ts: -------------------------------------------------------------------------------- 1 | import { Photo } from '../models/photo'; 2 | import { PhotosStateSlice } from '../reducers/photos-state-slice'; 3 | 4 | export const photo1: Photo = { 5 | id: '50179462511', 6 | title: 'Blauflügel-Prachtlibelle (Calopteryx virgo) (1)', 7 | url_q: 'https://live.staticflickr.com/65535/50179462511_0752249fba_q.jpg', 8 | url_m: 'https://live.staticflickr.com/65535/50179462511_0752249fba_m.jpg', 9 | datetaken: '2020-06-21T15:16:07-08:00', 10 | owner: '12639178@N07', 11 | ownername: 'naturgucker.de', 12 | tags: 'ngidn2020772215 calopteryxvirgo blauflügelprachtlibelle', 13 | }; 14 | 15 | export const photo1Link = `https://www.flickr.com/photos/${photo1.owner}/${photo1.id}`; 16 | 17 | export const photo2: Photo = { 18 | id: '50178927498', 19 | title: 'Blauflügel-Prachtlibelle (Calopteryx virgo) (2)', 20 | url_q: 'https://live.staticflickr.com/65535/50178927498_44162cb1a0_q.jpg', 21 | url_m: 'https://live.staticflickr.com/65535/50178927498_44162cb1a0_m.jpg', 22 | datetaken: '2020-06-21T15:16:17-08:00', 23 | owner: '12639178@N07', 24 | ownername: 'naturgucker.de', 25 | tags: 'ngid657236235 calopteryxvirgo blauflügelprachtlibelle', 26 | }; 27 | 28 | export const photo2Link = `https://www.flickr.com/photos/${photo1.owner}/${photo1.id}`; 29 | 30 | export const photos: Photo[] = [photo1, photo2]; 31 | 32 | export const searchTerm = 'calopteryx'; 33 | 34 | export const initialState: PhotosStateSlice = { 35 | searchTerm: '', 36 | photos: [], 37 | currentPhoto: null, 38 | }; 39 | 40 | export const stateWithSearchTerm: PhotosStateSlice = { 41 | searchTerm, 42 | photos: [], 43 | currentPhoto: null, 44 | }; 45 | 46 | export const stateWithPhotos: PhotosStateSlice = { 47 | searchTerm, 48 | photos, 49 | currentPhoto: null, 50 | }; 51 | 52 | export const stateWithCurrentPhoto: PhotosStateSlice = { 53 | searchTerm, 54 | photos, 55 | currentPhoto: photo1, 56 | }; 57 | -------------------------------------------------------------------------------- /src/app/components/photo-list/photo-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | 4 | import { Photo } from '../../models/photo'; 5 | import { 6 | expectText, 7 | findComponent, 8 | findComponents, 9 | } from '../../spec-helpers/element.spec-helper'; 10 | import { photo1, photo2 } from '../../spec-helpers/photo.spec-helper'; 11 | import { PhotoListComponent } from './photo-list.component'; 12 | 13 | const title = 'Hello World'; 14 | const photos = [photo1, photo2]; 15 | 16 | describe('PhotoListComponent', () => { 17 | let component: PhotoListComponent; 18 | let fixture: ComponentFixture; 19 | 20 | beforeEach(async () => { 21 | await TestBed.configureTestingModule({ 22 | declarations: [PhotoListComponent], 23 | schemas: [NO_ERRORS_SCHEMA], 24 | }).compileComponents(); 25 | 26 | fixture = TestBed.createComponent(PhotoListComponent); 27 | component = fixture.componentInstance; 28 | component.title = title; 29 | component.photos = photos; 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('renders the title', () => { 34 | expectText(fixture, 'photo-list-title', title); 35 | }); 36 | 37 | it('renders photo items', () => { 38 | const photoItems = findComponents(fixture, 'app-photo-item'); 39 | expect(photoItems.length).toBe(photos.length); 40 | photoItems.forEach((photoItem, i) => { 41 | expect(photoItem.properties.photo).toBe(photos[i]); 42 | }); 43 | }); 44 | 45 | it('focusses a photo', () => { 46 | const photoItem = findComponent(fixture, 'app-photo-item'); 47 | 48 | let photo: Photo | undefined; 49 | 50 | component.focusPhoto.subscribe((otherPhoto: Photo) => { 51 | photo = otherPhoto; 52 | }); 53 | 54 | photoItem.triggerEventHandler('focusPhoto', photoItem.properties.photo); 55 | 56 | expect(photo).toBe(photo1); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { EffectsModule } from '@ngrx/effects'; 5 | import { StoreModule } from '@ngrx/store'; 6 | import { StoreDevtoolsModule } from '@ngrx/store-devtools'; 7 | 8 | import { environment } from '../environments/environment'; 9 | import { AppComponent } from './app.component'; 10 | import { FlickrSearchNgrxComponent } from './components/flickr-search-ngrx/flickr-search-ngrx.component'; 11 | // eslint-disable-next-line max-len 12 | import { FlickrSearchComponent } from './components/flickr-search/flickr-search.component'; 13 | import { FullPhotoComponent } from './components/full-photo/full-photo.component'; 14 | import { PhotoItemComponent } from './components/photo-item/photo-item.component'; 15 | import { PhotoListComponent } from './components/photo-list/photo-list.component'; 16 | import { SearchFormComponent } from './components/search-form/search-form.component'; 17 | import { PhotosEffects } from './effects/photos.effects'; 18 | import { reducers } from './reducers'; 19 | import { FlickrService } from './services/flickr.service'; 20 | 21 | @NgModule({ 22 | declarations: [ 23 | AppComponent, 24 | FlickrSearchComponent, 25 | FlickrSearchNgrxComponent, 26 | SearchFormComponent, 27 | PhotoListComponent, 28 | PhotoItemComponent, 29 | FullPhotoComponent, 30 | ], 31 | imports: [ 32 | BrowserModule, 33 | HttpClientModule, 34 | StoreModule.forRoot(reducers, { 35 | runtimeChecks: { 36 | strictStateImmutability: true, 37 | strictActionImmutability: true, 38 | }, 39 | }), 40 | EffectsModule.forRoot([PhotosEffects]), 41 | StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production }), 42 | ], 43 | providers: [FlickrService], 44 | bootstrap: [AppComponent], 45 | }) 46 | export class AppModule {} 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flickr-search", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e", 11 | "cypress:open": "cypress open --e2e --browser chrome", 12 | "cypress:run": "cypress run --e2e --browser chrome", 13 | "deploy": "ng deploy --base-href=/angular-flickr-search/" 14 | }, 15 | "private": true, 16 | "license": "Unlicense", 17 | "author": "Mathias Schäfer (https://molily.de)", 18 | "dependencies": { 19 | "@angular/animations": "^15.1.2", 20 | "@angular/common": "^15.1.2", 21 | "@angular/compiler": "^15.1.2", 22 | "@angular/core": "^15.1.2", 23 | "@angular/forms": "^15.1.2", 24 | "@angular/platform-browser": "^15.1.2", 25 | "@angular/platform-browser-dynamic": "^15.1.2", 26 | "@angular/router": "^15.1.2", 27 | "@ngrx/effects": "^15.2.1", 28 | "@ngrx/store": "^15.2.1", 29 | "@ngrx/store-devtools": "^15.2.1", 30 | "rxjs": "~7.8.0", 31 | "tslib": "^2.5.0", 32 | "zone.js": "~0.12.0" 33 | }, 34 | "devDependencies": { 35 | "@angular-devkit/build-angular": "^15.1.3", 36 | "@angular-eslint/builder": "15.2.0", 37 | "@angular-eslint/eslint-plugin": "15.2.0", 38 | "@angular-eslint/eslint-plugin-template": "15.2.0", 39 | "@angular-eslint/schematics": "15.2.0", 40 | "@angular-eslint/template-parser": "15.2.0", 41 | "@angular/cli": "^15.1.3", 42 | "@angular/compiler-cli": "^15.1.2", 43 | "@cypress/schematic": "^2.5.0", 44 | "@ngneat/spectator": "^14.0.0", 45 | "@types/jasmine": "~4.3.1", 46 | "@typescript-eslint/eslint-plugin": "^5.49.0", 47 | "@typescript-eslint/parser": "^5.49.0", 48 | "angular-cli-ghpages": "^1.0.5", 49 | "cypress": "^12.4.1", 50 | "eslint": "^8.32.0", 51 | "jasmine-core": "~4.5.0", 52 | "jasmine-spec-reporter": "~7.0.0", 53 | "karma": "~6.4.1", 54 | "karma-chrome-launcher": "~3.1.1", 55 | "karma-coverage": "~2.2.0", 56 | "karma-firefox-launcher": "^2.1.2", 57 | "karma-jasmine": "~5.1.0", 58 | "karma-jasmine-html-reporter": "~2.0.0", 59 | "ng-mocks": "^14.6.0", 60 | "typescript": "~4.9.4" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /cypress/e2e/flickr-search-stub-network.cy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | photo1, 3 | photo1Link, 4 | photos, 5 | searchTerm, 6 | } from '../../src/app/spec-helpers/photo.spec-helper'; 7 | 8 | describe('Flickr search (with network stubbing)', () => { 9 | const encodedSearchTerm = encodeURIComponent(searchTerm); 10 | const expectedUrl = `https://www.flickr.com/services/rest/?tags=${encodedSearchTerm}&method=flickr.photos.search&format=json&nojsoncallback=1&tag_mode=all&media=photos&per_page=15&extras=tags,date_taken,owner_name,url_q,url_m&api_key=*`; 11 | 12 | const flickrResponse = { 13 | photos: { 14 | photo: photos, 15 | }, 16 | }; 17 | 18 | beforeEach(() => { 19 | cy.intercept('GET', expectedUrl, { 20 | headers: { 21 | 'Access-Control-Allow-Origin': '*', 22 | }, 23 | body: flickrResponse, 24 | }).as('flickrSearchRequest'); 25 | 26 | cy.visit('/'); 27 | }); 28 | 29 | it('searches for a term', () => { 30 | cy.byTestId('search-term-input').first().clear().type(searchTerm); 31 | cy.byTestId('submit-search').first().click(); 32 | 33 | cy.wait('@flickrSearchRequest'); 34 | 35 | cy.byTestId('photo-item-link') 36 | .should('have.length', 2) 37 | .each((link, index) => { 38 | expect(link.attr('href')).to.equal( 39 | `https://www.flickr.com/photos/${photos[index].owner}/${photos[index].id}`, 40 | ); 41 | }); 42 | cy.byTestId('photo-item-image') 43 | .should('have.length', 2) 44 | .each((image, index) => { 45 | expect(image.attr('src')).to.equal(photos[index].url_q); 46 | }); 47 | }); 48 | 49 | it('shows the full photo', () => { 50 | cy.byTestId('search-term-input').first().clear().type(searchTerm); 51 | cy.byTestId('submit-search').first().click(); 52 | 53 | cy.wait('@flickrSearchRequest'); 54 | 55 | cy.byTestId('photo-item-link').first().click(); 56 | cy.byTestId('full-photo').should('contain', searchTerm); 57 | cy.byTestId('full-photo-title').should('have.text', photo1.title); 58 | cy.byTestId('full-photo-tags').should('have.text', photo1.tags); 59 | cy.byTestId('full-photo-image').should('have.attr', 'src', photo1.url_m); 60 | cy.byTestId('full-photo-link').should('have.attr', 'href', photo1Link); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/app/services/flickr.service.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { createHttpFactory, HttpMethod } from '@ngneat/spectator'; 3 | 4 | import { Photo } from '../models/photo'; 5 | import { photos, searchTerm } from '../spec-helpers/photo.spec-helper'; 6 | import { FlickrService } from './flickr.service'; 7 | 8 | const encodedSearchTerm = encodeURIComponent(searchTerm); 9 | const expectedUrl = `https://www.flickr.com/services/rest/?tags=${encodedSearchTerm}&method=flickr.photos.search&format=json&nojsoncallback=1&tag_mode=all&media=photos&per_page=15&extras=tags,date_taken,owner_name,url_q,url_m&api_key=c3050d39a5bb308d9921bef0e15c437d`; 10 | 11 | describe('FlickrService', () => { 12 | const createHttp = createHttpFactory({ 13 | service: FlickrService, 14 | }); 15 | 16 | it('searches for public photos', () => { 17 | const { service, controller } = createHttp(); 18 | 19 | let actualPhotos: Photo[] | undefined; 20 | service.searchPublicPhotos(searchTerm).subscribe((otherPhotos) => { 21 | actualPhotos = otherPhotos; 22 | }); 23 | 24 | controller.expectOne(expectedUrl).flush({ photos: { photo: photos } }); 25 | expect(actualPhotos).toEqual(photos); 26 | 27 | // Spectator verifies the HTTP testing controller automatically 28 | }); 29 | 30 | it('passes through search errors', () => { 31 | const { service, expectOne } = createHttp(); 32 | 33 | const status = 500; 34 | const statusText = 'Internal Server Error'; 35 | const errorEvent = new ErrorEvent('API error'); 36 | 37 | let actualError: HttpErrorResponse | undefined; 38 | 39 | service.searchPublicPhotos(searchTerm).subscribe( 40 | () => { 41 | fail('next handler must not be called'); 42 | }, 43 | (error) => { 44 | actualError = error; 45 | }, 46 | () => { 47 | fail('complete handler must not be called'); 48 | }, 49 | ); 50 | 51 | expectOne(expectedUrl, HttpMethod.GET).error(errorEvent, { status, statusText }); 52 | 53 | if (!actualError) { 54 | throw new Error('Error needs to be defined'); 55 | } 56 | expect(actualError.error).toBe(errorEvent); 57 | expect(actualError.status).toBe(status); 58 | expect(actualError.statusText).toBe(statusText); 59 | 60 | // Spectator verifies the HTTP testing controller automatically 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example namespace declaration will help 3 | // with Intellisense and code completion in your 4 | // IDE or Text Editor. 5 | // *********************************************** 6 | // declare namespace Cypress { 7 | // interface Chainable { 8 | // customCommand(param: any): typeof customCommand; 9 | // } 10 | // } 11 | // 12 | // function customCommand(param: any): void { 13 | // console.warn(param); 14 | // } 15 | // 16 | // NOTE: You can use it like so: 17 | // Cypress.Commands.add('customCommand', customCommand); 18 | // 19 | // *********************************************** 20 | // This example commands.js shows you how to 21 | // create various custom commands and overwrite 22 | // existing commands. 23 | // 24 | // For more comprehensive examples of custom 25 | // commands please read more here: 26 | // https://on.cypress.io/custom-commands 27 | // *********************************************** 28 | // 29 | // 30 | // -- This is a parent command -- 31 | // Cypress.Commands.add("login", (email, password) => { ... }) 32 | // 33 | // 34 | // -- This is a child command -- 35 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 36 | // 37 | // 38 | // -- This is a dual command -- 39 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 40 | // 41 | // 42 | // -- This will overwrite an existing command -- 43 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 44 | 45 | declare namespace Cypress { 46 | interface Chainable { 47 | /** 48 | * Get one or more DOM elements by test id. 49 | * 50 | * @param id The test id 51 | * @param options The same options as cy.get 52 | */ 53 | byTestId( 54 | id: string, 55 | options?: Partial< 56 | Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow 57 | >, 58 | ): Cypress.Chainable>; 59 | } 60 | } 61 | 62 | Cypress.Commands.add( 63 | 'byTestId', 64 | // Borrow the signature from cy.get 65 | ( 66 | id: string, 67 | options?: Partial< 68 | Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow 69 | >, 70 | ): Cypress.Chainable> => cy.get(`[data-testid="${id}"]`, options), 71 | ); 72 | 73 | -------------------------------------------------------------------------------- /cypress/e2e/flickr-search-stub-network-intercept.cy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | photo1, 3 | photo1Link, 4 | photos, 5 | searchTerm, 6 | } from '../../src/app/spec-helpers/photo.spec-helper'; 7 | 8 | describe('Flickr search (with intercept network stubbing)', () => { 9 | const flickrResponse = { 10 | photos: { 11 | photo: photos, 12 | }, 13 | }; 14 | 15 | beforeEach(() => { 16 | cy.intercept( 17 | { 18 | method: 'GET', 19 | url: 'https://www.flickr.com/services/rest/*', 20 | query: { 21 | tags: searchTerm, 22 | method: 'flickr.photos.search', 23 | format: 'json', 24 | nojsoncallback: '1', 25 | tag_mode: 'all', 26 | media: 'photos', 27 | per_page: '15', 28 | extras: 'tags,date_taken,owner_name,url_q,url_m', 29 | api_key: '*', 30 | }, 31 | }, 32 | { 33 | body: flickrResponse, 34 | headers: { 35 | 'Access-Control-Allow-Origin': '*', 36 | }, 37 | }, 38 | ).as('flickrSearchRequest'); 39 | 40 | cy.visit('/'); 41 | }); 42 | 43 | it('searches for a term', () => { 44 | cy.byTestId('search-term-input').first().clear().type(searchTerm); 45 | cy.byTestId('submit-search').first().click(); 46 | 47 | cy.wait('@flickrSearchRequest'); 48 | 49 | cy.byTestId('photo-item-link') 50 | .should('have.length', 2) 51 | .each((link, index) => { 52 | expect(link.attr('href')).to.equal( 53 | `https://www.flickr.com/photos/${photos[index].owner}/${photos[index].id}`, 54 | ); 55 | }); 56 | cy.byTestId('photo-item-image') 57 | .should('have.length', 2) 58 | .each((image, index) => { 59 | expect(image.attr('src')).to.equal(photos[index].url_q); 60 | }); 61 | }); 62 | 63 | it('shows the full photo', () => { 64 | cy.byTestId('search-term-input').first().clear().type(searchTerm); 65 | cy.byTestId('submit-search').first().click(); 66 | 67 | cy.wait('@flickrSearchRequest'); 68 | 69 | cy.byTestId('photo-item-link').first().click(); 70 | cy.byTestId('full-photo').should('contain', searchTerm); 71 | cy.byTestId('full-photo-title').should('have.text', photo1.title); 72 | cy.byTestId('full-photo-tags').should('have.text', photo1.tags); 73 | cy.byTestId('full-photo-image').should('have.attr', 'src', photo1.url_m); 74 | cy.byTestId('full-photo-link').should('have.attr', 'href', photo1Link); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/app/services/flickr.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from '@angular/common/http'; 2 | import { 3 | HttpClientTestingModule, 4 | HttpTestingController, 5 | } from '@angular/common/http/testing'; 6 | import { TestBed } from '@angular/core/testing'; 7 | 8 | import { Photo } from '../models/photo'; 9 | import { photos, searchTerm } from '../spec-helpers/photo.spec-helper'; 10 | import { FlickrService } from './flickr.service'; 11 | 12 | const encodedSearchTerm = encodeURIComponent(searchTerm); 13 | const expectedUrl = `https://www.flickr.com/services/rest/?tags=${encodedSearchTerm}&method=flickr.photos.search&format=json&nojsoncallback=1&tag_mode=all&media=photos&per_page=15&extras=tags,date_taken,owner_name,url_q,url_m&api_key=c3050d39a5bb308d9921bef0e15c437d`; 14 | 15 | describe('FlickrService', () => { 16 | let flickrService: FlickrService; 17 | let controller: HttpTestingController; 18 | 19 | beforeEach(() => { 20 | TestBed.configureTestingModule({ 21 | imports: [HttpClientTestingModule], 22 | providers: [FlickrService], 23 | }); 24 | flickrService = TestBed.inject(FlickrService); 25 | controller = TestBed.inject(HttpTestingController); 26 | }); 27 | 28 | afterEach(() => { 29 | controller.verify(); 30 | }); 31 | 32 | it('searches for public photos', () => { 33 | let actualPhotos: Photo[] | undefined; 34 | flickrService.searchPublicPhotos(searchTerm).subscribe((otherPhotos) => { 35 | actualPhotos = otherPhotos; 36 | }); 37 | 38 | controller.expectOne(expectedUrl).flush({ photos: { photo: photos } }); 39 | expect(actualPhotos).toEqual(photos); 40 | }); 41 | 42 | it('passes through search errors', () => { 43 | const status = 500; 44 | const statusText = 'Internal Server Error'; 45 | const errorEvent = new ErrorEvent('API error'); 46 | 47 | let actualError: HttpErrorResponse | undefined; 48 | 49 | flickrService.searchPublicPhotos(searchTerm).subscribe( 50 | () => { 51 | fail('next handler must not be called'); 52 | }, 53 | (error) => { 54 | actualError = error; 55 | }, 56 | () => { 57 | fail('complete handler must not be called'); 58 | }, 59 | ); 60 | 61 | controller.expectOne(expectedUrl).error(errorEvent, { status, statusText }); 62 | 63 | if (!actualError) { 64 | throw new Error('Error needs to be defined'); 65 | } 66 | expect(actualError.error).toBe(errorEvent); 67 | expect(actualError.status).toBe(status); 68 | expect(actualError.statusText).toBe(statusText); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/app/effects/photos.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Action } from '@ngrx/store'; 4 | import { from, Observable, of, throwError } from 'rxjs'; 5 | import { toArray } from 'rxjs/operators'; 6 | 7 | import { search, searchResultsLoaded } from '../actions/photos.actions'; 8 | import { Photo } from '../models/photo'; 9 | import { FlickrService } from '../services/flickr.service'; 10 | import { photos, searchTerm } from '../spec-helpers/photo.spec-helper'; 11 | import { PhotosEffects } from './photos.effects'; 12 | 13 | const searchAction = search({ searchTerm }); 14 | 15 | type PartialFlickrService = Pick; 16 | 17 | const fakeFlickrService: PartialFlickrService = { 18 | searchPublicPhotos(): Observable { 19 | return of(photos); 20 | }, 21 | }; 22 | 23 | const apiError = new Error('API Error'); 24 | 25 | const fakeErrorFlickrService: PartialFlickrService = { 26 | searchPublicPhotos(): Observable { 27 | return throwError(apiError); 28 | }, 29 | }; 30 | 31 | function expectActions(effect: Observable, actions: Action[]): void { 32 | let actualActions: Action[] | undefined; 33 | effect.pipe(toArray()).subscribe((actualActions2) => { 34 | actualActions = actualActions2; 35 | }, fail); 36 | expect(actualActions).toEqual(actions); 37 | } 38 | 39 | function setup(actions: Action[], flickrService: PartialFlickrService): PhotosEffects { 40 | spyOn(flickrService, 'searchPublicPhotos').and.callThrough(); 41 | 42 | TestBed.configureTestingModule({ 43 | providers: [ 44 | provideMockActions(from(actions)), 45 | { provide: FlickrService, useValue: flickrService }, 46 | PhotosEffects, 47 | ], 48 | }); 49 | 50 | return TestBed.inject(PhotosEffects); 51 | } 52 | 53 | describe('PhotosEffects', () => { 54 | it('gets the photos from flickr on search', () => { 55 | const photosEffects = setup([searchAction], fakeFlickrService); 56 | 57 | expectActions(photosEffects.search$, [searchResultsLoaded({ photos })]); 58 | 59 | expect(fakeFlickrService.searchPublicPhotos).toHaveBeenCalledWith(searchTerm); 60 | }); 61 | 62 | it('handles errors from the service', () => { 63 | const photosEffects = setup( 64 | [searchAction, searchAction, searchAction], 65 | fakeErrorFlickrService, 66 | ); 67 | 68 | expectActions(photosEffects.search$, []); 69 | 70 | expect(fakeErrorFlickrService.searchPublicPhotos).toHaveBeenCalledWith(searchTerm); 71 | expect(fakeErrorFlickrService.searchPublicPhotos).toHaveBeenCalledTimes(3); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flickr Search – Angular example application 2 | 3 | 📖 This example is part of the **[free online book: Testing Angular – A Guide to Robust Angular Applications 4 | ](https://testing-angular.com/)**. 📖 5 | 6 | This is an Angular example application implementing a Flickr photo search. There is one version with plain Angular and one version with [NgRx](https://ngrx.io/). 7 | 8 | The application is fully tested with unit and end-to-end tests. 9 | 10 | Unit tests are written in plain Angular using `TestBed` and additionally using [Spectator](https://github.com/ngneat/spectator). 11 | 12 | End-to-end tests are written with [Cypress](https://www.cypress.io/). 13 | 14 | **[App structure plan (React version)](https://github.com/molily/learning-react/tree/main/5-flickr-search)** 15 | 16 | Other versions: 17 | 18 | - [Flickr Search with React](https://github.com/molily/learning-react/tree/main/5-flickr-search) 19 | - [Flickr Search with React and Redux](https://github.com/molily/learning-react/tree/main/7-flickr-search-redux) 20 | - [Flickr Search with jQuery](https://molily.de/javascript-introduction/flickr-jquery.html) 21 | - [Flickr Search with Backbone](https://molily.de/javascript-introduction/flickr-backbone.html) 22 | - [Source code for the jQuery and Backbone versions](https://github.com/molily/molily.de/tree/main/javascript-introduction) 23 | 24 | ## Terms of Use 25 | 26 | This non-commercial example application uses the Flickr API but is not endorsed or certified by Flickr Inc. or SmugMug, Inc. See the [Flickr API Terms of Use](https://www.flickr.com/help/terms/api). 27 | 28 | The code contains a Flickr API key that is bound to the example application. If you wish to use the Flickr API in your application, you need to [Request an API Key](https://www.flickr.com/services/apps/create/) yourself. 29 | 30 | ## Development server 31 | 32 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 33 | 34 | ## Build 35 | 36 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 37 | 38 | ## Running the unit & integration tests 39 | 40 | Run `ng test` to execute the unit & integration tests with Karma and Jasmine. 41 | 42 | ## Running the end-to-end tests with Cypress 43 | 44 | Run `ng run flickr-search:cypress-run` to execute the Cypress end-to-end tests. (This starts the development server automatically.) 45 | 46 | Run `ng run flickr-search:cypress-open` to start the interactive Cypress test runner. 47 | 48 | ## Deployment 49 | 50 | Run `npm run deploy` to the deploy the code to [https://9elements.github.io/angular-flickr-search/]. 51 | -------------------------------------------------------------------------------- /src/app/components/flickr-search/flickr-search.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientTestingModule } from '@angular/common/http/testing'; 2 | import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; 3 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 4 | import { of } from 'rxjs'; 5 | import { FlickrService } from 'src/app/services/flickr.service'; 6 | 7 | import { findComponent } from '../../spec-helpers/element.spec-helper'; 8 | import { photo1, photos } from '../../spec-helpers/photo.spec-helper'; 9 | import { FlickrSearchComponent } from './flickr-search.component'; 10 | 11 | describe('FlickrSearchComponent', () => { 12 | let fixture: ComponentFixture; 13 | let component: FlickrSearchComponent; 14 | let fakeFlickrService: Pick; 15 | 16 | let searchForm: DebugElement; 17 | let photoList: DebugElement; 18 | 19 | beforeEach(async () => { 20 | fakeFlickrService = { 21 | searchPublicPhotos: jasmine 22 | .createSpy('searchPublicPhotos') 23 | .and.returnValue(of(photos)), 24 | }; 25 | 26 | await TestBed.configureTestingModule({ 27 | imports: [HttpClientTestingModule], 28 | declarations: [FlickrSearchComponent], 29 | providers: [{ provide: FlickrService, useValue: fakeFlickrService }], 30 | schemas: [NO_ERRORS_SCHEMA], 31 | }).compileComponents(); 32 | }); 33 | 34 | beforeEach(() => { 35 | fixture = TestBed.createComponent(FlickrSearchComponent); 36 | component = fixture.debugElement.componentInstance; 37 | fixture.detectChanges(); 38 | 39 | searchForm = findComponent(fixture, 'app-search-form'); 40 | photoList = findComponent(fixture, 'app-photo-list'); 41 | }); 42 | 43 | it('renders the search form and the photo list, not the full photo', () => { 44 | expect(searchForm).toBeTruthy(); 45 | expect(photoList).toBeTruthy(); 46 | expect(photoList.properties.title).toBe(''); 47 | expect(photoList.properties.photos).toEqual([]); 48 | 49 | expect(() => { 50 | findComponent(fixture, 'app-full-photo'); 51 | }).toThrow(); 52 | }); 53 | 54 | it('searches and passes the resulting photos to the photo list', () => { 55 | const searchTerm = 'beautiful flowers'; 56 | searchForm.triggerEventHandler('search', searchTerm); 57 | fixture.detectChanges(); 58 | 59 | expect(fakeFlickrService.searchPublicPhotos).toHaveBeenCalledWith(searchTerm); 60 | expect(photoList.properties.title).toBe(searchTerm); 61 | expect(photoList.properties.photos).toBe(photos); 62 | }); 63 | 64 | it('renders the full photo when a photo is focussed', () => { 65 | expect(() => { 66 | findComponent(fixture, 'app-full-photo'); 67 | }).toThrow(); 68 | 69 | photoList.triggerEventHandler('focusPhoto', photo1); 70 | 71 | fixture.detectChanges(); 72 | 73 | const fullPhoto = findComponent(fixture, 'app-full-photo'); 74 | expect(fullPhoto.properties.photo).toBe(photo1); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/app/components/flickr-search/flickr-search.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator'; 2 | import { MockComponents } from 'ng-mocks'; 3 | import { of } from 'rxjs'; 4 | import { FlickrService } from 'src/app/services/flickr.service'; 5 | 6 | import { photo1, photos } from '../../spec-helpers/photo.spec-helper'; 7 | import { FullPhotoComponent } from '../full-photo/full-photo.component'; 8 | import { PhotoListComponent } from '../photo-list/photo-list.component'; 9 | import { SearchFormComponent } from '../search-form/search-form.component'; 10 | import { FlickrSearchComponent } from './flickr-search.component'; 11 | 12 | describe('FlickrSearchComponent with spectator', () => { 13 | let spectator: Spectator; 14 | 15 | let searchForm: SearchFormComponent | null; 16 | let photoList: PhotoListComponent | null; 17 | let fullPhoto: FullPhotoComponent | null; 18 | 19 | const createComponent = createComponentFactory({ 20 | component: FlickrSearchComponent, 21 | shallow: true, 22 | declarations: [ 23 | MockComponents(SearchFormComponent, PhotoListComponent, FullPhotoComponent), 24 | ], 25 | providers: [mockProvider(FlickrService)], 26 | }); 27 | 28 | beforeEach(() => { 29 | spectator = createComponent(); 30 | 31 | spectator.inject(FlickrService).searchPublicPhotos.and.returnValue(of(photos)); 32 | 33 | searchForm = spectator.query(SearchFormComponent); 34 | photoList = spectator.query(PhotoListComponent); 35 | fullPhoto = spectator.query(FullPhotoComponent); 36 | }); 37 | 38 | it('renders the search form and the photo list, not the full photo', () => { 39 | if (!(searchForm && photoList)) { 40 | throw new Error('searchForm or photoList not found'); 41 | } 42 | expect(photoList.title).toBe(''); 43 | expect(photoList.photos).toEqual([]); 44 | expect(fullPhoto).not.toExist(); 45 | }); 46 | 47 | it('searches and passes the resulting photos to the photo list', () => { 48 | if (!(searchForm && photoList)) { 49 | throw new Error('searchForm or photoList not found'); 50 | } 51 | const searchTerm = 'beautiful flowers'; 52 | searchForm.search.emit(searchTerm); 53 | 54 | spectator.detectChanges(); 55 | 56 | const flickrService = spectator.inject(FlickrService); 57 | expect(flickrService.searchPublicPhotos).toHaveBeenCalledWith(searchTerm); 58 | expect(photoList.title).toBe(searchTerm); 59 | expect(photoList.photos).toBe(photos); 60 | }); 61 | 62 | it('renders the full photo when a photo is focussed', () => { 63 | expect(fullPhoto).not.toExist(); 64 | 65 | if (!photoList) { 66 | throw new Error('photoList not found'); 67 | } 68 | photoList.focusPhoto.emit(photo1); 69 | 70 | spectator.detectChanges(); 71 | 72 | fullPhoto = spectator.query(FullPhotoComponent); 73 | if (!fullPhoto) { 74 | throw new Error('fullPhoto not found'); 75 | } 76 | expect(fullPhoto.photo).toBe(photo1); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/app/components/flickr-search-ngrx/flickr-search-ngrx.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core'; 2 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 3 | import { Store } from '@ngrx/store'; 4 | import { provideMockStore } from '@ngrx/store/testing'; 5 | import { focusPhoto, search } from '../../actions/photos.actions'; 6 | import { AppState } from '../../reducers'; 7 | import { findComponent } from '../../spec-helpers/element.spec-helper'; 8 | import { 9 | initialState, 10 | photo1, 11 | searchTerm, 12 | stateWithCurrentPhoto, 13 | stateWithPhotos, 14 | } from '../../spec-helpers/photo.spec-helper'; 15 | 16 | import { FlickrSearchNgrxComponent } from './flickr-search-ngrx.component'; 17 | 18 | describe('FlickrSearchNgrxComponent', () => { 19 | let fixture: ComponentFixture; 20 | let store$: Store; 21 | 22 | let searchForm: DebugElement; 23 | let photoList: DebugElement; 24 | 25 | async function setup(state: AppState): Promise { 26 | await TestBed.configureTestingModule({ 27 | declarations: [FlickrSearchNgrxComponent], 28 | providers: [provideMockStore({ initialState: state })], 29 | schemas: [NO_ERRORS_SCHEMA], 30 | }).compileComponents(); 31 | 32 | store$ = TestBed.inject(Store); 33 | spyOn(store$, 'dispatch'); 34 | 35 | fixture = TestBed.createComponent(FlickrSearchNgrxComponent); 36 | fixture.detectChanges(); 37 | 38 | searchForm = findComponent(fixture, 'app-search-form'); 39 | photoList = findComponent(fixture, 'app-photo-list'); 40 | } 41 | 42 | describe('initial state', () => { 43 | beforeEach(async () => { 44 | await setup({ photos: initialState }); 45 | }); 46 | 47 | it('renders the search form and the photo list, not the full photo', () => { 48 | expect(searchForm).toBeTruthy(); 49 | expect(photoList).toBeTruthy(); 50 | expect(photoList.properties.title).toBe(initialState.searchTerm); 51 | expect(photoList.properties.photos).toEqual(initialState.photos); 52 | 53 | expect(() => { 54 | findComponent(fixture, 'app-full-photo'); 55 | }).toThrow(); 56 | }); 57 | 58 | it('searches', () => { 59 | searchForm.triggerEventHandler('search', searchTerm); 60 | 61 | expect(store$.dispatch).toHaveBeenCalledWith(search({ searchTerm })); 62 | }); 63 | }); 64 | 65 | describe('with photos', () => { 66 | beforeEach(async () => { 67 | await setup({ photos: stateWithPhotos }); 68 | }); 69 | 70 | it('passes the photos to the photo list', () => { 71 | expect(photoList.properties.photos).toEqual(stateWithPhotos.photos); 72 | }); 73 | 74 | it('focusses a photo', () => { 75 | photoList.triggerEventHandler('focusPhoto', photo1); 76 | 77 | expect(store$.dispatch).toHaveBeenCalledWith(focusPhoto({ photo: photo1 })); 78 | }); 79 | }); 80 | 81 | describe('with current photo', () => { 82 | beforeEach(async () => { 83 | await setup({ photos: stateWithCurrentPhoto }); 84 | }); 85 | 86 | it('renders the full photo', () => { 87 | const fullPhoto = findComponent(fixture, 'app-full-photo'); 88 | expect(fullPhoto.properties.photo).toEqual(photo1); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/app/components/flickr-search-ngrx/flickr-search-ngrx.component.spectator.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { createComponentFactory, Spectator } from '@ngneat/spectator'; 3 | import { Store } from '@ngrx/store'; 4 | import { provideMockStore } from '@ngrx/store/testing'; 5 | import { MockComponents } from 'ng-mocks'; 6 | import { focusPhoto, search } from 'src/app/actions/photos.actions'; 7 | import { AppState } from 'src/app/reducers'; 8 | 9 | import { 10 | initialState, 11 | photo1, 12 | searchTerm, 13 | stateWithCurrentPhoto, 14 | stateWithPhotos, 15 | } from '../../spec-helpers/photo.spec-helper'; 16 | import { FullPhotoComponent } from '../full-photo/full-photo.component'; 17 | import { PhotoListComponent } from '../photo-list/photo-list.component'; 18 | import { SearchFormComponent } from '../search-form/search-form.component'; 19 | import { FlickrSearchNgrxComponent } from './flickr-search-ngrx.component'; 20 | 21 | describe('FlickrSearchNgrxComponent with spectator', () => { 22 | let spectator: Spectator; 23 | let store$: Store; 24 | let create: () => Spectator; 25 | 26 | let searchForm: SearchFormComponent | null; 27 | let photoList: PhotoListComponent | null; 28 | let fullPhoto: FullPhotoComponent | null; 29 | 30 | function setup(state: AppState): void { 31 | create = createComponentFactory({ 32 | component: FlickrSearchNgrxComponent, 33 | shallow: true, 34 | declarations: [ 35 | MockComponents(SearchFormComponent, PhotoListComponent, FullPhotoComponent), 36 | ], 37 | providers: [provideMockStore({ initialState: state })], 38 | }); 39 | 40 | beforeEach(() => { 41 | store$ = TestBed.inject(Store); 42 | spyOn(store$, 'dispatch'); 43 | 44 | spectator = create(); 45 | 46 | searchForm = spectator.query(SearchFormComponent); 47 | photoList = spectator.query(PhotoListComponent); 48 | fullPhoto = spectator.query(FullPhotoComponent); 49 | }); 50 | } 51 | 52 | describe('initial state', () => { 53 | setup({ photos: initialState }); 54 | 55 | it('renders the search form and the photo list, not the full photo', () => { 56 | if (!photoList) { 57 | throw new Error('photoList not found'); 58 | } 59 | expect(photoList.title).toEqual(initialState.searchTerm); 60 | expect(photoList.photos).toEqual(initialState.photos); 61 | 62 | expect(fullPhoto).toBeNull(); 63 | }); 64 | 65 | it('searches', () => { 66 | if (!searchForm) { 67 | throw new Error('searchForm not found'); 68 | } 69 | 70 | searchForm.search.emit(searchTerm); 71 | 72 | expect(store$.dispatch).toHaveBeenCalledWith(search({ searchTerm })); 73 | }); 74 | }); 75 | 76 | describe('with photos', () => { 77 | setup({ photos: stateWithPhotos }); 78 | 79 | it('passes the photos to the photo list', () => { 80 | if (!photoList) { 81 | throw new Error('photoList not found'); 82 | } 83 | expect(photoList.photos).toEqual(stateWithPhotos.photos); 84 | }); 85 | 86 | it('focusses a photo', () => { 87 | if (!photoList) { 88 | throw new Error('photoList not found'); 89 | } 90 | 91 | photoList.focusPhoto.emit(photo1); 92 | 93 | expect(store$.dispatch).toHaveBeenCalledWith(focusPhoto({ photo: photo1 })); 94 | }); 95 | }); 96 | 97 | describe('with current photo', () => { 98 | setup({ photos: stateWithCurrentPhoto }); 99 | 100 | it('renders the full photo', () => { 101 | if (!fullPhoto) { 102 | throw new Error('fullPhoto not found'); 103 | } 104 | expect(fullPhoto.photo).toEqual(photo1); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "flickr-search": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:application": { 10 | "strict": true 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/flickr-search", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": ["zone.js"], 24 | "tsConfig": "tsconfig.app.json", 25 | "assets": ["src/favicon.ico", "src/assets"], 26 | "styles": ["src/styles.css"], 27 | "scripts": [] 28 | }, 29 | "configurations": { 30 | "production": { 31 | "budgets": [ 32 | { 33 | "type": "initial", 34 | "maximumWarning": "500kb", 35 | "maximumError": "1mb" 36 | }, 37 | { 38 | "type": "anyComponentStyle", 39 | "maximumWarning": "2kb", 40 | "maximumError": "4kb" 41 | } 42 | ], 43 | "fileReplacements": [ 44 | { 45 | "replace": "src/environments/environment.ts", 46 | "with": "src/environments/environment.prod.ts" 47 | } 48 | ], 49 | "outputHashing": "all" 50 | }, 51 | "development": { 52 | "buildOptimizer": false, 53 | "optimization": false, 54 | "vendorChunk": true, 55 | "extractLicenses": false, 56 | "sourceMap": true, 57 | "namedChunks": true 58 | } 59 | }, 60 | "defaultConfiguration": "production" 61 | }, 62 | "serve": { 63 | "builder": "@angular-devkit/build-angular:dev-server", 64 | "configurations": { 65 | "production": { 66 | "browserTarget": "flickr-search:build:production" 67 | }, 68 | "development": { 69 | "browserTarget": "flickr-search:build:development" 70 | } 71 | }, 72 | "defaultConfiguration": "development" 73 | }, 74 | "extract-i18n": { 75 | "builder": "@angular-devkit/build-angular:extract-i18n", 76 | "options": { 77 | "browserTarget": "flickr-search:build" 78 | } 79 | }, 80 | "test": { 81 | "builder": "@angular-devkit/build-angular:karma", 82 | "options": { 83 | "polyfills": ["zone.js", "zone.js/testing"], 84 | "tsConfig": "tsconfig.spec.json", 85 | "karmaConfig": "karma.conf.js", 86 | "styles": ["src/styles.css"], 87 | "scripts": [], 88 | "assets": ["src/favicon.ico", "src/assets"] 89 | } 90 | }, 91 | "lint": { 92 | "builder": "@angular-eslint/builder:lint", 93 | "options": { 94 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"] 95 | } 96 | }, 97 | "deploy": { 98 | "builder": "angular-cli-ghpages:deploy", 99 | "options": {} 100 | }, 101 | "cypress-run": { 102 | "builder": "@cypress/schematic:cypress", 103 | "options": { 104 | "devServerTarget": "flickr-search:serve" 105 | }, 106 | "configurations": { 107 | "production": { 108 | "devServerTarget": "flickr-search:serve:production" 109 | } 110 | } 111 | }, 112 | "cypress-open": { 113 | "builder": "@cypress/schematic:cypress", 114 | "options": { 115 | "watch": true, 116 | "headless": false 117 | } 118 | }, 119 | "e2e": { 120 | "builder": "@cypress/schematic:cypress", 121 | "options": { 122 | "devServerTarget": "flickr-search:serve", 123 | "watch": true, 124 | "headless": false 125 | }, 126 | "configurations": { 127 | "production": { 128 | "devServerTarget": "flickr-search:serve:production" 129 | } 130 | } 131 | } 132 | } 133 | } 134 | }, 135 | "cli": { 136 | "analytics": false, 137 | "schematicCollections": ["@cypress/schematic", "@schematics/angular"] 138 | }, 139 | "schematics": { 140 | "@angular-eslint/schematics:application": { 141 | "setParserOptionsProject": true 142 | }, 143 | "@angular-eslint/schematics:library": { 144 | "setParserOptionsProject": true 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/app/spec-helpers/element.spec-helper.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { DebugElement } from '@angular/core'; 4 | import { ComponentFixture } from '@angular/core/testing'; 5 | import { By } from '@angular/platform-browser'; 6 | 7 | /** 8 | * Spec helpers for working with the DOM 9 | */ 10 | 11 | /** 12 | * Returns a selector for the `data-testid` attribute with the given attribute value. 13 | * 14 | * @param testId Test id set by `data-testid` 15 | * 16 | */ 17 | export function testIdSelector(testId: string): string { 18 | return `[data-testid="${testId}"]`; 19 | } 20 | 21 | /** 22 | * Finds a single element inside the Component by the given CSS selector. 23 | * Throws an error if no element was found. 24 | * 25 | * @param fixture Component fixture 26 | * @param selector CSS selector 27 | * 28 | */ 29 | export function queryByCss( 30 | fixture: ComponentFixture, 31 | selector: string, 32 | ): DebugElement { 33 | // The return type of DebugElement#query() is declared as DebugElement, 34 | // but the actual return type is DebugElement | null. 35 | // See https://github.com/angular/angular/issues/22449. 36 | const debugElement = fixture.debugElement.query(By.css(selector)); 37 | // Fail on null so the return type is always DebugElement. 38 | if (!debugElement) { 39 | throw new Error(`queryByCss: Element with ${selector} not found`); 40 | } 41 | return debugElement; 42 | } 43 | 44 | /** 45 | * Finds an element inside the Component by the given `data-testid` attribute. 46 | * Throws an error if no element was found. 47 | * 48 | * @param fixture Component fixture 49 | * @param testId Test id set by `data-testid` 50 | * 51 | */ 52 | export function findEl(fixture: ComponentFixture, testId: string): DebugElement { 53 | return queryByCss(fixture, testIdSelector(testId)); 54 | } 55 | 56 | /** 57 | * Finds all elements with the given `data-testid` attribute. 58 | * 59 | * @param fixture Component fixture 60 | * @param testId Test id set by `data-testid` 61 | */ 62 | export function findEls(fixture: ComponentFixture, testId: string): DebugElement[] { 63 | return fixture.debugElement.queryAll(By.css(testIdSelector(testId))); 64 | } 65 | 66 | /** 67 | * Gets the text content of an element with the given `data-testid` attribute. 68 | * 69 | * @param fixture Component fixture 70 | * @param testId Test id set by `data-testid` 71 | */ 72 | export function getText(fixture: ComponentFixture, testId: string): string { 73 | return findEl(fixture, testId).nativeElement.textContent; 74 | } 75 | 76 | /** 77 | * Expects that the element with the given `data-testid` attribute 78 | * has the given text content. 79 | * 80 | * @param fixture Component fixture 81 | * @param testId Test id set by `data-testid` 82 | * @param text Expected text 83 | */ 84 | export function expectText( 85 | fixture: ComponentFixture, 86 | testId: string, 87 | text: string, 88 | ): void { 89 | expect(getText(fixture, testId)).toBe(text); 90 | } 91 | 92 | /** 93 | * Expects that the element with the given `data-testid` attribute 94 | * has the given text content. 95 | * 96 | * @param fixture Component fixture 97 | * @param text Expected text 98 | */ 99 | export function expectContainedText(fixture: ComponentFixture, text: string): void { 100 | expect(fixture.nativeElement.textContent).toContain(text); 101 | } 102 | 103 | /** 104 | * Expects that a component has the given text content. 105 | * Both the component text content and the expected text are trimmed for reliability. 106 | * 107 | * @param fixture Component fixture 108 | * @param text Expected text 109 | */ 110 | export function expectContent(fixture: ComponentFixture, text: string): void { 111 | expect(fixture.nativeElement.textContent).toBe(text); 112 | } 113 | 114 | /** 115 | * Dispatches a fake event (synthetic event) at the given element. 116 | * 117 | * @param element Element that is the target of the event 118 | * @param type Event name, e.g. `input` 119 | * @param bubbles Whether the event bubbles up in the DOM tree 120 | */ 121 | export function dispatchFakeEvent( 122 | element: EventTarget, 123 | type: string, 124 | bubbles: boolean = false, 125 | ): void { 126 | const event = document.createEvent('Event'); 127 | event.initEvent(type, bubbles, false); 128 | element.dispatchEvent(event); 129 | } 130 | 131 | /** 132 | * Enters text into a form field (`input`, `textarea` or `select` element). 133 | * Triggers appropriate events so Angular takes notice of the change. 134 | * If you listen for the `change` event on `input` or `textarea`, 135 | * you need to trigger it separately. 136 | * 137 | * @param element Form field 138 | * @param value Form field value 139 | */ 140 | export function setFieldElementValue( 141 | element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, 142 | value: string, 143 | ): void { 144 | element.value = value; 145 | // Dispatch an `input` or `change` fake event 146 | // so Angular form bindings take notice of the change. 147 | const isSelect = element instanceof HTMLSelectElement; 148 | dispatchFakeEvent(element, isSelect ? 'change' : 'input', isSelect ? false : true); 149 | } 150 | 151 | /** 152 | * Sets the value of a form field with the given `data-testid` attribute. 153 | * 154 | * @param fixture Component fixture 155 | * @param testId Test id set by `data-testid` 156 | * @param value Form field value 157 | */ 158 | export function setFieldValue( 159 | fixture: ComponentFixture, 160 | testId: string, 161 | value: string, 162 | ): void { 163 | setFieldElementValue(findEl(fixture, testId).nativeElement, value); 164 | } 165 | 166 | /** 167 | * Checks or unchecks a checkbox or radio button. 168 | * Triggers appropriate events so Angular takes notice of the change. 169 | * 170 | * @param fixture Component fixture 171 | * @param testId Test id set by `data-testid` 172 | * @param checked Whether the checkbox or radio should be checked 173 | */ 174 | export function checkField( 175 | fixture: ComponentFixture, 176 | testId: string, 177 | checked: boolean, 178 | ): void { 179 | const { nativeElement } = findEl(fixture, testId); 180 | nativeElement.checked = checked; 181 | // Dispatch a `change` fake event so Angular form bindings take notice of the change. 182 | dispatchFakeEvent(nativeElement, 'change'); 183 | } 184 | 185 | /** 186 | * Makes a fake click event that provides the most important properties. 187 | * Sets the button to left. 188 | * The event can be passed to DebugElement#triggerEventHandler. 189 | * 190 | * @param target Element that is the target of the click event 191 | */ 192 | export function makeClickEvent(target: EventTarget): Partial { 193 | return { 194 | preventDefault(): void {}, 195 | stopPropagation(): void {}, 196 | stopImmediatePropagation(): void {}, 197 | type: 'click', 198 | target, 199 | currentTarget: target, 200 | bubbles: true, 201 | cancelable: true, 202 | button: 0 203 | }; 204 | } 205 | 206 | /** 207 | * Emulates a left click on the element with the given `data-testid` attribute. 208 | * 209 | * @param fixture Component fixture 210 | * @param testId Test id set by `data-testid` 211 | */ 212 | export function click(fixture: ComponentFixture, testId: string): void { 213 | const element = findEl(fixture, testId); 214 | const event = makeClickEvent(element.nativeElement); 215 | element.triggerEventHandler('click', event); 216 | } 217 | 218 | /** 219 | * Finds a nested Component by its selector, e.g. `app-example`. 220 | * Throws an error if no element was found. 221 | * Use this only for shallow component testing. 222 | * When finding other elements, use `findEl` / `findEls` and `data-testid` attributes. 223 | * 224 | * @param fixture Fixture of the parent Component 225 | * @param selector Element selector, e.g. `app-example` 226 | */ 227 | export function findComponent( 228 | fixture: ComponentFixture, 229 | selector: string, 230 | ): DebugElement { 231 | return queryByCss(fixture, selector); 232 | } 233 | 234 | /** 235 | * Finds all nested Components by its selector, e.g. `app-example`. 236 | */ 237 | export function findComponents( 238 | fixture: ComponentFixture, 239 | selector: string, 240 | ): DebugElement[] { 241 | return fixture.debugElement.queryAll(By.css(selector)); 242 | } 243 | --------------------------------------------------------------------------------