From 9d1b8a53d1a46f10ece217e210278d8418a39270 Mon Sep 17 00:00:00 2001 From: Lukas Spirig Date: Sun, 5 Jan 2020 15:24:55 +0100 Subject: [PATCH] feat: implement unit detail page --- .prettierignore | 2 +- angular.json | 7 ++ .../target/core/translation-target.service.ts | 52 +++++++++++- src/app/target/core/xlf-element-validator.ts | 20 +++++ src/app/target/orphans/orphans.component.html | 2 +- src/app/target/target-routing.module.ts | 5 ++ src/app/target/target.module.ts | 4 +- .../target/translate/translate-datasource.ts | 55 ++---------- .../target/translate/translate.component.html | 30 ++++++- .../target/translate/translate.component.scss | 4 + .../target/translate/translate.component.ts | 2 + src/app/target/unit/unit.component.html | 56 ++++++++++++ src/app/target/unit/unit.component.scss | 24 ++++++ src/app/target/unit/unit.component.spec.ts | 24 ++++++ src/app/target/unit/unit.component.ts | 85 +++++++++++++++++++ src/styles.scss | 2 +- 16 files changed, 317 insertions(+), 57 deletions(-) create mode 100644 src/app/target/core/xlf-element-validator.ts create mode 100644 src/app/target/unit/unit.component.html create mode 100644 src/app/target/unit/unit.component.scss create mode 100644 src/app/target/unit/unit.component.spec.ts create mode 100644 src/app/target/unit/unit.component.ts diff --git a/.prettierignore b/.prettierignore index df4cfa3..51cda5d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,7 +2,7 @@ browserslist LICENSE coverage -app +app/* package-lock.json .* *.ico diff --git a/angular.json b/angular.json index 2829bd1..0ccb729 100644 --- a/angular.json +++ b/angular.json @@ -31,6 +31,10 @@ "scripts": [] }, "configurations": { + "de": { + "i18nLocale": "de", + "i18nFile": "src/locale/xlf2/messages.de.xlf" + }, "production": { "fileReplacements": [ { @@ -67,6 +71,9 @@ "browserTarget": "angular-t9n:build" }, "configurations": { + "de": { + "browserTarget": "angular-t9n:build:de" + }, "production": { "browserTarget": "angular-t9n:build:production" } diff --git a/src/app/target/core/translation-target.service.ts b/src/app/target/core/translation-target.service.ts index 5c12cca..6561468 100644 --- a/src/app/target/core/translation-target.service.ts +++ b/src/app/target/core/translation-target.service.ts @@ -1,9 +1,21 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { AbstractControl } from '@angular/forms'; import { SortDirection } from '@angular/material/sort'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { filter, first, map, switchMap } from 'rxjs/operators'; +import { + debounceTime, + distinctUntilChanged, + filter, + first, + map, + skip, + startWith, + switchMap, + takeUntil, + tap +} from 'rxjs/operators'; import { PaginationResponse, TargetResponse, TranslationTargetUnitResponse } from '../../../models'; import { TranslationService } from '../../core/translation.service'; @@ -49,6 +61,44 @@ export class TranslationTargetService { ); } + updateUnitOnChange( + unit: TranslationTargetUnitResponse, + controls: { target: AbstractControl; state: AbstractControl }, + until: Observable + ) { + // The startWith, skip combination is necessary to deal with an IE11 bug + controls.target.valueChanges + .pipe( + takeUntil(until), + startWith(controls.target.value), + debounceTime(500), + distinctUntilChanged(), + skip(1), + tap(target => + target + ? controls.state.enable({ emitEvent: false }) + : controls.state.disable({ emitEvent: false }) + ), + switchMap(target => this.updateUnit({ ...unit, target, state: controls.state.value })) + ) + .subscribe(r => controls.state.setValue(r.state, { emitEvent: false })); + controls.state.valueChanges + .pipe( + takeUntil(until), + startWith(controls.state.value), + distinctUntilChanged(), + skip(1), + switchMap(state => + this.updateUnit({ + ...unit, + target: controls.target.value, + state + }) + ) + ) + .subscribe(); + } + updateUnit(unit: Partial) { if (unit.target === '' && unit.state !== 'initial') { unit.state = 'initial'; diff --git a/src/app/target/core/xlf-element-validator.ts b/src/app/target/core/xlf-element-validator.ts new file mode 100644 index 0000000..7db9ece --- /dev/null +++ b/src/app/target/core/xlf-element-validator.ts @@ -0,0 +1,20 @@ +import { AbstractControl, ValidatorFn } from '@angular/forms'; + +const parser = new DOMParser(); + +export function xlfElementValidator(source: string): ValidatorFn { + const doc = parser.parseFromString(`${source}`, 'text/xml'); + const placeholders = Array.from(doc.documentElement.childNodes as any) + .filter(n => n.nodeType !== doc.TEXT_NODE) + .map(p => p.outerHTML); + return !placeholders.length || + source.startsWith('{VAR_PLURAL') || + source.startsWith('{VAR_SELECT') + ? () => null + : (control: AbstractControl) => { + const value: string = control.value; + return typeof value !== 'string' || !value || placeholders.every(p => value.indexOf(p) >= 0) + ? null + : { placeholders }; + }; +} diff --git a/src/app/target/orphans/orphans.component.html b/src/app/target/orphans/orphans.component.html index fb0ddb0..6942b31 100644 --- a/src/app/target/orphans/orphans.component.html +++ b/src/app/target/orphans/orphans.component.html @@ -44,7 +44,7 @@ mat-cell [matTooltip]="row.locations?.join('\n')" [matTooltipDisabled]="!row.locations?.length" - matTooltipClass="tooltip-locations" + matTooltipClass="tooltip-linebreak" *matCellDef="let row" > {{ row.id }} diff --git a/src/app/target/target-routing.module.ts b/src/app/target/target-routing.module.ts index 5091b3a..0bd813c 100644 --- a/src/app/target/target-routing.module.ts +++ b/src/app/target/target-routing.module.ts @@ -6,6 +6,7 @@ import { ImportComponent } from './import/import.component'; import { OrphansComponent } from './orphans/orphans.component'; import { TargetComponent } from './target/target.component'; import { TranslateComponent } from './translate/translate.component'; +import { UnitComponent } from './unit/unit.component'; const routes: Routes = [ { @@ -16,6 +17,10 @@ const routes: Routes = [ path: '', component: TranslateComponent }, + { + path: 'unit/:unitId', + component: UnitComponent + }, { path: 'import', component: ImportComponent diff --git a/src/app/target/target.module.ts b/src/app/target/target.module.ts index 23b8b50..30d9d3c 100644 --- a/src/app/target/target.module.ts +++ b/src/app/target/target.module.ts @@ -23,6 +23,7 @@ import { OrphansComponent } from './orphans/orphans.component'; import { TargetRoutingModule } from './target-routing.module'; import { TargetComponent } from './target/target.component'; import { TranslateComponent } from './translate/translate.component'; +import { UnitComponent } from './unit/unit.component'; @NgModule({ declarations: [ @@ -30,7 +31,8 @@ import { TranslateComponent } from './translate/translate.component'; ExportComponent, ImportComponent, TargetComponent, - OrphansComponent + OrphansComponent, + UnitComponent ], imports: [ CommonModule, diff --git a/src/app/target/translate/translate-datasource.ts b/src/app/target/translate/translate-datasource.ts index ef7c88d..964a6ea 100644 --- a/src/app/target/translate/translate-datasource.ts +++ b/src/app/target/translate/translate-datasource.ts @@ -3,19 +3,11 @@ import { FormControl, FormGroup } from '@angular/forms'; import { MatPaginator } from '@angular/material/paginator'; import { MatSort } from '@angular/material/sort'; import { BehaviorSubject, merge, Observable, Subject } from 'rxjs'; -import { - debounceTime, - distinctUntilChanged, - map, - skip, - startWith, - switchMap, - takeUntil, - tap -} from 'rxjs/operators'; +import { debounceTime, map, startWith, switchMap, tap } from 'rxjs/operators'; import { FormTargetUnit } from '../../../models'; import { TranslationTargetService } from '../core/translation-target.service'; +import { xlfElementValidator } from '../core/xlf-element-validator'; export class TranslateDataSource extends DataSource { totalEntries: Observable; @@ -56,46 +48,11 @@ export class TranslateDataSource extends DataSource { unitPage._embedded!.entries.map(u => { const unit: FormTargetUnit = { ...u, - target: new FormControl(u.target), - state: new FormControl(u.state) + target: new FormControl(u.target, xlfElementValidator(u.source)), + state: new FormControl({ value: u.state, disabled: !u.target }) }; - if (!u.target) { - unit.state.disable(); - } - - // The startWith, skip combination is necessary to deal with an IE11 bug - unit.target.valueChanges - .pipe( - takeUntil(this._destroy), - startWith(unit.target.value), - debounceTime(500), - distinctUntilChanged(), - skip(1), - tap(target => - target - ? unit.state.enable({ emitEvent: false }) - : unit.state.disable({ emitEvent: false }) - ), - switchMap(target => - this._translationTargetService.updateUnit({ ...u, target, state: unit.state.value }) - ) - ) - .subscribe(r => unit.state.setValue(r.state, { emitEvent: false })); - unit.state.valueChanges - .pipe( - takeUntil(this._destroy), - startWith(unit.state.value), - distinctUntilChanged(), - skip(1), - switchMap(state => - this._translationTargetService.updateUnit({ - ...u, - target: unit.target.value, - state - }) - ) - ) - .subscribe(); + this._translationTargetService.updateUnitOnChange(u, unit, this._destroy); + unit.target.markAsTouched(); return unit; }) ) diff --git a/src/app/target/translate/translate.component.html b/src/app/target/translate/translate.component.html index 6e9b8ab..682ae00 100644 --- a/src/app/target/translate/translate.component.html +++ b/src/app/target/translate/translate.component.html @@ -6,7 +6,7 @@ mat-cell [matTooltip]="row.locations?.join('\n')" [matTooltipDisabled]="!row.locations?.length" - matTooltipClass="tooltip-locations" + matTooltipClass="tooltip-linebreak" *matCellDef="let row" > {{ row.id }} @@ -34,6 +34,12 @@ placeholder="Translation" [formControl]="row.target" > + Expected {{ placeholders.length }} placeholders + info + @@ -50,6 +56,18 @@ + + + + + edit + + + @@ -102,7 +120,10 @@ diff --git a/src/app/target/translate/translate.component.scss b/src/app/target/translate/translate.component.scss index 9b4932e..701bf2b 100644 --- a/src/app/target/translate/translate.component.scss +++ b/src/app/target/translate/translate.component.scss @@ -28,6 +28,10 @@ table { &.mat-column-state { width: 10rem; } + + &.mat-column-unit-link { + width: 2rem; + } } mat-form-field { diff --git a/src/app/target/translate/translate.component.ts b/src/app/target/translate/translate.component.ts index e24934b..a86f9de 100644 --- a/src/app/target/translate/translate.component.ts +++ b/src/app/target/translate/translate.component.ts @@ -29,6 +29,7 @@ export class TranslateComponent implements AfterViewInit, OnDestroy { @ViewChild(MatSort) sort!: MatSort; @ViewChild(MatTable) table!: MatTable; dataSource!: TranslateDataSource; + queryParams: Observable; filter: FormGroup; private _paginationHelper: PaginationHelper; private _destroy = new Subject(); @@ -40,6 +41,7 @@ export class TranslateComponent implements AfterViewInit, OnDestroy { formBuilder: FormBuilder ) { this._paginationHelper = new PaginationHelper(this, this._route, this._router); + this.queryParams = this._route.queryParams; this.filter = formBuilder.group({ id: '', description: '', diff --git a/src/app/target/unit/unit.component.html b/src/app/target/unit/unit.component.html new file mode 100644 index 0000000..3714427 --- /dev/null +++ b/src/app/target/unit/unit.component.html @@ -0,0 +1,56 @@ +

{{ (unit | async)?.id }}

+ + +
+ + Description + + + + + Meaning + + + + + Source + + + + + + Target + + Expected {{ placeholders.length }} placeholders + info + + + + + State + + Initial + Translated + Reviewed + Final + + +
+
+ + + keyboard_backspace Back + diff --git a/src/app/target/unit/unit.component.scss b/src/app/target/unit/unit.component.scss new file mode 100644 index 0000000..29c316b --- /dev/null +++ b/src/app/target/unit/unit.component.scss @@ -0,0 +1,24 @@ +:host { + display: block; + padding: 0 1.5rem; + padding-bottom: 1.5rem; +} + +form { + display: flex; + flex-wrap: wrap; + + mat-form-field { + flex: 1 1 100%; + + @media (min-width: 960px) { + margin-right: 1rem; + flex: 0 0 calc(50% - 1rem); + } + } +} + +a { + display: inline-flex; + align-items: center; +} diff --git a/src/app/target/unit/unit.component.spec.ts b/src/app/target/unit/unit.component.spec.ts new file mode 100644 index 0000000..bc29238 --- /dev/null +++ b/src/app/target/unit/unit.component.spec.ts @@ -0,0 +1,24 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UnitComponent } from './unit.component'; + +describe('UnitComponent', () => { + let component: UnitComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [UnitComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UnitComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/target/unit/unit.component.ts b/src/app/target/unit/unit.component.ts new file mode 100644 index 0000000..0c09389 --- /dev/null +++ b/src/app/target/unit/unit.component.ts @@ -0,0 +1,85 @@ +import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute, Params } from '@angular/router'; +import { Observable, Subject } from 'rxjs'; +import { map, share, switchMap } from 'rxjs/operators'; + +import { TranslationTargetUnitResponse } from '../../../models'; +import { TranslationTargetService } from '../core/translation-target.service'; +import { xlfElementValidator } from '../core/xlf-element-validator'; + +@Component({ + selector: 't9n-unit', + templateUrl: './unit.component.html', + styleUrls: ['./unit.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UnitComponent implements OnDestroy { + unit: Observable; + params: Observable; + form: Observable; + private _destroy = new Subject(); + + constructor( + private _translationTargetService: TranslationTargetService, + private _route: ActivatedRoute, + private _formBuilder: FormBuilder, + private _snackbar: MatSnackBar + ) { + this.params = this._route.params.pipe(map(({ unitId, ...params }) => params)); + this.unit = this._route.paramMap.pipe( + switchMap(p => this._translationTargetService.unit(p.get('unitId')!)), + share() + ); + this.form = this.unit.pipe(map(u => this._createForm(u))); + } + + ngOnDestroy(): void { + this._destroy.next(); + this._destroy.complete(); + } + + copySourceToClipboard(source: string) { + if (!document) { + return; + } + + const textarea = document.createElement('textarea'); + textarea.classList.add('cdk-visually-hidden'); + textarea.value = source; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + if (document.execCommand('copy')) { + this._snackbar.open('Copied source to clipboard', undefined, { duration: 3000 }); + return; + } else { + throw new Error(); + } + } catch { + this._snackbar.open('Failed to copy source to clipboard', undefined, { duration: 3000 }); + } + + document.body.removeChild(textarea); + } + + private _createForm(unit: TranslationTargetUnitResponse) { + const form = this._formBuilder.group({ + description: [{ value: unit.description || '-', disabled: true }], + meaning: [{ value: unit.meaning || '-', disabled: true }], + source: [{ value: unit.source, disabled: true }], + target: [unit.target, xlfElementValidator(unit.source)], + state: [{ value: unit.state, disabled: !unit.target }] + }); + this._translationTargetService.updateUnitOnChange( + unit, + form.controls as { target: AbstractControl; state: AbstractControl }, + this._destroy + ); + form.controls.target.markAsTouched(); + return form; + } +} diff --git a/src/styles.scss b/src/styles.scss index 0147827..c299087 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -21,7 +21,7 @@ dl { } } -.tooltip-locations.mat-tooltip { +.tooltip-linebreak.mat-tooltip { white-space: pre-line; max-width: 40rem; }