@adaptabletools/adaptable
Version:
Powerful data-agnostic HTML5 AG Grid extension which provides advanced, cutting-edge functionality to meet all DataGrid requirements
520 lines (519 loc) • 25.2 kB
JavaScript
import { defer, of } from 'rxjs';
import { count, debounceTime, delay, filter, map, mergeAll, mergeMap, startWith, takeUntil, tap, withLatestFrom, } from 'rxjs/operators';
import parseInt from 'lodash/parseInt';
import { ExpressionEvaluationError } from '../../parser/src/ExpressionEvaluationError';
import { extractColumnParameter, extractParameter, getDataChangeLog$, getGridChangeLog$, getNumericValue, handleColumnFunction, handleWhereFunction, validateColumnType, } from './expressionFunctionUtils';
import { getTypedKeys } from '../Extensions/TypeExtensions';
// numeric value(digits) followed by a single 's', 'm' or 'h' letter (case insensitive)
const TIMEFRAME_REGEX = /^(\d+)(s|m|h)$/i;
const SYSTEM_MAX_TIMEFRAME_SIZE = 86400000; // 24h
export const observableExpressionFunctions = {
WHERE: {
handler(args, context) {
return handleWhereFunction(args, context);
},
isHiddenFromMenu: true,
description: 'Limits the rows against which the Observable expression will be evaluated',
signatures: ['<main_query> WHERE <boolean_query>'],
examples: ['<main_query> WHERE <boolean_query>', '<main_query> WHERE QUERY("abc")'],
hasEagerEvaluation: true,
category: 'conditional',
},
ROW_CHANGE: {
handler(args, context) {
const operandParameter = extractParameter('ROW_CHANGE', 'operand', ['COUNT', 'MAX', 'MIN', 'NONE'], args);
const timeframeParameter = extractParameter('ROW_CHANGE', 'config', ['TIMEFRAME'], args);
let dataChangeLog$ = getDataChangeLog$(context, operandParameter.column);
switch (operandParameter.name) {
case 'COUNT': {
const timeframeChange$ = getTrailingRowCountChange$(dataChangeLog$, timeframeParameter.value, operandParameter.value);
return getDataChangeCount$(dataChangeLog$, timeframeChange$, operandParameter.value);
}
case 'MIN': {
validateColumnType(operandParameter.column, ['number'], 'MIN', context.adaptableApi);
const timeframeChange$ = getTrailingRowValueChange$(dataChangeLog$, timeframeParameter.value);
return getDataChangeMin$(dataChangeLog$, timeframeChange$);
}
case 'MAX': {
validateColumnType(operandParameter.column, ['number'], 'MAX', context.adaptableApi);
const timeframeChange$ = getTrailingRowValueChange$(dataChangeLog$, timeframeParameter.value);
return getDataChangeMax$(dataChangeLog$, timeframeChange$);
}
case 'NONE': {
return dataChangeLog$.pipe(
// wait for the given time
debounceTime(timeframeParameter.value),
// completing the observable (ex. alert deletion) will also fire the NONE event
// takeUntil takes care of this, ignoring any emissions as soon as the source completes
// (count() result is irrelevant here, the main thing is that it emits an source completion)
takeUntil(dataChangeLog$.pipe(count())));
}
}
},
returnType: 'boolean',
description: 'Observes changes with each row in a distinct scope',
signatures: ['ROW_CHANGE(changeFunction: COUNT|MIN|MAX|NONE, timeframe:TIMEFRAME)'],
examples: [
"ROW_CHANGE( COUNT([columnName],2),TIMEFRAME('20s'))",
"ROW_CHANGE( MAX(COL('columnName')), TIMEFRAME('20s'))",
],
category: 'observable',
},
GRID_CHANGE: {
handler(args, context) {
const operandParameter = extractParameter('GRID_CHANGE', 'operand', ['COUNT', 'MAX', 'MIN', 'NONE'], args);
const timeframeParameter = extractParameter('GRID_CHANGE', 'config', ['TIMEFRAME'], args);
let dataChangeLog$ = getDataChangeLog$(context, operandParameter.column);
switch (operandParameter.name) {
case 'COUNT': {
const timeframeChange$ = getTrailingGridCountChange$(dataChangeLog$, timeframeParameter.value, operandParameter.value);
return getDataChangeCount$(dataChangeLog$, timeframeChange$, operandParameter.value);
}
case 'MIN': {
validateColumnType(operandParameter.column, ['number'], 'MIN', context.adaptableApi);
const timeframeChange$ = getTrailingGridValueChange$(dataChangeLog$, timeframeParameter.value);
return getDataChangeMin$(dataChangeLog$, timeframeChange$);
}
case 'MAX': {
validateColumnType(operandParameter.column, ['number'], 'MAX', context.adaptableApi);
const timeframeChange$ = getTrailingGridValueChange$(dataChangeLog$, timeframeParameter.value);
return getDataChangeMax$(dataChangeLog$, timeframeChange$);
}
case 'NONE': {
return dataChangeLog$.pipe(
// add a synthetic first value to ensure the grid is observed even when there are no changes
startWith(getDataChangedInfoStub(context)),
// wait for the given time
debounceTime(timeframeParameter.value),
// completing the observable (ex. alert deletion) will also fire the NONE event
// takeUntil takes care of this, ignoring any emissions as soon as the source completes
// (count() result is irrelevant here, the main thing is that it emits a source completion)
takeUntil(dataChangeLog$.pipe(count())));
}
}
},
returnType: 'boolean',
description: 'Observes changes with the entire grid in a single scope',
signatures: ['GRID_CHANGE(changeFunction: COUNT|MIN|MAX|NONE, timeframe:TIMEFRAME)'],
examples: [
"GRID_CHANGE( COUNT([columnName],2),TIMEFRAME('20s'))",
"GRID_CHANGE( MAX(COL('columnName')), TIMEFRAME('20s'))",
],
category: 'observable',
},
ROW_ADDED: {
handler(args, context) {
const gridChangeLog$ = getGridChangeLog$(context, 'Add');
return handleGridRowAddedOrRemoved(args, gridChangeLog$, 'ROW_ADDED', context);
},
returnType: 'boolean',
description: 'Observes added rows in the grid',
signatures: [
'ROW_ADDED()',
'ROW_ADDED(rowCount: number)',
'ROW_ADDED(timeframe:TIMEFRAME)',
'ROW_ADDED(rowCount: number, timeframe:TIMEFRAME)',
],
examples: [
'ROW_ADDED()',
'ROW_ADDED(2)',
"ROW_ADDED(TIMEFRAME('20s'))",
"ROW_ADDED(2, TIMEFRAME('20s'))",
],
category: 'observable',
},
ROW_REMOVED: {
handler(args, context) {
const gridChangeLog$ = getGridChangeLog$(context, 'Delete');
return handleGridRowAddedOrRemoved(args, gridChangeLog$, 'ROW_REMOVED', context);
},
returnType: 'boolean',
description: 'Observes removed rows in the grid',
signatures: [
'ROW_REMOVED()',
'ROW_REMOVED(rowCount: number)',
'ROW_REMOVED(timeframe:TIMEFRAME)',
'ROW_REMOVED(rowCount: number, timeframe:TIMEFRAME)',
],
examples: [
'ROW_REMOVED()',
'ROW_REMOVED(2)',
"ROW_REMOVED(TIMEFRAME('20s'))",
"ROW_REMOVED(2, TIMEFRAME('20s'))",
],
category: 'observable',
},
COL: {
handler(args, context) {
return handleColumnFunction(args, context);
},
description: 'References a column by its unique identifier',
signatures: ['[colName]', 'COL(name: string)'],
examples: ['[col1]', "COL('col1')"],
category: 'special',
},
COUNT: {
handler(args) {
const columnParameter = extractColumnParameter('COUNT', args);
const countValue = args.find((arg) => typeof arg === 'number');
if (countValue == null || countValue <= 0) {
throw new ExpressionEvaluationError('COUNT', 'expects a positive number as argument');
}
const result = {
type: 'operand',
name: 'COUNT',
value: countValue,
column: columnParameter.value,
};
return result;
},
description: 'Observes if a column value has changed a given number of times.\nValid only as an operand for an observable function.',
signatures: [
'COUNT([colName],changeCount: number)',
'COUNT(COL(name: string),changeCount: number)',
],
examples: ['COUNT([colName],2)', `COUNT(COL('col1'),10)`],
category: 'operand',
},
NONE: {
handler(args) {
const columnParameter = extractColumnParameter('NONE', args);
const result = {
type: 'operand',
name: 'NONE',
column: columnParameter.value,
};
return result;
},
description: 'Observes if a column value has NOT changed.\nValid only as an operand for an observable function.',
signatures: ['NONE([colName])', 'NONE(COL(name: string))'],
examples: ['NONE([colA])', `NONE(COL('col1'))`],
category: 'operand',
},
MIN: {
handler(args) {
const columnParameter = extractColumnParameter('MIN', args);
const result = {
type: 'operand',
name: 'MIN',
column: columnParameter.value,
};
return result;
},
description: 'Observes if the changed column value is the lowest.\nValid only as an operand for an observable function.',
signatures: ['MIN([colName])', 'MIN(COL(name: string))'],
examples: ['MIN([colA])', `MIN(COL('col1'))`],
category: 'operand',
},
MAX: {
handler(args) {
const columnParameter = extractColumnParameter('MAX', args);
const result = {
type: 'operand',
name: 'MAX',
column: columnParameter.value,
};
return result;
},
description: 'Observes if the changed column value is the highest.\nValid only as an operand for an observable function.',
signatures: ['MAX([colName])', 'MAX(COL(name: string))'],
examples: ['MAX([colA])', `MAX(COL('col1'))`],
category: 'operand',
},
TIMEFRAME: {
handler(args, context) {
// unit -> milliseconds
const durationUnitRatios = {
s: 1000,
m: 60000,
h: 3600000,
};
const input = args[0] + '';
//we allow only seconds, minutes & hours durations
const matchingResult = input.match(TIMEFRAME_REGEX);
const value = matchingResult?.[1];
const unit = matchingResult?.[2]?.toLowerCase();
let duration;
if (value && unit && durationUnitRatios[unit]) {
duration = parseInt(value) * durationUnitRatios[unit];
}
if (!duration) {
throw new ExpressionEvaluationError('TIMEFRAME', `timeframe expression is invalid`);
}
// check if the given duration is greater than the generally defined maxTimeframe size
duration = getMaxTimeframeSize(duration, context);
const result = {
type: 'config',
name: 'TIMEFRAME',
value: duration,
};
return result;
},
description: 'Limits the monitoring operators to a trailing timeframe defined in seconds, minutes or hours.\nValid only as an operand for an observable function.',
signatures: [
"TIMEFRAME('%numeric_value%s')",
"TIMEFRAME('%numeric_value%m')",
"TIMEFRAME('%numeric_value%h')",
],
examples: ["TIMEFRAME('20s')", "TIMEFRAME('5m')", "TIMEFRAME('1h')"],
category: 'operand',
},
};
export const observableExpressionFunctionNames = getTypedKeys(observableExpressionFunctions);
// return TRUE if the last(tail) element has the greatest value
const isLastElementMaxValue = (values) => {
const [tailValue] = values.slice(-1);
const restValues = values.slice(0, -1);
return restValues.every((value) => value < tailValue);
};
// return TRUE if the last(tail) element has the greatest value
const isLastElementMinValue = (values) => {
const [tailValue] = values.slice(-1);
const restValues = values.slice(0, -1);
return restValues.every((value) => value > tailValue);
};
// useful for functions which do NOT have an initial change (ex. GRID_CHANGE NONE)
const getDataChangedInfoStub = (context) => {
let rowNodeStub;
if (!context.filterFn) {
rowNodeStub = context.node ?? context.adaptableApi.gridApi.getFirstRowNode();
}
else {
// if there is a WHERE clause defined, find the first rowNode which satisfies the condition
context.adaptableApi.internalApi.forAllRowNodesDo((rowNode) => {
if (!rowNodeStub) {
if (context.filterFn(rowNode)) {
rowNodeStub = rowNode;
}
}
});
}
const rowNode = rowNodeStub;
if (rowNode) {
const primaryKeyValue = context.adaptableApi.gridApi.getPrimaryKeyValueForRowNode(rowNode);
const columnId = context.adaptableApi.columnApi.getQueryableColumns()[0]?.columnId;
const oldValue = context.adaptableApi.gridApi.getCellRawValue(primaryKeyValue, columnId);
const column = context.adaptableApi.columnApi.getColumnWithColumnId(columnId);
const newValue = oldValue;
return {
changedAt: Date.now(),
rowNode,
primaryKeyValue,
column: column,
oldValue,
newValue,
};
}
else {
return {
changedAt: Date.now(),
};
}
};
// returns an observable which fires if the source$ emitted `targetCount` times in the given `timeframeChange$` period
const getDataChangeCount$ = (dataChangeLog$, timeframeChange$, targetCount) => {
return dataChangeLog$.pipe(withLatestFrom(timeframeChange$), filter(([_, changeCountWithinTimeframe]) => {
return targetCount === changeCountWithinTimeframe;
}), map(([source]) => source));
};
// returns an observable which fires if the last source$ emission had the lowest(min) value in the given `timeframeChange$` period
const getDataChangeMin$ = (dataChangeLog$, timeframeChange$) => {
return dataChangeLog$.pipe(withLatestFrom(timeframeChange$), filter(([changeLog, values]) => {
return values.length > 1
? isLastElementMinValue(values)
: getNumericValue(changeLog.oldValue) > getNumericValue(changeLog.newValue);
}), map(([changeLog]) => {
return changeLog;
}));
};
// returns an observable which fires if the last source$ emission had the highest(max) value in the given `timeframeChange$` period
const getDataChangeMax$ = (dataChangeLog$, timeframeChange$) => {
return dataChangeLog$.pipe(withLatestFrom(timeframeChange$), filter(([changeLog, values]) => {
return values.length > 1
? isLastElementMaxValue(values)
: getNumericValue(changeLog.oldValue) < getNumericValue(changeLog.newValue);
}), map(([changeLog]) => {
return changeLog;
}));
};
// returns an observable which maps a dataChangeLogEntry to the number(count) of changed values in the entire grid
// the counter is continuously up-to-date in the given timeframe
// if the given counterLimit is reached, the counter is reset
const getTrailingGridCountChange$ = (source$, trailingPeriod, counterLimit) => {
return defer(() => {
// keep the counter value in an internal intermediary state
let counter = 0;
const movingTimeWindow$ = getSlidingTimeframe$(source$, trailingPeriod, (dataChangeLog) => {
counter++;
}, (dataChangeLog) => {
// counter may have been reset during the timeframe, in which case we skip the decrement
if (counter > 0) {
counter--;
}
});
return movingTimeWindow$.pipe(map(() => {
const gridCounter = counter;
if (gridCounter === counterLimit) {
// reset counter
counter = 0;
}
return gridCounter;
}));
});
};
// returns an observable which maps a dataChangeLogEntry to the number(count) of changed values in the row of the given dataChangeLogEntry
// the counter is continuously up-to-date in the given timeframe
// if the given counterLimit is reached, the counter is reset
const getTrailingRowCountChange$ = (source$, trailingPeriod, counterLimit) => {
return defer(() => {
// keep the counter value in an internal intermediary state (distinct per row PK)
const counterMap = new Map();
const getCellCounter = (dataChangeLog) => counterMap.get(dataChangeLog.primaryKeyValue) ?? 0;
const slidingTimeframe$ = getSlidingTimeframe$(source$, trailingPeriod, (dataChangeLog) => {
let currentCounter = getCellCounter(dataChangeLog);
counterMap.set(dataChangeLog.primaryKeyValue, ++currentCounter);
}, (dataChangeLog) => {
let currentCounter = getCellCounter(dataChangeLog);
// counter may have been reset during the timeframe, in which case we skip the decrement
if (currentCounter > 0) {
counterMap.set(dataChangeLog.primaryKeyValue, --currentCounter);
}
});
return slidingTimeframe$.pipe(map((dataChangeLog) => {
const cellCounter = getCellCounter(dataChangeLog);
if (cellCounter === counterLimit) {
// reset counter
counterMap.set(dataChangeLog.primaryKeyValue, 0);
}
return cellCounter;
}));
});
};
// returns an observable which maps a dataChangeLogEntry to the array of changed values in the entire grid
// the changed values array is continuously up-to-date in the given timeframe
const getTrailingGridValueChange$ = (source$, trailingPeriod) => {
return defer(() => {
// keep the changed values in an internal intermediary state
let values = [];
const doubleInsertionsMap = new WeakMap();
const slidingTimeframe$ = getSlidingTimeframe$(source$, trailingPeriod, (dataChangeLog) => {
if (!values.length) {
// values is empty, so we evaluate both old & new node values
values.push(getNumericValue(dataChangeLog.oldValue));
// mark the double insertion, we will have to pop 2 elements
doubleInsertionsMap.set(dataChangeLog, true);
}
values.push(getNumericValue(dataChangeLog.newValue));
}, (dataChangeLog) => {
values.shift();
if (doubleInsertionsMap.get(dataChangeLog)) {
// the current change inserted both old and new values, so we have to extract 2 elements
values.shift();
doubleInsertionsMap.delete(dataChangeLog);
}
});
return slidingTimeframe$.pipe(map(() => values));
});
};
// returns an observable which maps a dataChangeLogEntry to the array of changed values in the row of the given dataChangeLogEntry
// the changed values array is continuously up-to-date in the given timeframe
const getTrailingRowValueChange$ = (source$, trailingPeriod) => {
return defer(() => {
// keep the changed values in an internal intermediary state (distinct per row PK)
const rowValuesMap = new Map();
const doubleInsertionsMap = new WeakMap();
const getRowValues = (dataChangeLog) => rowValuesMap.get(dataChangeLog.primaryKeyValue) ?? [];
const slidingTimeframe$ = getSlidingTimeframe$(source$, trailingPeriod, (dataChangeLog) => {
let rowValues = getRowValues(dataChangeLog);
if (!rowValues.length) {
// values is empty, so we evaluate both old & new node values
rowValues.push(getNumericValue(dataChangeLog.oldValue));
// mark the double insertion, we will have to pop 2 elements
doubleInsertionsMap.set(dataChangeLog, true);
}
rowValues.push(getNumericValue(dataChangeLog.newValue));
rowValuesMap.set(dataChangeLog.primaryKeyValue, rowValues);
}, (dataChangeLog) => {
let rowValues = getRowValues(dataChangeLog);
rowValues.shift();
if (doubleInsertionsMap.get(dataChangeLog)) {
// the current change inserted both old and new values, so we have to extract 2 elements
rowValues.shift();
doubleInsertionsMap.delete(dataChangeLog);
}
rowValuesMap.set(dataChangeLog.primaryKeyValue, rowValues);
});
return slidingTimeframe$.pipe(map((dataChangeLog) => getRowValues(dataChangeLog)));
});
};
// return an observable which will emit when the source$ event enters, respectively exits the timeframe
// it also executes the provided onEnter/onExit handlers
const getSlidingTimeframe$ = (source$, timeframeDuration, onTimeframeEnter, onTimeframeExit) => {
return source$.pipe(
// create intermediary observable which, for each emission from source$:
mergeMap((dataChangeLog) => {
// 1. it will emit the payload immediately...
const enter$ = of(dataChangeLog).pipe(
// ...and execute the provided onPushHandler() callback
tap((dataChangeLog) => onTimeframeEnter(dataChangeLog)));
// 2. and after a given 'timeWindowSize' delay it will re-emit the payload...
const exit$ = of(dataChangeLog).pipe(delay(timeframeDuration),
// ...and execute the provided onPopHandler() callback
tap((dataChangeLog) => onTimeframeExit(dataChangeLog)));
return of(enter$, exit$).pipe(mergeAll());
}));
};
const getMaxTimeframeSize = (expressionValue, context) => {
let maxTimeframeSize = context.adaptableApi.optionsApi.getExpressionOptions().maxTimeframeSize;
if (maxTimeframeSize > SYSTEM_MAX_TIMEFRAME_SIZE) {
maxTimeframeSize = SYSTEM_MAX_TIMEFRAME_SIZE;
}
return expressionValue > maxTimeframeSize ? maxTimeframeSize : expressionValue;
};
const handleGridRowAddedOrRemoved = (args, gridChangeLog$, consumingFunction, context) => {
let countValue = args.find((arg) => typeof arg === 'number');
if (countValue < 0) {
throw new ExpressionEvaluationError(consumingFunction, 'supports only zero or a positive number as argument');
}
let timeframeParameter = extractParameter(consumingFunction, 'config', ['TIMEFRAME'], args, { isOptional: true });
if (countValue === 0 && timeframeParameter == null) {
throw new ExpressionEvaluationError(consumingFunction, 'requires a TIMEFRAME parameter when observing for no changes');
}
if (countValue == null && timeframeParameter == null) {
// default - return all new rows
return gridChangeLog$;
}
if (countValue == null) {
// default count value of 1
countValue = 1;
}
if (timeframeParameter == null) {
// default time parameter of max
timeframeParameter = {
name: 'TIMEFRAME',
type: 'config',
value: SYSTEM_MAX_TIMEFRAME_SIZE,
};
}
if (countValue === 0) {
// handle special case when observing NO changes
const rowDataChangeInfoStub = {
rowTrigger: consumingFunction === 'ROW_ADDED' ? 'Add' : 'Delete',
rowNodes: [],
dataRows: [],
changedAt: Date.now(),
...context.adaptableApi.internalApi.buildBaseContext(),
};
return gridChangeLog$.pipe(
// add a synthetic first value to ensure the grid is observed even when there are no changes
startWith(rowDataChangeInfoStub),
// wait for the given time
debounceTime(timeframeParameter.value),
// completing the observable (ex. alert deletion) will also fire the NONE event
// takeUntil takes care of this, ignoring any emissions as soon as the source completes
// (count() result is irrelevant here, the main thing is that it emits a source completion)
takeUntil(gridChangeLog$.pipe(count())));
}
const timeframeChange$ = getTrailingGridCountChange$(gridChangeLog$, timeframeParameter.value, countValue);
return getDataChangeCount$(gridChangeLog$, timeframeChange$, countValue);
};