Skip to content

Commit

Permalink
feat(history-commands)
Browse files Browse the repository at this point in the history
Save history command in local storage. Some refactor to make the flow more reactive
  • Loading branch information
gigitux committed Jan 11, 2021
1 parent 5da1257 commit 522fbde
Show file tree
Hide file tree
Showing 6 changed files with 247 additions and 49 deletions.
8 changes: 8 additions & 0 deletions src/app/core/utilities/local-storage.utilities.ts
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions src/app/core/utilities/logic.utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const isNil = <T>(value: T): value is null => value === null;
export const isNotNil = <T>(value: T): value is T => value !== null;
30 changes: 13 additions & 17 deletions src/app/features/command/command-line/command-line.component.html
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
<div class="command-line-container">
<input type="text"
class="form-control"
placeholder="Insert command"
[formControl]="commandLine"
(keyup.enter)="executeCommand(commandLine.value)"
(keydown)="getHistory($event)">
<input #input type="text" class="form-control" placeholder="Insert command" [formControl]="commandLine"
(keyup.enter)="executeCommandSubj$.next(commandLine.value)" (keydown.arrowup)="arrowUpClickSubj$.next($event)"
(keydown.arrowdown)="arrowDownClickSubj$.next($event)" />

<div class="font-italic text-muted suggestions pl-2 mb-0 h-auto">
<small *ngIf="!commandLine.dirty || commandLine.value.length < 3">no command enter (min. 3 char)</small>
<small *ngIf="commandLine.valid" class="suggestion"> {{ activeCommand?.suggestion }}</small>
<small
class="text-danger"
*ngIf="!commandLine.valid
&& commandLine.errors
&& commandLine.errors.allowedCommand
&& commandLine.errors.allowedCommand.value
&& commandLine.errors.allowedCommand.value.length >= 3">
command not found
<small *ngIf="
!commandLine.dirty || commandLine.value.length < commandLineMinLetter
">
no command enter (min. 3 char)
</small>
<small *ngIf="commandLine.valid" class="suggestion">
{{ activeCommand?.suggestion }}</small>
<small class="text-danger" *ngIf="isValidCommand()">
command not found
</small>
</div>
</div>
</div>
144 changes: 112 additions & 32 deletions src/app/features/command/command-line/command-line.component.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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>({
commandsHistory: getHistoryFromLocalStorage(),
commandsHistoryCursor: 0,
});

arrowUpClickSubj$ = new Subject<Event>();
arrowDownClickSubj$ = new Subject<Event>();
inputElementRefSubj$ = new ReplaySubject<ElementRef<HTMLInputElement> | null>(
1
);
executeCommandSubj$ = new ReplaySubject<string>(1);

executeCommandPipe$: Observable<void>;
historyPipe$: Observable<void>;

@Input() allowedCommands: Array<Command> = [];
@Input() activeCommand: Command;
Expand All @@ -41,24 +84,68 @@ export class CommandLineComponent implements OnInit {
}
}

@ViewChild('input') set inputElement(
inputElement: ElementRef<HTMLInputElement> | null
) {
this.inputElementRefSubj$.next(inputElement);
}

@Output() detectCommand: EventEmitter<any> = new EventEmitter();
@Output() execute: EventEmitter<any> = new EventEmitter();

private destroySubj$: Subject<void> = new Subject<void>();

/**
* Init command line input text with its validator
* and start to observe its value changes.
*/
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));
}

/**
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface CommandsHistory {
commandsHistory: Array<string>;
commandsHistoryCursor: number;
}
108 changes: 108 additions & 0 deletions src/app/features/command/command-line/command-line.utilities.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setValueFn>;
export const createHistoryPipe = (
arrowUp$: Observable<Event>,
arrowDown$: Observable<Event>,
commandsHistorySubj$: BehaviorSubject<CommandsHistory>,
commandsHistory$: Observable<CommandsHistory>,
inputElementRef$: Observable<ElementRef<HTMLInputElement> | null>,
destroy$: Observable<void>,
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<string>
) => {
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<string>;
return history ? history : [];
} catch (_) {
return [];
}
};

0 comments on commit 522fbde

Please sign in to comment.