feat(notification): Add undoable notification, close #318 (#338)

* feat(notification): Add undoable notification

* feat(notification): Add undoable notification

* feat(notification): Use uhk-header to the notification

* half ready solution

* - fix: "Keymap has been deleted" is displayed for macros.
- When a keymap/macro deletion gets undone, please set the route of the restored keymap/macro.
- When the user switches to another route, please make the undo notification disappear.

* fix(keymap): Store prev user configuration in the application reducer

Store the previous state in application reducer, because refactoring the
user-config reducer is not easy

* feat(keymap): Fix review request
This commit is contained in:
Róbert Kiss
2017-07-23 22:17:53 +02:00
committed by László Monda
parent ce55cac380
commit 42683e32f9
19 changed files with 295 additions and 64 deletions

View File

@@ -106,6 +106,7 @@ import { AppUpdateRendererService } from './services/app-update-renderer.service
import { reducer } from './store';
import { AutoUpdateSettings } from './shared/components/auto-update-settings/auto-update-settings';
import { angularNotifierConfig } from './shared/models/angular-notifier-config';
import { UndoableNotifierComponent } from './shared/components/undoable-notifier';
import { UhkHeader } from './shared/components/uhk-header/uhk-header';
import { AppRendererService } from './services/app-renderer.service';
@@ -166,6 +167,7 @@ import { AppRendererService } from './services/app-renderer.service';
SafeStylePipe,
UpdateAvailableComponent,
AutoUpdateSettings,
UndoableNotifierComponent,
UhkHeader
],
imports: [

View File

@@ -14,4 +14,4 @@
<div *ngIf="!macro" class="not-found">
There is no macro with id {{ route.params.select('id') | async }}.
</div>
</div>

View File

@@ -1,4 +1,8 @@
<uhk-header></uhk-header>
<div class="not-found">
You don't have any macros. Try to add one!
<div class="container-fluid">
<uhk-header>
<h1>&nbsp;</h1>
</uhk-header>
<div class="not-found">
You don't have any macros. Try to add one!
</div>
</div>

View File

@@ -1,3 +1,12 @@
<div>
<ng-content></ng-content>
</div>
<div class="row">
<div class="col-xs-12">
<undoable-notifier [notification]="undoableNotification$ | async"
(close)="onDismissLastNotification()"
(undo)="onUndoLastNotification($event)">
</undoable-notifier>
</div>
</div>

View File

@@ -1,4 +1,10 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { Notification } from '../../models/notification';
import { AppState, getUndoableNotification } from '../../store/index';
import { DismissUndoNotificationAction, UndoLastAction } from '../../store/actions/app.action';
@Component({
selector: 'uhk-header',
@@ -6,4 +12,18 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UhkHeader {
undoableNotification$: Observable<Notification>;
constructor(private store: Store<AppState>) {
this.undoableNotification$ = this.store.select(getUndoableNotification);
}
onUndoLastNotification(data: any): void {
this.store.dispatch(new UndoLastAction(data));
}
onDismissLastNotification(): void {
this.store.dispatch(new DismissUndoNotificationAction());
}
}

View File

@@ -0,0 +1 @@
export * from './undoable-notifier.component';

View File

@@ -0,0 +1,12 @@
<div class="pull-right">
<div class="alert alert-warning alert-dismissible" role="alert" [@slideInOut]="slideInOut">
<button type="button"
class="close"
aria-label="Close"
(click)="clickOnClose()">
<span aria-hidden="true">&times;</span>
</button>
{{text}}
<a *ngIf="undoable" (click)="clickOnUndo()" class="undo-button">Undo</a>
</div>
</div>

View File

@@ -0,0 +1,13 @@
.alert {
padding: 5px 10px 5px 5px;
margin-bottom: 0.25em;
margin-top: -2em;
.close {
right: -5px;
}
.undo-button {
cursor: pointer;
}
}

View File

@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Notification } from '../../models/notification';
@Component({
selector: 'undoable-notifier',
templateUrl: './undoable-notifier.component.html',
styleUrls: ['./undoable-notifier.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('slideInOut', [
state('in', style({
transform: 'translate3d(0, 0, 0)'
})),
state('out', style({
transform: 'translate3d(200%, 0, 0)'
})),
transition('in => out', animate('400ms ease-in-out')),
transition('out => in', animate('400ms ease-in-out'))
])
]
})
export class UndoableNotifierComponent implements OnChanges {
text: string;
undoable: boolean;
@Input() notification: Notification;
@Output() close = new EventEmitter();
@Output() undo = new EventEmitter();
get slideInOut() {
return this.notification ? 'in' : 'out';
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['notification']) {
const not: Notification = changes['notification'].currentValue;
if (not) {
this.text = not.message;
this.undoable = !!not.extra;
}
}
}
clickOnClose(): void {
this.close.emit();
}
clickOnUndo(): void {
this.undo.emit(this.notification.extra);
}
}

View File

@@ -1,14 +1,17 @@
import { Action } from '@ngrx/store';
export enum NotificationType {
Default,
Success,
Error,
Warning,
Info
Default = 'default',
Success = 'success',
Error = 'error',
Warning = 'warning',
Info = 'info',
Undoable = 'undoable'
}
export interface Notification {
type: NotificationType;
title?: string;
message: string;
extra?: any;
extra?: Action;
}

View File

@@ -0,0 +1,4 @@
export interface UndoUserConfigData {
path: string;
config: string;
}

View File

@@ -12,7 +12,10 @@ export const ActionTypes = {
APP_STARTED: type(PREFIX + 'started'),
APP_SHOW_NOTIFICATION: type(PREFIX + 'show notification'),
APP_TOGGLE_ADDON_MENU: type(PREFIX + 'toggle add-on menu'),
APP_PROCESS_COMMAND_LINE_ARGS: type(PREFIX + 'process command line args')
APP_PROCESS_COMMAND_LINE_ARGS: type(PREFIX + 'process command line args'),
UNDO_LAST: type(PREFIX + 'undo last action'),
UNDO_LAST_SUCCESS: type(PREFIX + 'undo last action success'),
DISMISS_UNDO_NOTIFICATION: type(PREFIX + 'dismiss notification action')
};
export class AppBootsrappedAction implements Action {
@@ -41,9 +44,26 @@ export class ProcessCommandLineArgsAction implements Action {
constructor(public payload: CommandLineArgs) { }
}
export class UndoLastAction implements Action {
type = ActionTypes.UNDO_LAST;
constructor(public payload: any) {}
}
export class UndoLastSuccessAction implements Action {
type = ActionTypes.UNDO_LAST_SUCCESS;
}
export class DismissUndoNotificationAction implements Action {
type = ActionTypes.DISMISS_UNDO_NOTIFICATION;
}
export type Actions
= AppStartedAction
| AppBootsrappedAction
| ShowNotificationAction
| ToggleAddonMenuAction
| ProcessCommandLineArgsAction;
| ProcessCommandLineArgsAction
| UndoLastAction
| UndoLastSuccessAction
| DismissUndoNotificationAction;

View File

@@ -16,6 +16,7 @@ export namespace KeymapActions {
export const CHECK_MACRO = KeymapActions.PREFIX + 'Check deleted macro';
export const LOAD_KEYMAPS = KeymapActions.PREFIX + 'Load keymaps';
export const LOAD_KEYMAPS_SUCCESS = KeymapActions.PREFIX + 'Load keymaps success';
export const UNDO_LAST_ACTION = KeymapActions.PREFIX + 'Undo last action';
export function loadKeymaps(): Action {
return {

View File

@@ -19,13 +19,13 @@ export class LoadUserConfigAction implements Action {
export class LoadUserConfigSuccessAction implements Action {
type = ActionTypes.LOAD_USER_CONFIG_SUCCESS;
constructor(public payload: UserConfiguration) {
}
constructor(public payload: UserConfiguration) { }
}
export class SaveUserConfigSuccessAction implements Action {
type = ActionTypes.SAVE_USER_CONFIG_SUCCESS;
constructor(public payload: UserConfiguration) { }
}
export type Actions

View File

@@ -4,10 +4,13 @@ import { Actions, Effect, toPayload } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';
import { NotifierService } from 'angular-notifier';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/operator/catch';
import { ActionTypes, ToggleAddonMenuAction } from '../actions/app.action';
import { ActionTypes, DismissUndoNotificationAction, ToggleAddonMenuAction } from '../actions/app.action';
import { Notification, NotificationType } from '../../models/notification';
import { CommandLineArgs } from '../../models/command-line-args';
@@ -19,8 +22,10 @@ export class ApplicationEffects {
.ofType(ActionTypes.APP_SHOW_NOTIFICATION)
.map(toPayload)
.do((notification: Notification) => {
const type = ApplicationEffects.mapNotificationType(notification.type);
this.notifierService.notify(type, notification.message);
if (notification.type === NotificationType.Undoable) {
return;
}
this.notifierService.notify(notification.type, notification.message);
});
@Effect()
@@ -29,26 +34,10 @@ export class ApplicationEffects {
.map(toPayload)
.map((args: CommandLineArgs) => new ToggleAddonMenuAction(args.addons || false));
// TODO: Change typescript -> 2.4 and use string enum.
// Corrently ngrx store is not compatible witn typescript 2.4
private static mapNotificationType(type: NotificationType): string {
switch (type) {
case NotificationType.Success:
return 'success';
case NotificationType.Error:
return 'error';
case NotificationType.Info:
return 'info';
case NotificationType.Warning:
return 'warning';
default:
return 'default';
}
}
@Effect() undoLastNotification$: Observable<Action> = this.actions$
.ofType(ActionTypes.UNDO_LAST)
.map(toPayload)
.mergeMap((action: Action) => [action, new DismissUndoNotificationAction()]);
constructor(private actions$: Actions,
private notifierService: NotifierService) { }

View File

@@ -1,5 +1,6 @@
import { Inject, Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
import { go } from '@ngrx/router-store';
import { Actions, Effect, toPayload } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';
import { Action, Store } from '@ngrx/store';
@@ -7,6 +8,7 @@ import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
import 'rxjs/add/operator/startWith';
import 'rxjs/add/operator/withLatestFrom';
import 'rxjs/add/operator/mergeMap';
import 'rxjs/add/observable/of';
import {
@@ -19,9 +21,12 @@ import {
import { UserConfiguration } from '../../config-serializer/config-items/user-configuration';
import { DATA_STORAGE_REPOSITORY, DataStorageRepositoryService } from '../../services/datastorage-repository.service';
import { DefaultUserConfigurationService } from '../../services/default-user-configuration.service';
import { AppState, getUserConfiguration } from '../index';
import { AppState, getPrevUserConfiguration, getUserConfiguration } from '../index';
import { KeymapActions } from '../actions/keymap';
import { MacroActions } from '../actions/macro';
import { DismissUndoNotificationAction, ShowNotificationAction } from '../actions/app.action';
import { NotificationType } from '../../models/notification';
import { UndoUserConfigData } from '../../models/undo-user-config-data';
@Injectable()
export class UserConfigEffects {
@@ -29,33 +34,49 @@ export class UserConfigEffects {
@Effect() loadUserConfig$: Observable<Action> = this.actions$
.ofType(ActionTypes.LOAD_USER_CONFIG)
.startWith(new LoadUserConfigAction())
.switchMap(() => {
const configJsonObject = this.dataStorageRepository.getConfig();
let config: UserConfiguration;
if (configJsonObject) {
if (configJsonObject.dataModelVersion === this.defaultUserConfigurationService.getDefault().dataModelVersion) {
config = new UserConfiguration().fromJsonObject(configJsonObject);
}
}
if (!config) {
config = this.defaultUserConfigurationService.getDefault();
}
return Observable.of(new LoadUserConfigSuccessAction(config));
});
.switchMap(() => Observable.of(new LoadUserConfigSuccessAction(this.getUserConfiguration())));
@Effect() saveUserConfig$: Observable<Action> = this.actions$
.ofType(
KeymapActions.ADD, KeymapActions.DUPLICATE, KeymapActions.EDIT_NAME, KeymapActions.EDIT_ABBR,
KeymapActions.SET_DEFAULT, KeymapActions.REMOVE, KeymapActions.SAVE_KEY, KeymapActions.CHECK_MACRO,
KeymapActions.SET_DEFAULT, KeymapActions.REMOVE, KeymapActions.SAVE_KEY,
MacroActions.ADD, MacroActions.DUPLICATE, MacroActions.EDIT_NAME, MacroActions.REMOVE, MacroActions.ADD_ACTION,
MacroActions.SAVE_ACTION, MacroActions.DELETE_ACTION, MacroActions.REORDER_ACTION)
.withLatestFrom(this.store.select(getUserConfiguration))
.map(([action, config]) => {
.withLatestFrom(this.store.select(getUserConfiguration), this.store.select(getPrevUserConfiguration))
.mergeMap(([action, config, prevUserConfiguration]) => {
this.dataStorageRepository.saveConfig(config);
return new SaveUserConfigSuccessAction();
if (action.type === KeymapActions.REMOVE || action.type === MacroActions.REMOVE) {
const text = action.type === KeymapActions.REMOVE ? 'Keymap' : 'Macro';
const pathPrefix = action.type === KeymapActions.REMOVE ? 'keymap' : 'macro';
const payload: UndoUserConfigData = {
path: `/${pathPrefix}/${action.payload}`,
config: prevUserConfiguration.toJsonObject()
};
return [
new SaveUserConfigSuccessAction(config),
new ShowNotificationAction({
type: NotificationType.Undoable,
message: `${text} has been deleted`,
extra: {
payload,
type: KeymapActions.UNDO_LAST_ACTION
}
})
];
}
return [new SaveUserConfigSuccessAction(config), new DismissUndoNotificationAction()];
});
@Effect() undoUserConfig$: Observable<Action> = this.actions$
.ofType(KeymapActions.UNDO_LAST_ACTION)
.map(toPayload)
.mergeMap((payload: UndoUserConfigData) => {
const config = new UserConfiguration().fromJsonObject(payload.config);
this.dataStorageRepository.saveConfig(config);
return [new LoadUserConfigSuccessAction(config), go(payload.path)];
});
constructor(private actions$: Actions,
@@ -63,4 +84,22 @@ export class UserConfigEffects {
private store: Store<AppState>,
private defaultUserConfigurationService: DefaultUserConfigurationService) {
}
private getUserConfiguration() {
const configJsonObject = this.dataStorageRepository.getConfig();
let config: UserConfiguration;
if (configJsonObject) {
if (configJsonObject.dataModelVersion === this.defaultUserConfigurationService.getDefault().dataModelVersion) {
config = new UserConfiguration().fromJsonObject(configJsonObject);
}
}
if (!config) {
config = this.defaultUserConfigurationService.getDefault();
}
return config;
}
}

View File

@@ -19,6 +19,8 @@ export const getUserConfiguration = (state: AppState) => state.userConfiguration
export const appState = (state: AppState) => state.app;
export const showAddonMenu = createSelector(appState, fromApp.showAddonMenu);
export const getUndoableNotification = createSelector(appState, fromApp.getUndoableNotification);
export const getPrevUserConfiguration = createSelector(appState, fromApp.getPrevUserConfiguration);
export const appUpdateState = (state: AppState) => state.autoUpdateSettings;

View File

@@ -1,25 +1,81 @@
import { routerActions } from '@ngrx/router-store';
import { Action } from '@ngrx/store';
import { ActionTypes } from '../actions/app.action';
import { ActionTypes, ShowNotificationAction } from '../actions/app.action';
import { ActionTypes as UserConfigActionTypes } from '../actions/user-config';
import { Notification, NotificationType } from '../../models/notification';
import { UserConfiguration } from '../../config-serializer/config-items/user-configuration';
export interface State {
started: boolean;
showAddonMenu: boolean;
undoableNotification?: Notification;
navigationCountAfterNotification: number;
prevUserConfig?: UserConfiguration;
}
const initialState: State = {
started: false,
showAddonMenu: false
showAddonMenu: false,
navigationCountAfterNotification: 0
};
export function reducer(state = initialState, action: Action) {
switch (action.type) {
case ActionTypes.APP_STARTED: {
return Object.assign({ ...state }, { started: true });
return {
...state,
started: true
};
}
case ActionTypes.APP_TOGGLE_ADDON_MENU: {
return Object.assign({ ...state }, { showAddonMenu: action.payload });
return {
...state,
showAddonMenu: action.payload
};
}
case ActionTypes.APP_SHOW_NOTIFICATION: {
const currentAction = <ShowNotificationAction>action;
if (currentAction.payload.type !== NotificationType.Undoable) {
return state;
}
return {
...state,
undoableNotification: currentAction.payload,
navigationCountAfterNotification: 0
};
}
// Required to dismiss the undoNotification dialog, when user navigate in the app.
// When deleted a keymap or macro the app automaticaly navigate to other keymap, or macro, so
// so we have to count the navigations and when reach the 2nd then remove the dialog.
case routerActions.UPDATE_LOCATION: {
const newState = { ...state };
newState.navigationCountAfterNotification++;
if (newState.navigationCountAfterNotification > 1) {
newState.undoableNotification = null;
}
return newState;
}
case ActionTypes.UNDO_LAST_SUCCESS:
case ActionTypes.DISMISS_UNDO_NOTIFICATION: {
return {
...state,
undoableNotification: null
};
}
case UserConfigActionTypes.LOAD_USER_CONFIG_SUCCESS:
case UserConfigActionTypes.SAVE_USER_CONFIG_SUCCESS: {
return {
...state,
prevUserConfig: action.payload
};
}
default:
@@ -28,3 +84,5 @@ export function reducer(state = initialState, action: Action) {
}
export const showAddonMenu = (state: State) => state.showAddonMenu;
export const getUndoableNotification = (state: State) => state.undoableNotification;
export const getPrevUserConfiguration = (state: State) => state.prevUserConfig;

View File

@@ -87,6 +87,7 @@ import { reducer } from './shared/store/reducers/index';
import { LogService } from './shared/services/logger.service';
import { AutoUpdateSettings } from './shared/components/auto-update-settings/auto-update-settings';
import { angularNotifierConfig } from './shared/models/angular-notifier-config';
import { UndoableNotifierComponent } from './shared/components/undoable-notifier';
import { UhkHeader } from './shared/components/uhk-header/uhk-header';
@NgModule({
@@ -141,6 +142,7 @@ import { UhkHeader } from './shared/components/uhk-header/uhk-header';
TooltipDirective,
SafeStylePipe,
AutoUpdateSettings,
UndoableNotifierComponent,
UhkHeader
],
imports: [