@gridengine/angular-datagrid-enterprise 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -778,6 +778,715 @@ class UndoRedoManager {
|
|
|
778
778
|
}
|
|
779
779
|
}
|
|
780
780
|
|
|
781
|
+
/**
|
|
782
|
+
* FormulaEngine — evaluates Excel-like formula strings against row data.
|
|
783
|
+
* Formulas start with '=' and reference other columns by field name.
|
|
784
|
+
*
|
|
785
|
+
* Functions: SUM, AVG, MIN, MAX, COUNT, IF, CONCAT, ROUND, ABS, FLOOR, CEIL,
|
|
786
|
+
* LEN, UPPER, LOWER. Bare identifiers that aren't function names are treated as
|
|
787
|
+
* field lookups on the current row.
|
|
788
|
+
*
|
|
789
|
+
* Error values: '#ERROR!', '#DIV/0!', '#REF!', '#CIRCULAR!', '#NAME?'.
|
|
790
|
+
* A formula that directly/transitively references its own cell yields
|
|
791
|
+
* '#CIRCULAR!'.
|
|
792
|
+
*/
|
|
793
|
+
class Parser {
|
|
794
|
+
_src;
|
|
795
|
+
_pos = 0;
|
|
796
|
+
constructor(src) {
|
|
797
|
+
this._src = src.trim();
|
|
798
|
+
}
|
|
799
|
+
parse() {
|
|
800
|
+
const node = this._parseExpr();
|
|
801
|
+
this._skipWS();
|
|
802
|
+
if (this._pos < this._src.length) {
|
|
803
|
+
throw new Error(`Unexpected character at position ${this._pos}: '${this._src[this._pos]}'`);
|
|
804
|
+
}
|
|
805
|
+
return node;
|
|
806
|
+
}
|
|
807
|
+
_skipWS() {
|
|
808
|
+
while (this._pos < this._src.length && /\s/.test(this._src[this._pos])) {
|
|
809
|
+
this._pos++;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
_consume(ch) {
|
|
813
|
+
this._skipWS();
|
|
814
|
+
if (this._src[this._pos] !== ch) {
|
|
815
|
+
throw new Error(`Expected '${ch}' at position ${this._pos}`);
|
|
816
|
+
}
|
|
817
|
+
this._pos++;
|
|
818
|
+
}
|
|
819
|
+
_parseExpr() {
|
|
820
|
+
return this._parseComparison();
|
|
821
|
+
}
|
|
822
|
+
_parseComparison() {
|
|
823
|
+
let left = this._parseAddSub();
|
|
824
|
+
this._skipWS();
|
|
825
|
+
while (this._pos < this._src.length) {
|
|
826
|
+
const ch = this._src[this._pos];
|
|
827
|
+
const ch2 = this._src.slice(this._pos, this._pos + 2);
|
|
828
|
+
if (ch2 === '==' || ch2 === '!=' || ch2 === '<=' || ch2 === '>=') {
|
|
829
|
+
this._pos += 2;
|
|
830
|
+
const right = this._parseAddSub();
|
|
831
|
+
left = { kind: 'binary', op: ch2, left, right };
|
|
832
|
+
}
|
|
833
|
+
else if (ch === '<' || ch === '>') {
|
|
834
|
+
this._pos++;
|
|
835
|
+
const right = this._parseAddSub();
|
|
836
|
+
left = { kind: 'binary', op: ch, left, right };
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
this._skipWS();
|
|
842
|
+
}
|
|
843
|
+
return left;
|
|
844
|
+
}
|
|
845
|
+
_parseAddSub() {
|
|
846
|
+
let left = this._parseMulDiv();
|
|
847
|
+
this._skipWS();
|
|
848
|
+
while (this._pos < this._src.length &&
|
|
849
|
+
(this._src[this._pos] === '+' || this._src[this._pos] === '-')) {
|
|
850
|
+
const op = this._src[this._pos++];
|
|
851
|
+
const right = this._parseMulDiv();
|
|
852
|
+
left = { kind: 'binary', op, left, right };
|
|
853
|
+
this._skipWS();
|
|
854
|
+
}
|
|
855
|
+
return left;
|
|
856
|
+
}
|
|
857
|
+
_parseMulDiv() {
|
|
858
|
+
let left = this._parseUnary();
|
|
859
|
+
this._skipWS();
|
|
860
|
+
while (this._pos < this._src.length &&
|
|
861
|
+
(this._src[this._pos] === '*' || this._src[this._pos] === '/')) {
|
|
862
|
+
const op = this._src[this._pos++];
|
|
863
|
+
const right = this._parseUnary();
|
|
864
|
+
left = { kind: 'binary', op, left, right };
|
|
865
|
+
this._skipWS();
|
|
866
|
+
}
|
|
867
|
+
return left;
|
|
868
|
+
}
|
|
869
|
+
_parseUnary() {
|
|
870
|
+
this._skipWS();
|
|
871
|
+
if (this._src[this._pos] === '-') {
|
|
872
|
+
this._pos++;
|
|
873
|
+
return { kind: 'unary', op: '-', operand: this._parseUnary() };
|
|
874
|
+
}
|
|
875
|
+
return this._parsePrimary();
|
|
876
|
+
}
|
|
877
|
+
_parsePrimary() {
|
|
878
|
+
this._skipWS();
|
|
879
|
+
const ch = this._src[this._pos];
|
|
880
|
+
if (ch === undefined) {
|
|
881
|
+
throw new Error('Unexpected end of expression');
|
|
882
|
+
}
|
|
883
|
+
if (ch === '(') {
|
|
884
|
+
this._pos++;
|
|
885
|
+
const node = this._parseExpr();
|
|
886
|
+
this._consume(')');
|
|
887
|
+
return node;
|
|
888
|
+
}
|
|
889
|
+
if (ch === '"' || ch === "'") {
|
|
890
|
+
return this._parseString(ch);
|
|
891
|
+
}
|
|
892
|
+
if (ch === '.' || (ch >= '0' && ch <= '9')) {
|
|
893
|
+
return this._parseNumber();
|
|
894
|
+
}
|
|
895
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
896
|
+
return this._parseIdentifier();
|
|
897
|
+
}
|
|
898
|
+
throw new Error(`Unexpected token at position ${this._pos}: '${ch}'`);
|
|
899
|
+
}
|
|
900
|
+
_parseString(quote) {
|
|
901
|
+
this._pos++; // skip opening quote
|
|
902
|
+
let s = '';
|
|
903
|
+
while (this._pos < this._src.length && this._src[this._pos] !== quote) {
|
|
904
|
+
s += this._src[this._pos++];
|
|
905
|
+
}
|
|
906
|
+
this._pos++; // skip closing quote
|
|
907
|
+
return { kind: 'string', value: s };
|
|
908
|
+
}
|
|
909
|
+
_parseNumber() {
|
|
910
|
+
let s = '';
|
|
911
|
+
while (this._pos < this._src.length && /[\d.]/.test(this._src[this._pos])) {
|
|
912
|
+
s += this._src[this._pos++];
|
|
913
|
+
}
|
|
914
|
+
if (this._src[this._pos] === 'e' || this._src[this._pos] === 'E') {
|
|
915
|
+
s += this._src[this._pos++];
|
|
916
|
+
if (this._src[this._pos] === '+' || this._src[this._pos] === '-') {
|
|
917
|
+
s += this._src[this._pos++];
|
|
918
|
+
}
|
|
919
|
+
while (this._pos < this._src.length && /\d/.test(this._src[this._pos])) {
|
|
920
|
+
s += this._src[this._pos++];
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return { kind: 'number', value: Number(s) };
|
|
924
|
+
}
|
|
925
|
+
_parseIdentifier() {
|
|
926
|
+
let name = '';
|
|
927
|
+
while (this._pos < this._src.length && /[\w]/.test(this._src[this._pos])) {
|
|
928
|
+
name += this._src[this._pos++];
|
|
929
|
+
}
|
|
930
|
+
this._skipWS();
|
|
931
|
+
if (this._src[this._pos] === '(') {
|
|
932
|
+
this._pos++;
|
|
933
|
+
const args = [];
|
|
934
|
+
this._skipWS();
|
|
935
|
+
if (this._src[this._pos] !== ')') {
|
|
936
|
+
args.push(this._parseExpr());
|
|
937
|
+
this._skipWS();
|
|
938
|
+
while (this._src[this._pos] === ',') {
|
|
939
|
+
this._pos++;
|
|
940
|
+
args.push(this._parseExpr());
|
|
941
|
+
this._skipWS();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
this._consume(')');
|
|
945
|
+
return { kind: 'call', fn: name.toUpperCase(), args };
|
|
946
|
+
}
|
|
947
|
+
return { kind: 'field', name };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
const FUNCTIONS = {
|
|
951
|
+
SUM: (args) => args.reduce((acc, v) => acc + (Number(v) || 0), 0),
|
|
952
|
+
AVG: (args) => args.length === 0 ? 0 : args.reduce((acc, v) => acc + (Number(v) || 0), 0) / args.length,
|
|
953
|
+
MIN: (args) => Math.min(...args.map(Number)),
|
|
954
|
+
MAX: (args) => Math.max(...args.map(Number)),
|
|
955
|
+
COUNT: (args) => args.filter((v) => !isNaN(Number(v)) && v !== '' && v != null).length,
|
|
956
|
+
CONCAT: (args) => args.join(''),
|
|
957
|
+
ROUND: ([n, d]) => Number(Number(n).toFixed(Number(d ?? 0))),
|
|
958
|
+
ABS: ([n]) => Math.abs(Number(n)),
|
|
959
|
+
FLOOR: ([n]) => Math.floor(Number(n)),
|
|
960
|
+
CEIL: ([n]) => Math.ceil(Number(n)),
|
|
961
|
+
LEN: ([s]) => String(s ?? '').length,
|
|
962
|
+
UPPER: ([s]) => String(s ?? '').toUpperCase(),
|
|
963
|
+
LOWER: ([s]) => String(s ?? '').toLowerCase(),
|
|
964
|
+
IF: ([cond, t, f]) => (cond ? t : f),
|
|
965
|
+
};
|
|
966
|
+
function evalBinary(op, l, r) {
|
|
967
|
+
switch (op) {
|
|
968
|
+
case '+':
|
|
969
|
+
return typeof l === 'string' || typeof r === 'string'
|
|
970
|
+
? String(l) + String(r)
|
|
971
|
+
: Number(l) + Number(r);
|
|
972
|
+
case '-':
|
|
973
|
+
return Number(l) - Number(r);
|
|
974
|
+
case '*':
|
|
975
|
+
return Number(l) * Number(r);
|
|
976
|
+
case '/': {
|
|
977
|
+
const divisor = Number(r);
|
|
978
|
+
return divisor === 0 ? '#DIV/0!' : Number(l) / divisor;
|
|
979
|
+
}
|
|
980
|
+
case '==':
|
|
981
|
+
return l == r;
|
|
982
|
+
case '!=':
|
|
983
|
+
return l != r;
|
|
984
|
+
case '<':
|
|
985
|
+
return Number(l) < Number(r);
|
|
986
|
+
case '<=':
|
|
987
|
+
return Number(l) <= Number(r);
|
|
988
|
+
case '>':
|
|
989
|
+
return Number(l) > Number(r);
|
|
990
|
+
case '>=':
|
|
991
|
+
return Number(l) >= Number(r);
|
|
992
|
+
default:
|
|
993
|
+
return '#ERROR!';
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
function evaluate(node, row, visiting) {
|
|
997
|
+
switch (node.kind) {
|
|
998
|
+
case 'number':
|
|
999
|
+
return node.value;
|
|
1000
|
+
case 'string':
|
|
1001
|
+
return node.value;
|
|
1002
|
+
case 'field': {
|
|
1003
|
+
const key = node.name;
|
|
1004
|
+
if (visiting.has(key))
|
|
1005
|
+
return '#CIRCULAR!';
|
|
1006
|
+
const raw = row[key];
|
|
1007
|
+
if (typeof raw === 'string' && raw.startsWith('=')) {
|
|
1008
|
+
visiting.add(key);
|
|
1009
|
+
try {
|
|
1010
|
+
const ast = new Parser(raw.slice(1)).parse();
|
|
1011
|
+
return evaluate(ast, row, visiting);
|
|
1012
|
+
}
|
|
1013
|
+
finally {
|
|
1014
|
+
visiting.delete(key);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (raw === undefined)
|
|
1018
|
+
return '#REF!';
|
|
1019
|
+
return raw;
|
|
1020
|
+
}
|
|
1021
|
+
case 'unary': {
|
|
1022
|
+
const v = evaluate(node.operand, row, visiting);
|
|
1023
|
+
if (typeof v === 'string' && v.startsWith('#'))
|
|
1024
|
+
return v;
|
|
1025
|
+
return -Number(v);
|
|
1026
|
+
}
|
|
1027
|
+
case 'binary': {
|
|
1028
|
+
const l = evaluate(node.left, row, visiting);
|
|
1029
|
+
const r = evaluate(node.right, row, visiting);
|
|
1030
|
+
if (typeof l === 'string' && l.startsWith('#'))
|
|
1031
|
+
return l;
|
|
1032
|
+
if (typeof r === 'string' && r.startsWith('#'))
|
|
1033
|
+
return r;
|
|
1034
|
+
return evalBinary(node.op, l, r);
|
|
1035
|
+
}
|
|
1036
|
+
case 'call': {
|
|
1037
|
+
const fn = FUNCTIONS[node.fn];
|
|
1038
|
+
if (!fn)
|
|
1039
|
+
return '#NAME?';
|
|
1040
|
+
const args = node.args.map((a) => evaluate(a, row, visiting));
|
|
1041
|
+
const err = args.find((a) => typeof a === 'string' && a.startsWith('#'));
|
|
1042
|
+
if (err)
|
|
1043
|
+
return err;
|
|
1044
|
+
return fn(args);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
class FormulaEngine {
|
|
1049
|
+
_formulaFields;
|
|
1050
|
+
constructor(options) {
|
|
1051
|
+
this._formulaFields = options.formulaFields;
|
|
1052
|
+
}
|
|
1053
|
+
/** Evaluate all formula fields for a row, returning a new (unmutated) row. */
|
|
1054
|
+
evaluateRow(row) {
|
|
1055
|
+
const result = { ...row };
|
|
1056
|
+
for (const [field, formula] of Object.entries(this._formulaFields)) {
|
|
1057
|
+
result[field] = this.evaluateFormula(formula, row, field);
|
|
1058
|
+
}
|
|
1059
|
+
return result;
|
|
1060
|
+
}
|
|
1061
|
+
/** Evaluate a single formula string against a row. */
|
|
1062
|
+
evaluateFormula(formula, row, selfField) {
|
|
1063
|
+
const expr = formula.startsWith('=') ? formula.slice(1) : formula;
|
|
1064
|
+
try {
|
|
1065
|
+
const ast = new Parser(expr).parse();
|
|
1066
|
+
const visiting = selfField ? new Set([selfField]) : new Set();
|
|
1067
|
+
return evaluate(ast, row, visiting);
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
return '#ERROR!';
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/** Whether a formula string is parseable (no evaluation). */
|
|
1074
|
+
isValidFormula(formula) {
|
|
1075
|
+
const expr = formula.startsWith('=') ? formula.slice(1) : formula;
|
|
1076
|
+
try {
|
|
1077
|
+
new Parser(expr).parse();
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
class SSRMEngine {
|
|
1087
|
+
_dataSource;
|
|
1088
|
+
blockSize;
|
|
1089
|
+
maxBlocksInCache;
|
|
1090
|
+
_blocks = new Map();
|
|
1091
|
+
_accessClock = 0;
|
|
1092
|
+
_totalCount = null;
|
|
1093
|
+
_filterModel = {};
|
|
1094
|
+
_sortModel = [];
|
|
1095
|
+
_groupKeys = [];
|
|
1096
|
+
_onChange;
|
|
1097
|
+
constructor(options) {
|
|
1098
|
+
this._dataSource = options.dataSource;
|
|
1099
|
+
this.blockSize = options.blockSize ?? 100;
|
|
1100
|
+
this.maxBlocksInCache = options.maxBlocksInCache ?? 10;
|
|
1101
|
+
}
|
|
1102
|
+
subscribe(listener) {
|
|
1103
|
+
this._onChange = listener;
|
|
1104
|
+
return () => {
|
|
1105
|
+
if (this._onChange === listener)
|
|
1106
|
+
this._onChange = undefined;
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
setFilterModel(model) {
|
|
1110
|
+
this._filterModel = model;
|
|
1111
|
+
this._invalidateAll();
|
|
1112
|
+
}
|
|
1113
|
+
setSortModel(model) {
|
|
1114
|
+
this._sortModel = model;
|
|
1115
|
+
this._invalidateAll();
|
|
1116
|
+
}
|
|
1117
|
+
setGroupKeys(keys) {
|
|
1118
|
+
this._groupKeys = keys;
|
|
1119
|
+
this._invalidateAll();
|
|
1120
|
+
}
|
|
1121
|
+
/** Total row count from the last server response (null = not yet known). */
|
|
1122
|
+
getTotalCount() {
|
|
1123
|
+
return this._totalCount;
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Get the row at an absolute zero-based index, fetching the containing block
|
|
1127
|
+
* if needed. Returns null while the block is loading.
|
|
1128
|
+
*/
|
|
1129
|
+
getRow(rowIndex) {
|
|
1130
|
+
const blockIndex = Math.floor(rowIndex / this.blockSize);
|
|
1131
|
+
const block = this._getOrCreateBlock(blockIndex);
|
|
1132
|
+
if (block.state === 'loaded') {
|
|
1133
|
+
const localIndex = rowIndex - blockIndex * this.blockSize;
|
|
1134
|
+
return block.rows[localIndex] ?? null;
|
|
1135
|
+
}
|
|
1136
|
+
return null;
|
|
1137
|
+
}
|
|
1138
|
+
/** Rows for the viewport [startRow, endRow); missing slots are null. */
|
|
1139
|
+
getRowSlice(startRow, endRow) {
|
|
1140
|
+
const result = [];
|
|
1141
|
+
for (let i = startRow; i < endRow; i++) {
|
|
1142
|
+
result.push(this.getRow(i));
|
|
1143
|
+
}
|
|
1144
|
+
return result;
|
|
1145
|
+
}
|
|
1146
|
+
getBlockState(blockIndex) {
|
|
1147
|
+
return this._blocks.get(blockIndex)?.state ?? 'idle';
|
|
1148
|
+
}
|
|
1149
|
+
getBlocks() {
|
|
1150
|
+
return this._blocks;
|
|
1151
|
+
}
|
|
1152
|
+
/** Evict all cached blocks and reset total count. */
|
|
1153
|
+
invalidateAll() {
|
|
1154
|
+
this._invalidateAll();
|
|
1155
|
+
}
|
|
1156
|
+
/** Evict a specific block (e.g. after a row mutation on that page). */
|
|
1157
|
+
invalidateBlock(blockIndex) {
|
|
1158
|
+
this._blocks.delete(blockIndex);
|
|
1159
|
+
this._notify();
|
|
1160
|
+
}
|
|
1161
|
+
_getOrCreateBlock(blockIndex) {
|
|
1162
|
+
let block = this._blocks.get(blockIndex);
|
|
1163
|
+
if (!block) {
|
|
1164
|
+
if (this._blocks.size >= this.maxBlocksInCache) {
|
|
1165
|
+
this._evictOldest();
|
|
1166
|
+
}
|
|
1167
|
+
block = { index: blockIndex, state: 'idle', rows: [], lastAccessed: ++this._accessClock };
|
|
1168
|
+
this._blocks.set(blockIndex, block);
|
|
1169
|
+
}
|
|
1170
|
+
block.lastAccessed = ++this._accessClock;
|
|
1171
|
+
if (block.state === 'idle' || block.state === 'error') {
|
|
1172
|
+
this._fetchBlock(block);
|
|
1173
|
+
}
|
|
1174
|
+
return block;
|
|
1175
|
+
}
|
|
1176
|
+
_fetchBlock(block, retryCount = 0) {
|
|
1177
|
+
block.state = 'loading';
|
|
1178
|
+
this._notify();
|
|
1179
|
+
const startRow = block.index * this.blockSize;
|
|
1180
|
+
const params = {
|
|
1181
|
+
startRow,
|
|
1182
|
+
endRow: startRow + this.blockSize,
|
|
1183
|
+
filterModel: this._filterModel,
|
|
1184
|
+
sortModel: this._sortModel,
|
|
1185
|
+
groupKeys: this._groupKeys,
|
|
1186
|
+
};
|
|
1187
|
+
this._dataSource
|
|
1188
|
+
.getRows(params)
|
|
1189
|
+
.then((result) => {
|
|
1190
|
+
block.state = 'loaded';
|
|
1191
|
+
block.rows = result.rows;
|
|
1192
|
+
block.error = undefined;
|
|
1193
|
+
this._totalCount = result.totalCount;
|
|
1194
|
+
this._notify();
|
|
1195
|
+
})
|
|
1196
|
+
.catch((err) => {
|
|
1197
|
+
if (retryCount < 3) {
|
|
1198
|
+
const delay = Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s
|
|
1199
|
+
setTimeout(() => this._fetchBlock(block, retryCount + 1), delay);
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
block.state = 'error';
|
|
1203
|
+
block.error = err;
|
|
1204
|
+
this._notify();
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
_evictOldest() {
|
|
1209
|
+
let oldest = null;
|
|
1210
|
+
for (const block of this._blocks.values()) {
|
|
1211
|
+
if (!oldest || block.lastAccessed < oldest.lastAccessed) {
|
|
1212
|
+
oldest = block;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (oldest) {
|
|
1216
|
+
this._blocks.delete(oldest.index);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
_invalidateAll() {
|
|
1220
|
+
this._blocks.clear();
|
|
1221
|
+
this._totalCount = null;
|
|
1222
|
+
this._notify();
|
|
1223
|
+
}
|
|
1224
|
+
_notify() {
|
|
1225
|
+
this._onChange?.();
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
class TransactionEngine {
|
|
1230
|
+
_rowIdField;
|
|
1231
|
+
_onCommit;
|
|
1232
|
+
_onRollback;
|
|
1233
|
+
_baseRows;
|
|
1234
|
+
_dirty = new Map();
|
|
1235
|
+
constructor(opts) {
|
|
1236
|
+
this._rowIdField = opts.rowIdField ?? 'id';
|
|
1237
|
+
this._onCommit = opts.onTransactionCommit;
|
|
1238
|
+
this._onRollback = opts.onTransactionRollback;
|
|
1239
|
+
this._baseRows = [...opts.initialRows];
|
|
1240
|
+
}
|
|
1241
|
+
/** Stage new rows; an existing ID is treated as an update instead. */
|
|
1242
|
+
addRows(rows) {
|
|
1243
|
+
for (const row of rows) {
|
|
1244
|
+
const id = this._rowId(row);
|
|
1245
|
+
const existing = this._findBase(id);
|
|
1246
|
+
if (existing !== null) {
|
|
1247
|
+
this._stageUpdate(id, existing, row);
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
this._dirty.set(id, { state: 'added', original: null, current: { ...row } });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
/** Stage updates for existing rows; unknown IDs are ignored with a warning. */
|
|
1255
|
+
updateRows(rows) {
|
|
1256
|
+
for (const row of rows) {
|
|
1257
|
+
const id = this._rowId(row);
|
|
1258
|
+
const existing = this._dirty.get(id);
|
|
1259
|
+
if (existing?.state === 'added') {
|
|
1260
|
+
existing.current = { ...existing.current, ...row };
|
|
1261
|
+
}
|
|
1262
|
+
else {
|
|
1263
|
+
const base = this._findBase(id);
|
|
1264
|
+
if (base === null) {
|
|
1265
|
+
console.warn(`TransactionEngine.updateRows: row with id '${id}' not found.`);
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
this._stageUpdate(id, existing?.original ?? base, row);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
/** Stage rows for removal by ID; a still-staged add is simply cancelled. */
|
|
1273
|
+
removeRows(ids) {
|
|
1274
|
+
for (const id of ids) {
|
|
1275
|
+
const existing = this._dirty.get(id);
|
|
1276
|
+
if (existing?.state === 'added') {
|
|
1277
|
+
this._dirty.delete(id);
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
const base = this._findBase(id);
|
|
1281
|
+
if (base === null) {
|
|
1282
|
+
console.warn(`TransactionEngine.removeRows: row with id '${id}' not found.`);
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
this._dirty.set(id, { state: 'removed', original: existing?.original ?? base, current: base });
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
commitTransaction() {
|
|
1289
|
+
const delta = this._buildDelta();
|
|
1290
|
+
for (const [id, entry] of this._dirty) {
|
|
1291
|
+
if (entry.state === 'added') {
|
|
1292
|
+
this._baseRows.push(entry.current);
|
|
1293
|
+
}
|
|
1294
|
+
else if (entry.state === 'updated') {
|
|
1295
|
+
const idx = this._baseRows.findIndex((r) => this._rowId(r) === id);
|
|
1296
|
+
if (idx !== -1)
|
|
1297
|
+
this._baseRows[idx] = entry.current;
|
|
1298
|
+
}
|
|
1299
|
+
else if (entry.state === 'removed') {
|
|
1300
|
+
this._baseRows = this._baseRows.filter((r) => this._rowId(r) !== id);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
this._dirty.clear();
|
|
1304
|
+
this._onCommit?.(delta);
|
|
1305
|
+
}
|
|
1306
|
+
rollbackTransaction() {
|
|
1307
|
+
this._dirty.clear();
|
|
1308
|
+
this._onRollback?.();
|
|
1309
|
+
}
|
|
1310
|
+
/** True if there are any staged (uncommitted) changes. */
|
|
1311
|
+
isDirty() {
|
|
1312
|
+
return this._dirty.size > 0;
|
|
1313
|
+
}
|
|
1314
|
+
/** All current staged changes, grouped by kind. */
|
|
1315
|
+
getDirtyRows() {
|
|
1316
|
+
const added = [];
|
|
1317
|
+
const updated = [];
|
|
1318
|
+
const removed = [];
|
|
1319
|
+
for (const entry of this._dirty.values()) {
|
|
1320
|
+
if (entry.state === 'added')
|
|
1321
|
+
added.push(entry.current);
|
|
1322
|
+
else if (entry.state === 'updated')
|
|
1323
|
+
updated.push(entry.current);
|
|
1324
|
+
else if (entry.state === 'removed' && entry.original !== null)
|
|
1325
|
+
removed.push(entry.original);
|
|
1326
|
+
}
|
|
1327
|
+
return { added, updated, removed };
|
|
1328
|
+
}
|
|
1329
|
+
/** The merged row list: base rows with staged changes applied. */
|
|
1330
|
+
getDisplayRows() {
|
|
1331
|
+
const rows = [];
|
|
1332
|
+
for (const baseRow of this._baseRows) {
|
|
1333
|
+
const id = this._rowId(baseRow);
|
|
1334
|
+
const entry = this._dirty.get(id);
|
|
1335
|
+
if (!entry) {
|
|
1336
|
+
rows.push(baseRow);
|
|
1337
|
+
}
|
|
1338
|
+
else if (entry.state === 'updated') {
|
|
1339
|
+
rows.push(entry.current);
|
|
1340
|
+
}
|
|
1341
|
+
// 'removed' rows are omitted; 'added' rows are appended below.
|
|
1342
|
+
}
|
|
1343
|
+
for (const entry of this._dirty.values()) {
|
|
1344
|
+
if (entry.state === 'added')
|
|
1345
|
+
rows.push(entry.current);
|
|
1346
|
+
}
|
|
1347
|
+
return rows;
|
|
1348
|
+
}
|
|
1349
|
+
/** Replace the base rows (e.g. after a refresh); staged changes are kept. */
|
|
1350
|
+
resetRows(rows) {
|
|
1351
|
+
this._baseRows = [...rows];
|
|
1352
|
+
}
|
|
1353
|
+
_rowId(row) {
|
|
1354
|
+
const id = row[this._rowIdField];
|
|
1355
|
+
if (id === undefined || id === null) {
|
|
1356
|
+
throw new Error(`TransactionEngine: row has no '${this._rowIdField}' field. Set rowIdField to match your data.`);
|
|
1357
|
+
}
|
|
1358
|
+
return id;
|
|
1359
|
+
}
|
|
1360
|
+
_findBase(id) {
|
|
1361
|
+
return this._baseRows.find((r) => this._rowId(r) === id) ?? null;
|
|
1362
|
+
}
|
|
1363
|
+
_stageUpdate(id, original, next) {
|
|
1364
|
+
this._dirty.set(id, { state: 'updated', original, current: { ...original, ...next } });
|
|
1365
|
+
}
|
|
1366
|
+
_buildDelta() {
|
|
1367
|
+
const added = [];
|
|
1368
|
+
const updated = [];
|
|
1369
|
+
const removedIds = [];
|
|
1370
|
+
for (const [id, entry] of this._dirty) {
|
|
1371
|
+
if (entry.state === 'added')
|
|
1372
|
+
added.push(entry.current);
|
|
1373
|
+
else if (entry.state === 'updated')
|
|
1374
|
+
updated.push(entry.current);
|
|
1375
|
+
else if (entry.state === 'removed')
|
|
1376
|
+
removedIds.push(id);
|
|
1377
|
+
}
|
|
1378
|
+
return { added, updated, removedIds };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
class MasterDetailEngine {
|
|
1383
|
+
_getDetailRowData;
|
|
1384
|
+
_onExpand;
|
|
1385
|
+
_rowIdField;
|
|
1386
|
+
_expanded = new Set();
|
|
1387
|
+
_cache = new Map();
|
|
1388
|
+
_onChange;
|
|
1389
|
+
constructor(options) {
|
|
1390
|
+
this._getDetailRowData = options.getDetailRowData;
|
|
1391
|
+
this._onExpand = options.onExpand;
|
|
1392
|
+
this._rowIdField = options.rowIdField ?? 'id';
|
|
1393
|
+
}
|
|
1394
|
+
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
1395
|
+
subscribe(listener) {
|
|
1396
|
+
this._onChange = listener;
|
|
1397
|
+
return () => {
|
|
1398
|
+
if (this._onChange === listener)
|
|
1399
|
+
this._onChange = undefined;
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
isExpanded(rowId) {
|
|
1403
|
+
return this._expanded.has(rowId);
|
|
1404
|
+
}
|
|
1405
|
+
getLoadState(rowId) {
|
|
1406
|
+
return this._cache.get(rowId)?.state ?? 'idle';
|
|
1407
|
+
}
|
|
1408
|
+
/** Cached detail rows, or an empty array if not yet loaded. */
|
|
1409
|
+
getDetailData(rowId) {
|
|
1410
|
+
return this._cache.get(rowId)?.data ?? [];
|
|
1411
|
+
}
|
|
1412
|
+
/** Expand a master row, triggering a load if not already cached. */
|
|
1413
|
+
expand(masterRow) {
|
|
1414
|
+
const id = this._rowId(masterRow);
|
|
1415
|
+
if (this._expanded.has(id))
|
|
1416
|
+
return;
|
|
1417
|
+
this._expanded.add(id);
|
|
1418
|
+
this._onExpand?.(masterRow, true);
|
|
1419
|
+
this._notify();
|
|
1420
|
+
this._ensureLoaded(masterRow, id);
|
|
1421
|
+
}
|
|
1422
|
+
/** Collapse a master row; cached data is retained for re-expansion. */
|
|
1423
|
+
collapse(masterRow) {
|
|
1424
|
+
const id = this._rowId(masterRow);
|
|
1425
|
+
if (!this._expanded.has(id))
|
|
1426
|
+
return;
|
|
1427
|
+
this._expanded.delete(id);
|
|
1428
|
+
this._onExpand?.(masterRow, false);
|
|
1429
|
+
this._notify();
|
|
1430
|
+
}
|
|
1431
|
+
toggle(masterRow) {
|
|
1432
|
+
if (this.isExpanded(this._rowId(masterRow))) {
|
|
1433
|
+
this.collapse(masterRow);
|
|
1434
|
+
}
|
|
1435
|
+
else {
|
|
1436
|
+
this.expand(masterRow);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
/** Pre-fetch detail data without expanding (e.g. hover prefetch). */
|
|
1440
|
+
prefetch(masterRow) {
|
|
1441
|
+
this._ensureLoaded(masterRow, this._rowId(masterRow));
|
|
1442
|
+
}
|
|
1443
|
+
/** Invalidate one row's cache, or the entire cache when rowId is omitted. */
|
|
1444
|
+
invalidateCache(rowId) {
|
|
1445
|
+
if (rowId !== undefined) {
|
|
1446
|
+
this._cache.delete(rowId);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
this._cache.clear();
|
|
1450
|
+
}
|
|
1451
|
+
this._notify();
|
|
1452
|
+
}
|
|
1453
|
+
/** Collapse all expanded rows. Cache is preserved. */
|
|
1454
|
+
collapseAll() {
|
|
1455
|
+
this._expanded.clear();
|
|
1456
|
+
this._notify();
|
|
1457
|
+
}
|
|
1458
|
+
/** A read-only snapshot of currently expanded row IDs. */
|
|
1459
|
+
getExpandedIds() {
|
|
1460
|
+
return this._expanded;
|
|
1461
|
+
}
|
|
1462
|
+
_rowId(row) {
|
|
1463
|
+
const id = row[this._rowIdField];
|
|
1464
|
+
if (id === undefined || id === null) {
|
|
1465
|
+
throw new Error(`MasterDetailEngine: row has no '${this._rowIdField}' field. Set rowIdField to the correct field name.`);
|
|
1466
|
+
}
|
|
1467
|
+
return id;
|
|
1468
|
+
}
|
|
1469
|
+
_ensureLoaded(masterRow, id) {
|
|
1470
|
+
const existing = this._cache.get(id);
|
|
1471
|
+
if (existing && (existing.state === 'loading' || existing.state === 'loaded'))
|
|
1472
|
+
return;
|
|
1473
|
+
this._cache.set(id, { state: 'loading', data: [] });
|
|
1474
|
+
this._notify();
|
|
1475
|
+
this._getDetailRowData(masterRow)
|
|
1476
|
+
.then((data) => {
|
|
1477
|
+
this._cache.set(id, { state: 'loaded', data });
|
|
1478
|
+
this._notify();
|
|
1479
|
+
})
|
|
1480
|
+
.catch((error) => {
|
|
1481
|
+
this._cache.set(id, { state: 'error', data: [], error });
|
|
1482
|
+
this._notify();
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
_notify() {
|
|
1486
|
+
this._onChange?.();
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
781
1490
|
/*
|
|
782
1491
|
* Public API Surface of @gridengine/angular-datagrid-enterprise
|
|
783
1492
|
*
|
|
@@ -790,5 +1499,5 @@ class UndoRedoManager {
|
|
|
790
1499
|
* Generated bundle index. Do not edit.
|
|
791
1500
|
*/
|
|
792
1501
|
|
|
793
|
-
export { ClipboardEngine, DataGridPro, FillHandleEngine, GridLicenseWatermark, LicenseManager, PRODUCT_ID, PURCHASE_URL, RangeSelectionEngine, UndoRedoManager, parseTSV, provideGridEngineLicense, toNumber, toTimestamp };
|
|
1502
|
+
export { ClipboardEngine, DataGridPro, FillHandleEngine, FormulaEngine, GridLicenseWatermark, LicenseManager, MasterDetailEngine, PRODUCT_ID, PURCHASE_URL, RangeSelectionEngine, SSRMEngine, TransactionEngine, UndoRedoManager, parseTSV, provideGridEngineLicense, toNumber, toTimestamp };
|
|
794
1503
|
//# sourceMappingURL=gridengine-angular-datagrid-enterprise.mjs.map
|