diff --git a/src/app/core/models/undoredo.model.ts b/src/app/core/models/undoredo.model.ts index a59fd8d4f..80d98c906 100644 --- a/src/app/core/models/undoredo.model.ts +++ b/src/app/core/models/undoredo.model.ts @@ -20,6 +20,21 @@ export class UndoRedo { this.intervalTime = intervalTime; } + public static isUndo(event: KeyboardEvent) { + // prevents undo from firing when ctrl shift z is pressed + if (navigator.platform.indexOf('Mac') === 0) { + return event.metaKey && event.code === 'KeyZ' && !event.shiftKey; + } + return event.ctrlKey && event.code === 'KeyZ' && !event.shiftKey; + } + + public static isRedo(event: KeyboardEvent) { + if (navigator.platform.indexOf('Mac') === 0) { + return event.metaKey && event.shiftKey && event.code === 'KeyZ'; + } + return (event.ctrlKey && event.shiftKey && event.code === 'KeyZ') || (event.ctrlKey && event.code === 'KeyY'); + } + /** * Function to be called right before a change is made / stores the latest last state * preferably to be called with "beforeinput" event diff --git a/src/app/phase-bug-reporting/new-issue/new-issue.component.css b/src/app/phase-bug-reporting/new-issue/new-issue.component.css index dfd602a10..c7bd63c26 100644 --- a/src/app/phase-bug-reporting/new-issue/new-issue.component.css +++ b/src/app/phase-bug-reporting/new-issue/new-issue.component.css @@ -3,7 +3,7 @@ margin: 0 auto; } -mat-form-field { +app-title-editor { width: 100%; } diff --git a/src/app/phase-bug-reporting/new-issue/new-issue.component.html b/src/app/phase-bug-reporting/new-issue/new-issue.component.html index 5af3d8332..774243899 100644 --- a/src/app/phase-bug-reporting/new-issue/new-issue.component.html +++ b/src/app/phase-bug-reporting/new-issue/new-issue.component.html @@ -4,12 +4,12 @@

New Issue

- - - Title required. - Title cannot exceed 256 characters. - {{ 256 - title.value?.length }} characters remaining. - + +
+ + + + Title required. + + + Title cannot exceed {{ maxLength }} characters. + + + {{ maxLength - titleField.value?.length }} characters remaining. + + + diff --git a/src/app/shared/issue/title-editor/title-editor.component.ts b/src/app/shared/issue/title-editor/title-editor.component.ts new file mode 100644 index 000000000..4eb8724f2 --- /dev/null +++ b/src/app/shared/issue/title-editor/title-editor.component.ts @@ -0,0 +1,119 @@ +import { Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; +import { AbstractControl, FormGroup, Validators } from '@angular/forms'; +import { UndoRedo } from '../../../core/models/undoredo.model'; + +const ISSUE_BODY_SIZE_LIMIT = 256; + +type textEntry = { + text: string; + selectStart: number; + selectEnd: number; +}; + +@Component({ + selector: 'app-title-editor', + templateUrl: './title-editor.component.html', + styleUrls: ['./title-editor.component.css'] +}) +export class TitleEditorComponent implements OnInit { + + constructor() {} + + @Input() titleField: AbstractControl; // Compulsory Input + @Input() titleForm: FormGroup; // Compulsory Input + @Input() id: string; // Compulsory Input + + @Input() initialTitle?: string; + placeholderText = 'Title'; + + @ViewChild('titleInput', { static: true }) titleInput: ElementRef; + + maxLength = ISSUE_BODY_SIZE_LIMIT; + + history: UndoRedo; + + ngOnInit() { + if (this.initialTitle !== undefined) { + this.titleField.setValue(this.initialTitle); + } + + if (this.titleField === undefined || this.titleForm === undefined || this.id === undefined) { + throw new Error("Title Editor's compulsory properties are not defined."); + } + + this.titleField.setValidators([Validators.required, Validators.maxLength(this.maxLength)]); + this.history = new UndoRedo( + 75, + () => { + return { + text: this.titleInput.nativeElement.value, + selectStart: this.titleInput.nativeElement.selectionStart, + selectEnd: this.titleInput.nativeElement.selectionEnd + }; + }, + 500 + ); + } + + onKeyPress(event: KeyboardEvent) { + if (UndoRedo.isUndo(event)) { + event.preventDefault(); + this.undo(); + return; + } else if (UndoRedo.isRedo(event)) { + this.redo(); + event.preventDefault(); + return; + } + } + + handleBeforeInputChange(event: InputEvent): void { + switch (event.inputType) { + case 'historyUndo': + case 'historyRedo': + // ignore these events that doesn't modify the text + event.preventDefault(); + break; + case 'insertFromPaste': + this.history.forceSave(null, true, false); + break; + + default: + this.history.updateBeforeChange(); + } + } + + handleInputChange(event: InputEvent): void { + switch (event.inputType) { + case 'historyUndo': + case 'historyRedo': + // ignore these events that doesn't modify the text + event.preventDefault(); + break; + case 'insertFromPaste': + // paste events will be handled exclusively by handleBeforeInputChange + break; + + default: + this.history.createDelayedSave(); + } + } + + private undo(): void { + const entry = this.history.undo(); + if (entry === null) { + return; + } + this.titleField.setValue(entry.text); + this.titleInput.nativeElement.setSelectionRange(entry.selectStart, entry.selectEnd); + } + + private redo(): void { + const entry = this.history.redo(); + if (entry === null) { + return; + } + this.titleInput.nativeElement.value = entry.text; + this.titleInput.nativeElement.setSelectionRange(entry.selectStart, entry.selectEnd); + } +} diff --git a/src/app/shared/issue/title/title.component.css b/src/app/shared/issue/title/title.component.css index 2b45af156..ffc5b2c93 100644 --- a/src/app/shared/issue/title/title.component.css +++ b/src/app/shared/issue/title/title.component.css @@ -13,11 +13,16 @@ .title-button { display: flex; flex-direction: row; + justify-content: center; align-items: center; margin: 5px; float: right; } +app-title-editor { + width: 80%; +} + :host ::ng-deep .mat-progress-spinner { color: rgba(0, 0, 0, 0.5); display: inline-block; diff --git a/src/app/shared/issue/title/title.component.html b/src/app/shared/issue/title/title.component.html index 62717e92f..98baa67f9 100644 --- a/src/app/shared/issue/title/title.component.html +++ b/src/app/shared/issue/title/title.component.html @@ -20,28 +20,30 @@

- - - Title is required. - Title cannot exceed 256 characters. - - {{ 256 - issueTitleForm.get('title').value?.length }} characters remaining. - - - - - +
+ + + +
+ + +
+
diff --git a/src/app/shared/issue/title/title.component.ts b/src/app/shared/issue/title/title.component.ts index ef9a9c5cf..73c40a1d4 100644 --- a/src/app/shared/issue/title/title.component.ts +++ b/src/app/shared/issue/title/title.component.ts @@ -47,7 +47,7 @@ export class TitleComponent implements OnInit { ngOnInit() { this.issueTitleForm = this.formBuilder.group({ - title: new FormControl('', [Validators.required, Validators.maxLength(256)]) + title: [''] }); // Build the loading service spinner this.loadingService diff --git a/tests/app/shared/issue/title-editor/title-editor.component.spec.ts b/tests/app/shared/issue/title-editor/title-editor.component.spec.ts new file mode 100644 index 000000000..6b9ce6f2e --- /dev/null +++ b/tests/app/shared/issue/title-editor/title-editor.component.spec.ts @@ -0,0 +1,150 @@ +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormGroup, FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { TitleEditorComponent } from '../../../../../src/app/shared/issue/title-editor/title-editor.component'; +import { SharedModule } from '../../../../../src/app/shared/shared.module'; + +describe('TitleEditor', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let component: TitleEditorComponent; + + const TEST_INITIAL_TITLE = 'abc'; + const TEST_257_CHARS = '0'.repeat(257); + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TitleEditorComponent], + imports: [FormsModule, SharedModule, BrowserAnimationsModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(TitleEditorComponent); + debugElement = fixture.debugElement; + component = fixture.componentInstance; + + // initialize compulsory inputs + const titleField: FormControl = new FormControl(''); + const titleForm: FormGroup = new FormGroup({ + title: titleField + }); + const id = 'title'; + + // manually inject inputs into the component + component.titleField = titleField; + component.titleForm = titleForm; + component.id = id; + }); + + describe('text input box', () => { + it('should render', () => { + fixture.detectChanges(); + + const textBoxDe: DebugElement = debugElement.query(By.css('input')); + expect(textBoxDe).toBeTruthy(); + }); + + it('should contain an empty string if no initial description is provided', () => { + fixture.detectChanges(); + + const textBox: any = debugElement.query(By.css('input')).nativeElement; + expect(textBox.value).toEqual(''); + }); + + it('should contain an initial description if one is provided', () => { + component.initialTitle = TEST_INITIAL_TITLE; + fixture.detectChanges(); + + const textBox: any = debugElement.query(By.css('input')).nativeElement; + expect(textBox.value).toEqual(TEST_INITIAL_TITLE); + }); + + it('should allow users to input text', async () => { + fixture.detectChanges(); + + const textBox: any = debugElement.query(By.css('input')).nativeElement; + textBox.value = '123'; + textBox.dispatchEvent(new Event('input')); + + await fixture.whenStable().then(() => { + expect(textBox.value).toEqual('123'); + }); + }); + + it('should undo a change on undo input', () => { + fixture.detectChanges(); + + const textBox: any = debugElement.query(By.css('input')).nativeElement; + + // saves empty state: { text: '', selectStart: 0, selectEnd: 0 } + component.history.forceSave(); + + textBox.value = '123'; + // saves state: { text: '123', selectStart: 3, selectEnd: 3 } + component.history.forceSave(); + + // undo to empty state: { text: '', selectStart: 0, selectEnd: 0 } + if (navigator.platform.indexOf('Mac') === 0) { + textBox.dispatchEvent(new KeyboardEvent('keydown', {key: 'z', code: 'KeyZ', metaKey: true})); + } else { + textBox.dispatchEvent(new KeyboardEvent('keydown', {key: 'z', code: 'KeyZ', ctrlKey: true})); + } + + fixture.whenStable().then(() => { + expect(textBox.value).toEqual(''); + }); + }); + + it('should redo an undone change on redo input', () => { + fixture.detectChanges(); + + const textBox: any = debugElement.query(By.css('input')).nativeElement; + + // saves empty state: { text: '', selectStart: 0, selectEnd: 0 } + component.history.forceSave(); + + textBox.value = '123'; + // saves state: { text: '123', selectStart: 3, selectEnd: 3 } + component.history.forceSave(); + + // undo to empty state: { text: '', selectStart: 0, selectEnd: 0 } + // redo to state: { text: '123', selectStart: 3, selectEnd: 3 } + if (navigator.platform.indexOf('Mac') === 0) { + textBox.dispatchEvent(new KeyboardEvent('keydown', {key: 'z', code: 'KeyZ', metaKey: true})); + textBox.dispatchEvent(new KeyboardEvent('keydown', {key: 'z', code: 'KeyZ', metaKey: true, shiftKey: true})); + } else { + textBox.dispatchEvent(new KeyboardEvent('keydown', {key: 'z', code: 'KeyZ', ctrlKey: true})); + textBox.dispatchEvent(new KeyboardEvent('keydown', {key: 'y', code: 'KeyY', ctrlKey: true})); + } + + fixture.whenStable().then(() => { + expect(textBox.value).toEqual('123'); + }); + }); + + it('blank input should be invalid', () => { + fixture.autoDetectChanges(); + + fixture.whenStable().then(() => { + expect(component.titleField.valid).toBe(false); + expect(component.titleField.hasError('required')).toEqual(true); + }); + }); + + it('input of more than 256 characters should be invalid', async () => { + fixture.detectChanges(); + + const textBox: any = debugElement.query(By.css('input')).nativeElement; + textBox.value = TEST_257_CHARS; + textBox.dispatchEvent(new Event('input')); + + await fixture.whenStable().then(() => { + expect(component.titleField.valid).toBe(false); + expect(component.titleField.hasError('maxlength')).toEqual(true); + }); + }); + }); +});