igniteui-angular-sovn
Version:
Ignite UI for Angular is a dependency-free Angular toolkit for building modern web apps
336 lines (305 loc) • 13.5 kB
text/typescript
import { Transaction, State, TransactionType, TransactionEventOrigin, Action } from './transaction';
import { IgxBaseTransactionService } from './base-transaction';
import { isObject, mergeObjects } from '../../core/utils';
export class IgxTransactionService<T extends Transaction, S extends State> extends IgxBaseTransactionService<T, S> {
protected _transactions: T[] = [];
protected _redoStack: Action<T>[][] = [];
protected _undoStack: Action<T>[][] = [];
protected _states: Map<any, S> = new Map();
/**
* @returns if there are any transactions in the Undo stack
*/
public override get canUndo(): boolean {
return this._undoStack.length > 0;
}
/**
* @returns if there are any transactions in the Redo stack
*/
public override get canRedo(): boolean {
return this._redoStack.length > 0;
}
/**
* Adds provided transaction with recordRef if any
*
* @param transaction Transaction to be added
* @param recordRef Reference to the value of the record in the data source related to the changed item
*/
public override add(transaction: T, recordRef?: any): void {
const states = this._isPending ? this._pendingStates : this._states;
this.verifyAddedTransaction(states, transaction, recordRef);
this.addTransaction(transaction, states, recordRef);
}
/**
* Returns all recorded transactions in chronological order
*
* @param id Optional record id to get transactions for
* @returns All transaction in the service or for the specified record
*/
public override getTransactionLog(id?: any): T[] {
if (id !== undefined) {
return this._transactions.filter(t => t.id === id);
}
return [...this._transactions];
}
/**
* Returns aggregated changes from all transactions
*
* @param mergeChanges If set to true will merge each state's value over relate recordRef
* and will record resulting value in the related transaction
* @returns Collection of aggregated transactions for each changed record
*/
public override getAggregatedChanges(mergeChanges: boolean): T[] {
const result: T[] = [];
this._states.forEach((state: S, key: any) => {
const value = mergeChanges ? this.mergeValues(state.recordRef, state.value) : state.value;
result.push({ id: key, newValue: value, type: state.type } as T);
});
return result;
}
/**
* Returns the state of the record with provided id
*
* @param id The id of the record
* @param pending Should get pending state
* @returns State of the record if any
*/
public override getState(id: any, pending = false): S {
return pending ? this._pendingStates.get(id) : this._states.get(id);
}
/**
* Returns whether transaction is enabled for this service
*/
public override get enabled(): boolean {
return true;
}
/**
* Returns value of the required id including all uncommitted changes
*
* @param id The id of the record to return value for
* @param mergeChanges If set to true will merge state's value over relate recordRef
* and will return merged value
* @returns Value with changes or **null**
*/
public override getAggregatedValue(id: any, mergeChanges: boolean): any {
const state = this._states.get(id);
const pendingState = super.getState(id);
// if there is no state and there is no pending state return null
if (!state && !pendingState) {
return null;
}
const pendingChange = super.getAggregatedValue(id, false);
const change = state && state.value;
let aggregatedValue = this.mergeValues(change, pendingChange);
if (mergeChanges) {
const originalValue = state ? state.recordRef : pendingState.recordRef;
aggregatedValue = this.mergeValues(originalValue, aggregatedValue);
}
return aggregatedValue;
}
/**
* Clears all pending transactions and aggregated pending state. If commit is set to true
* commits pending states as single transaction
*
* @param commit Should commit the pending states
*/
public override endPending(commit: boolean): void {
this._isPending = false;
if (commit) {
const actions: Action<T>[] = [];
// don't use addTransaction due to custom undo handling
for (const transaction of this._pendingTransactions) {
const pendingState = this._pendingStates.get(transaction.id);
this._transactions.push(transaction);
this.updateState(this._states, transaction, pendingState.recordRef);
actions.push({ transaction, recordRef: pendingState.recordRef });
}
this._undoStack.push(actions);
this._redoStack = [];
this.onStateUpdate.emit({ origin: TransactionEventOrigin.END, actions });
}
super.endPending(commit);
}
/**
* Applies all transactions over the provided data
*
* @param data Data source to update
* @param id Optional record id to commit transactions for
*/
public override commit(data: any[], id?: any): void {
if (id !== undefined) {
const state = this.getState(id);
if (state) {
this.updateRecord(data, state);
}
} else {
this._states.forEach((s: S) => {
this.updateRecord(data, s);
});
}
this.clear(id);
}
/**
* Clears all transactions
*
* @param id Optional record id to clear transactions for
*/
public override clear(id?: any): void {
if (id !== undefined) {
this._transactions = this._transactions.filter(t => t.id !== id);
this._states.delete(id);
// Undo stack is an array of actions. Each action is array of transaction like objects
// We are going trough all the actions. For each action we are filtering out transactions
// with provided id. Finally if any action ends up as empty array we are removing it from
// undo stack
this._undoStack = this._undoStack.map(a => a.filter(t => t.transaction.id !== id)).filter(a => a.length > 0);
} else {
this._transactions = [];
this._states.clear();
this._undoStack = [];
}
this._redoStack = [];
this.onStateUpdate.emit({ origin: TransactionEventOrigin.CLEAR, actions: [] });
}
/**
* Remove the last transaction if any
*/
public override undo(): void {
if (this._undoStack.length <= 0) {
return;
}
const lastActions: Action<T>[] = this._undoStack.pop();
this._transactions.splice(this._transactions.length - lastActions.length);
this._redoStack.push(lastActions);
this._states.clear();
for (const currentActions of this._undoStack) {
for (const transaction of currentActions) {
this.updateState(this._states, transaction.transaction, transaction.recordRef);
}
}
this.onStateUpdate.emit({ origin: TransactionEventOrigin.UNDO, actions: lastActions });
}
/**
* Applies the last undone transaction if any
*/
public override redo(): void {
if (this._redoStack.length > 0) {
const actions: Action<T>[] = this._redoStack.pop();
for (const action of actions) {
this.updateState(this._states, action.transaction, action.recordRef);
this._transactions.push(action.transaction);
}
this._undoStack.push(actions);
this.onStateUpdate.emit({ origin: TransactionEventOrigin.REDO, actions });
}
}
protected addTransaction(transaction: T, states: Map<any, S>, recordRef?: any) {
this.updateState(states, transaction, recordRef);
const transactions = this._isPending ? this._pendingTransactions : this._transactions;
transactions.push(transaction);
if (!this._isPending) {
const actions = [{ transaction, recordRef }];
this._undoStack.push(actions);
this._redoStack = [];
this.onStateUpdate.emit({ origin: TransactionEventOrigin.ADD, actions });
}
}
/**
* Verifies if the passed transaction is correct. If not throws an exception.
*
* @param transaction Transaction to be verified
*/
protected verifyAddedTransaction(states: Map<any, S>, transaction: T, recordRef?: any): void {
const state = states.get(transaction.id);
switch (transaction.type) {
case TransactionType.ADD:
if (state) {
// cannot add same item twice
throw new Error(`Cannot add this transaction. Transaction with id: ${transaction.id} has been already added.`);
}
break;
case TransactionType.DELETE:
case TransactionType.UPDATE:
if (state && state.type === TransactionType.DELETE) {
// cannot delete or update deleted items
throw new Error(`Cannot add this transaction. Transaction with id: ${transaction.id} has been already deleted.`);
}
if (!state && !recordRef && !this._isPending) {
// cannot initially add transaction or delete item with no recordRef
throw new Error(`Cannot add this transaction. This is first transaction of type ${transaction.type} ` +
`for id ${transaction.id}. For first transaction of this type recordRef is mandatory.`);
}
break;
}
}
/**
* Updates the provided states collection according to passed transaction and recordRef
*
* @param states States collection to apply the update to
* @param transaction Transaction to apply to the current state
* @param recordRef Reference to the value of the record in data source, if any, where transaction should be applied
*/
protected override updateState(states: Map<any, S>, transaction: T, recordRef?: any): void {
let state = states.get(transaction.id);
// if TransactionType is ADD simply add transaction to states;
// if TransactionType is DELETE:
// - if there is state with this id of type ADD remove it from the states;
// - if there is state with this id of type UPDATE change its type to DELETE;
// - if there is no state with this id add transaction to states;
// if TransactionType is UPDATE:
// - if there is state with this id of type ADD merge new value and state recordRef into state new value
// - if there is state with this id of type UPDATE merge new value into state new value
// - if there is state with this id and state type is DELETE change its type to UPDATE
// - if there is no state with this id add transaction to states;
if (state) {
switch (transaction.type) {
case TransactionType.DELETE:
if (state.type === TransactionType.ADD) {
states.delete(transaction.id);
} else if (state.type === TransactionType.UPDATE) {
state.value = transaction.newValue;
state.type = TransactionType.DELETE;
}
break;
case TransactionType.UPDATE:
if (isObject(state.value)) {
if (state.type === TransactionType.ADD) {
state.value = this.mergeValues(state.value, transaction.newValue);
}
if (state.type === TransactionType.UPDATE) {
mergeObjects(state.value, transaction.newValue);
}
} else {
state.value = transaction.newValue;
}
}
} else {
state = { value: this.cloneStrategy.clone(transaction.newValue), recordRef, type: transaction.type } as S;
states.set(transaction.id, state);
}
this.cleanState(transaction.id, states);
}
/**
* Updates state related record in the provided data
*
* @param data Data source to update
* @param state State to update data from
*/
protected updateRecord(data: any[], state: S) {
const index = data.findIndex(i => JSON.stringify(i) === JSON.stringify(state.recordRef || {}));
switch (state.type) {
case TransactionType.ADD:
data.push(state.value);
break;
case TransactionType.DELETE:
if (0 <= index && index < data.length) {
data.splice(index, 1);
}
break;
case TransactionType.UPDATE:
if (0 <= index && index < data.length) {
data[index] = this.updateValue(state);
}
break;
}
}
}