@easyquery/ui 7.4.2 → 7.5.0-rc02

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.
@@ -42,11 +42,15 @@ class EntitiesPanel extends Widget {
42
42
  * The default constructor.
43
43
  * @param slot The html element.
44
44
  */
45
- constructor(slot) {
45
+ constructor(slot, customQuery) {
46
46
  super(slot);
47
+ this.customQuery = null;
47
48
  this.queryChangedCallbackId = null;
48
49
  this.group = WidgetGroup.Model | WidgetGroup.Query;
49
50
  this.panel = slot;
51
+ if (customQuery) {
52
+ this.customQuery = customQuery;
53
+ }
50
54
  }
51
55
  getWidgetType() {
52
56
  return 'entitiesPanel';
@@ -61,6 +65,12 @@ class EntitiesPanel extends Widget {
61
65
  this.setOptions(options);
62
66
  this.renderProgressBlock();
63
67
  }
68
+ getQuery() {
69
+ if (this.customQuery) {
70
+ return this.customQuery;
71
+ }
72
+ return this.context.getQuery();
73
+ }
64
74
  /**
65
75
  * Refresh widget implementation
66
76
  */
@@ -110,11 +120,11 @@ class EntitiesPanel extends Widget {
110
120
  utils.assignDeep(this.options, options);
111
121
  }
112
122
  if (this.queryChangedCallbackId) {
113
- const query = this.context.getQuery();
123
+ const query = this.getQuery();
114
124
  query.removeChangedCallback(this.queryChangedCallbackId);
115
125
  }
116
126
  if (this.options.syncWithColumns) {
117
- const query = this.context.getQuery();
127
+ const query = this.getQuery();
118
128
  this.queryChangedCallbackId = query.addChangedCallback((ev) => {
119
129
  const edata = ev.data;
120
130
  if (!edata || (edata.part != QueryChangePart.Columns && edata.part != QueryChangePart.All)) {
@@ -225,7 +235,7 @@ class EntitiesPanel extends Widget {
225
235
  if (this.options.syncWithColumns) {
226
236
  attrInput.addEventListener('change', (ev) => {
227
237
  const model = this.context.getModel();
228
- const query = this.context.getQuery();
238
+ const query = this.getQuery();
229
239
  const checkbox = ev.currentTarget;
230
240
  const attrId = checkbox.parentElement.parentElement.getAttribute('data-id');
231
241
  if (model.checkAttrProperty(attrId, "useInResult")) {
@@ -324,7 +334,7 @@ class EntitiesPanel extends Widget {
324
334
  entity.checked = element.checked;
325
335
  }
326
336
  if (this.options.syncWithColumns) {
327
- const query = this.context.getQuery();
337
+ const query = this.getQuery();
328
338
  const model = this.context.getModel();
329
339
  const attrList = [];
330
340
  const attrElems = Array.from(entityChildren.querySelectorAll(`.${this.cssPrefix}-entity-attr`));
@@ -429,7 +439,7 @@ class EntitiesPanel extends Widget {
429
439
  const attrElements = this.panel.querySelectorAll(`.${this.cssPrefix}-entity-attr`);
430
440
  let attrList = [];
431
441
  let attrId;
432
- const query = this.context.getQuery();
442
+ const query = this.getQuery();
433
443
  query.fireProcessEvent({
434
444
  source: self,
435
445
  status: "start"
@@ -467,7 +477,7 @@ class EntitiesPanel extends Widget {
467
477
  let attrElements = this.panel.querySelectorAll(`.${this.cssPrefix}-entity-attr`);
468
478
  let attrList = [];
469
479
  let attrId;
470
- let query = this.context.getQuery();
480
+ let query = this.getQuery();
471
481
  query.fireProcessEvent({
472
482
  source: self,
473
483
  status: "start"
@@ -530,7 +540,7 @@ class EntitiesPanel extends Widget {
530
540
  }
531
541
  refreshCheckedStateByColumns() {
532
542
  this.deselectAll();
533
- const query = this.context.getQuery();
543
+ const query = this.getQuery();
534
544
  const exprColumns = query.getColumns()
535
545
  .filter(col => col.expr.tag === ExprTag.EntityAttribute);
536
546
  for (const column of exprColumns) {
@@ -647,6 +657,80 @@ var ConditionsEditMode;
647
657
  ConditionsEditMode[ConditionsEditMode["Full"] = 255] = "Full";
648
658
  })(ConditionsEditMode || (ConditionsEditMode = {}));
649
659
 
660
+ function computeMenuPlacement(input) {
661
+ const minOffset = input.minOffset != null ? input.minOffset : 15;
662
+ const alignOffset = input.alignOffset != null ? input.alignOffset : 0;
663
+ const { anchorY, anchorHeight, menuHeight, scrollDivHeight, scrollY, viewportHeight, levelIndex, useMaxSpace } = input;
664
+ const viewportTopDoc = scrollY;
665
+ const viewportBottomDoc = scrollY + viewportHeight;
666
+ // A sub-level sits beside its parent item, so it aligns top-with-top when
667
+ // opening downward and bottom-with-bottom when opening upward. The root
668
+ // level opens below or above its anchor element.
669
+ const sideAttached = levelIndex > 0;
670
+ const downTop = sideAttached ? anchorY - alignOffset : anchorY + anchorHeight;
671
+ const upBottom = sideAttached ? anchorY + anchorHeight + alignOffset : anchorY;
672
+ const spaceBelow = (viewportBottomDoc - minOffset) - downTop;
673
+ const spaceAbove = upBottom - (viewportTopDoc + minOffset);
674
+ // A. fits below the anchor without scrolling
675
+ if (menuHeight < spaceBelow) {
676
+ return { top: downTop, maxHeight: null };
677
+ }
678
+ // B. fits above without scrolling
679
+ if (menuHeight < spaceAbove) {
680
+ return { top: upBottom - menuHeight, maxHeight: null };
681
+ }
682
+ // C. useMaxSpace: occupy as much of the viewport as the list needs,
683
+ // growing from the anchor toward the side with more room and sliding
684
+ // past the anchor only as far as the viewport edge requires; a list
685
+ // taller than the viewport fills it and scrolls internally. With the
686
+ // option off, every level stays attached to its trigger instead (D/E).
687
+ if (useMaxSpace) {
688
+ if (spaceAbove <= spaceBelow) {
689
+ let top = sideAttached ? downTop : anchorY;
690
+ const overflowBottom = (top + menuHeight) - (viewportBottomDoc - minOffset);
691
+ if (overflowBottom > 0) {
692
+ top -= overflowBottom;
693
+ }
694
+ let maxHeight = null;
695
+ const overflowTop = (viewportTopDoc + minOffset) - top;
696
+ if (overflowTop > 0) {
697
+ top += overflowTop;
698
+ maxHeight = scrollDivHeight - overflowTop;
699
+ }
700
+ return { top, maxHeight };
701
+ }
702
+ let top = upBottom - menuHeight;
703
+ const overflowTop = (viewportTopDoc + minOffset) - top;
704
+ if (overflowTop > 0) {
705
+ top += overflowTop;
706
+ }
707
+ let maxHeight = null;
708
+ const overflowBottom = (top + menuHeight) - (viewportBottomDoc - minOffset);
709
+ if (overflowBottom > 0) {
710
+ maxHeight = scrollDivHeight - overflowBottom;
711
+ }
712
+ return { top, maxHeight };
713
+ }
714
+ // D. drop down with an internal scroll
715
+ if (spaceAbove <= spaceBelow) {
716
+ let maxHeight = null;
717
+ const overflow = menuHeight - spaceBelow;
718
+ if (overflow > 0) {
719
+ maxHeight = scrollDivHeight - overflow;
720
+ }
721
+ return { top: downTop, maxHeight };
722
+ }
723
+ // E. drop up with an internal scroll
724
+ let top = upBottom - menuHeight;
725
+ let maxHeight = null;
726
+ const overflowTop = (viewportTopDoc + minOffset) - top;
727
+ if (overflowTop > 0) {
728
+ maxHeight = scrollDivHeight - overflowTop;
729
+ top += overflowTop;
730
+ }
731
+ return { top, maxHeight };
732
+ }
733
+
650
734
  class MenuLevel {
651
735
  get applyItem() {
652
736
  return this._applyItem;
@@ -823,6 +907,7 @@ class MenuLevel {
823
907
  .addClass(getMobileCssClass());
824
908
  scrollDiv.style.overflowX = "hidden";
825
909
  scrollDiv.style.overflowY = "auto";
910
+ scrollDiv.style.setProperty("overscroll-behavior", "contain");
826
911
  scrollDiv.style.position = "relative";
827
912
  baseDiv.appendChild(scrollDiv);
828
913
  this.levelDiv = baseDiv;
@@ -914,7 +999,7 @@ class MenuLevel {
914
999
  }
915
1000
  }
916
1001
  focusScrollDiv() {
917
- this.scrollDiv.focus();
1002
+ this.scrollDiv.focus({ preventScroll: true });
918
1003
  }
919
1004
  turnCheckboxes(items) {
920
1005
  //turn checkboxes on for selected items
@@ -1431,8 +1516,7 @@ class MenuLevel {
1431
1516
  const leftSpace = pos.x - scroll.left;
1432
1517
  const rightSpace = winSize.width - leftSpace - menuItem.itemDiv.offsetWidth;
1433
1518
  pos.x += menuItem.itemDiv.offsetParent.offsetWidth;
1434
- pos.y -= 5;
1435
- menuItem.subLevel.showAt(pos.x, pos.y, true, true);
1519
+ menuItem.subLevel.showAt(pos.x, pos.y, true, true, menuItem.itemDiv);
1436
1520
  menuItem.subLevel.levelDiv.style.width = '';
1437
1521
  menuItem.subLevel.levelDiv.style.right = '';
1438
1522
  if ((rightSpace >= menuItem.subLevel.levelDiv.offsetWidth) || (rightSpace >= leftSpace)) { //show level to the right
@@ -1487,48 +1571,25 @@ class MenuLevel {
1487
1571
  const viewportHeight = getViewportSize().height;
1488
1572
  const anchorRect = anchor ? anchor.getBoundingClientRect() : null;
1489
1573
  const anchorHeight = anchorRect ? anchorRect.height : 0;
1490
- const upperSpace = y - scrollY;
1491
- const lowerSpace = viewportHeight - upperSpace - anchorHeight;
1492
- const minOffset = 15;
1493
- // Рахуємо початковий контейнер
1494
- let levelDivRect = this.levelDiv.getBoundingClientRect();
1495
- if (this.levelIndex == 0 && levelDivRect.height < lowerSpace - minOffset) { //root level. Have enough space to drop down without scroll
1496
- levelStyle.top = (y + anchorHeight) + 'px';
1497
- }
1498
- else if (this.levelIndex == 0 && levelDivRect.height < upperSpace - minOffset) { //root level. Have enough space to drop up without scroll
1499
- levelStyle.top = (y - levelDivRect.height) + 'px';
1500
- }
1501
- else if (useMaxSpace || this.levelIndex > 0) { //sub-level or useMaxSpace option set. Use all the space
1502
- const outsideBottom = levelDivRect.bottom + minOffset - scrollY - viewportHeight;
1503
- if (outsideBottom > 0) {
1504
- levelStyle.top = levelDivRect.top - outsideBottom + 'px';
1505
- }
1506
- levelDivRect = this.levelDiv.getBoundingClientRect();
1507
- const outsideTop = scrollY + minOffset - levelDivRect.top;
1508
- if (outsideTop > 0) {
1509
- const scrollDivRect = this.scrollDiv.getBoundingClientRect();
1510
- this.scrollDiv.style.maxHeight = scrollDivRect.height - outsideTop + 'px';
1511
- levelStyle.top = levelDivRect.top + outsideTop + 'px';
1512
- }
1513
- }
1514
- else if (upperSpace <= lowerSpace) { //root level. Drop down with scroll
1515
- levelStyle.top = (y + anchorHeight) + 'px';
1516
- const outsideBottom = levelDivRect.height + minOffset - lowerSpace;
1517
- if (outsideBottom > 0) {
1518
- const scrollDivRect = this.scrollDiv.getBoundingClientRect();
1519
- this.scrollDiv.style.maxHeight = scrollDivRect.height - outsideBottom + 'px';
1520
- }
1521
- }
1522
- else { //root level. Drop up with scroll
1523
- y -= levelDivRect.height;
1524
- const outsideTop = scrollY + minOffset - y;
1525
- if (outsideTop > 0) {
1526
- const scrollDivRect = this.scrollDiv.getBoundingClientRect();
1527
- this.scrollDiv.style.maxHeight = scrollDivRect.height - outsideTop + 'px';
1528
- y += outsideTop;
1529
- }
1530
- levelStyle.top = y + 'px';
1531
- }
1574
+ // Measure natural sizes (maxHeight was just reset above), then delegate
1575
+ // all vertical placement to a pure, document-coordinate helper.
1576
+ const levelDivRect = this.levelDiv.getBoundingClientRect();
1577
+ const scrollDivRect = this.scrollDiv.getBoundingClientRect();
1578
+ const placement = computeMenuPlacement({
1579
+ anchorY: y,
1580
+ anchorHeight,
1581
+ menuHeight: levelDivRect.height,
1582
+ scrollDivHeight: scrollDivRect.height,
1583
+ scrollY,
1584
+ viewportHeight,
1585
+ levelIndex: this.levelIndex,
1586
+ useMaxSpace,
1587
+ // sub-levels sit beside their parent item; compensate the menu
1588
+ // padding so the first/last sub-item lines up with the item
1589
+ alignOffset: this.levelIndex > 0 ? 5 : 0,
1590
+ });
1591
+ levelStyle.top = placement.top + 'px';
1592
+ this.scrollDiv.style.maxHeight = placement.maxHeight == null ? '' : placement.maxHeight + 'px';
1532
1593
  //setting minimal level width if it is defined
1533
1594
  let minItemWidth = this.parentMenu.minItemWidth;
1534
1595
  if (minItemWidth > 0 && this.scrollDiv.offsetWidth < minItemWidth) {
@@ -1547,10 +1608,10 @@ class MenuLevel {
1547
1608
  }
1548
1609
  focus() {
1549
1610
  window.setTimeout(() => {
1550
- this.scrollDiv.focus();
1611
+ this.scrollDiv.focus({ preventScroll: true });
1551
1612
  if (this.searchBox != null) {
1552
1613
  if (this.parentMenu.options.searchBoxAutoFocus) {
1553
- this.searchBox.focus();
1614
+ this.searchBox.focus({ preventScroll: true });
1554
1615
  }
1555
1616
  this.searchBox.value = "";
1556
1617
  }
@@ -1832,6 +1893,10 @@ class PopupMenu {
1832
1893
  this.rootLevel.levelDiv.style.position = 'fixed';
1833
1894
  this.rootLevel.levelDiv.style.fontSize = '20px';
1834
1895
  this.rootLevel.showAt(0, 0, true, true);
1896
+ //the full-screen menu is a column: the list of items takes all the space left
1897
+ //after the search box and the "Close" button (see the .eqjs-mobile rules in the CSS).
1898
+ //It's set after showAt() which turns any level into a plain block.
1899
+ this.rootLevel.levelDiv.style.display = 'flex';
1835
1900
  }
1836
1901
  else {
1837
1902
  const anchor = options.anchor || document.documentElement;
@@ -3369,7 +3434,7 @@ class ColumnsPanel extends Widget {
3369
3434
  get cssPrefix() {
3370
3435
  return 'eqjs-qc';
3371
3436
  }
3372
- constructor(slot) {
3437
+ constructor(slot, customQuery) {
3373
3438
  super(slot);
3374
3439
  this.options = {
3375
3440
  isSubQuery: false,
@@ -3403,6 +3468,7 @@ class ColumnsPanel extends Widget {
3403
3468
  showIndicatorOnLoad: true,
3404
3469
  editMode: ColumnsEditMode.Full
3405
3470
  };
3471
+ this.customQuery = null;
3406
3472
  this.landingSlot = domel('div')
3407
3473
  .addClass(`${this.cssPrefix}-col-landing-slot`)
3408
3474
  .addChildElement(domel('div')
@@ -3411,6 +3477,9 @@ class ColumnsPanel extends Widget {
3411
3477
  this.landingIndex = -1;
3412
3478
  this.slot.classList.add(`${this.cssPrefix}-panel`);
3413
3479
  this.group = WidgetGroup.Query;
3480
+ if (customQuery) {
3481
+ this.customQuery = customQuery;
3482
+ }
3414
3483
  }
3415
3484
  getWidgetType() {
3416
3485
  return 'columnsPanel';
@@ -3659,6 +3728,9 @@ class ColumnsPanel extends Widget {
3659
3728
  }
3660
3729
  }
3661
3730
  getQuery() {
3731
+ if (this.customQuery) {
3732
+ return this.customQuery;
3733
+ }
3662
3734
  return this.getContext().getQuery();
3663
3735
  }
3664
3736
  showLandingSlot(pageX, pageY) {
@@ -5620,7 +5692,7 @@ class SortingBar extends Widget {
5620
5692
  get cssPrefix() {
5621
5693
  return "eqjs-sb";
5622
5694
  }
5623
- constructor(slot) {
5695
+ constructor(slot, customQuery) {
5624
5696
  super(slot);
5625
5697
  this.options = {
5626
5698
  showAddRow: true,
@@ -5633,6 +5705,7 @@ class SortingBar extends Widget {
5633
5705
  itemRenderedCallback: null
5634
5706
  },
5635
5707
  };
5708
+ this.customQuery = null;
5636
5709
  this.sortMenuList = [
5637
5710
  { id: 'None', text: i18n.getText('CmdNotSorted') },
5638
5711
  { id: 'Ascending', text: i18n.getText('CmdAscending') },
@@ -5651,6 +5724,9 @@ class SortingBar extends Widget {
5651
5724
  .addClass(`${this.cssPrefix}-panel`)
5652
5725
  .addClass(getMobileCssClass());
5653
5726
  this.group = WidgetGroup.Query;
5727
+ if (customQuery) {
5728
+ this.customQuery = customQuery;
5729
+ }
5654
5730
  }
5655
5731
  getWidgetType() {
5656
5732
  return 'sortingBar';
@@ -5716,6 +5792,9 @@ class SortingBar extends Widget {
5716
5792
  }
5717
5793
  }
5718
5794
  getQuery() {
5795
+ if (this.customQuery) {
5796
+ return this.customQuery;
5797
+ }
5719
5798
  return this.getContext().getQuery();
5720
5799
  }
5721
5800
  getModel() {
@@ -8978,6 +9057,25 @@ class Grid extends Widget {
8978
9057
  }
8979
9058
  }
8980
9059
 
9060
+ /**
9061
+ * Computes a stable per-column identity used to persist column widths:
9062
+ * the query column's expression (attribute id, else the serialized expression),
9063
+ * falling back to the server-provided origin attribute id when the query has
9064
+ * no matching column (e.g. a data-filtering fetch with an empty column list,
9065
+ * where the server reflects all entity properties into auto-columns).
9066
+ * Returns null when the column has no stable identity — such columns are not
9067
+ * persisted (transient ids would only pollute the store).
9068
+ */
9069
+ function stableColumnKey(qcol, dataColumn) {
9070
+ if (qcol) {
9071
+ const exprData = qcol.expr.saveToData();
9072
+ if (exprData) {
9073
+ return exprData.id || JSON.stringify(exprData);
9074
+ }
9075
+ }
9076
+ return (dataColumn && dataColumn.originAttrId) || null;
9077
+ }
9078
+
8981
9079
  class EasyGridWidget extends Grid {
8982
9080
  /**
8983
9081
  * The default constructor.
@@ -8986,6 +9084,10 @@ class EasyGridWidget extends Grid {
8986
9084
  constructor(slot) {
8987
9085
  super(slot);
8988
9086
  this.hasCustomTotalsSettings = false;
9087
+ this.columnWidthCache = {};
9088
+ this.columnWidthScope = 'model';
9089
+ this.columnWidthsEnabled = true;
9090
+ this.loadedWidthScopeKey = null;
8989
9091
  //!!!! DO NOT forget to add types for options
8990
9092
  this.options = { autoRefresh: true };
8991
9093
  this.group = WidgetGroup.Result;
@@ -9005,6 +9107,25 @@ class EasyGridWidget extends Grid {
9005
9107
  this.options = utils.assign(this.options, gridOptions);
9006
9108
  gridOptions.onPlusButtonClick = (ev) => this.addColumnClickHandler(ev);
9007
9109
  gridOptions.onColumnMoved = (ev) => this.columnMovedHandler(ev);
9110
+ const userSync = gridOptions.onSyncGridColumn;
9111
+ gridOptions.onSyncGridColumn = (col) => {
9112
+ this.syncGridColumn(col);
9113
+ this.applyColumnWidth(col);
9114
+ if (userSync)
9115
+ userSync(col);
9116
+ };
9117
+ gridOptions.onColumnSort = (ev) => this.columnSortHandler(ev);
9118
+ // gridOptions already merges widgets.resultGrid / widgets.easyGrid / options
9119
+ // (spread above), with `options` winning, so a single read honours priority.
9120
+ const cwOpt = gridOptions.columnWidthPersistence || {};
9121
+ this.columnWidthsEnabled = cwOpt.enabled !== false;
9122
+ this.columnWidthScope = cwOpt.scope === 'query' ? 'query' : 'model';
9123
+ const userResize = gridOptions.onColumnResize;
9124
+ gridOptions.onColumnResize = (ev) => {
9125
+ this.columnResizeHandler(ev);
9126
+ if (userResize)
9127
+ userResize(ev);
9128
+ };
9008
9129
  if (gridOptions.aggregates) {
9009
9130
  this.hasCustomTotalsSettings = true;
9010
9131
  }
@@ -9017,8 +9138,12 @@ class EasyGridWidget extends Grid {
9017
9138
  if (this.options.autoRefresh) {
9018
9139
  this.attachQueryObserver();
9019
9140
  }
9141
+ this.loadColumnWidths();
9020
9142
  }
9021
9143
  refreshCore() {
9144
+ // Re-checks the scope key (no-ops when unchanged) so a query switch under
9145
+ // scope:'query', or a model that loads after init, picks up stored widths.
9146
+ this.loadColumnWidths();
9022
9147
  this.clear();
9023
9148
  this.updateTotalsSettings();
9024
9149
  this.render();
@@ -9108,6 +9233,119 @@ class EasyGridWidget extends Grid {
9108
9233
  });
9109
9234
  return col;
9110
9235
  }
9236
+ toGridSort(dir) {
9237
+ if (dir === SortDirection.Ascending)
9238
+ return 'asc';
9239
+ if (dir === SortDirection.Descending)
9240
+ return 'desc';
9241
+ return 'none';
9242
+ }
9243
+ fromGridSort(dir) {
9244
+ if (dir === 'asc')
9245
+ return SortDirection.Ascending;
9246
+ if (dir === 'desc')
9247
+ return SortDirection.Descending;
9248
+ return SortDirection.None;
9249
+ }
9250
+ /** Reflects the query's current sorting in the grid header indicator. */
9251
+ syncGridColumn(col) {
9252
+ if (col.isRowNum || !col.dataColumn)
9253
+ return;
9254
+ const query = this.getQuery();
9255
+ const qcol = query.getColumns().find(c => c.id === col.dataColumn.id);
9256
+ col.sortDirection = qcol ? this.toGridSort(query.getColumnSorting(qcol)) : 'none';
9257
+ }
9258
+ /** Handles a header-sort click: multi-column server-side sort, then reload. */
9259
+ columnSortHandler(ev) {
9260
+ const query = this.getQuery();
9261
+ const target = query.getColumns().find(c => c.id === ev.columnId);
9262
+ if (!target)
9263
+ return;
9264
+ // Multi-column sort: write the new direction (already cycled by EasyGrid:
9265
+ // none -> asc -> desc -> none) to only the clicked column. The query keeps
9266
+ // the full sorted set; when syncSortOrderWithColumnsOrder is enabled (the
9267
+ // default), Query.adjustColumnsSorting aligns sort priority with the column
9268
+ // order, matching ColumnsPanel.
9269
+ const prevSorting = query.getColumnSorting(target);
9270
+ query.setColumnSorting(target, this.fromGridSort(ev.direction));
9271
+ // Pure result re-ordering: reload sorted data and re-render the result grid
9272
+ // (which re-applies the header sort indicators via onSyncGridColumn), then
9273
+ // refresh the query-side widgets (SortingBar/ColumnsPanel). This deliberately
9274
+ // does not route through the view's clear/refetch path, so there is no
9275
+ // result-clearing flash and no double fetch.
9276
+ this.context.fetchData({
9277
+ success: () => this.context.refreshWidgets(WidgetGroup.Result),
9278
+ error: () => {
9279
+ // The failed sorting must not stay in the query, otherwise every
9280
+ // subsequent fetch would repeat the failing ORDER BY (e.g. a column
9281
+ // the server cannot sort by). Only the query-side widgets need a
9282
+ // refresh: the grid header changes its arrows only on re-render,
9283
+ // which never happened for the failed direction - re-rendering the
9284
+ // grid here would just make it blink.
9285
+ query.setColumnSorting(target, prevSorting);
9286
+ this.context.refreshWidgets(WidgetGroup.Query);
9287
+ }
9288
+ });
9289
+ this.context.refreshWidgets(WidgetGroup.Query);
9290
+ }
9291
+ /** Computes the storage scope key for the current model/query. */
9292
+ columnWidthScopeKey() {
9293
+ return this.columnWidthScope === 'query'
9294
+ ? this.getQuery().getId()
9295
+ : this.getContext().getModel().getId();
9296
+ }
9297
+ /** Stable per-column identity: the query column's expression, else the
9298
+ * server-provided originAttrId (covers the data-filtering scenario where
9299
+ * the query has no columns and the server auto-generates them). */
9300
+ columnKey(dataColumn) {
9301
+ const qcol = this.getQuery().getColumns().find(c => c.id === dataColumn.id);
9302
+ return stableColumnKey(qcol, dataColumn);
9303
+ }
9304
+ /** Applies a cached width to a grid column (called from onSyncGridColumn). */
9305
+ applyColumnWidth(col) {
9306
+ if (!this.columnWidthsEnabled || col.isRowNum || !col.dataColumn)
9307
+ return;
9308
+ const key = this.columnKey(col.dataColumn);
9309
+ if (key && this.columnWidthCache[key] != null) {
9310
+ col.width = this.columnWidthCache[key];
9311
+ col.manualWidth = true;
9312
+ }
9313
+ }
9314
+ /** Persists a width when the user finishes a resize drag. */
9315
+ columnResizeHandler(ev) {
9316
+ if (!this.columnWidthsEnabled)
9317
+ return;
9318
+ const gridCol = this.grid.getColumns().getItems()
9319
+ .find(c => c.dataColumn && c.dataColumn.id === ev.columnId);
9320
+ if (!gridCol)
9321
+ return;
9322
+ const key = this.columnKey(gridCol.dataColumn);
9323
+ if (!key)
9324
+ return;
9325
+ const scopeKey = this.columnWidthScopeKey();
9326
+ if (!scopeKey)
9327
+ return; // no model/query id -> avoid a colliding empty key
9328
+ this.columnWidthCache[key] = ev.width;
9329
+ this.getContext().getColumnWidthStore().save(scopeKey, key, ev.width);
9330
+ }
9331
+ /** Loads the width map once per scope key, then refreshes to apply. */
9332
+ loadColumnWidths() {
9333
+ if (!this.columnWidthsEnabled)
9334
+ return;
9335
+ const model = this.getContext().getModel();
9336
+ if (!model || model.isEmpty())
9337
+ return; // no model yet -> nothing to scope by
9338
+ const scopeKey = this.columnWidthScopeKey();
9339
+ if (scopeKey === this.loadedWidthScopeKey)
9340
+ return;
9341
+ this.loadedWidthScopeKey = scopeKey;
9342
+ this.getContext().getColumnWidthStore().load(scopeKey).then((map) => {
9343
+ this.columnWidthCache = map || {};
9344
+ if (Object.keys(this.columnWidthCache).length > 0 && this.grid) {
9345
+ this.grid.refresh();
9346
+ }
9347
+ });
9348
+ }
9111
9349
  }
9112
9350
 
9113
9351
  /**
@@ -9714,7 +9952,25 @@ class ChartWidget extends Widget {
9714
9952
  getCurrentChartType() {
9715
9953
  return this.chartType;
9716
9954
  }
9955
+ /**
9956
+ * Changes the current chart type and redraws the chart in place.
9957
+ * Deliberately avoids a full re-render: emptying the widget (even for
9958
+ * the drawing delay) shrinks the scrollable parent, so the browser
9959
+ * clamps - and loses - its scroll position.
9960
+ * @param type The new chart type.
9961
+ */
9962
+ changeChartType(type) {
9963
+ this.chartType = type;
9964
+ this.drawChart(0);
9965
+ }
9717
9966
  render() {
9967
+ // hold the current height while the content is being rebuilt:
9968
+ // an emptied widget shrinks the scrollable parent and the browser
9969
+ // clamps (loses) its scroll position. drawChart() releases the lock.
9970
+ const prevHeight = this.element.offsetHeight;
9971
+ if (prevHeight > 0) {
9972
+ this.element.style.minHeight = prevHeight + 'px';
9973
+ }
9718
9974
  this.clear();
9719
9975
  if (this.hasData() && this.hasColumnsForChart()
9720
9976
  && (this.context.resultTable.getTotal()
@@ -9735,8 +9991,7 @@ class ChartWidget extends Widget {
9735
9991
  headerDiv.appendChild(chartTypeSelector);
9736
9992
  chartTypeSelector.addEventListener('change', (ev) => {
9737
9993
  let ctkey = ev.target.value;
9738
- this.chartType = parseInt(ctkey);
9739
- this.refresh();
9994
+ this.changeChartType(parseInt(ctkey));
9740
9995
  });
9741
9996
  this.settingsDiv = document.createElement('div');
9742
9997
  addCssClass(this.settingsDiv, `${this.cssPrefix}-settings`);
@@ -9758,6 +10013,7 @@ class ChartWidget extends Widget {
9758
10013
  }
9759
10014
  else {
9760
10015
  this.addContentDiv(mainDiv, 'Unable to render the chart. None of the supported chart libraries (ChartJS, Google Charts, etc) is found.');
10016
+ this.element.style.minHeight = '';
9761
10017
  }
9762
10018
  if (!this.options.hideSettings) {
9763
10019
  this.element.appendChild(headerDiv);
@@ -9767,6 +10023,7 @@ class ChartWidget extends Widget {
9767
10023
  }
9768
10024
  else {
9769
10025
  this.element.style.display = "none";
10026
+ this.element.style.minHeight = '';
9770
10027
  }
9771
10028
  }
9772
10029
  addContentDiv(mainDiv, text) {
@@ -9787,11 +10044,16 @@ class ChartWidget extends Widget {
9787
10044
  */
9788
10045
  drawChart(delay) {
9789
10046
  showElement(this.chartDiv);
10047
+ const draw = () => {
10048
+ this.drawCore();
10049
+ //release the height lock held by render() for the redraw gap
10050
+ this.element.style.minHeight = '';
10051
+ };
9790
10052
  if (delay > 0) {
9791
- setTimeout(() => this.drawCore(), delay);
10053
+ setTimeout(draw, delay);
9792
10054
  }
9793
10055
  else {
9794
- this.drawCore();
10056
+ draw();
9795
10057
  }
9796
10058
  }
9797
10059
  /**
@@ -9910,6 +10172,7 @@ class ChartJsWidget extends ChartWidget {
9910
10172
  ];
9911
10173
  this.chartLabels = [];
9912
10174
  this.chartColumns = [];
10175
+ this.chart = null;
9913
10176
  this.hasChartTypes = [
9914
10177
  ChartType.Column,
9915
10178
  ChartType.Bar,
@@ -9934,6 +10197,16 @@ class ChartJsWidget extends ChartWidget {
9934
10197
  static hasChartJs() {
9935
10198
  return typeof Chart !== 'undefined';
9936
10199
  }
10200
+ /**
10201
+ * Returns the major version of the Chart.js library loaded on the page.
10202
+ * `Chart.version` was introduced in 3.0, so its absence means 2.x.
10203
+ */
10204
+ static getChartJsMajorVersion() {
10205
+ if (!ChartJsWidget.hasChartJs())
10206
+ return 0;
10207
+ const major = parseInt(Chart.version, 10);
10208
+ return isNaN(major) ? 2 : major;
10209
+ }
9937
10210
  canDraw() {
9938
10211
  return ChartJsWidget.hasChartJs();
9939
10212
  }
@@ -9949,7 +10222,20 @@ class ChartJsWidget extends ChartWidget {
9949
10222
  let config = this.generateConfig();
9950
10223
  let chartCanvas = this.getChartCanvas(this.chartDiv);
9951
10224
  let chartContext = chartCanvas.getContext('2d');
9952
- new Chart(chartContext, config);
10225
+ //we redraw into the same canvas when the chart settings change,
10226
+ //and Chart.js 3+ refuses to reuse a canvas that still holds a chart
10227
+ this.destroyChart();
10228
+ this.chart = new Chart(chartContext, config);
10229
+ }
10230
+ }
10231
+ destroyCore() {
10232
+ this.destroyChart();
10233
+ super.destroyCore();
10234
+ }
10235
+ destroyChart() {
10236
+ if (this.chart) {
10237
+ this.chart.destroy();
10238
+ this.chart = null;
9953
10239
  }
9954
10240
  }
9955
10241
  updateChartColumns() {
@@ -10018,24 +10304,39 @@ class ChartJsWidget extends ChartWidget {
10018
10304
  datasets: [dataset1],
10019
10305
  labels: this.chartLabels
10020
10306
  },
10021
- options: {
10022
- legend: {
10023
- display: displayLegend,
10024
- position: this.options.legend.position || 'bottom'
10025
- },
10026
- title: {
10027
- display: true,
10028
- text: datasetName
10029
- }
10030
- }
10307
+ options: this.generateOptions(datasetName, displayLegend)
10308
+ };
10309
+ }
10310
+ generateOptions(datasetName, displayLegend) {
10311
+ const legend = {
10312
+ display: displayLegend,
10313
+ position: this.options.legend.position || 'bottom'
10314
+ };
10315
+ const title = {
10316
+ display: true,
10317
+ text: datasetName
10031
10318
  };
10319
+ if (ChartJsWidget.getChartJsMajorVersion() < 3) {
10320
+ return { legend, title };
10321
+ }
10322
+ //both were moved under `plugins` in 3.0 - left at the root
10323
+ //they are silently ignored
10324
+ const options = { plugins: { legend, title } };
10325
+ //3.0 dropped the `horizontalBar` type in favour of flipping a bar chart
10326
+ if (this.chartType == ChartType.Bar) {
10327
+ options.indexAxis = 'y';
10328
+ }
10329
+ return options;
10032
10330
  }
10033
10331
  getChartType() {
10034
10332
  switch (this.chartType) {
10035
10333
  case ChartType.Column:
10036
10334
  return "bar";
10037
10335
  case ChartType.Bar:
10038
- return "horizontalBar";
10336
+ //3.0 replaced this type with `bar` + `indexAxis: 'y'`
10337
+ return ChartJsWidget.getChartJsMajorVersion() < 3
10338
+ ? "horizontalBar"
10339
+ : "bar";
10039
10340
  case ChartType.Area:
10040
10341
  case ChartType.SteppedLine:
10041
10342
  case ChartType.Line:
@@ -12480,7 +12781,8 @@ class View {
12480
12781
  },
12481
12782
  widgets: {
12482
12783
  resultGrid: {
12483
- addColumns: false
12784
+ addColumns: false,
12785
+ sortable: true
12484
12786
  }
12485
12787
  },
12486
12788
  handlers: {}
@@ -13703,7 +14005,15 @@ class DataFilterView extends View {
13703
14005
  this.context.syncQuery();
13704
14006
  }
13705
14007
  });
13706
- this.context.addEventListener('ready', this.fetchData.bind(this));
14008
+ this.context.addEventListener('ready', () => {
14009
+ // Turn "show the whole entity" into an ordinary query with explicit
14010
+ // columns (silently - the fetch below is the only one). A query
14011
+ // loaded from storage keeps its own columns.
14012
+ if (options.autoFillColumns !== false) {
14013
+ this.context.getQuery().fillColumnsWithRootAttributes();
14014
+ }
14015
+ this.fetchData();
14016
+ });
13707
14017
  return options;
13708
14018
  }
13709
14019
  createWidgets(options) {