File

libs/ngx-pfe/services/pfe-state-service/state.service.ts

Description

The PfeStateService is the central location to hold the app state.

We need to tell the Angular compiler that the NGX_PFE_INITIAL_STATE token with a Promise is ok: It doesn't like the Promise there, as that could cause issues during runtime (Check runs due to strictMetadataEmit). This will not be required anymore, once the library builds are also switched to ivy.

Index

Properties
Methods

Constructor

constructor(ngxPfeModuleConfiguration: NgxPfeModuleConfiguration, ngxPfeStateKeyConfig: PfeStateSessionStorageKeyConfig, restoreStateToken: Promise<PfeUserInputState>, pfeRestService: PfeRestService, pfeAppStateFactory: ModelFactory<PfeUserInputState>, pfeUtilService: PfeUtilService, appRef: ApplicationRef, pfeConditionsService: PfeConditionsService, pfeConfigService: PfeConfigurationService, _document: Document, logger: NgxLoggerService)
Parameters :
Name Type Optional
ngxPfeModuleConfiguration NgxPfeModuleConfiguration No
ngxPfeStateKeyConfig PfeStateSessionStorageKeyConfig No
restoreStateToken Promise<PfeUserInputState> No
pfeRestService PfeRestService No
pfeAppStateFactory ModelFactory<PfeUserInputState> No
pfeUtilService PfeUtilService No
appRef ApplicationRef No
pfeConditionsService PfeConditionsService No
pfeConfigService PfeConfigurationService No
_document Document No
logger NgxLoggerService No

Methods

analyzePageForStale
analyzePageForStale(page: StaleStateConfig)
Parameters :
Name Type Optional
page StaleStateConfig No
Returns : void
automaticallyRestoreState
automaticallyRestoreState()

Tries to restore a state from these sources:

  1. NGX_PFE_RESTORED_STATE injection token
  2. Backend
  3. SessionStorage

Errors from the NGX_PFE_RESTORED_STATE injection token are forwarded. Errors from the other sources are swallowed.

Returns : Promise<any>
cleanStaleStateByServiceActivator
cleanStaleStateByServiceActivator()

Method is triggered by serviceActivator

Returns : void
getFullState
getFullState()

Returns a copy of the current PfeAppState. To change data within the state, the StateService methods need to be used.

Returns : PfeUserInputState
getObservableForExpressionKey
getObservableForExpressionKey(expression: JsonPathExpression, triggerForUndefined?: boolean, disableDeepEqual?: boolean)

Returns an observable for am expression in the state. Use subscribe to react to changes. The observable gets only triggered, if the actual value of the expression changes. If it is set again with the same value, the observable won't trigger.

Do not forget to unsubscribe, once the subscription is not needed anymore.

Parameters :
Name Type Optional Description
expression JsonPathExpression No

JsonPathExpression to get the value in the state.

triggerForUndefined boolean Yes

Trigger the observable, also when the value is undefined. Default: false

disableDeepEqual boolean Yes

By default the observable only triggers for a deepEqual change. This behavior can be disabled. Default: false

Returns : Observable<any>
getObservableForKey
getObservableForKey(key: string, triggerForUndefined?: boolean, disableDeepEqual?: boolean)

Returns an observable for a specific key in the state. Use subscribe to react to changes. The observable gets only triggered, if the actual value of the key changes. If it is set again with the same value, the observable won't trigger.

Do not forget to unsubscribe, once the subscription is not needed anymore.

Parameters :
Name Type Optional Description
key string No

The key of the value in the state.

triggerForUndefined boolean Yes

Trigger the observable, also when the value is undefined.

disableDeepEqual boolean Yes

By default the observable only triggers for a deepEqual change. This behavior can be disabled. Default: false

Returns : Observable<any>
getStateValueByExpression
getStateValueByExpression(jsonPathExpression: JsonPathExpression)
Parameters :
Name Type Optional
jsonPathExpression JsonPathExpression No
Returns : any
getValue
getValue(key: string)

Returns previously stored data by its key.

Parameters :
Name Type Optional Description
key string No

The key in the state

Returns : any
isSessionStorageEnabled
isSessionStorageEnabled()
Returns : Promise<boolean>
removePageFromState
removePageFromState(pageIds: string[], currentPageId: string)
Parameters :
Name Type Optional
pageIds string[] No
currentPageId string No
Returns : void
removeStateInSessionStorage
removeStateInSessionStorage(stateKey: string)
Parameters :
Name Type Optional Default value
stateKey string No this.stateKey
Returns : Promise<void>
resetState
resetState()
Public restoreState
restoreState(state: PfeUserInputState, resolve?: (value?: PfeUserInputState) => void)
Parameters :
Name Type Optional
state PfeUserInputState No
resolve function Yes
Returns : void
restoreStateFromBackend
restoreStateFromBackend()
restoreStateFromBackendOrSessionStorage
Please switch to the PfeBusinessService restoreState() method.
restoreStateFromBackendOrSessionStorage()
Returns : Promise<any>
retrieveStateFromSessionStorage
retrieveStateFromSessionStorage()
Returns : any
setFullState
setFullState(pfeUserInputState: PfeUserInputState)
Parameters :
Name Type Optional
pfeUserInputState PfeUserInputState No
Returns : void
setStateIds
setStateIds(formIds: string[])
Parameters :
Name Type Optional
formIds string[] No
Returns : void
storeState
storeState()
Returns : any
storeStateInSessionStorage
storeStateInSessionStorage()
Returns : Promise<void>
Async storeStateOnBackend
storeStateOnBackend()
Returns : any
storeValue
storeValue(key: string, value: any)

Store a value under the key in the state. Existing values with the same key, are overwritten.

Parameters :
Name Type Optional Description
key string No

The key has to be a string value.

value any No

The value can be of any type. Typecasting needs to be handled by the retrieving component.

Returns : void
storeValueByExpression
storeValueByExpression(expression: JsonPathExpression, value: any)

Store a value under the expression in the state. Existing values with the same key, are overwritten.

Parameters :
Name Type Optional Description
expression JsonPathExpression No

The expression has to be a valid JsonPathExpression.

value any No

The value can be of any type. Typecasting needs to be handled by the retrieving component.

Returns : void
Public updateStateValues
updateStateValues(stateUpdates: StateUpdates[] | undefined)

This method analyze if a navConfig have define userInputState updates and apply them

Parameters :
Name Type Optional Description
stateUpdates StateUpdates[] | undefined No

navigation config to be analyze

Returns : void

Properties

Readonly _staleState
Type : StaleStateInstance
Default value : { pages: {}, currentPageId: '', removePageFromState: [], }

staleState

Public pfeUserInputState$
Type : Observable<PfeUserInputState>

This observable triggers for every change of the whole model. It should only be used if absolutely necessary.

import { NgxLoggerService } from '@allianz/ngx-logger';
import { DOCUMENT } from '@angular/common';
import { ApplicationRef, Inject, Injectable, Optional } from '@angular/core';
import equal from 'fast-deep-equal/es6';
import { Observable, identity } from 'rxjs';
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { Model, ModelFactory } from '../../3rd-party/angular-extensions/model/model';
import { JsonPathExpression } from '../../models/jsonpath-expression-type.model';
import { StateOperations, StateUpdates } from '../../models/navigation-config.model';
import { NGX_PFE_CONFIGURATION, NgxPfeModuleConfiguration } from '../../models/ngx-pfe-module-configuration.model';
import { PfeUserInputState } from '../../models/pfe-state/user-input-state.model';
import { PfeConditionsService } from '../../pfe-conditions/public-api';
import { ARRAY_STATE_EXPRESSION, PfeUrlParameters, PfeUtilService, STATE_EXPRESSION } from '../../pfe-util/services/util.service';
import { clone } from '../../util/clone';
import { isObject } from '../../util/type-checks';
import { PfeConfigurationService } from '../pfe-config-service/config-service.service';
import { PfeRestService } from '../pfe-rest-service/rest.service';
import { PFE_STATE_INTERNAL_STATE_KEY } from './pfe-state-keys';
import { NGX_PFE_INITIAL_STATE, NGX_PFE_STATE_SESSION_STORAGE_KEY_CONFIG, PfeStateSessionStorageKeyConfig } from './state.model';

export type StateID = string;
export interface StaleStateInstance {
  pages: Record<string, StaleState>;
  currentPageId: string;
  removePageFromState: string[];
}

export interface StaleState {
  keys: string[];
  staleKeys: string[];
  revisit: boolean;
}

export interface StaleStateConfig {
  pageId: string;
  removePageFromState?: string[];
}

const PFE_INTERNAL_VALUES = ['pfeHistory', 'pfeVisitedPages', 'validationErrorData', 'lastVisitedPage', '_pfe'];

/**
 * The PfeStateService is the central location to hold the app state.
 *
 * We need to tell the Angular compiler that the NGX_PFE_INITIAL_STATE token with a Promise is ok:
 * @dynamic
 * It doesn't like the Promise there, as that could cause issues during runtime (Check runs due to strictMetadataEmit).
 * This will not be required anymore, once the library builds are also switched to ivy.
 *
 * @export
 */
@Injectable()
export class PfeStateService {
  // To be backwards compatible, the pfeUserInputState$ is kept and exposed:
  /**
   * This observable triggers for every change of the whole model.
   * It should only be used if absolutely necessary.
   */
  public pfeUserInputState$: Observable<PfeUserInputState>;

  /**
   * staleState
   */
  // Keeping the previous naming for now, as it would be potentially breaking (Even with the _)
  // eslint-disable-next-line @typescript-eslint/naming-convention
  readonly _staleState: StaleStateInstance = {
    pages: {},
    currentPageId: '',
    removePageFromState: [],
  };

  private _stateID: StateID | undefined;
  private pfeUserInputState: Model<PfeUserInputState>;

  /**
   * This observable triggers for every change of the whole model.
   * Keep the number of direct/unfiltered subscriptions low.
   */
  private pfeUserInputStateInternal$: Observable<PfeUserInputState>;

  private stateKey = 'state';

  constructor(
    @Inject(NGX_PFE_CONFIGURATION) private ngxPfeModuleConfiguration: NgxPfeModuleConfiguration,
    @Optional() @Inject(NGX_PFE_STATE_SESSION_STORAGE_KEY_CONFIG) private ngxPfeStateKeyConfig: PfeStateSessionStorageKeyConfig,
    @Optional() @Inject(NGX_PFE_INITIAL_STATE) private restoreStateToken: Promise<PfeUserInputState>,
    private pfeRestService: PfeRestService,
    private pfeAppStateFactory: ModelFactory<PfeUserInputState>,
    private pfeUtilService: PfeUtilService,
    private appRef: ApplicationRef,
    private pfeConditionsService: PfeConditionsService,
    private pfeConfigService: PfeConfigurationService,
    @Inject(DOCUMENT) private _document: Document,
    private logger: NgxLoggerService
  ) {
    // Replace with a nicer solution, once
    // https://github.com/Microsoft/TypeScript/issues/10853 and
    // https://github.com/Microsoft/TypeScript/issues/11304
    // are resolved.
    const pfeUserInputState: PfeUserInputState = {
      [PFE_STATE_INTERNAL_STATE_KEY]: {},
      staleState: this._staleState,
    };
    /**
     * The pfeUserInputState is created without the immutable behavior here.
     * That means, this needs to be done manually in every location where the pfeUserInputState or pfeUserInputState$
     * is returned to the outside world. Wether it be in full or only as a slice from within the data.
     *
     * The background for this is, that the immutable version of the Model always copies the whole state
     * when it is accessed. This results in a worsening performance the bigger the data in the state is.
     *
     * Copying the whole state is not necessary, when only a slice of data is accessed. For example via the getObservableFor* methods.
     * I those cases is is enough to only copy the slice of data that is actually returned.
     *
     * For this reason, the state itself is created as mutable and only the returned data to outside this service is
     * copied.
     *
     * Additionally, the performance when updating the state via the store* methods is improved as the state is now only copied once instead of twice.
     */
    this.pfeUserInputState = this.pfeAppStateFactory.createMutable(pfeUserInputState);
    this.pfeUserInputStateInternal$ = this.pfeUserInputState.data$;
    this.pfeUserInputState$ = this.pfeUserInputStateInternal$.pipe(map((data) => clone(data)));

    const stateIDFomURL = this.pfeUtilService.getURLParam(PfeUrlParameters.STATE_ID);
    this.stateID = stateIDFomURL;
    this.readURLStateParameters();
    this.setupHotkeys();
    this.setUpStateKey();
  }

  private set stateID(stateID: StateID) {
    if (!stateID) {
      return;
    }
    this._stateID = stateID;
    this.storeValue('pfeStateID', this._stateID);
  }

  /**
   * Store a value under the key in the state.
   * Existing values with the same key, are overwritten.
   *
   * @param key The key has to be a string value.
   * @param value The value can be of any type. Typecasting needs to be handled by the retrieving component.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  storeValue(key: string, value: any) {
    this.addKeyToStale(key);
    const pfeAppState = this.pfeUserInputState.get();
    pfeAppState[key] = value;
    this.pfeUserInputState.set(clone(pfeAppState));
  }

  /**
   * Store a value under the expression in the state.
   * Existing values with the same key, are overwritten.
   *
   * @param expression The expression has to be a valid JsonPathExpression.
   * @param value The value can be of any type. Typecasting needs to be handled by the retrieving component.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  storeValueByExpression(expression: JsonPathExpression, value: any) {
    const pfeAppState = this.pfeUserInputState.get();
    PfeUtilService.setValueFromJsonExpression(pfeAppState, expression, value);
    this.pfeUserInputState.set(clone(pfeAppState));
    // store the userInput state
    this.addKeyToStale(expression);
  }

  /**
   * Returns previously stored data by its key.
   *
   * @param key The key in the state
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  getValue(key: string): any {
    const pfeAppState = this.pfeUserInputState.get();
    return clone(pfeAppState[key]);
  }

  getStateValueByExpression(jsonPathExpression: JsonPathExpression) {
    const pfeAppState = this.pfeUserInputState.get();
    return clone(PfeUtilService.getValueOrList(pfeAppState, jsonPathExpression));
  }

  async storeStateOnBackend() {
    if (!this.ngxPfeModuleConfiguration.stateServiceEndpoint) {
      return;
    }

    const currentState: PfeUserInputState = clone(this.pfeUserInputState.get());
    const stateSize = Object.keys(currentState).length;

    if (stateSize > 0) {
      if (!this._stateID) {
        await this.createStateAndGetStateID(currentState);
      } else {
        await this.updateState(this._stateID, currentState);
      }
    }
  }

  public restoreState(state: PfeUserInputState, resolve?: (value: PfeUserInputState) => void) {
    this.pfeUserInputState.set(clone(state));
    this.populateBrowserHistory(state);
    this.readURLStateParameters();
    this.appRef.tick();
    if (resolve) {
      resolve(state);
    }
    this.pfeUtilService.trackingStateRestoredSubject$.next(state);
  }

  restoreStateFromBackend(): Promise<PfeUserInputState> {
    if (!this.ngxPfeModuleConfiguration.stateServiceEndpoint || !this._stateID) {
      return Promise.reject(undefined);
    }

    return new Promise((resolve, reject) => {
      if (!this._stateID) {
        reject();
      } else {
        this.pfeRestService.getClientState(this._stateID).subscribe(
          (data) => {
            const state: PfeUserInputState = { ...data };
            const stateSize = Object.keys(state).length;
            if (stateSize > 0) {
              this.restoreState(state, resolve);
            } else if (resolve) {
              resolve(state);
            }
          },
          (error) => {
            // TODO: Error handling
            this.logger.errorToServer('restoreStateFromBackend', error);
            reject();
          }
        );
      }
    });
  }

  /**
   * Returns a copy of the current PfeAppState.
   * To change data within the state, the StateService methods need to be used.
   */
  getFullState(): PfeUserInputState {
    return clone(this.pfeUserInputState.get());
  }

  setFullState(pfeUserInputState: PfeUserInputState) {
    // TODO: track stale state key when using setFullState function
    this.pfeUserInputState.set(clone(pfeUserInputState));
  }

  resetState(): Promise<PfeUserInputState> {
    return new Promise((resolve) => this.restoreState({}, resolve));
  }

  /**
   * Returns an observable for a specific key in the state.
   * Use subscribe to react to changes.
   * The observable gets only triggered, if the actual value of the key changes.
   * If it is set again with the same value, the observable won't trigger.
   *
   * Do not forget to unsubscribe, once the subscription is not needed anymore.
   *
   * @param key The key of the value in the state.
   * @param triggerForUndefined Trigger the observable, also when the value is undefined.
   * @param disableDeepEqual By default the observable only triggers for a deepEqual change. This behavior can be disabled. Default: false
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  getObservableForKey(key: string, triggerForUndefined?: boolean, disableDeepEqual?: boolean): Observable<any> {
    return this.getObservableForAccessor((entry) => entry[key], triggerForUndefined, disableDeepEqual);
  }

  /**
   * Returns an observable for am expression in the state.
   * Use subscribe to react to changes.
   * The observable gets only triggered, if the actual value of the expression changes.
   * If it is set again with the same value, the observable won't trigger.
   *
   * Do not forget to unsubscribe, once the subscription is not needed anymore.
   *
   * @param expression JsonPathExpression to get the value in the state.
   * @param triggerForUndefined Trigger the observable, also when the value is undefined. Default: false
   * @param disableDeepEqual By default the observable only triggers for a deepEqual change. This behavior can be disabled. Default: false
   */
  getObservableForExpressionKey(
    expression: JsonPathExpression,
    triggerForUndefined?: boolean,
    disableDeepEqual?: boolean
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ): Observable<any> {
    return this.getObservableForAccessor(
      (entry) => PfeUtilService.getValueOrList(entry, expression),
      triggerForUndefined,
      disableDeepEqual
    );
  }

  retrieveStateFromSessionStorage() {
    return this.isSessionStorageEnabled().then((value) => {
      if (value) {
        return this.restoreStateFromSessionStorage().catch(() => {
          // do nothing here
        });
      }
    });
  }

  storeStateInSessionStorage(): Promise<void> {
    return this.isSessionStorageEnabled().then((value) => {
      if (value) {
        return this.storeStateOnSessionStorage();
      }
    });
  }

  removeStateInSessionStorage(stateKey: string = this.stateKey): Promise<void> {
    return this.isSessionStorageEnabled().then((value) => {
      if (value) {
        return this.removeStateOnSessionStorage(stateKey);
      }
    });
  }

  isSessionStorageEnabled(): Promise<boolean> {
    return this.pfeConfigService.getConfig().then((config) => !!config?.appConfiguration?.pfeConfig?.enableSessionStorageState);
  }

  /**
   * Tries to restore a state from these sources:
   *
   * 1. NGX_PFE_RESTORED_STATE injection token
   * 2. Backend
   * 3. SessionStorage
   *
   * Errors from the NGX_PFE_RESTORED_STATE injection token are forwarded.
   * Errors from the other sources are swallowed.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  automaticallyRestoreState(): Promise<any> {
    if (this.restoreStateToken) {
      return this.restoreStateFromToken(this.restoreStateToken);
    } else {
      return this.restoreStateFromBackend()
        .then((data) => {
          const state: PfeUserInputState = { ...data };
          const stateSize = Object.keys(state).length;
          if (!(stateSize > 0)) {
            return this.retrieveStateFromSessionStorage();
          }
        })
        .catch(() => this.retrieveStateFromSessionStorage());
    }
  }

  /**
   * @deprecated Please switch to the PfeBusinessService restoreState() method.
   */
  restoreStateFromBackendOrSessionStorage() {
    return this.automaticallyRestoreState();
  }

  storeState() {
    return Promise.all([this.storeStateOnBackend(), this.storeStateInSessionStorage()]);
  }

  /**
   *
   * @param page
   */
  analyzePageForStale(page: StaleStateConfig) {
    if (this.pfeConfigService?.pfeApplicationConfiguration?.pfeConfig?.enableStateCleaning) {
      /**
       * check for page excluded from stale analysis
       */
      const excludedState = this.pfeConfigService.pfeApplicationConfiguration.pfeConfig.excludeFromStaleState;
      if (excludedState && this.contains(excludedState, page.pageId)) {
        return;
      }
      this.startStaleForPage(page.pageId);
      /**
       * check if page configuration contains pages to remove from state
       */
      if (Object.prototype.hasOwnProperty.call(page, 'removePageFromState')) {
        if (page.removePageFromState) {
          this.removePageFromState(page.removePageFromState, page.pageId);
        }
      }
    }
  }

  /**
   * @param pageIds
   * @param currentPageId
   */
  removePageFromState(pageIds: string[], currentPageId: string) {
    pageIds.forEach((pageId: string) => {
      if (this.pfeConfigService?.pfeApplicationConfiguration?.pfeConfig?.autoCleanStaleState) {
        this.removeStaleKeysFromState(pageId, true);
      } else if (!this.contains(this._staleState.removePageFromState, pageId)) {
        this._staleState.removePageFromState.push(pageId);
      }
    });

    const pageIndexInRemoveState = this._staleState.removePageFromState.indexOf(currentPageId);
    if (-1 < pageIndexInRemoveState) {
      this._staleState.removePageFromState.splice(pageIndexInRemoveState, 1);
    }
  }

  /**
   * Method is triggered by serviceActivator
   */
  cleanStaleStateByServiceActivator() {
    const pfeAppState = this.pfeUserInputState.get();
    const allPagesKeys = this.getAllPagesKeys(this._staleState.removePageFromState);
    // remove stalePages
    this._staleState.removePageFromState.forEach((pageId) => this.removePageFromStaleState(pageId, pfeAppState, allPagesKeys));
    this._staleState.removePageFromState.length = 0;
    // save state
    this.pfeUserInputState.set(pfeAppState);
  }

  /**
   * @param formIds
   */
  setStateIds(formIds: string[]) {
    formIds.forEach((key) => this.addKeyToStale(key));
  }

  /**
   * This method analyze if a navConfig have define userInputState updates and apply them
   * @param stateUpdates navigation config to be analyze
   */
  public updateStateValues(stateUpdates: StateUpdates[] | undefined) {
    // Exit if no updates are define
    if (!stateUpdates || stateUpdates.length === 0) {
      return;
    }

    try {
      // Filter the updates that could be done
      stateUpdates
        .filter((update) => {
          if (!update.conditions) {
            return true;
          }
          return this.pfeConditionsService.evaluateConditions(update.conditions, this.getFullState());
        })
        .forEach((update) => {
          if (update.operation) {
            this.executeOperationState(update);
          } else if (
            typeof update.value === 'string' &&
            (update.value.startsWith(STATE_EXPRESSION) || update.value.startsWith(ARRAY_STATE_EXPRESSION))
          ) {
            const object = this.getStateValueByExpression(update.value);
            this.storeValueByExpression(update.key, object);
          } else {
            this.storeValueByExpression(update.key, update.value);
          }
        });
    } catch (error) {
      this.logger.error(`Error during stateUpdates "${(error as Error).message}, used in config : `, stateUpdates);
    }
  }

  private async createStateAndGetStateID(currentState: PfeUserInputState) {
    try {
      const generatedStateID = (await this.pfeRestService.createClientState(currentState)).id;
      this.stateID = encodeURIComponent(generatedStateID);
    } catch (error) {
      // TODO: Error handling:
      this.logger.errorToServer('createStateAndGetStateID', error);
    }
  }

  private async updateState(stateID: StateID, currentState: PfeUserInputState) {
    try {
      await this.pfeRestService.updateClientState(stateID, currentState);
    } catch (error) {
      // TODO: Error handling:
      this.logger.errorToServer('updateState', error);
    }
  }

  private restoreStateFromToken(tokenPromise: Promise<PfeUserInputState | undefined>): Promise<PfeUserInputState> {
    return tokenPromise.then((state) => {
      const validState: PfeUserInputState = isObject(state) ? state : this.pfeUserInputState.get();
      return new Promise((resolve) => this.restoreState(validState, resolve));
    });
  }

  /**
   * Called by the public getObservableForExpressionKey and getObservableForKey
   * to get the same pipe with different accessors.
   */
  private getObservableForAccessor(
    accessor: (value: PfeUserInputState, index: number) => unknown,
    triggerForUndefined: boolean | undefined,
    disableDeepEqual: boolean | undefined
  ) {
    return this.pfeUserInputStateInternal$.pipe(
      map(accessor),
      filter((entry) => {
        if (triggerForUndefined) {
          return true;
        } else {
          return entry != null;
        }
      }),
      map((data) => clone(data)),
      !disableDeepEqual ? distinctUntilChanged((prev: unknown, next: unknown) => equal(prev, next)) : identity
    );
  }

  private readURLStateParameters() {
    const urlParams = this.pfeUtilService.getUrlParams(this._document.location.search, '?');
    this.copyValuesToState(urlParams);
    const hashParams = this.pfeUtilService.getUrlParams(this._document.location.hash, '#');
    this.copyValuesToState(hashParams);
  }

  private copyValuesToState(parameters: Record<string, string>) {
    for (const key in parameters) {
      if (Object.prototype.hasOwnProperty.call(parameters, key)) {
        const value = parameters[key];
        this.saveUrlParamsToState(key, value);
      }
    }
  }

  private saveUrlParamsToState(key: string, value: string) {
    this.pfeConfigService.getAppConfiguration().then((appConfiguration) => {
      if (appConfiguration?.pfeConfig?.urlParametersInState && appConfiguration.pfeConfig.urlParametersInState.length > 0) {
        appConfiguration.pfeConfig.urlParametersInState.forEach((urlParametersInState) => {
          if (urlParametersInState.key === key) {
            this.storeValueByExpression(urlParametersInState.stateKeyExpression, value);
          }
        });
      }
    });
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  private populateBrowserHistory(state: PfeUserInputState) {
    // TODO: Implement me
  }

  private async setupHotkeys() {
    if (!this.ngxPfeModuleConfiguration.disableStateDebuggingHotkey) {
      this._document.addEventListener('keydown', (event) => {
        if ((event.altKey || event.metaKey) && event.ctrlKey && event.key === 'l') {
          const state = this.getFullState();
          console.log(state);
          return false; // Prevent bubbling
        }
      });
    }
  }

  private setUpStateKey() {
    if (this.ngxPfeStateKeyConfig?.keySuffix) {
      this.stateKey = `state_${this.ngxPfeStateKeyConfig.keySuffix}`;
    }
  }

  private restoreStateFromSessionStorage(): Promise<PfeUserInputState> {
    const state = sessionStorage.getItem(this.stateKey);
    if (state) {
      return new Promise((resolve) => this.restoreState(JSON.parse(state), resolve));
    } else {
      return Promise.reject();
    }
  }

  private storeStateOnSessionStorage() {
    const currentState: PfeUserInputState = this.pfeUserInputState.get();
    const stateSize = Object.keys(currentState).length;

    if (stateSize > 0) {
      sessionStorage.setItem(this.stateKey, JSON.stringify(currentState));
    }
  }

  private removeStateOnSessionStorage(stateKey: string) {
    sessionStorage.removeItem(stateKey);
  }

  /**
   *
   * @param stateKeys
   * @param pfeStateObj
   */
  private removeKeysFromState(stateKeys: string[], pfeStateObj: PfeUserInputState) {
    stateKeys.forEach((key) => this.getValueForKey(key, pfeStateObj, true));
  }

  /**
   *
   * @param key
   * @param pfeStateObj
   */
  private getValueForKey(key: string, pfeStateObj: PfeUserInputState, removeKey?: boolean) {
    const props = key.split('.'),
      last: keyof PfeUserInputState | undefined = props.pop();
    let context = pfeStateObj;
    props.forEach((dKey) => {
      context = context && context[dKey] ? context[dKey] : null;
    });

    if (removeKey && context && last) {
      delete context[last];
    }

    return context;
  }

  /**
   * registers a page for staling
   * @param pageID
   */
  private startStaleForPage(pageID: string) {
    this._staleState.currentPageId = pageID;
    if (!Object.prototype.hasOwnProperty.call(this._staleState.pages, pageID)) {
      this._staleState.pages[pageID] = {
        keys: [],
        staleKeys: [],
        revisit: false,
      };
    } else {
      const state = this._staleState.pages[pageID];
      state.revisit = true;
      // eslint-disable-next-line prefer-spread
      state.staleKeys.push.apply(
        state.staleKeys,
        state.keys.filter((key) => !this.contains(state.staleKeys, key))
      );
      state.keys.length = 0;
    }
  }

  /**
   *
   * @param pageId
   * @param pfeAppState
   */
  private cleanUpRevisitedPage(pageId: string, pfeAppState: PfeUserInputState) {
    const state = this._staleState.pages[pageId];
    if (state && state.revisit) {
      /**
       * remove the values from old state
       */
      this.removeKeysFromState(
        state.staleKeys.filter((key) => !this.contains(state.keys, key)),
        pfeAppState
      );
      state.staleKeys.length = 0;
    }
  }

  private getAllPagesKeys(exclude: string[]): string[] {
    const keys: string[] = [];
    this.staleForEach((key: string) => {
      const state = this._staleState.pages[key];
      if (!this.contains(exclude, key)) {
        // eslint-disable-next-line prefer-spread
        keys.push.apply(
          keys,
          state.keys.filter((id) => !this.contains(keys, id))
        );
      }
    });

    return keys;
  }

  /**
   *
   * @param list
   * @param key
   */
  private contains(list: string[], key: string) {
    return list.includes(key);
  }

  /**
   *
   * @param pageId
   * @param pfeAppState
   */
  private removePageFromStaleState(pageId: string, pfeAppState: PfeUserInputState, allPagesKeys: string[]) {
    if (Object.prototype.hasOwnProperty.call(this._staleState.pages, pageId)) {
      const state = this._staleState.pages[pageId];
      const keysToRemove = state.keys.concat(state.staleKeys).reduce((acc: string[], key: string) => {
        if (!this.contains(acc, key) && !this.contains(allPagesKeys, key)) {
          acc.push(key);
        }
        return acc;
      }, []);
      this.removeKeysFromState(keysToRemove, pfeAppState);
      delete this._staleState.pages[pageId];
      const pageIndexInVisitedPage = pfeAppState.pfeVisitedPages.indexOf(pageId);
      /**
       * remove pageId values from pfeStateObject
       */
      if (-1 < pageIndexInVisitedPage) {
        pfeAppState.pfeVisitedPages.splice(pageIndexInVisitedPage, 1);
        delete pfeAppState[pageId];
      }
    }
  }

  /**
   *
   * @param key
   */
  private addKeyToStale(key: string) {
    if (
      this.pfeConfigService.pfeApplicationConfiguration &&
      this.pfeConfigService.pfeApplicationConfiguration.pfeConfig &&
      this.pfeConfigService.pfeApplicationConfiguration.pfeConfig.enableStateCleaning
    ) {
      /**
       * remove expression from key
       */
      key = (key.split('$.')[1] || key).replace(/(\[)/g, '.').replace(/(])/g, '').replace(/['"]/g, '');

      if (
        this._staleState.currentPageId &&
        !this.contains(PFE_INTERNAL_VALUES, key) &&
        !Object.prototype.hasOwnProperty.call(this._staleState.pages, key)
      ) {
        const state = this._staleState.pages[this._staleState.currentPageId];
        if (!this.contains(state.keys, key)) {
          state.keys.push(key);
        }
      }
    }
  }

  /**
   *
   * @param pageId
   * @param removeFromStateObj
   */
  private removeStaleKeysFromState(pageId: string, removeFromStateObj = false) {
    const pfeAppState = this.pfeUserInputState.get();
    if (removeFromStateObj) {
      const allPagesKeys = this.getAllPagesKeys([pageId]);
      this.removePageFromStaleState(pageId, pfeAppState, allPagesKeys);
    } else {
      this.cleanUpRevisitedPage(pageId, pfeAppState);
    }
    this.pfeUserInputState.set(pfeAppState);
  }

  private staleForEach(method: (value: string, index: number, array: string[]) => void) {
    Object.keys(this._staleState.pages).forEach(method);
  }

  /**
   * Execute state operations
   * @param state
   */
  private executeOperationState(state: StateUpdates) {
    switch (state.operation) {
      case StateOperations.REMOVE:
        // Only trigger the remove operation if the value actually exists in the state.
        // The background of this is, that parents of a non existing expression would be automatically created in that case.
        // As we do not want that to happen, we first check if the value exists.
        if (this.getStateValueByExpression(state.key) !== undefined) {
          this.storeValueByExpression(state.key, undefined);
        }
        break;
      case StateOperations.PUSH: {
        // Create or retrieve list
        let object = this.getStateValueByExpression(state.key);
        object = !object ? [] : object;
        // Retrieve value if state one or use it
        let insertVal = state.value;
        if (
          typeof state.value === 'string' &&
          (state.value.startsWith(STATE_EXPRESSION) || state.value.startsWith(ARRAY_STATE_EXPRESSION))
        ) {
          insertVal = this.getStateValueByExpression(state.value);
        }
        // If we want to push something using spread assignation, we should have an array
        if (!Array.isArray(insertVal)) {
          insertVal = [insertVal];
        }
        // Store new list
        this.storeValueByExpression(state.key, [...(object as unknown[]), ...insertVal]);
        break;
      }
      case StateOperations.INCREASE: {
        const object = this.getStateValueByExpression(state.key);
        if (typeof state.value === 'number' && typeof object === 'number') {
          this.storeValueByExpression(state.key, object + state.value);
        }
        break;
      }
      case StateOperations.DECREASE: {
        const object = this.getStateValueByExpression(state.key);
        if (typeof state.value === 'number' && typeof object === 'number') {
          this.storeValueByExpression(state.key, object - state.value);
        }
        break;
      }
      case StateOperations.JOIN: {
        const object = this.getStateValueByExpression(state.key);
        if (Array.isArray(object)) {
          this.storeValueByExpression(state.key, object.join(state.value));
        }
        break;
      }
    }
  }
}

results matching ""

    No results matching ""