diff --git a/src/app/core/utilities/local-storage.utilities.ts b/src/app/core/utilities/local-storage.utilities.ts new file mode 100644 index 0000000..2a748a3 --- /dev/null +++ b/src/app/core/utilities/local-storage.utilities.ts @@ -0,0 +1,8 @@ +export enum SESSIONSTORAGEKEYS { + HISTORYCOMMAND = 'HistoryCommand', +} +export const getFromLocalStorage = (key: SESSIONSTORAGEKEYS) => + sessionStorage.getItem(key); + +export const setToLocalStorage = (key: SESSIONSTORAGEKEYS, value: string) => + sessionStorage.setItem(key, value); diff --git a/src/app/core/utilities/logic.utilities.ts b/src/app/core/utilities/logic.utilities.ts new file mode 100644 index 0000000..9442cc9 --- /dev/null +++ b/src/app/core/utilities/logic.utilities.ts @@ -0,0 +1,2 @@ +export const isNil = (value: T): value is null => value === null; +export const isNotNil = (value: T): value is T => value !== null; diff --git a/src/app/features/command/command-line/command-line.component.html b/src/app/features/command/command-line/command-line.component.html index a1463f8..c6d3d15 100644 --- a/src/app/features/command/command-line/command-line.component.html +++ b/src/app/features/command/command-line/command-line.component.html @@ -1,22 +1,18 @@
- +
- no command enter (min. 3 char) - {{ activeCommand?.suggestion }} - - command not found + + no command enter (min. 3 char) + + + {{ activeCommand?.suggestion }} + + command not found
-
+ \ No newline at end of file diff --git a/src/app/features/command/command-line/command-line.component.ts b/src/app/features/command/command-line/command-line.component.ts index 9b9009b..a2a73e9 100644 --- a/src/app/features/command/command-line/command-line.component.ts +++ b/src/app/features/command/command-line/command-line.component.ts @@ -1,9 +1,36 @@ -import { Component, EventEmitter, Input, OnInit, Output, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + ChangeDetectionStrategy, + ViewChild, + ElementRef, + OnDestroy, +} from '@angular/core'; import { FormControl, Validators } from '@angular/forms'; -import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { + debounceTime, + distinctUntilChanged, + filter, + map, + mapTo, + takeUntil, + tap, + withLatestFrom, +} from 'rxjs/operators'; import { allowedCommandValidator } from './command-line.validator'; import { Command } from '@app/shared/models/command.interface'; +import { BehaviorSubject, Observable, ReplaySubject, Subject } from 'rxjs'; +import { CommandsHistory } from './command-line.interface'; +import { + createHistoryPipe, + getHistoryFromLocalStorage, + setNewCommandsHistoryInLocalStorage, + setValueFn, +} from './command-line.utilities'; @Component({ selector: 'tr-command-line', @@ -12,20 +39,36 @@ import { Command } from '@app/shared/models/command.interface'; ` .input-group-text { background: white; - font-size: .9em; + font-size: 0.9em; } .form-control { - font-size: .9em; + font-size: 0.9em; } - ` + `, ], - changeDetection: ChangeDetectionStrategy.OnPush + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CommandLineComponent implements OnInit { +export class CommandLineComponent implements OnInit, OnDestroy { commandLine: FormControl; commandsHistory: string[] = []; commandsHistoryCursor = 0; + commandLineMinLetter = 3; + + commandsHistorySubj$ = new BehaviorSubject({ + commandsHistory: getHistoryFromLocalStorage(), + commandsHistoryCursor: 0, + }); + + arrowUpClickSubj$ = new Subject(); + arrowDownClickSubj$ = new Subject(); + inputElementRefSubj$ = new ReplaySubject | null>( + 1 + ); + executeCommandSubj$ = new ReplaySubject(1); + + executeCommandPipe$: Observable; + historyPipe$: Observable; @Input() allowedCommands: Array = []; @Input() activeCommand: Command; @@ -41,9 +84,17 @@ export class CommandLineComponent implements OnInit { } } + @ViewChild('input') set inputElement( + inputElement: ElementRef | null + ) { + this.inputElementRefSubj$.next(inputElement); + } + @Output() detectCommand: EventEmitter = new EventEmitter(); @Output() execute: EventEmitter = new EventEmitter(); + private destroySubj$: Subject = new Subject(); + /** * Init command line input text with its validator * and start to observe its value changes. @@ -51,14 +102,50 @@ export class CommandLineComponent implements OnInit { ngOnInit() { this.commandLine = new FormControl('', [ Validators.required, - allowedCommandValidator(this.allowedCommands) + allowedCommandValidator(this.allowedCommands), ]); - this.commandLine.valueChanges.pipe( - debounceTime(200), + + this.historyPipe$ = createHistoryPipe( + this.arrowUpClickSubj$.asObservable(), + this.arrowDownClickSubj$.asObservable(), + this.commandsHistorySubj$, + this.commandsHistorySubj$.asObservable(), + this.inputElementRefSubj$.asObservable(), + this.destroySubj$.asObservable(), + setValueFn(this.commandLine) + ); + + this.executeCommandPipe$ = this.executeCommandSubj$.pipe( filter(() => this.commandLine.valid), - map((value) => value.split(' ')[0]), - distinctUntilChanged() - ).subscribe((value) => this.detectCommand.emit(value)); + tap((value) => this.execute.emit(value.trim())), + withLatestFrom(this.commandsHistorySubj$), + tap(([command, { commandsHistory }]) => { + const newHistory = [command, ...commandsHistory]; + + setNewCommandsHistoryInLocalStorage(newHistory); + + this.commandsHistorySubj$.next({ + commandsHistory: newHistory, + commandsHistoryCursor: 0, + }); + }), + mapTo(void 0), + takeUntil(this.destroySubj$.asObservable()) + ); + + this.historyPipe$.subscribe(); + this.executeCommandPipe$.subscribe(); + + this.commandLine.valueChanges + .pipe( + debounceTime(200), + filter(() => this.commandLine.valid), + map((value) => value.split(' ')[0]), + distinctUntilChanged(), + takeUntil(this.destroySubj$.asObservable()) + + ) + .subscribe((value) => this.detectCommand.emit(value)); } /** @@ -78,25 +165,18 @@ export class CommandLineComponent implements OnInit { } } - /** - * Implements command input history on keyboard arrow up/down press event - * @param event a keyboard event - */ - getHistory(event: KeyboardEvent) { - if (event.key === 'ArrowUp') { - event.preventDefault(); - if (this.commandsHistory.length > 0 && this.commandsHistory.length > this.commandsHistoryCursor) { - this.commandLine.setValue(this.commandsHistory[this.commandsHistoryCursor++]); - } - } + isValidCommand() { + return ( + !this.commandLine.valid && + this.commandLine.errors && + this.commandLine.errors.allowedCommand && + this.commandLine.errors.allowedCommand.value && + this.commandLine.errors.allowedCommand.value.length >= + this.commandLineMinLetter + ); + } - if (event.key === 'ArrowDown') { - if (this.commandsHistory.length > 0 && this.commandsHistoryCursor > 0) { - this.commandLine.setValue(this.commandsHistory[--this.commandsHistoryCursor]); - } else { - this.commandsHistoryCursor = 0; - this.commandLine.setValue(''); - } - } + ngOnDestroy() { + this.destroySubj$.next(); } } diff --git a/src/app/features/command/command-line/command-line.interface.ts b/src/app/features/command/command-line/command-line.interface.ts new file mode 100644 index 0000000..0dd666d --- /dev/null +++ b/src/app/features/command/command-line/command-line.interface.ts @@ -0,0 +1,4 @@ +export interface CommandsHistory { + commandsHistory: Array; + commandsHistoryCursor: number; +} diff --git a/src/app/features/command/command-line/command-line.utilities.ts b/src/app/features/command/command-line/command-line.utilities.ts new file mode 100644 index 0000000..69f4ee5 --- /dev/null +++ b/src/app/features/command/command-line/command-line.utilities.ts @@ -0,0 +1,108 @@ +import { ReturnStatement } from '@angular/compiler'; +import { ElementRef } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { + getFromLocalStorage, + SESSIONSTORAGEKEYS, + setToLocalStorage, +} from '@app/core/utilities/local-storage.utilities'; +import { isNotNil } from '@app/core/utilities/logic.utilities'; +import { BehaviorSubject, merge, Observable } from 'rxjs'; +import { + withLatestFrom, + tap, + map, + mapTo, + filter, + switchMapTo, + takeUntil, +} from 'rxjs/operators'; +import { CommandsHistory } from './command-line.interface'; + +export const setValueFn = (commandLine: FormControl) => (value: string) => + commandLine.setValue(value); + +export type SetValueFn = ReturnType; +export const createHistoryPipe = ( + arrowUp$: Observable, + arrowDown$: Observable, + commandsHistorySubj$: BehaviorSubject, + commandsHistory$: Observable, + inputElementRef$: Observable | null>, + destroy$: Observable, + setValue: SetValueFn +) => { + const setValueArrowUpClick$ = arrowUp$.pipe( + tap((event) => event.preventDefault()), + withLatestFrom(commandsHistory$), + tap(([, { commandsHistory, commandsHistoryCursor }]) => + commandsHistorySubj$.next({ + commandsHistoryCursor: + commandsHistory.length > commandsHistoryCursor + 1 + ? commandsHistoryCursor + 1 + : commandsHistoryCursor, + commandsHistory, + }) + ), + map( + ([, { commandsHistory, commandsHistoryCursor }]) => + commandsHistory[commandsHistoryCursor] + ), + filter(isNotNil), + tap(setValue) + ); + + const setValueArrowDownClick$ = arrowDown$.pipe( + tap((event) => event.preventDefault()), + withLatestFrom(commandsHistory$), + tap(([, { commandsHistory, commandsHistoryCursor }]) => + commandsHistorySubj$.next({ + commandsHistoryCursor: + commandsHistoryCursor - 1 <= 0 ? 0 : commandsHistoryCursor - 1, + commandsHistory, + }) + ), + map(([, { commandsHistory, commandsHistoryCursor }]) => + commandsHistoryCursor <= 0 + ? '' + : commandsHistory[commandsHistoryCursor - 1] + ), + filter(isNotNil), + tap(setValue) + ); + + return merge(setValueArrowUpClick$, setValueArrowDownClick$).pipe( + switchMapTo(inputElementRef$), + filter(isNotNil), + tap((mutableElementRef) => { + const selectionEnd = mutableElementRef.nativeElement.selectionEnd; + mutableElementRef.nativeElement.selectionStart = selectionEnd; + mutableElementRef.nativeElement.selectionEnd = selectionEnd; + }), + mapTo(void 0), + takeUntil(destroy$) + ); +}; + +export const setNewCommandsHistoryInLocalStorage = ( + commandsHistory: Array +) => { + const commandsHistoryStringify = JSON.stringify(commandsHistory); + setToLocalStorage( + SESSIONSTORAGEKEYS.HISTORYCOMMAND, + commandsHistoryStringify + ); +}; + +export const getHistoryFromLocalStorage = () => { + const commandsHistory = getFromLocalStorage( + SESSIONSTORAGEKEYS.HISTORYCOMMAND + ); + + try { + const history = JSON.parse(commandsHistory) as Array; + return history ? history : []; + } catch (_) { + return []; + } +};