UNPKG

@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
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); };