Extract publication edition into a separated component.

This commit is contained in:
Florian THIERRY
2024-09-05 17:28:30 +02:00
parent 5610bd170a
commit 5804d8cc9f
12 changed files with 394 additions and 268 deletions

View File

@@ -19,7 +19,7 @@ export const routes: Routes = [
},
{
path: 'publications/:publicationId/edit',
loadChildren: () => import('./pages/publication-edition/publication-edition.routes').then(module => module.ROUTES)
loadChildren: () => import('./pages/publication-edition/publication-update.routes').then(module => module.ROUTES)
},
{
path: 'publications',

View File

@@ -0,0 +1,87 @@
<form [formGroup]="publicationEditionForm" (submit)="save()" ngNativeValidate>
<header>
<h1>Modification de l'article {{ publication.title }}</h1>
</header>
<mat-tab-group dynamicHeight (selectedIndexChange)="onTabChange($event)">
<mat-tab label="Edition">
<div class="form-content">
<div class="first-part">
<div>
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput type="text" formControlName="title" />
</mat-form-field>
<mat-form-field>
<mat-label>Description</mat-label>
<input matInput type="text" formControlName="description" />
</mat-form-field>
</div>
<div class="picture-container">
<img src="/api/pictures/{{publication.illustrationId}}" (click)="displayPictureSectionDialog()" matTooltip="Click to change illustration"/>
</div>
</div>
<div class="actions">
<button type="button" matTooltip="Click to insert a title 1 section" (click)="insertTitle(1)">
H1
</button>
<button type="button" matTooltip="Click to insert a title 2 section" (click)="insertTitle(2)">
H2
</button>
<button type="button" matTooltip="Click to insert a title 1 section" (click)="insertTitle(3)">
H3
</button>
<button type="button" matTooltip="Click to insert a picture" (click)="selectAPicture()">
<mat-icon>image</mat-icon>
</button>
<button type="button" matTooltip="Click to insert a link" (click)="insertLink()">
<mat-icon>link</mat-icon>
</button>
<button type="button" matTooltip="Click to insert a code block" (click)="displayCodeBlockDialog()">
<mat-icon>code</mat-icon>
</button>
<button type="button" disabled matTooltip="Click to display editor help">
<mat-icon>help</mat-icon>
</button>
</div>
<mat-form-field class="example-form-field">
<mat-label>Content</mat-label>
<textarea
#textArea
matInput
formControlName="text"
class="text-input"
(keyup)="updateCursorPosition($event)"
(click)="updateCursorPosition($event)">
</textarea>
</mat-form-field>
</div>
</mat-tab>
<mat-tab label="Previewing">
<div class="preview">
@if ((isPreviewing$ | async) === true) {
<div class="preview-loading">
<h2>Preview is loading...</h2>
<mat-spinner></mat-spinner>
</div>
} @else {
<img class="illustration" src="/pictures/{{ publication.illustrationId }}" />
<header>
<h1>{{ publication.title }}</h1>
<h2>{{ publication.description }}</h2>
</header>
<main [innerHTML]="publication.parsedText"></main>
}
</div>
</mat-tab>
</mat-tab-group>
<footer>
<app-submit-button label="Save" [requestPending]="!!(isSaving$ | async)"></app-submit-button>
<button type="button" class="secondary" (click)="goPreviousLocation()">
Cancel
</button>
</footer>
</form>

View File

@@ -8,7 +8,7 @@
margin: 1em;
max-width: 80em;
width: 90%;
border-radius: .5em;
border-radius: .5em;
box-shadow: 0 2px 5px 0 rgba(0,0,0,.16),0 2px 10px 0 rgba(0,0,0,.12);
& > header {
@@ -113,10 +113,6 @@
}
}
:host ::ng-deep .test circle {
stroke: white;
}
button, a.button {
padding: .8em 1.2em;
border-radius: 10em;
@@ -151,9 +147,15 @@ button, a.button {
flex-direction: column;
max-height: 80vh;
overflow-y: auto;
align-items: center;
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
}
.illustration {
flex: 1;
height: 12em;
object-fit: cover;
transition: height .2s ease-in-out;

View File

@@ -0,0 +1,155 @@
import { CommonModule, Location } from "@angular/common";
import { Component, EventEmitter, inject, Input, OnChanges, OnDestroy, Output } from "@angular/core";
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms";
import { MatDialogModule } from "@angular/material/dialog";
import { MatIconModule } from "@angular/material/icon";
import { MatInputModule } from "@angular/material/input";
import { MatProgressSpinnerModule } from "@angular/material/progress-spinner";
import { MatTabsModule } from "@angular/material/tabs";
import { MatTooltipModule } from "@angular/material/tooltip";
import { map, Observable, of, Subscription } from "rxjs";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { PictureSelectionDialog } from "../../pages/publication-edition/picture-selection-dialog/picture-selection-dialog.component";
import { SubmitButtonComponent } from "../submit-button/submit-button.component";
import { PublicationEditionService } from "./publication-edition.service";
@Component({
selector: 'app-publication-edition',
standalone: true,
templateUrl: './publication-edition.component.html',
styleUrl: './publication-edition.component.scss',
imports: [
CommonModule,
MatDialogModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatTabsModule,
MatTooltipModule,
PictureSelectionDialog,
ReactiveFormsModule,
SubmitButtonComponent
],
providers: [PublicationEditionService]
})
export class PublicationEditionComponent implements OnChanges, OnDestroy {
@Input()
publication!: Publication;
@Input()
isSaving$: Observable<boolean> = of(false);
@Output()
publicationSave = new EventEmitter<Publication>();
private readonly formBuilder = inject(FormBuilder);
private readonly location = inject(Location);
private readonly publicationEditionService = inject(PublicationEditionService);
private publicationInEdition!: Publication;
private subscriptions: Subscription[] = [];
publicationEditionForm: FormGroup = this.formBuilder.group({
title: new FormControl<string | undefined>('', [Validators.required]),
description: new FormControl<string | undefined>('', [Validators.required]),
text: new FormControl<string | undefined>('', [Validators.required]),
illustrationId: new FormControl<string | undefined>('', [Validators.required]),
categoryId: new FormControl<string | undefined>('', [Validators.required])
});
get isLoading$(): Observable<boolean> {
return this.publicationEditionService.isLoading$;
}
get isPreviewing$(): Observable<boolean> {
return this.publicationEditionService.isPreviewing$;
}
ngOnChanges(): void {
this.ngOnDestroy();
this.publicationInEdition = this.publication;
this.publicationEditionService.init(this.publicationInEdition);
['title', 'description', 'text'].forEach(fieldName => {
const fieldSubscription = this.publicationEditionForm.controls[fieldName].valueChanges
.pipe(
map(value => value?.length ? value as string : '')
)
.subscribe(fieldValue => {
switch (fieldName) {
case 'title':
this.publicationEditionService.editTitle(fieldValue);
break;
case 'description':
this.publicationEditionService.editDescription(fieldValue);
break;
case 'text':
this.publicationEditionService.editText(fieldValue);
break;
default:
break;
}
});
this.subscriptions.push(fieldSubscription);
});
const publicationSubscription = this.publicationEditionService.state$.subscribe(state => {
this.publicationInEdition = state.publication;
this.publicationEditionForm.controls['title'].setValue(this.publication.title, { emitEvent: false });
this.publicationEditionForm.controls['description'].setValue(this.publication.description, { emitEvent: false });
this.publicationEditionForm.controls['text'].setValue(this.publication.text, { emitEvent: false });
this.publicationEditionForm.controls['illustrationId'].setValue(this.publication.illustrationId, { emitEvent: false });
this.publicationEditionForm.controls['categoryId'].setValue(this.publication.categoryId, { emitEvent: false });
});
this.subscriptions.push(publicationSubscription);
}
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription?.unsubscribe());
}
goPreviousLocation(): void {
this.location.back();
}
insertTitle(titleNumber: number): void {
this.publicationEditionService.insertTitle(titleNumber);
}
selectAPicture(): void {
this.publicationEditionService.selectAPicture();
}
insertLink(): void {
this.publicationEditionService.insertLink();
}
displayCodeBlockDialog(): void {
this.publicationEditionService.displayCodeBlockDialog();
}
save(): void {
this.publicationSave.emit(this.publicationInEdition);
}
displayPictureSectionDialog(): void {
this.publicationEditionService.displayPictureSectionDialog();
}
updateCursorPosition(event: KeyboardEvent | MouseEvent): void {
if (event.target) {
const textarea = event.target as HTMLTextAreaElement;
const positionStart = textarea.selectionStart;
const positionEnd = textarea.selectionEnd;
const selectedCharacterCount = positionEnd - positionStart;
console.log(`cursor position updated: [${positionStart}, ${positionEnd}] (${selectedCharacterCount})`);
this.publicationEditionService.editCursorPosition(positionStart, positionEnd);
}
}
onTabChange(tabSelectedIndex: number): void {
if (tabSelectedIndex === 1) {
this.publicationEditionService.loadPreview();
}
}
}

View File

@@ -1,14 +1,16 @@
import { Location } from "@angular/common";
import { inject, Injectable, OnDestroy } from "@angular/core";
import { MatSnackBar } from "@angular/material/snack-bar";
import { ActivatedRoute, Router } from "@angular/router";
import { BehaviorSubject, Observable, Subscription, timeout } from "rxjs";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { PublicationRestService } from "../../core/rest-services/publications/publication.rest-service";
import { copy } from '../../core/utils/ObjectUtils';
import { MatSnackBar } from "@angular/material/snack-bar";
import { MatDialog } from "@angular/material/dialog";
import { PictureSelectionDialog } from "./picture-selection-dialog/picture-selection-dialog.component";
import { CodeBlockDialog } from "./code-block-dialog/code-block-dialog.component";
import { BehaviorSubject, Observable, Subscription } from "rxjs";
import { copy } from "../../core/utils/ObjectUtils";
import { Publication } from "../../core/rest-services/publications/model/publication";
import { Location } from "@angular/common";
import { PictureSelectionDialog } from "../../pages/publication-edition/picture-selection-dialog/picture-selection-dialog.component";
import { CodeBlockDialog } from "../../pages/publication-edition/code-block-dialog/code-block-dialog.component";
declare let Prism: any;
export class CursorPosition {
start: number;
@@ -119,6 +121,12 @@ export class PublicationEditionService implements OnDestroy {
});
}
init(publication: Publication): void {
const state = this._state;
state.publication = publication;
this.stateSubject.next(state);
}
editTitle(title: string): void {
const state = this._state;
state.publication.title = title;
@@ -276,10 +284,13 @@ export class PublicationEditionService implements OnDestroy {
.then(parsedText => {
state.publication.parsedText = parsedText;
this._save(state);
setTimeout(() => Prism.highlightAll(), 1000);
})
.catch(error => {
console.error(error);
})
.finally(() => this.isPreviewingSubject.next(false));
.finally(() => {
this.isPreviewingSubject.next(false);
});
}
}

View File

@@ -1,97 +0,0 @@
@if ((isLoading$ | async) == true) {
<mat-spinner></mat-spinner>
}
@else {
@if (publication) {
<form [formGroup]="publicationEditionForm" (submit)="save()" ngNativeValidate>
<header>
<h1>Modification de l'article {{ publication.title }}</h1>
</header>
<mat-tab-group dynamicHeight (selectedIndexChange)="onTabChange($event)">
<mat-tab label="Edition">
<div class="form-content">
<div class="first-part">
<div>
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput type="text" formControlName="title" />
</mat-form-field>
<mat-form-field>
<mat-label>Description</mat-label>
<input matInput type="text" formControlName="description" />
</mat-form-field>
</div>
<div class="picture-container">
<img src="/api/pictures/{{publication.illustrationId}}" (click)="displayPictureSectionDialog()" matTooltip="Click to change illustration"/>
</div>
</div>
<div class="actions">
<button type="button" matTooltip="Click to insert a title 1 section" (click)="insertTitle(1)">
H1
</button>
<button type="button" matTooltip="Click to insert a title 2 section" (click)="insertTitle(2)">
H2
</button>
<button type="button" matTooltip="Click to insert a title 1 section" (click)="insertTitle(3)">
H3
</button>
<button type="button" matTooltip="Click to insert a picture" (click)="selectAPicture()">
<mat-icon>image</mat-icon>
</button>
<button type="button" matTooltip="Click to insert a link" (click)="insertLink()">
<mat-icon>link</mat-icon>
</button>
<button type="button" matTooltip="Click to insert a code block" (click)="displayCodeBlockDialog()">
<mat-icon>code</mat-icon>
</button>
<button type="button" disabled matTooltip="Click to display editor help">
<mat-icon>help</mat-icon>
</button>
</div>
<mat-form-field class="example-form-field">
<mat-label>Content</mat-label>
<textarea
#textArea
matInput
formControlName="text"
class="text-input"
(keyup)="updateCursorPosition($event)"
(click)="updateCursorPosition($event)">
</textarea>
</mat-form-field>
</div>
</mat-tab>
<mat-tab label="Previewing">
<div class="preview">
@if ((isPreviewing$ | async) === true) {
<h2>Preview is loading...</h2>
<mat-spinner></mat-spinner>
} @else {
<img class="illustration" src="/pictures/{{ publication.illustrationId }}" />
<header>
<h1>{{ publication.title }}</h1>
<h2>{{ publication.description }}</h2>
</header>
<main [innerHTML]="publication.parsedText"></main>
}
</div>
</mat-tab>
</mat-tab-group>
<footer>
<app-submit-button label="Save" [requestPending]="(isSaving$ | async) == true"></app-submit-button>
<button type="button" class="secondary" (click)="goPreviousLocation()">
Cancel
</button>
</footer>
</form>
}
@else {
<div class="loading-failed">
<h1>Publication failed to load...</h1>
</div>
}
}

View File

@@ -1,149 +0,0 @@
import { CommonModule, Location } from '@angular/common';
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { map, Observable, Subscription } from 'rxjs';
import { SubmitButtonComponent } from '../../components/submit-button/submit-button.component';
import { Publication } from '../../core/rest-services/publications/model/publication';
import { PictureSelectionDialog } from './picture-selection-dialog/picture-selection-dialog.component';
import { PublicationEditionService } from './publication-edition.service';
@Component({
selector: 'app-publication-edition',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatTabsModule,
MatTooltipModule,
PictureSelectionDialog,
ReactiveFormsModule,
SubmitButtonComponent
],
templateUrl: './publication-edition.component.html',
styleUrl: './publication-edition.component.scss',
providers: [PublicationEditionService]
})
export class PublicationEditionComponent implements OnInit, OnDestroy {
private formBuilder = inject(FormBuilder);
private location = inject(Location);
private publicationEditionService = inject(PublicationEditionService);
private subscriptions: Subscription[] = [];
publication!: Publication;
publicationEditionForm: FormGroup = this.formBuilder.group({
title: new FormControl<string | undefined>('', [Validators.required]),
description: new FormControl<string | undefined>('', [Validators.required]),
text: new FormControl<string | undefined>('', [Validators.required]),
illustrationId: new FormControl<string | undefined>('', [Validators.required]),
categoryId: new FormControl<string | undefined>('', [Validators.required])
});
get isLoading$(): Observable<boolean> {
return this.publicationEditionService.isLoading$;
}
get isSaving$(): Observable<boolean> {
return this.publicationEditionService.isSaving$;
}
get isPreviewing$(): Observable<boolean> {
return this.publicationEditionService.isPreviewing$;
}
ngOnInit(): void {
['title', 'description', 'text'].forEach(fieldName => {
const fieldSubscription = this.publicationEditionForm.controls[fieldName].valueChanges
.pipe(
map(value => value?.length ? value as string : '')
)
.subscribe(fieldValue => {
switch (fieldName) {
case 'title':
this.publicationEditionService.editTitle(fieldValue);
break;
case 'description':
this.publicationEditionService.editDescription(fieldValue);
break;
case 'text':
this.publicationEditionService.editText(fieldValue);
break;
default:
break;
}
});
this.subscriptions.push(fieldSubscription);
});
const publicationSubscription = this.publicationEditionService.state$.subscribe(state => {
this.publication = state.publication;
this.publicationEditionForm.controls['title'].setValue(this.publication.title, { emitEvent: false });
this.publicationEditionForm.controls['description'].setValue(this.publication.description, { emitEvent: false });
this.publicationEditionForm.controls['text'].setValue(this.publication.text, { emitEvent: false });
this.publicationEditionForm.controls['illustrationId'].setValue(this.publication.illustrationId, { emitEvent: false });
this.publicationEditionForm.controls['categoryId'].setValue(this.publication.categoryId, { emitEvent: false });
});
this.subscriptions.push(publicationSubscription);
this.publicationEditionService.loadPublication();
}
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription?.unsubscribe());
}
goPreviousLocation(): void {
this.location.back();
}
insertTitle(titleNumber: number): void {
this.publicationEditionService.insertTitle(titleNumber);
}
selectAPicture(): void {
this.publicationEditionService.selectAPicture();
}
insertLink(): void {
this.publicationEditionService.insertLink();
}
displayCodeBlockDialog(): void {
this.publicationEditionService.displayCodeBlockDialog();
}
save(): void {
this.publicationEditionService.save();
}
displayPictureSectionDialog(): void {
this.publicationEditionService.displayPictureSectionDialog();
}
updateCursorPosition(event: KeyboardEvent | MouseEvent): void {
if (event.target) {
const textarea = event.target as HTMLTextAreaElement;
const positionStart = textarea.selectionStart;
const positionEnd = textarea.selectionEnd;
const selectedCharacterCount = positionEnd - positionStart;
console.log(`cursor position updated: [${positionStart}, ${positionEnd}] (${selectedCharacterCount})`);
this.publicationEditionService.editCursorPosition(positionStart, positionEnd);
}
}
onTabChange(tabSelectedIndex: number): void {
if (tabSelectedIndex === 1) {
this.publicationEditionService.loadPreview();
}
}
}

View File

@@ -1,7 +0,0 @@
import { Route } from "@angular/router";
import { PublicationEditionComponent } from "./publication-edition.component";
import { authenticationGuard } from "../../core/guard/authentication.guard";
export const ROUTES: Route[] = [
{ path: '', component: PublicationEditionComponent, canActivate: [authenticationGuard] }
]

View File

@@ -0,0 +1,13 @@
@if ((isLoading$ | async) == true) {
<mat-spinner></mat-spinner>
}
@else {
@if (publication) {
<app-publication-edition [publication]="publication" [isSaving$]="isSaving$" (publicationSave)="onPublicationSave($event)"></app-publication-edition>
}
@else {
<div class="loading-failed">
<h1>Publication failed to load...</h1>
</div>
}
}

View File

@@ -0,0 +1,7 @@
:host {
display: flex;
app-publication-edition {
flex: 1;
}
}

View File

@@ -0,0 +1,97 @@
import { CommonModule, Location } from '@angular/common';
import { Component, inject, OnDestroy, OnInit } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatDialogModule } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { PublicationEditionComponent } from '../../components/publication-edition/publication-edition.component';
import { SubmitButtonComponent } from '../../components/submit-button/submit-button.component';
import { Publication } from '../../core/rest-services/publications/model/publication';
import { PublicationRestService } from '../../core/rest-services/publications/publication.rest-service';
import { PictureSelectionDialog } from './picture-selection-dialog/picture-selection-dialog.component';
@Component({
selector: 'app-publication-update',
standalone: true,
imports: [
CommonModule,
MatDialogModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatTabsModule,
MatTooltipModule,
PictureSelectionDialog,
ReactiveFormsModule,
SubmitButtonComponent,
PublicationEditionComponent
],
templateUrl: './publication-update.component.html',
styleUrl: './publication-update.component.scss',
})
export class PublicationUpdateComponent implements OnInit, OnDestroy {
private readonly publicationRestService = inject(PublicationRestService);
private readonly activatedRoute = inject(ActivatedRoute);
private readonly location = inject(Location);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private isLoadingSubject = new BehaviorSubject<boolean>(false);
private isSavingSubject = new BehaviorSubject<boolean>(false);
private subscriptions: Subscription[] = [];
publication: Publication | undefined;
get isLoading$(): Observable<boolean> {
return this.isLoadingSubject.asObservable();
}
get isSaving$(): Observable<boolean> {
return this.isSavingSubject.asObservable();
}
ngOnInit(): void {
this.isLoadingSubject.next(true);
this.activatedRoute.paramMap.subscribe(params => {
const publicationId = params.get('publicationId');
if (publicationId == undefined) {
this.snackBar.open('A technical error occurred while loading publication data.', 'Close', { duration: 5000 });
this.location.back();
} else {
this.publicationRestService.getById(publicationId)
.then(publication => {
this.publication = publication;
})
.catch(error => {
const errorMessage = 'A technical error occurred while loading publication data.';
this.snackBar.open(errorMessage, 'Close', { duration: 5000 });
console.error(errorMessage, error)
})
.finally(() => this.isLoadingSubject.next(false));
}
});
}
ngOnDestroy(): void {
this.subscriptions.forEach(subscription => subscription?.unsubscribe());
}
onPublicationSave(publication: Publication): void {
this.isSavingSubject.next(true);
this.publicationRestService.update(publication)
.then(() => {
this.snackBar.open('Publication updated succesfully!', 'Close', { duration: 5000 });
this.router.navigate(['/home']);
})
.catch(error => {
const errorMessage = 'An error occured while saving publication modifications.';
console.error(errorMessage, error);
this.snackBar.open(errorMessage, 'Close', { duration: 5000 });
})
.finally(() => this.isSavingSubject.next(false));
}
}

View File

@@ -0,0 +1,7 @@
import { Route } from "@angular/router";
import { PublicationUpdateComponent } from "./publication-update.component";
import { authenticationGuard } from "../../core/guard/authentication.guard";
export const ROUTES: Route[] = [
{ path: '', component: PublicationUpdateComponent, canActivate: [authenticationGuard] }
]