20230802FertigstellungVonBookMonkey

This commit is contained in:
2023-08-02 11:39:13 +02:00
parent 6ae0d27dd4
commit 02261e7e13
33 changed files with 507 additions and 39 deletions

13
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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({

View File

@@ -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 {}

View 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>

View 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();
});
});

View 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]);
});
}
}

View File

@@ -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> -->

View File

@@ -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));
}
} }

View File

@@ -0,0 +1 @@
<p class="error" *gnFor="let error of errors">{{ error }}</p>

View 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();
});
});

View 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];
});
}
}

View 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();
});
});

View 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)));
};
}
}

View 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 };
}
};

View File

@@ -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({

View File

@@ -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 },
], ],

View File

@@ -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>

View File

@@ -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');
}); });
} }
}
} }

View File

@@ -9,5 +9,5 @@
{{ author }} {{ author }}
</li> </li>
</ul> </ul>
<div>ISBN {{ book.isbn }}</div> <div>ISBN {{ book.isbn | isbn }}</div>
</a> </a>

View File

@@ -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({

View File

@@ -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: [],

View File

@@ -0,0 +1,8 @@
import { ConfirmDirective } from './confirm.directive';
describe('ConfirmDirective', () => {
it('should create an instance', () => {
const directive = new ConfirmDirective();
expect(directive).toBeTruthy();
});
});

View 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() {}
}

View File

@@ -0,0 +1,8 @@
import { IsbnPipe } from './isbn.pipe';
describe('IsbnPipe', () => {
it('create an instance', () => {
const pipe = new IsbnPipe();
expect(pipe).toBeTruthy();
});
});

View 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)}`;
}
}

View 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();
});
});

View 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();
}
}

View 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();
});
});

View 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');
}
}),
);
};

View File

@@ -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`);
}
} }