Skip to content

Commit

Permalink
refactor(core/filterModel): Use router hooks to save/restore filters
Browse files Browse the repository at this point in the history
  • Loading branch information
christopherthielen committed Jul 15, 2019
1 parent 39a27e6 commit d39ab96
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 418 deletions.
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import { Ng1StateDeclaration, StateParams } from '@uirouter/angularjs';
import { $rootScope } from 'ngimport';

import { FilterModelService, IFilterConfig, IFilterModel } from 'core/filterModel';
import { UrlParser } from 'core/navigation/urlParser';

export const filterModelConfig: IFilterConfig[] = [
{ model: 'filter', param: 'q', clearValue: '', type: 'string', filterLabel: 'search' },
Expand Down Expand Up @@ -30,93 +26,11 @@ export const filterModelConfig: IFilterConfig[] = [
];

export class ClusterFilterModel {
private mostRecentParams: any;
public asFilterModel: IFilterModel;

constructor() {
this.asFilterModel = FilterModelService.configureFilterModel(this as any, filterModelConfig);
this.bindEvents();
FilterModelService.registerSaveAndRestoreRouterHooks(this.asFilterModel, '**.application.insight.clusters');
this.asFilterModel.activate();
}

private isClusterState(stateName: string): boolean {
return (
stateName === 'home.applications.application.insight.clusters' ||
stateName === 'home.project.application.insight.clusters'
);
}

private isClusterStateOrChild(stateName: string): boolean {
return this.isClusterState(stateName) || this.isChildState(stateName);
}

private isChildState(stateName: string): boolean {
return stateName.includes('clusters.');
}

private movingToClusterState(toState: Ng1StateDeclaration): boolean {
return this.isClusterStateOrChild(toState.name);
}

private movingFromClusterState(toState: Ng1StateDeclaration, fromState: Ng1StateDeclaration): boolean {
return this.isClusterStateOrChild(fromState.name) && !this.isClusterStateOrChild(toState.name);
}

private fromApplicationListState(fromState: Ng1StateDeclaration): boolean {
return fromState.name === 'home.applications';
}

private shouldRouteToSavedState(toParams: StateParams, fromState: Ng1StateDeclaration): boolean {
return this.asFilterModel.hasSavedState(toParams) && !this.isClusterStateOrChild(fromState.name);
}

private bindEvents(): void {
// WHY??? Because, when the stateChangeStart event fires, the $location.search() will return whatever the query
// params are on the route we are going to, so if the user is using the back button, for example, to go to the
// Infrastructure page with a search already entered, we'll pick up whatever search was entered there, and if we
// come back to this application's clusters view, we'll get whatever that search was.
$rootScope.$on('$locationChangeStart', (_event, toUrl: string, fromUrl: string) => {
const [oldBase, oldQuery] = fromUrl.split('?'),
[newBase, newQuery] = toUrl.split('?');

if (oldBase === newBase) {
this.mostRecentParams = newQuery ? UrlParser.parseQueryString(newQuery) : {};
} else {
this.mostRecentParams = oldQuery ? UrlParser.parseQueryString(oldQuery) : {};
}
});

$rootScope.$on(
'$stateChangeStart',
(
_event,
toState: Ng1StateDeclaration,
_toParams: StateParams,
fromState: Ng1StateDeclaration,
fromParams: StateParams,
) => {
if (this.movingFromClusterState(toState, fromState)) {
this.asFilterModel.saveState(fromState, fromParams, this.mostRecentParams);
}
},
);

$rootScope.$on(
'$stateChangeSuccess',
(_event, toState: Ng1StateDeclaration, toParams: StateParams, fromState: Ng1StateDeclaration) => {
if (this.movingToClusterState(toState) && this.isClusterStateOrChild(fromState.name)) {
this.asFilterModel.applyParamsToUrl();
return;
}
if (this.movingToClusterState(toState)) {
if (this.shouldRouteToSavedState(toParams, fromState)) {
this.asFilterModel.restoreState(toParams);
}
if (this.fromApplicationListState(fromState) && !this.asFilterModel.hasSavedState(toParams)) {
this.asFilterModel.clearFilters();
}
}
},
);
}
}
122 changes: 67 additions & 55 deletions app/scripts/modules/core/src/filterModel/FilterModelService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { StateParams } from '@uirouter/core';
import { cloneDeep, size, some, reduce, forOwn, includes, chain } from 'lodash';
import { $location, $timeout } from 'ngimport';
import { cloneDeep, size, some, isNil, reduce, forOwn, includes, chain, pick } from 'lodash';
import { $location } from 'ngimport';

import { IFilterModel, IFilterConfig, ISortFilter } from './IFilterModel';
import { ReactInjector } from 'core/reactShims';
Expand Down Expand Up @@ -104,69 +104,20 @@ export class FilterModelService {
public static configureFilterModel(filterModel: IFilterModel, filterModelConfig: IFilterConfig[]) {
const { converters } = this;

filterModelConfig.forEach(property => (property.param = property.param || property.model));
filterModel.config = filterModelConfig;
filterModel.groups = [];
filterModel.tags = [];
filterModel.displayOptions = {};
filterModel.savedState = {};
filterModel.sortFilter = {} as ISortFilter;

filterModelConfig.forEach(property => (property.param = property.param || property.model));

filterModel.addTags = () => {
filterModel.tags = [];
filterModelConfig
.filter(property => !property.displayOption)
.forEach(property => this.addTagsForSection(filterModel, property));
};

filterModel.saveState = (state, params, filters) => {
if (params.application) {
filters = filters || $location.search();
filterModel.savedState[params.application] = {
filters: cloneDeep(filters),
state,
params,
};
}
};

filterModel.restoreState = toParams => {
const application = toParams.application;
const savedState = filterModel.savedState[application];
if (savedState) {
Object.keys(ReactInjector.$stateParams).forEach(k => delete ReactInjector.$stateParams[k]);
Object.assign(ReactInjector.$stateParams, cloneDeep(savedState.params));
const currentParams = $location.search();
// clear any shared params between states, e.g. previous state set 'acct', which this state also uses,
// but this state does not have that field set, so angular.extend will not overwrite it
forOwn(currentParams, function(_val, key) {
if (savedState.filters.hasOwnProperty(key)) {
delete currentParams[key];
}
});
$timeout(function() {
Object.assign(currentParams, savedState.filters);
$location.search(currentParams);
filterModel.activate();
$location.replace();
});
}
};

filterModel.hasSavedState = toParams => {
const application = toParams.application;
const serverGroup = toParams.serverGroup;

const savedStateForApplication = filterModel.savedState[application];

return (
savedStateForApplication !== undefined &&
savedStateForApplication.params !== undefined &&
(!serverGroup ||
(savedStateForApplication.params.serverGroup && savedStateForApplication.params.serverGroup === serverGroup))
);
};

filterModel.clearFilters = () => {
filterModelConfig.forEach(function(property) {
if (!property.displayOption) {
Expand All @@ -181,9 +132,28 @@ export class FilterModelService {
});
};

// What is this trying to do?

// The filter model is stored as keys/values on the `sortFilter` object
// When the user modifies the filter, the ng-model binding is updated to the `sortFilter`
// After the ng-model binding is updated, this function is called to update the URL.

// The values in `sortFilter` are sometimes non-primitive nested objects such as `region: { east: true, west: true }`
// To represent these values in the URL, they are encoded as strings using a custom ui-router parameter types.
// In addition, there is also parameter encode/decode logic in the `converters` registry that predates the ui-router parameter types.
filterModel.applyParamsToUrl = () => {
const newFilters = Object.keys(ReactInjector.$stateParams).reduce(
// Get the current state parameters
const params = ReactInjector.$stateParams;
const newFilters = Object.keys(params).reduce(
// Iterate over each state parameter
(acc, paramName) => {
// Find the filter model config for that parameter
// If there is a model config:
// - Try to convert the param's object model into a query parameter string
// - If the string is nil, set the accumulator to null
// - Otherwise, copy the sortfilter value for that param to the accumulator
// If there isn't a model config:
// - clone the current state parameter value and store on the accumulator
const modelConfig = filterModelConfig.find(c => c.param === paramName);
if (modelConfig) {
const converted = converters[modelConfig.type].toParam(filterModel, modelConfig);
Expand All @@ -200,12 +170,54 @@ export class FilterModelService {
{} as StateParams,
);

ReactInjector.$state.go('.', newFilters, { inherit: false });
// Finally, apply the accumulator as the new state parameters
const promise = ReactInjector.$state.go('.', newFilters, { inherit: false });
const handleResult = () => {
if (promise.transition.success) {
console.log(
'applyParamsToUrl transition was successful and changed the following params: ',
JSON.stringify(promise.transition.paramsChanged()),
);
} else {
console.log('applyParamsToUrl had no effect');
}
};
promise.then(handleResult, handleResult);
};

return filterModel;
}

public static registerSaveAndRestoreRouterHooks(filterModel: IFilterModel, stateGlob: string) {
const { transitionService } = ReactInjector.$uiRouter;
const filterParams = filterModel.config.map(cfg => cfg.param);
let savedParamsForScreen: any = {};

// When exiting the screen, save the filters for that screen
transitionService.onSuccess({ exiting: stateGlob, retained: '**.application' }, trans => {
const fromParams = trans.params('from');
savedParamsForScreen = pick(fromParams, filterParams);
});

// When entering the screen, restore the filters for that screen
transitionService.onBefore({ entering: stateGlob, retained: '**.application' }, trans => {
const toParams = trans.params();
const hasFilters = filterParams.some(key => !isNil(toParams[key]));

const savedParams = savedParamsForScreen;
const hasSavedFilters = filterParams.some(key => !isNil(savedParams[key]));

// Don't restore the saved filters if there are already filters specified (via url, ui-sref, etc)
const shouldRedirectWithSavedParams = !hasFilters && hasSavedFilters;
return shouldRedirectWithSavedParams ? trans.targetState().withParams(savedParams) : null;
});

// When switching apps, clear the saved state
transitionService.onStart({ exiting: '**.application' }, () => {
savedParamsForScreen = {};
});
}

public static isFilterable(sortFilterModel: { [key: string]: boolean }): boolean {
return size(sortFilterModel) > 0 && some(sortFilterModel);
}
Expand Down
5 changes: 1 addition & 4 deletions app/scripts/modules/core/src/filterModel/IFilterModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,12 @@ export interface ISortFilter {
}

export interface IFilterModel {
config: IFilterConfig[];
groups: any[];
tags: any[];
displayOptions: any;
savedState: any;
sortFilter: ISortFilter;
addTags: () => void;
saveState: (state: Ng1StateDeclaration, params: StateParams, filters: any) => void;
restoreState: (toParams: StateParams) => void;
hasSavedState: (toParams: StateParams) => boolean;
clearFilters: () => void;
activate: () => void;
applyParamsToUrl: () => void;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
import { Ng1StateDeclaration, StateParams } from '@uirouter/angularjs';
import { $rootScope } from 'ngimport';

import { ILoadBalancerGroup } from 'core/domain';
import { IFilterConfig, IFilterModel } from 'core/filterModel/IFilterModel';
import { FilterModelService } from 'core/filterModel';
import { UrlParser } from 'core/navigation/urlParser';

export const filterModelConfig: IFilterConfig[] = [
{ model: 'account', param: 'acct', type: 'trueKeyObject' },
Expand All @@ -28,94 +24,11 @@ export interface ILoadBalancerFilterModel extends IFilterModel {
}

export class LoadBalancerFilterModel {
private mostRecentParams: any;
public asFilterModel: ILoadBalancerFilterModel;

constructor() {
this.asFilterModel = FilterModelService.configureFilterModel(this as any, filterModelConfig);
this.bindEvents();
FilterModelService.registerSaveAndRestoreRouterHooks(this.asFilterModel, '**.application.insight.loadBalancers');
this.asFilterModel.activate();
}

private isLoadBalancerState(stateName: string) {
return stateName === 'home.applications.application.insight.loadBalancers';
}

private isLoadBalancerStateOrChild(stateName: string) {
return this.isLoadBalancerState(stateName) || this.isChildState(stateName);
}

private isChildState(stateName: string) {
return stateName.includes('loadBalancers.');
}

private movingToLoadBalancerState(toState: Ng1StateDeclaration) {
return this.isLoadBalancerStateOrChild(toState.name);
}

private movingFromLoadBalancerState(toState: Ng1StateDeclaration, fromState: Ng1StateDeclaration) {
return this.isLoadBalancerStateOrChild(fromState.name) && !this.isLoadBalancerStateOrChild(toState.name);
}

private shouldRouteToSavedState(toParams: StateParams, fromState: Ng1StateDeclaration) {
return this.asFilterModel.hasSavedState(toParams) && !this.isLoadBalancerStateOrChild(fromState.name);
}

private fromLoadBalancersState(fromState: Ng1StateDeclaration) {
return (
fromState.name.indexOf('home.applications.application.insight') === 0 &&
!fromState.name.includes('home.applications.application.insight.loadBalancers')
);
}

private bindEvents(): void {
// WHY??? Because, when the stateChangeStart event fires, the $location.search() will return whatever the query
// params are on the route we are going to, so if the user is using the back button, for example, to go to the
// Infrastructure page with a search already entered, we'll pick up whatever search was entered there, and if we
// come back to this application, we'll get whatever that search was.
$rootScope.$on('$locationChangeStart', (_event, toUrl: string, fromUrl: string) => {
const [oldBase, oldQuery] = fromUrl.split('?'),
[newBase, newQuery] = toUrl.split('?');

if (oldBase === newBase) {
this.mostRecentParams = newQuery ? UrlParser.parseQueryString(newQuery) : {};
} else {
this.mostRecentParams = oldQuery ? UrlParser.parseQueryString(oldQuery) : {};
}
});

$rootScope.$on(
'$stateChangeStart',
(
_event,
toState: Ng1StateDeclaration,
_toParams: StateParams,
fromState: Ng1StateDeclaration,
fromParams: StateParams,
) => {
if (this.movingFromLoadBalancerState(toState, fromState)) {
this.asFilterModel.saveState(fromState, fromParams, this.mostRecentParams);
}
},
);

$rootScope.$on(
'$stateChangeSuccess',
(_event, toState: Ng1StateDeclaration, toParams: StateParams, fromState: Ng1StateDeclaration) => {
if (this.isLoadBalancerStateOrChild(toState.name) && this.isLoadBalancerStateOrChild(fromState.name)) {
this.asFilterModel.applyParamsToUrl();
return;
}
if (this.movingToLoadBalancerState(toState)) {
if (this.shouldRouteToSavedState(toParams, fromState)) {
this.asFilterModel.restoreState(toParams);
}

if (this.fromLoadBalancersState(fromState) && !this.asFilterModel.hasSavedState(toParams)) {
this.asFilterModel.clearFilters();
}
}
},
);
}
}
Loading

0 comments on commit d39ab96

Please sign in to comment.