20230802FertigstellungVonBookMonkey
This commit is contained in:
13
package-lock.json
generated
13
package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@angular/platform-browser": "^16.1.0",
|
"@angular/platform-browser": "^16.1.0",
|
||||||
"@angular/platform-browser-dynamic": "^16.1.0",
|
"@angular/platform-browser-dynamic": "^16.1.0",
|
||||||
"@angular/router": "^16.1.0",
|
"@angular/router": "^16.1.0",
|
||||||
|
"angular-date-value-accessor": "^3.0.0",
|
||||||
"book-monkey5-styles": "^1.0.4",
|
"book-monkey5-styles": "^1.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
@@ -4562,6 +4563,18 @@
|
|||||||
"ajv": "^8.8.2"
|
"ajv": "^8.8.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/angular-date-value-accessor": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/angular-date-value-accessor/-/angular-date-value-accessor-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-1Oxm2fkKgleIQdZ50BiBSoMz7+qyQLI4rD+qllfJo7gCVIpkXLlWAObFUPVFte+SngdHSycH00bVMb8co8/lhw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": ">= 14.0.0",
|
||||||
|
"@angular/core": ">= 14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-colors": {
|
"node_modules/ansi-colors": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@angular/platform-browser": "^16.1.0",
|
"@angular/platform-browser": "^16.1.0",
|
||||||
"@angular/platform-browser-dynamic": "^16.1.0",
|
"@angular/platform-browser-dynamic": "^16.1.0",
|
||||||
"@angular/router": "^16.1.0",
|
"@angular/router": "^16.1.0",
|
||||||
|
"angular-date-value-accessor": "^3.0.0",
|
||||||
"book-monkey5-styles": "^1.0.4",
|
"book-monkey5-styles": "^1.0.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
import { BookCreateComponent } from './book-create/book-create.component';
|
import { BookCreateComponent } from './book-create/book-create.component';
|
||||||
|
import { BookEditComponent } from './book-edit/book-edit.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: 'admin', redirectTo: 'admin/create' },
|
{ path: '', redirectTo: 'create', pathMatch: 'full' },
|
||||||
{ path: 'admin/create', component: BookCreateComponent },
|
{ path: 'create', component: BookCreateComponent },
|
||||||
|
{ path: 'edit/:isbn', component: BookEditComponent },
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import { NgModule } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
import { AdminRoutingModule } from './admin-routing.module';
|
import { AdminRoutingModule } from './admin-routing.module';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { LocalIsoDateValueAccessor } from 'angular-date-value-accessor';
|
||||||
import { BookFormComponent } from './book-form/book-form.component';
|
import { BookFormComponent } from './book-form/book-form.component';
|
||||||
import { BookCreateComponent } from './book-create/book-create.component';
|
import { BookCreateComponent } from './book-create/book-create.component';
|
||||||
|
import { BookEditComponent } from './book-edit/book-edit.component';
|
||||||
|
import { FormErrorsComponent } from './form-errors/form-errors.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [BookFormComponent, BookCreateComponent, BookEditComponent, FormErrorsComponent],
|
||||||
BookFormComponent,
|
imports: [
|
||||||
BookCreateComponent
|
CommonModule,
|
||||||
|
AdminRoutingModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
LocalIsoDateValueAccessor,
|
||||||
],
|
],
|
||||||
imports: [CommonModule, AdminRoutingModule, FormsModule],
|
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
0
src/app/admin/book-edit/book-edit.component.css
Normal file
0
src/app/admin/book-edit/book-edit.component.css
Normal file
7
src/app/admin/book-edit/book-edit.component.html
Normal file
7
src/app/admin/book-edit/book-edit.component.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<h1>Edit Book</h1>
|
||||||
|
|
||||||
|
<bm-book-form
|
||||||
|
*ngIf="book$ | async as book"
|
||||||
|
[book]="book"
|
||||||
|
(submitBook)="update($event)"
|
||||||
|
></bm-book-form>
|
||||||
21
src/app/admin/book-edit/book-edit.component.spec.ts
Normal file
21
src/app/admin/book-edit/book-edit.component.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { BookEditComponent } from './book-edit.component';
|
||||||
|
|
||||||
|
describe('BookEditComponent', () => {
|
||||||
|
let component: BookEditComponent;
|
||||||
|
let fixture: ComponentFixture<BookEditComponent>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [BookEditComponent]
|
||||||
|
});
|
||||||
|
fixture = TestBed.createComponent(BookEditComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/app/admin/book-edit/book-edit.component.ts
Normal file
31
src/app/admin/book-edit/book-edit.component.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { map, Observable, switchMap } from 'rxjs';
|
||||||
|
import { Book } from 'src/shared/book';
|
||||||
|
import { BookStoreService } from 'src/app/shared/book-store.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'bm-book-edit',
|
||||||
|
templateUrl: './book-edit.component.html',
|
||||||
|
styleUrls: ['./book-edit.component.css'],
|
||||||
|
})
|
||||||
|
export class BookEditComponent {
|
||||||
|
book$: Observable<Book>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private service: BookStoreService,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
) {
|
||||||
|
this.book$ = this.route.paramMap.pipe(
|
||||||
|
map((params) => params.get('isbn')!),
|
||||||
|
switchMap((isbn) => this.service.getSingle(isbn)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(book: Book) {
|
||||||
|
this.service.update(book).subscribe((updatedBook) => {
|
||||||
|
this.router.navigate(['/books', updatedBook.isbn]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,56 @@
|
|||||||
<form (ngSubmit)="submitForm()" #form="ngForm">
|
<form [formGroup]="form" (ngSubmit)="submitForm()">
|
||||||
<label for="title">Title</label>
|
<label for="title">Title</label>
|
||||||
<input name="title" id="title" [(ngModel)]="book.title" required />
|
<input id="title" formControlName="title" />
|
||||||
|
<bm-form-errors
|
||||||
|
controlName="title"
|
||||||
|
[messages]="{ required: 'Title is required' }"
|
||||||
|
></bm-form-errors>
|
||||||
|
|
||||||
|
<label for="subtitle">Subtitle</label>
|
||||||
|
<input id="subtitle" formControlName="subtitle" />
|
||||||
|
|
||||||
<label for="isbn">ISBN</label>
|
<label for="isbn">ISBN</label>
|
||||||
|
<input id="isbn" formControlName="isbn" />
|
||||||
|
<bm-form-errors
|
||||||
|
controlName="isbn"
|
||||||
|
[messages]="{
|
||||||
|
required: 'ISBN is required',
|
||||||
|
isbnformat: 'ISBN must have 10 or 13 chars',
|
||||||
|
isbnexists: 'ISBN alreads exists'
|
||||||
|
}"
|
||||||
|
></bm-form-errors>
|
||||||
|
|
||||||
|
<label>Authors</label>
|
||||||
|
<button type="button" class="add" (click)="addAuthorControl()">
|
||||||
|
+ Author
|
||||||
|
</button>
|
||||||
|
<fieldset formArrayName="authors">
|
||||||
<input
|
<input
|
||||||
name="isbn"
|
*ngFor="let a of authors.controls; index as i"
|
||||||
id="isbn"
|
[attr.aria-label]="'Author' + i"
|
||||||
[(ngModel)]="book.isbn"
|
[formControlName]="i"
|
||||||
required
|
/>
|
||||||
minlength="10"
|
</fieldset>
|
||||||
maxlength="13"
|
<bm-form-errors
|
||||||
|
controlName="authors"
|
||||||
|
[messages]="{ atleastonevalue: 'At least one author required' }"
|
||||||
|
></bm-form-errors>
|
||||||
|
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea id="description" formControlName="description"></textarea>
|
||||||
|
|
||||||
|
<label for="published">Published</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
useValueAsLocalIso
|
||||||
|
id="published"
|
||||||
|
formControlName="published"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label for="author">Author</label>
|
<label for="thumbnailUrl">Thumbnail Url</label>
|
||||||
<input name="author" id="author" [(ngModel)]="book.authors[0]" required />
|
<input type="url" id="thumbnailUrl" formControlName="thumbnailUrl" />
|
||||||
|
|
||||||
<button type="submit" [disabled]="form.invalid">Save</button>
|
<button type="submit" [disabled]="form.invalid">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- <pre>{{ form.value | json }}</pre> -->
|
||||||
|
|||||||
@@ -1,21 +1,94 @@
|
|||||||
import { Component, Output, EventEmitter } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
Output,
|
||||||
|
EventEmitter,
|
||||||
|
OnChanges,
|
||||||
|
Input,
|
||||||
|
inject,
|
||||||
|
} from '@angular/core';
|
||||||
import { Book } from 'src/shared/book';
|
import { Book } from 'src/shared/book';
|
||||||
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { FormArray } from '@angular/forms';
|
||||||
|
import { atLeastOneValue, isbnFormat } from '../shared/validators';
|
||||||
|
import { AsyncValidatorsService } from '../shared/async-validators.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'bm-book-form',
|
selector: 'bm-book-form',
|
||||||
templateUrl: './book-form.component.html',
|
templateUrl: './book-form.component.html',
|
||||||
styleUrls: ['./book-form.component.css'],
|
styleUrls: ['./book-form.component.css'],
|
||||||
})
|
})
|
||||||
export class BookFormComponent {
|
export class BookFormComponent implements OnChanges {
|
||||||
@Output() submitBook = new EventEmitter<Book>();
|
@Output() submitBook = new EventEmitter<Book>();
|
||||||
|
@Input() book?: Book;
|
||||||
|
|
||||||
|
form = new FormGroup({
|
||||||
|
title: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: Validators.required,
|
||||||
|
}),
|
||||||
|
subtitle: new FormControl('', { nonNullable: true }),
|
||||||
|
isbn: new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, isbnFormat],
|
||||||
|
asyncValidators: inject(AsyncValidatorsService).isbnExists(),
|
||||||
|
}),
|
||||||
|
description: new FormControl('', { nonNullable: true }),
|
||||||
|
published: new FormControl('', { nonNullable: true }),
|
||||||
|
thumbnailUrl: new FormControl('', { nonNullable: true }),
|
||||||
|
authors: this.buildAuthorsArray(['']),
|
||||||
|
});
|
||||||
|
|
||||||
submitForm() {
|
submitForm() {
|
||||||
this.submitBook.emit(this.book);
|
const formValue = this.form.getRawValue();
|
||||||
|
const authors = formValue.authors.filter((author) => !!author);
|
||||||
|
const newBook: Book = {
|
||||||
|
...formValue,
|
||||||
|
authors, // TODO:echte Eingabe
|
||||||
|
};
|
||||||
|
this.submitBook.emit(newBook);
|
||||||
}
|
}
|
||||||
|
|
||||||
book: Book = {
|
// book: Book = {
|
||||||
isbn: '',
|
// isbn: '',
|
||||||
title: '',
|
// title: '',
|
||||||
authors: [''],
|
// authors: [''],
|
||||||
};
|
// };
|
||||||
|
|
||||||
|
get authors() {
|
||||||
|
return this.form.controls.authors;
|
||||||
|
}
|
||||||
|
|
||||||
|
addAuthorControl() {
|
||||||
|
this.authors.push(new FormControl('', { nonNullable: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAuthorsArray(authors: string[]) {
|
||||||
|
return new FormArray(
|
||||||
|
authors.map((v) => new FormControl(v, { nonNullable: true })),
|
||||||
|
atLeastOneValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(): void {
|
||||||
|
if (this.book) {
|
||||||
|
this.setFormValues(this.book);
|
||||||
|
this.setEditMode(true);
|
||||||
|
} else {
|
||||||
|
this.setEditMode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setEditMode(isEditing: boolean) {
|
||||||
|
const isbnControle = this.form.controls.isbn;
|
||||||
|
if (isEditing) {
|
||||||
|
isbnControle.disable();
|
||||||
|
} else {
|
||||||
|
isbnControle.enable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setFormValues(book: Book) {
|
||||||
|
this.form.patchValue(book);
|
||||||
|
this.form.setControl('authors', this.buildAuthorsArray(book.authors));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
0
src/app/admin/form-errors/form-errors.component.css
Normal file
0
src/app/admin/form-errors/form-errors.component.css
Normal file
1
src/app/admin/form-errors/form-errors.component.html
Normal file
1
src/app/admin/form-errors/form-errors.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p class="error" *gnFor="let error of errors">{{ error }}</p>
|
||||||
21
src/app/admin/form-errors/form-errors.component.spec.ts
Normal file
21
src/app/admin/form-errors/form-errors.component.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FormErrorsComponent } from './form-errors.component';
|
||||||
|
|
||||||
|
describe('FormErrorsComponent', () => {
|
||||||
|
let component: FormErrorsComponent;
|
||||||
|
let fixture: ComponentFixture<FormErrorsComponent>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [FormErrorsComponent]
|
||||||
|
});
|
||||||
|
fixture = TestBed.createComponent(FormErrorsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/app/admin/form-errors/form-errors.component.ts
Normal file
30
src/app/admin/form-errors/form-errors.component.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { FormGroupDirective } from '@angular/forms';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'bm-form-errors',
|
||||||
|
templateUrl: './form-errors.component.html',
|
||||||
|
styleUrls: ['./form-errors.component.css'],
|
||||||
|
})
|
||||||
|
export class FormErrorsComponent {
|
||||||
|
@Input() controlName?: string;
|
||||||
|
@Input() messages: { [errorCode: string]: string } = {};
|
||||||
|
|
||||||
|
constructor(private form: FormGroupDirective) {}
|
||||||
|
|
||||||
|
get errors(): string[] {
|
||||||
|
if (!this.controlName) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const control = this.form.control.get(this.controlName);
|
||||||
|
|
||||||
|
if (!control || !control.errors || !control.touched) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(control.errors).map((errorCode) => {
|
||||||
|
return this.messages[errorCode];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/admin/shared/async-validators.service.spec.ts
Normal file
16
src/app/admin/shared/async-validators.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AsyncValidatorsService } from './async-validators.service';
|
||||||
|
|
||||||
|
describe('AsyncValidatorsService', () => {
|
||||||
|
let service: AsyncValidatorsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(AsyncValidatorsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/app/admin/shared/async-validators.service.ts
Normal file
19
src/app/admin/shared/async-validators.service.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { AsyncValidatorFn } from '@angular/forms';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { BookStoreService } from 'src/app/shared/book-store.service';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class AsyncValidatorsService {
|
||||||
|
constructor(private service: BookStoreService) {}
|
||||||
|
|
||||||
|
isbnExists(): AsyncValidatorFn {
|
||||||
|
return (control) => {
|
||||||
|
return this.service
|
||||||
|
.check(control.value)
|
||||||
|
.pipe(map((exists) => (exists ? { isbnexists: true } : null)));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/admin/shared/validators.ts
Normal file
26
src/app/admin/shared/validators.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { isFormArray, ValidatorFn } from '@angular/forms';
|
||||||
|
|
||||||
|
export const atLeastOneValue: ValidatorFn = function (control) {
|
||||||
|
if (!isFormArray(control)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (control.controls.some((el) => !!el.value)) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return { atleastonevalue: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isbnFormat: ValidatorFn = function (control) {
|
||||||
|
if (!control.value || typeof control.value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const isbnWithoutDashes = control.value.replace(/-/g, '');
|
||||||
|
const length = isbnWithoutDashes.length;
|
||||||
|
|
||||||
|
if (length === 10 || length === 13) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return { isbnformat: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
import { HomeComponent } from './home/home.component';
|
import { HomeComponent } from './home/home.component';
|
||||||
|
import { authGuard } from './shared/auth.guard';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
||||||
{ path: 'home', component: HomeComponent },
|
{ path: 'home', component: HomeComponent },
|
||||||
|
{
|
||||||
|
path: 'books',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./books/books.module').then((m) => m.BooksModule),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./admin/admin.module').then((m) => m.AdminModule),
|
||||||
|
canActivate: [authGuard],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -3,16 +3,14 @@ import { BrowserModule } from '@angular/platform-browser';
|
|||||||
|
|
||||||
import { AppRoutingModule } from './app-routing.module';
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { BooksModule } from './books/books.module';
|
|
||||||
import { HomeComponent } from './home/home.component';
|
import { HomeComponent } from './home/home.component';
|
||||||
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
|
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
|
||||||
import { SearchComponent } from './search/search.component';
|
import { SearchComponent } from './search/search.component';
|
||||||
import { AuthInterceptor } from './shared/auth.interceptor';
|
import { AuthInterceptor } from './shared/auth.interceptor';
|
||||||
import { AdminModule } from './admin/admin.module';
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AppComponent, HomeComponent, SearchComponent],
|
declarations: [AppComponent, HomeComponent, SearchComponent],
|
||||||
imports: [BrowserModule, AppRoutingModule, BooksModule, HttpClientModule, AdminModule],
|
imports: [BrowserModule, AppRoutingModule, HttpClientModule],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<div class="details" *ngIf="book$ | async as book">
|
<div class="details" *ngIf="book$ | async as book">
|
||||||
<h1>{{ book.title }}</h1>
|
<h1>{{ book.title }}</h1>
|
||||||
<p role="doc-subtitle" *ngIf="book.subtilte">{{ book.subtilte }}</p>
|
<p role="doc-subtitle" *ngIf="book.subtilte">{{ book.subtilte }}</p>
|
||||||
|
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Authors</h2>
|
<h2>Authors</h2>
|
||||||
@@ -8,18 +9,30 @@
|
|||||||
<li *ngFor="let author of book.authors">{{ author }}</li>
|
<li *ngFor="let author of book.authors">{{ author }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2>ISBN</h2>
|
<h2>ISBN</h2>
|
||||||
{{ book.isbn }}
|
{{ book.isbn | isbn }}
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="book.published">
|
<div *ngIf="book.published">
|
||||||
<h2>Published</h2>
|
<h2>Published</h2>
|
||||||
{{ book.published }}
|
{{ book.published | date: "longDate" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Description</h2>
|
<h2>Description</h2>
|
||||||
<p>{{ book.description }}</p>
|
<p>{{ book.description }}</p>
|
||||||
<img *ngIf="book.thumbnailUrl" [src]="book.thumbnailUrl" alt="Cover" />
|
<img *ngIf="book.thumbnailUrl" [src]="book.thumbnailUrl" alt="Cover" />
|
||||||
<a class="button" routerLink="..">Back to list</a>
|
<a class="button" routerLink="..">Back to list</a>
|
||||||
<button class="red" (click)="removeBook(book.isbn)">Remove book</button>
|
<ng-container *bmLoggedinOnly>
|
||||||
|
<button
|
||||||
|
class="red"
|
||||||
|
bmConfirm="Remove book?"
|
||||||
|
(confirm)="removeBook(book.isbn)"
|
||||||
|
>
|
||||||
|
Remove book
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a class="button" [routerLink]="['/admin/edit', book.isbn]">Edit book</a>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ export class BookDetailsComponent {
|
|||||||
this.book$ = this.service.getSingle(isbn);
|
this.book$ = this.service.getSingle(isbn);
|
||||||
}
|
}
|
||||||
removeBook(isbn: string) {
|
removeBook(isbn: string) {
|
||||||
if (window.confirm('Remove book?')) {
|
|
||||||
this.service.remove(isbn).subscribe(() => {
|
this.service.remove(isbn).subscribe(() => {
|
||||||
this.router.navigateByUrl('/books');
|
this.router.navigateByUrl('/books');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
{{ author }}
|
{{ author }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div>ISBN {{ book.isbn }}</div>
|
<div>ISBN {{ book.isbn | isbn }}</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { BookDetailsComponent } from './book-details/book-details.component';
|
|||||||
import { BookListComponent } from './book-list/book-list.component';
|
import { BookListComponent } from './book-list/book-list.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: 'books', component: BookListComponent },
|
{ path: '', component: BookListComponent },
|
||||||
{ path: 'books/:isbn', component: BookDetailsComponent },
|
{ path: ':isbn', component: BookDetailsComponent },
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import { BooksRoutingModule } from './books-routing.module';
|
|||||||
import { BookListComponent } from './book-list/book-list.component';
|
import { BookListComponent } from './book-list/book-list.component';
|
||||||
import { BookListItemComponent } from './book-list-item/book-list-item.component';
|
import { BookListItemComponent } from './book-list-item/book-list-item.component';
|
||||||
import { BookDetailsComponent } from './book-details/book-details.component';
|
import { BookDetailsComponent } from './book-details/book-details.component';
|
||||||
|
import { IsbnPipe } from './shared/isbn.pipe';
|
||||||
|
import { ConfirmDirective } from './shared/confirm.directive';
|
||||||
|
import { LoggedinOnlyDirective } from './shared/loggedin-only.directive';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
BookDetailsComponent,
|
BookDetailsComponent,
|
||||||
BookListComponent,
|
BookListComponent,
|
||||||
BookListItemComponent,
|
BookListItemComponent,
|
||||||
|
IsbnPipe,
|
||||||
|
ConfirmDirective,
|
||||||
|
LoggedinOnlyDirective,
|
||||||
],
|
],
|
||||||
imports: [CommonModule, BooksRoutingModule],
|
imports: [CommonModule, BooksRoutingModule],
|
||||||
exports: [],
|
exports: [],
|
||||||
|
|||||||
8
src/app/books/shared/confirm.directive.spec.ts
Normal file
8
src/app/books/shared/confirm.directive.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ConfirmDirective } from './confirm.directive';
|
||||||
|
|
||||||
|
describe('ConfirmDirective', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const directive = new ConfirmDirective();
|
||||||
|
expect(directive).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/app/books/shared/confirm.directive.ts
Normal file
21
src/app/books/shared/confirm.directive.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[bmConfirm]',
|
||||||
|
})
|
||||||
|
export class ConfirmDirective {
|
||||||
|
@Input('bmConform') confirmText?: string;
|
||||||
|
@Output() confirm = new EventEmitter<void>();
|
||||||
|
@HostListener('click') onClick() {
|
||||||
|
if (window.confirm(this.confirmText)) {
|
||||||
|
this.confirm.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
constructor() {}
|
||||||
|
}
|
||||||
8
src/app/books/shared/isbn.pipe.spec.ts
Normal file
8
src/app/books/shared/isbn.pipe.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsbnPipe } from './isbn.pipe';
|
||||||
|
|
||||||
|
describe('IsbnPipe', () => {
|
||||||
|
it('create an instance', () => {
|
||||||
|
const pipe = new IsbnPipe();
|
||||||
|
expect(pipe).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/app/books/shared/isbn.pipe.ts
Normal file
13
src/app/books/shared/isbn.pipe.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'isbn',
|
||||||
|
})
|
||||||
|
export class IsbnPipe implements PipeTransform {
|
||||||
|
transform(value: string): string {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${value.substring(0, 3)}-${value.substring(3)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/app/books/shared/loggedin-only.directive.spec.ts
Normal file
8
src/app/books/shared/loggedin-only.directive.spec.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { LoggedinOnlyDirective } from './loggedin-only.directive';
|
||||||
|
|
||||||
|
describe('LoggedinOnlyDirective', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const directive = new LoggedinOnlyDirective();
|
||||||
|
expect(directive).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
34
src/app/books/shared/loggedin-only.directive.ts
Normal file
34
src/app/books/shared/loggedin-only.directive.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
OnDestroy,
|
||||||
|
ViewContainerRef,
|
||||||
|
TemplateRef,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { AuthService } from 'src/app/shared/auth.service';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[bmLoggedinOnly]',
|
||||||
|
})
|
||||||
|
export class LoggedinOnlyDirective implements OnDestroy {
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private template: TemplateRef<unknown>,
|
||||||
|
private viewContainer: ViewContainerRef,
|
||||||
|
private authService: AuthService,
|
||||||
|
) {
|
||||||
|
this.authService.isAuthenticated$
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((isAuthenticated) => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
this.viewContainer.createEmbeddedView(this.template);
|
||||||
|
} else {
|
||||||
|
this.viewContainer.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/shared/auth.guard.spec.ts
Normal file
17
src/app/shared/auth.guard.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { CanActivateFn } from '@angular/router';
|
||||||
|
|
||||||
|
import { authGuard } from './auth.guard';
|
||||||
|
|
||||||
|
describe('authGuard', () => {
|
||||||
|
const executeGuard: CanActivateFn = (...guardParameters) =>
|
||||||
|
TestBed.runInInjectionContext(() => authGuard(...guardParameters));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(executeGuard).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/app/shared/auth.guard.ts
Normal file
21
src/app/shared/auth.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Inject, inject } from '@angular/core';
|
||||||
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { map, take } from 'rxjs';
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = (route, state) => {
|
||||||
|
const authService = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
|
||||||
|
return authService.isAuthenticated$.pipe(
|
||||||
|
take(1),
|
||||||
|
map((isAuthenticated) => {
|
||||||
|
if (authService.isAuthenticated) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
window.alert('Not logged in!');
|
||||||
|
return router.parseUrl('home');
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -40,4 +40,12 @@ export class BookStoreService {
|
|||||||
create(book: Book): Observable<Book> {
|
create(book: Book): Observable<Book> {
|
||||||
return this.http.post<Book>(`${this.apiUrl}/books`, book);
|
return this.http.post<Book>(`${this.apiUrl}/books`, book);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
update(book: Book): Observable<Book> {
|
||||||
|
return this.http.put<Book>(`${this.apiUrl}/book/${book.isbn}`, book);
|
||||||
|
}
|
||||||
|
|
||||||
|
check(isbn: string): Observable<boolean> {
|
||||||
|
return this.http.get<boolean>(`${this.apiUrl}/books/${isbn}/check`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user