cx 26.5.1 → 26.7.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.
Files changed (64) hide show
  1. package/build/data/ExposedValueView.js +1 -1
  2. package/build/data/comparer.d.ts +6 -8
  3. package/build/data/comparer.d.ts.map +1 -1
  4. package/build/locale/de-de.js +1 -0
  5. package/build/locale/en-us.js +1 -0
  6. package/build/locale/es-es.js +1 -0
  7. package/build/locale/fr-fr.js +1 -0
  8. package/build/locale/nl-nl.js +1 -0
  9. package/build/locale/pt-pt.js +1 -0
  10. package/build/locale/sr-latn-ba.js +1 -0
  11. package/build/ui/Prop.d.ts +1 -1
  12. package/build/ui/Prop.d.ts.map +1 -1
  13. package/build/ui/adapter/ArrayAdapter.d.ts +4 -6
  14. package/build/ui/adapter/ArrayAdapter.d.ts.map +1 -1
  15. package/build/ui/adapter/GroupAdapter.d.ts +22 -2
  16. package/build/ui/adapter/GroupAdapter.d.ts.map +1 -1
  17. package/build/ui/adapter/GroupAdapter.js +80 -5
  18. package/build/widgets/drag-drop/DragSource.d.ts +3 -2
  19. package/build/widgets/drag-drop/DragSource.d.ts.map +1 -1
  20. package/build/widgets/drag-drop/DropZone.d.ts +4 -4
  21. package/build/widgets/drag-drop/DropZone.d.ts.map +1 -1
  22. package/build/widgets/drag-drop/ops.d.ts +13 -2
  23. package/build/widgets/drag-drop/ops.d.ts.map +1 -1
  24. package/build/widgets/drag-drop/ops.js +36 -9
  25. package/build/widgets/form/LookupField.d.ts +6 -0
  26. package/build/widgets/form/LookupField.d.ts.map +1 -1
  27. package/build/widgets/form/LookupField.js +12 -0
  28. package/build/widgets/form/NumberField.js +1 -0
  29. package/build/widgets/grid/Grid.d.ts +24 -3
  30. package/build/widgets/grid/Grid.d.ts.map +1 -1
  31. package/build/widgets/grid/Grid.js +5 -2
  32. package/build/widgets/overlay/captureMouse.d.ts +4 -4
  33. package/build/widgets/overlay/captureMouse.d.ts.map +1 -1
  34. package/build/widgets/overlay/captureMouse.js +8 -3
  35. package/dist/data.js +1 -1
  36. package/dist/manifest.js +857 -857
  37. package/dist/ui.js +94 -9
  38. package/dist/widgets.css +8 -0
  39. package/dist/widgets.js +60 -12
  40. package/package.json +1 -1
  41. package/src/data/ExposedValueView.spec.ts +57 -0
  42. package/src/data/ExposedValueView.ts +1 -1
  43. package/src/data/comparer.ts +6 -7
  44. package/src/locale/de-de.ts +1 -0
  45. package/src/locale/en-us.ts +1 -0
  46. package/src/locale/es-es.ts +1 -0
  47. package/src/locale/fr-fr.ts +1 -0
  48. package/src/locale/nl-nl.ts +1 -0
  49. package/src/locale/pt-pt.ts +1 -0
  50. package/src/locale/sr-latn-ba.ts +1 -0
  51. package/src/ui/Prop.ts +1 -1
  52. package/src/ui/adapter/ArrayAdapter.ts +6 -8
  53. package/src/ui/adapter/GroupAdapter.spec.ts +75 -0
  54. package/src/ui/adapter/GroupAdapter.ts +103 -10
  55. package/src/widgets/drag-drop/DragSource.tsx +3 -3
  56. package/src/widgets/drag-drop/DropZone.tsx +5 -5
  57. package/src/widgets/drag-drop/ops.tsx +54 -12
  58. package/src/widgets/form/LookupField.spec.tsx +149 -0
  59. package/src/widgets/form/LookupField.tsx +27 -0
  60. package/src/widgets/form/NumberField.scss +4 -0
  61. package/src/widgets/form/NumberField.tsx +1 -0
  62. package/src/widgets/grid/Grid.scss +6 -0
  63. package/src/widgets/grid/Grid.tsx +31 -4
  64. package/src/widgets/overlay/captureMouse.ts +14 -7
package/dist/ui.js CHANGED
@@ -3829,9 +3829,38 @@ function startHotAppLoop(appModule, element, store, widgets, options = {}) {
3829
3829
  }
3830
3830
 
3831
3831
  class GroupAdapter extends ArrayAdapter {
3832
+ /**
3833
+ * Per-level comparer derived from the active record sorters, used to reorder groups when
3834
+ * `sortGroupsBySorters` is set. A level's entry is `null` when no active sorter maps to that
3835
+ * level's key/aggregate, so the grouping's own configured order is kept.
3836
+ */
3837
+ groupSortComparers;
3832
3838
  constructor(config) {
3833
3839
  super(config);
3834
3840
  }
3841
+ sort(sorters) {
3842
+ super.sort(sorters);
3843
+ // When enabled, derive a group comparer from the active record sorters so that interactive
3844
+ // column sorting also reorders the groups. A column only reorders groups at levels where its
3845
+ // field is a group key or aggregate; other levels keep their configured order. The record-level
3846
+ // value selector is ignored — the field is resolved against the group's key/aggregates/name.
3847
+ if (this.sortGroupsBySorters && this.groupings) {
3848
+ const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
3849
+ const colSorters = isNonEmptyArray(sorters)
3850
+ ? sorters.map((s) => ({
3851
+ field: s.field,
3852
+ direction: s.direction,
3853
+ comparer: s.comparer,
3854
+ }))
3855
+ : [];
3856
+ this.groupSortComparers = this.groupings.map((g) => {
3857
+ if (colSorters.length === 0) return null;
3858
+ const fields = groupFieldNames(g, this.aggregates);
3859
+ const applicable = colSorters.filter((s) => s.field && fields.has(s.field));
3860
+ return applicable.length > 0 ? buildGroupComparer(applicable, cultureComparer) : null;
3861
+ });
3862
+ }
3863
+ }
3835
3864
  init() {
3836
3865
  super.init();
3837
3866
  if (this.groupRecordsAlias) {
@@ -3866,8 +3895,12 @@ class GroupAdapter extends ArrayAdapter {
3866
3895
  grouper.reset();
3867
3896
  grouper.processAll(records);
3868
3897
  let results = grouper.getResults();
3869
- if (grouping.comparer && !this.preserveOrder) {
3870
- results.sort(grouping.comparer);
3898
+ // An active column sort that maps to this level's key/aggregate (when `sortGroupsBySorters` is
3899
+ // enabled) takes over; otherwise the grouping's configured comparer (or implicit key order) applies.
3900
+ const dynamicComparer = this.sortGroupsBySorters ? this.groupSortComparers?.[level] : undefined;
3901
+ const comparer = dynamicComparer ?? grouping.comparer;
3902
+ if (comparer && !this.preserveOrder) {
3903
+ results.sort(comparer);
3871
3904
  }
3872
3905
  results.forEach((gr) => {
3873
3906
  keys.push(gr.key);
@@ -3949,15 +3982,26 @@ class GroupAdapter extends ArrayAdapter {
3949
3982
  (r) => r.store.getData(),
3950
3983
  g.text,
3951
3984
  );
3985
+ const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
3986
+ // Sort groups by an aggregate/key/name through `sortField`/`sortDirection` or a `sorters` array.
3987
+ let sortSorters = null;
3988
+ if (isNonEmptyArray(g.sorters)) sortSorters = g.sorters;
3989
+ else if (g.sortField)
3990
+ sortSorters = [
3991
+ {
3992
+ field: g.sortField,
3993
+ direction: g.sortDirection ?? "ASC",
3994
+ },
3995
+ ];
3996
+ // `comparer`, `sortField`/`sorters` and the implicit key order are equivalent alternatives; an
3997
+ // explicit `comparer` wins, then declarative sorters, then sorting by key in declaration order.
3952
3998
  const comparer =
3953
3999
  g.comparer ??
3954
- (groupSorters.length > 0
3955
- ? getComparer(
3956
- groupSorters,
3957
- (x) => x.key,
3958
- this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined,
3959
- )
3960
- : null);
4000
+ (sortSorters
4001
+ ? buildGroupComparer(sortSorters, cultureComparer)
4002
+ : groupSorters.length > 0
4003
+ ? getComparer(groupSorters, (x) => x.key, cultureComparer)
4004
+ : null);
3961
4005
  return {
3962
4006
  ...g,
3963
4007
  key: resolvedKey,
@@ -3972,6 +4016,47 @@ class GroupAdapter extends ArrayAdapter {
3972
4016
  }
3973
4017
  GroupAdapter.prototype.groupName = "$group";
3974
4018
  GroupAdapter.prototype.preserveOrder = false;
4019
+ GroupAdapter.prototype.sortGroupsBySorters = false;
4020
+ // The set of fields a group can be sorted by at a given level: its key fields, its aggregate aliases
4021
+ // and the group name. Used to decide whether an active column sort applies to that grouping level.
4022
+ function groupFieldNames(grouping, adapterAggregates) {
4023
+ const names = new Set();
4024
+ for (const k of grouping.grouper.keys) names.add(k.name);
4025
+ const aggregates = {
4026
+ ...adapterAggregates,
4027
+ ...grouping.aggregates,
4028
+ };
4029
+ for (const a in aggregates) names.add(a);
4030
+ names.add("name");
4031
+ names.add("$name");
4032
+ return names;
4033
+ }
4034
+ // Reads a sort field straight from the GroupResult — first its key fields, then its aggregates, then
4035
+ // its name. Returns a plain selector so groups can be sorted without cloning the result per comparison.
4036
+ function groupFieldSelector(field) {
4037
+ if (field === "name" || field === "$name") return (gr) => gr?.name;
4038
+ return (gr) => {
4039
+ if (gr?.key && field in gr.key) return gr.key[field];
4040
+ if (gr?.aggregates && field in gr.aggregates) return gr.aggregates[field];
4041
+ return undefined;
4042
+ };
4043
+ }
4044
+ // Builds a group comparer from a list of sorters. Each sorter resolves by its explicit `value`
4045
+ // selector, or by `field` looked up against the group's key/aggregates/name.
4046
+ function buildGroupComparer(sorters, cultureComparer) {
4047
+ return getComparer(
4048
+ sorters.map((s) =>
4049
+ isDefined(s.value) || !s.field
4050
+ ? s
4051
+ : {
4052
+ ...s,
4053
+ value: groupFieldSelector(s.field),
4054
+ },
4055
+ ),
4056
+ undefined,
4057
+ cultureComparer,
4058
+ );
4059
+ }
3975
4060
  function serializeKey(data) {
3976
4061
  if (isDataRecord(data)) {
3977
4062
  return Object.keys(data)
package/dist/widgets.css CHANGED
@@ -1086,6 +1086,10 @@
1086
1086
  padding-left: 26px;
1087
1087
  }
1088
1088
 
1089
+ .cxs-clear > .cxe-numberfield-input {
1090
+ padding-right: 22px;
1091
+ }
1092
+
1089
1093
  .cxe-numberfield-tool {
1090
1094
  pointer-events: none;
1091
1095
  display: block;
@@ -5313,6 +5317,10 @@ th.cxe-calendar-display {
5313
5317
  content: counter(cx-row-number);
5314
5318
  }
5315
5319
 
5320
+ .cxe-grid-group-caption.cxs-reset-row-numbers {
5321
+ counter-set: cx-row-number 0;
5322
+ }
5323
+
5316
5324
  .cxe-grid-fixed-header {
5317
5325
  overflow: hidden;
5318
5326
  position: absolute;
package/dist/widgets.js CHANGED
@@ -2050,6 +2050,7 @@ function captureMouse2(e, options) {
2050
2050
  }
2051
2051
  }
2052
2052
  e.stopPropagation();
2053
+ return tear;
2053
2054
  function move(e) {
2054
2055
  if (!active) {
2055
2056
  tear();
@@ -2105,7 +2106,11 @@ function captureMouseOrTouch2(e, options) {
2105
2106
  el.addEventListener("touchmove", move);
2106
2107
  el.addEventListener("touchend", end);
2107
2108
  e.stopPropagation();
2108
- } else captureMouse2(e, options);
2109
+ return () => {
2110
+ el.removeEventListener("touchmove", move);
2111
+ el.removeEventListener("touchend", end);
2112
+ };
2113
+ } else return captureMouse2(e, options);
2109
2114
  }
2110
2115
  /**
2111
2116
  * Legacy function for capturing mouse events with individual parameters
@@ -2116,7 +2121,7 @@ function captureMouseOrTouch2(e, options) {
2116
2121
  * @param cursor - CSS cursor style for the capture surface
2117
2122
  */
2118
2123
  function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
2119
- captureMouse2(e, {
2124
+ return captureMouse2(e, {
2120
2125
  onMouseMove,
2121
2126
  onMouseUp,
2122
2127
  captureData,
@@ -2132,7 +2137,7 @@ function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
2132
2137
  * @param cursor - CSS cursor style for the capture surface
2133
2138
  */
2134
2139
  function captureMouseOrTouch(e, onMouseMove, onMouseUp, captureData, cursor) {
2135
- captureMouseOrTouch2(e, {
2140
+ return captureMouseOrTouch2(e, {
2136
2141
  onMouseMove,
2137
2142
  onMouseUp,
2138
2143
  captureData,
@@ -2403,6 +2408,7 @@ let puppet = null;
2403
2408
  let scrollTimer = null;
2404
2409
  let vscrollParent = null;
2405
2410
  let hscrollParent = null;
2411
+ let releaseCapture = null;
2406
2412
  function registerDropZone(dropZone) {
2407
2413
  dropZones.push(dropZone);
2408
2414
  return () => {
@@ -2488,7 +2494,15 @@ function initiateDragDrop(e, options = {}, onDragEnd) {
2488
2494
  dragStartedZones.set(zone, true);
2489
2495
  });
2490
2496
  notifyDragMove(e);
2491
- captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
2497
+ releaseCapture = captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
2498
+ document.addEventListener("keydown", onDragKeyDown, true);
2499
+ }
2500
+ function onDragKeyDown(e) {
2501
+ if (!puppet || e.key !== "Escape") return;
2502
+ //cancel the operation without dropping; there is no pointer event to report
2503
+ e.preventDefault();
2504
+ e.stopPropagation();
2505
+ notifyDragDrop(null, true);
2492
2506
  }
2493
2507
  function notifyDragMove(e, _captureData) {
2494
2508
  let event = getDragEvent(e, "dragmove");
@@ -2613,21 +2627,36 @@ function clearScrollTimer() {
2613
2627
  scrollTimer = null;
2614
2628
  }
2615
2629
  }
2616
- function notifyDragDrop(e) {
2630
+ function notifyDragDrop(e, cancelled = false) {
2631
+ if (!puppet) return;
2617
2632
  clearScrollTimer();
2618
- let event = getDragEvent(e, "dragdrop");
2633
+ document.removeEventListener("keydown", onDragKeyDown, true);
2619
2634
  if (puppet.stop) puppet.stop();
2620
- if (activeZone && activeZone.onDrop) event.result = activeZone.onDrop(event);
2635
+ //a keyboard cancellation has no pointer event, so the drop and away notifications are skipped
2636
+ let dropEvent = !cancelled && e ? getDragEvent(e, "dragdrop") : null;
2637
+ let result;
2638
+ if (dropEvent && activeZone && activeZone.onDrop) result = activeZone.onDrop(dropEvent);
2639
+ let endEvent = {
2640
+ type: "dragend",
2641
+ event: e,
2642
+ cursor: e ? getCursorPos(e) : null,
2643
+ source: puppet.source,
2644
+ cancelled,
2645
+ result,
2646
+ };
2621
2647
  dropZones.forEach((zone) => {
2622
- if (nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(event);
2648
+ if (dropEvent && nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(dropEvent);
2623
2649
  if (!dragStartedZones.has(zone)) return;
2624
- if (zone.onDragEnd) zone.onDragEnd(event);
2650
+ if (zone.onDragEnd) zone.onDragEnd(endEvent);
2625
2651
  });
2626
- if (puppet.onDragEnd) puppet.onDragEnd(event);
2652
+ if (puppet.onDragEnd) puppet.onDragEnd(endEvent);
2653
+ //tear down the mouse/touch capture when cancelling; on a normal drop the capture ends itself
2654
+ if (cancelled && releaseCapture) releaseCapture();
2627
2655
  nearZones = null;
2628
2656
  activeZone = null;
2629
2657
  puppet = null;
2630
2658
  dragStartedZones = null;
2659
+ releaseCapture = null;
2631
2660
  }
2632
2661
  function getDragEvent(event, type) {
2633
2662
  return {
@@ -8677,6 +8706,19 @@ class LookupField extends Field {
8677
8706
  }
8678
8707
  }
8679
8708
  instance.lastDropdown = context.lastDropdown;
8709
+ if (this.validateOptionExists && isArray(data.options) && !this.isEmpty(data)) {
8710
+ let invalid = this.multiple
8711
+ ? isArray(data.values) && data.records.length < data.values.length
8712
+ : !data.options.some(($option) =>
8713
+ areKeysEqual(
8714
+ getOptionKey(this.keyBindings, {
8715
+ $option,
8716
+ }),
8717
+ data.selectedKeys[0],
8718
+ ),
8719
+ );
8720
+ if (invalid) data.error = this.invalidOptionText;
8721
+ }
8680
8722
  super.prepareData(context, instance);
8681
8723
  }
8682
8724
  renderInput(context, instance, key) {
@@ -8742,6 +8784,8 @@ LookupField.prototype.hideSearchField = false;
8742
8784
  LookupField.prototype.minOptionsForSearchField = 7;
8743
8785
  LookupField.prototype.loadingText = "Loading...";
8744
8786
  LookupField.prototype.queryErrorText = "Error occurred while querying for lookup data.";
8787
+ LookupField.prototype.validateOptionExists = false;
8788
+ LookupField.prototype.invalidOptionText = "The selected option is no longer available.";
8745
8789
  LookupField.prototype.noResultsText = "No results found.";
8746
8790
  LookupField.prototype.optionIdField = "id";
8747
8791
  LookupField.prototype.optionTextField = "text";
@@ -9775,6 +9819,7 @@ let Input$1 = class Input extends VDOM.Component {
9775
9819
  visited: state.visited,
9776
9820
  focus: this.state.focus,
9777
9821
  icon: !!icon,
9822
+ clear: insideButton != null,
9778
9823
  empty: empty && !data.placeholder,
9779
9824
  error: data.error && (state.visited || !suppressErrorsUntilVisited || !empty),
9780
9825
  }),
@@ -15023,6 +15068,7 @@ class Grid extends ContainerBase {
15023
15068
  sortOptions: this.sortOptions,
15024
15069
  groupings: grouping,
15025
15070
  preserveOrder: this.preserveGroupOrder,
15071
+ sortGroupsBySorters: this.sortGroups,
15026
15072
  },
15027
15073
  this.dataAdapter,
15028
15074
  );
@@ -15600,12 +15646,13 @@ class Grid extends ContainerBase {
15600
15646
  renderGroupHeader(context, instance, g, level, group, i, store, fixedColumns) {
15601
15647
  let { CSS, baseClass } = this;
15602
15648
  let data = store.getData();
15649
+ let resetRowNumbers = g.resetRowNumbers ? "reset-row-numbers" : null;
15603
15650
  if (g.caption) {
15604
15651
  let caption = g.caption(data);
15605
15652
  return jsx(
15606
15653
  "tbody",
15607
15654
  {
15608
- className: CSS.element(baseClass, "group-caption", ["level-" + level]),
15655
+ className: CSS.element(baseClass, "group-caption", ["level-" + level, resetRowNumbers]),
15609
15656
  "data-group-key": group.$key,
15610
15657
  "data-group-element": `group-caption-${level}`,
15611
15658
  children: jsx("tr", {
@@ -15696,7 +15743,7 @@ class Grid extends ContainerBase {
15696
15743
  return jsx(
15697
15744
  "tbody",
15698
15745
  {
15699
- className: CSS.element(baseClass, "group-caption", ["level-" + level]),
15746
+ className: CSS.element(baseClass, "group-caption", ["level-" + level, resetRowNumbers]),
15700
15747
  "data-group-key": group.$key,
15701
15748
  "data-group-element": `group-caption-${level}`,
15702
15749
  children: lines,
@@ -15967,6 +16014,7 @@ Grid.prototype.hoverChannel = "default";
15967
16014
  Grid.prototype.focusable = null; // automatically resolved
15968
16015
  Grid.prototype.allowsFileDrops = false;
15969
16016
  Grid.prototype.preserveGroupOrder = false;
16017
+ Grid.prototype.sortGroups = false;
15970
16018
  Widget.alias("grid", Grid);
15971
16019
  Localization.registerPrototype("cx/widgets/Grid", Grid);
15972
16020
  class GridComponent extends VDOM.Component {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cx",
3
- "version": "26.5.1",
3
+ "version": "26.7.0",
4
4
  "description": "Advanced JavaScript UI framework for admin and dashboard applications with ready to use grid, form and chart components.",
5
5
  "exports": {
6
6
  "./data": {
@@ -0,0 +1,57 @@
1
+ import assert from "assert";
2
+
3
+ import { Binding } from "./Binding";
4
+ import { Store } from "./Store";
5
+ import { ExposedValueView } from "./ExposedValueView";
6
+
7
+ describe("ExposedValueView", () => {
8
+ // Mirrors how Sandbox wires it: a `storage` object keyed by a slot `key`,
9
+ // exposed to children under `recordName` (e.g. "$page").
10
+ const getView = (key = "~/ordering/new") => {
11
+ let store = new Store({
12
+ data: {
13
+ pages: {
14
+ "~/ordering/new": { description: "draft order", count: 3 },
15
+ },
16
+ },
17
+ });
18
+
19
+ let view = new ExposedValueView({
20
+ store,
21
+ containerBinding: Binding.get("pages"),
22
+ key,
23
+ recordName: "$page",
24
+ });
25
+
26
+ return { store, view };
27
+ };
28
+
29
+ it("exposes the keyed slot under the record name", () => {
30
+ let { view } = getView();
31
+ assert.equal(view.get("$page.description"), "draft order");
32
+ assert.equal(view.get("$page.count"), 3);
33
+ });
34
+
35
+ it("writes back to the keyed slot on set", () => {
36
+ let { store, view } = getView();
37
+ view.set("$page.description", "edited");
38
+ assert.equal(store.get("pages")["~/ordering/new"].description, "edited");
39
+ });
40
+
41
+ it("deletes a property within the exposed record", () => {
42
+ let { store, view } = getView();
43
+ view.delete("$page.count");
44
+ assert.equal(view.get("$page.count"), undefined);
45
+ assert.ok(!store.get("pages")["~/ordering/new"].hasOwnProperty("count"));
46
+ });
47
+
48
+ it("deletes the entire exposed record (removes the keyed slot)", () => {
49
+ let { store, view } = getView();
50
+ view.delete("$page");
51
+ assert.equal(view.get("$page"), undefined);
52
+ assert.ok(
53
+ !store.get("pages").hasOwnProperty("~/ordering/new"),
54
+ "the keyed storage slot should be removed"
55
+ );
56
+ });
57
+ });
@@ -67,7 +67,7 @@ export class ExposedValueView extends View {
67
67
  if (path == this.recordName) {
68
68
  data = this.getData();
69
69
  container = this.containerBinding.value(data);
70
- if (!container || !container.hasOwnProperty(path)) return false;
70
+ if (!container || !container.hasOwnProperty(this.key)) return false;
71
71
  newContainer = Object.assign({}, container);
72
72
  delete newContainer[this.key];
73
73
  this.store.set(this.containerBinding.path, newContainer);
@@ -1,17 +1,16 @@
1
1
  import { getSelector } from "./getSelector";
2
2
  import { isDefined } from "../util/isDefined";
3
3
  import { defaultCompare } from "./defaultCompare";
4
+ import type { CollatorOptions, Sorter } from "../ui/Prop";
4
5
 
5
- interface Sorter {
6
- value?: any;
7
- field?: string;
8
- direction?: string;
6
+ export interface ExtendedSorter extends Sorter {
9
7
  comparer?: (a: any, b: any) => number;
10
8
  compare?: (a: any, b: any) => number;
9
+ sortOptions?: CollatorOptions;
11
10
  }
12
11
 
13
12
  export function getComparer(
14
- sorters: Sorter[],
13
+ sorters: ExtendedSorter[],
15
14
  dataAccessor?: (x: any) => any,
16
15
  comparer?: (a: any, b: any) => number,
17
16
  ): (a: any, b: any) => number {
@@ -51,7 +50,7 @@ export function getComparer(
51
50
  }
52
51
 
53
52
  export function indexSorter(
54
- sorters: Sorter[],
53
+ sorters: ExtendedSorter[],
55
54
  dataAccessor?: (x: any) => any,
56
55
  compare?: (a: any, b: any) => number,
57
56
  ): (data: any[]) => number[] {
@@ -64,7 +63,7 @@ export function indexSorter(
64
63
  }
65
64
 
66
65
  export function sorter(
67
- sorters: Sorter[],
66
+ sorters: ExtendedSorter[],
68
67
  dataAccessor?: (x: any) => any,
69
68
  compare?: (a: any, b: any) => number,
70
69
  ): (data: any[]) => any[] {
@@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", {
16
16
  queryErrorText: "Bei der Abfrage der gesuchten Daten ist ein Felhler aufgetreten.",
17
17
  noResultsText: "Keine Ergebnisse gefunden.",
18
18
  minQueryLengthMessageText: "Geben Sie mindestens {0} Zeichen ein.",
19
+ invalidOptionText: "Die ausgewählte Option ist nicht mehr verfügbar.",
19
20
  });
20
21
 
21
22
  // In common for Calendar and MonthPicker
@@ -15,6 +15,7 @@ Localization.localize(c, "cx/widgets/LookupField", {
15
15
  queryErrorText: "Error occurred while querying for lookup data.",
16
16
  noResultsText: "No results found.",
17
17
  minQueryLengthMessageText: "Type in at least {0} character(s).",
18
+ invalidOptionText: "The selected option is no longer available.",
18
19
  });
19
20
 
20
21
  // In common for Calendar and MonthPicker
@@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", {
16
16
  queryErrorText: "Se produjo un error al consultar los datos de búsqueda.",
17
17
  noResultsText: "No se han encontrado resultados.",
18
18
  minQueryLengthMessageText: "Escriba al menos {0} caracteres.",
19
+ invalidOptionText: "La opción seleccionada ya no está disponible.",
19
20
  });
20
21
 
21
22
  // In common for Calendar and MonthPicker
@@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", {
16
16
  queryErrorText: "Une erreur s'est produite lors de l'interrogation des données de recherche.",
17
17
  noResultsText: "Aucun résultat trouvé.",
18
18
  minQueryLengthMessageText: "Tapez au moins {0} caractère (s).",
19
+ invalidOptionText: "L'option sélectionnée n'est plus disponible.",
19
20
  });
20
21
 
21
22
  // In common for Calendar and MonthPicker
@@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", {
16
16
  queryErrorText: "Er is een fout opgetreden bij het weergeven van gegevens.",
17
17
  noResultsText: "Geen resultaten gevonden",
18
18
  minQueryLengthMessageText: "Voer minimaal {0} tekens in.",
19
+ invalidOptionText: "De geselecteerde optie is niet meer beschikbaar.",
19
20
  });
20
21
 
21
22
  // In common for Calendar and MonthPicker
@@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", {
16
16
  queryErrorText: "Ocorreu um erro ao consultar os dados de pesquisa.",
17
17
  noResultsText: "Nenhum resultado encontrado.",
18
18
  minQueryLengthMessageText: "Digite pelo menos {0} caractere(s).",
19
+ invalidOptionText: "A opção selecionada já não está disponível.",
19
20
  });
20
21
 
21
22
  // In common for Calendar and MonthPicker
@@ -16,6 +16,7 @@ Localization.localize(c, "cx/widgets/LookupField", {
16
16
  queryErrorText: "Došlo je do greške kod pribavljanja podataka za prikaz.",
17
17
  noResultsText: "Rezultati nisu pronađeni.",
18
18
  minQueryLengthMessageText: "Unesite najmanje {0} karakter(a).",
19
+ invalidOptionText: "Izabrana opcija više nije dostupna.",
19
20
  });
20
21
 
21
22
  // In common for Calendar and MonthPicker
package/src/ui/Prop.ts CHANGED
@@ -127,7 +127,7 @@ export type SortDirection = "ASC" | "DESC";
127
127
 
128
128
  export interface Sorter {
129
129
  field?: string;
130
- value?: (record: DataRecord) => any;
130
+ value?: Prop<any>;
131
131
  direction: SortDirection;
132
132
  }
133
133
 
@@ -1,6 +1,6 @@
1
1
  import { DataAdapter, DataAdapterRecord, DataAdapterConfig } from "./DataAdapter";
2
2
  import { ReadOnlyDataView } from "../../data/ReadOnlyDataView";
3
- import { sorter } from "../../data/comparer";
3
+ import { sorter, type ExtendedSorter } from "../../data/comparer";
4
4
  import { isArray } from "../../util/isArray";
5
5
  import { ArrayElementView } from "../../data/ArrayElementView";
6
6
  import { Accessor, getAccessor } from "../../data/getAccessor";
@@ -9,7 +9,7 @@ import { isDefined, isObject } from "../../util";
9
9
  import { RenderingContext } from "../RenderingContext";
10
10
  import { Instance } from "../Instance";
11
11
  import { View } from "../../data/View";
12
- import { Prop, Sorter, CollatorOptions } from "../Prop";
12
+ import { Prop, CollatorOptions } from "../Prop";
13
13
 
14
14
  export interface RecordStoreCache {
15
15
  recordStoreCache: WeakMap<any, View>;
@@ -26,10 +26,8 @@ export interface ArrayAdapterConfig extends DataAdapterConfig {
26
26
  preserveOrder?: boolean;
27
27
  }
28
28
 
29
- export interface ExtendedSorter extends Sorter {
30
- comparer?: (a: any, b: any) => number;
31
- sortOptions?: CollatorOptions;
32
- }
29
+ // Re-exported for back-compat; the canonical definition lives in `data/comparer`.
30
+ export type { ExtendedSorter } from "../../data/comparer";
33
31
 
34
32
  export interface ResolvedSorter {
35
33
  getter: (x: any) => any;
@@ -213,9 +211,9 @@ export class ArrayAdapter<T = any> extends DataAdapter<T> {
213
211
  }
214
212
  }
215
213
 
216
- public sort(sorters?: Sorter[] | ExtendedSorter[]): void {
214
+ public sort(sorters?: ExtendedSorter[]): void {
217
215
  if (sorters) {
218
- this.buildSorter(sorters as ExtendedSorter[]);
216
+ this.buildSorter(sorters);
219
217
  }
220
218
  }
221
219
  }
@@ -39,4 +39,79 @@ describe("GroupAdapter", () => {
39
39
 
40
40
  assert.deepStrictEqual(newKeys, [], `Grouping config was mutated. New keys: ${newKeys.join(", ")}`);
41
41
  });
42
+
43
+ // Region totals after aggregation: A=10, B=30, C=20.
44
+ const records = [
45
+ { region: "A", sales: 10, product: "p3" },
46
+ { region: "B", sales: 30, product: "p1" },
47
+ { region: "C", sales: 20, product: "p2" },
48
+ ];
49
+
50
+ const baseGrouping: GroupingConfig = {
51
+ key: { region: { bind: "$record.region" } },
52
+ aggregates: { total: { type: "sum", value: { bind: "$record.sales" } } },
53
+ };
54
+
55
+ // Runs the full grouping pipeline and returns the resulting group order (region keys).
56
+ function groupOrder(grouping: GroupingConfig[], options?: { sortGroupsBySorters?: boolean }, sorters?: any[]) {
57
+ const adapter = new GroupAdapter({ groupings: grouping, ...options });
58
+ adapter.init();
59
+ if (sorters) adapter.sort(sorters);
60
+ const result = adapter.getRecords({} as any, {} as any, records, new Store({ data: {} }));
61
+ return result.filter((r) => r.type === "group-header").map((r: any) => r.group.region);
62
+ }
63
+
64
+ it("sorts groups by an aggregate via sortField/sortDirection", () => {
65
+ assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sortField: "total", sortDirection: "DESC" }]), [
66
+ "B",
67
+ "C",
68
+ "A",
69
+ ]);
70
+ });
71
+
72
+ it("sorts groups by a key field via sortField", () => {
73
+ assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sortField: "region", sortDirection: "DESC" }]), [
74
+ "C",
75
+ "B",
76
+ "A",
77
+ ]);
78
+ });
79
+
80
+ it("supports a multi-field sorters array", () => {
81
+ assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sorters: [{ field: "total", direction: "ASC" }] }]), [
82
+ "A",
83
+ "C",
84
+ "B",
85
+ ]);
86
+ });
87
+
88
+ it("defaults to ascending sort by key", () => {
89
+ assert.deepStrictEqual(groupOrder([baseGrouping]), ["A", "B", "C"]);
90
+ });
91
+
92
+ it("lets an explicit comparer win over sortField", () => {
93
+ // Custom comparer sorts groups by total ascending, despite sortField asking for DESC.
94
+ const comparer = (a: any, b: any) => a.aggregates.total - b.aggregates.total;
95
+ assert.deepStrictEqual(groupOrder([{ ...baseGrouping, comparer, sortField: "total", sortDirection: "DESC" }]), [
96
+ "A",
97
+ "C",
98
+ "B",
99
+ ]);
100
+ });
101
+
102
+ it("reorders groups by the active column sort when sortGroupsBySorters is set", () => {
103
+ // The record-level value selector must be ignored; the column field drives group order.
104
+ const order = groupOrder([baseGrouping], { sortGroupsBySorters: true }, [
105
+ { field: "total", direction: "DESC", value: () => 999 },
106
+ ]);
107
+ assert.deepStrictEqual(order, ["B", "C", "A"]);
108
+ });
109
+
110
+ it("keeps configured group order when the sorted column is not a group field", () => {
111
+ // `product` is neither a key nor an aggregate, so groups keep their default (key ASC) order.
112
+ const order = groupOrder([baseGrouping], { sortGroupsBySorters: true }, [
113
+ { field: "product", direction: "DESC" },
114
+ ]);
115
+ assert.deepStrictEqual(order, ["A", "B", "C"]);
116
+ });
42
117
  });