├── 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 |
7 |
--------------------------------------------------------------------------------
/src/app/components/photo-list/photo-list.component.html:
--------------------------------------------------------------------------------
1 | {{ title }}
2 |
3 |
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 |
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 |
20 |
--------------------------------------------------------------------------------
/src/app/components/flickr-search-ngrx/flickr-search-ngrx.component.html:
--------------------------------------------------------------------------------
1 | Flickr Search with NgRx
2 |
3 |
4 |
5 |
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 |
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 |
--------------------------------------------------------------------------------