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
@@ -4,11 +4,13 @@ import { ReadOnlyDataView } from "../../data/ReadOnlyDataView";
4
4
  import { View } from "../../data/View";
5
5
  import { isDataRecord } from "../../util";
6
6
  import { isArray } from "../../util/isArray";
7
+ import { isDefined } from "../../util/isDefined";
8
+ import { isNonEmptyArray } from "../../util/isNonEmptyArray";
7
9
  import { Culture } from "../Culture";
8
10
  import { Instance } from "../Instance";
9
11
  import { Prop, SortDirection, Sorter, StructuredProp } from "../Prop";
10
12
  import { RenderingContext } from "../RenderingContext";
11
- import { ArrayAdapter, ArrayAdapterConfig, RecordStoreCache } from "./ArrayAdapter";
13
+ import { ArrayAdapter, ArrayAdapterConfig, ExtendedSorter, RecordStoreCache } from "./ArrayAdapter";
12
14
  import { DataAdapterRecord } from "./DataAdapter";
13
15
 
14
16
  export interface GroupKey {
@@ -21,7 +23,17 @@ export interface GroupingConfig {
21
23
  text?: Prop<string>;
22
24
  includeHeader?: boolean;
23
25
  includeFooter?: boolean;
26
+ /** Custom group comparer. Takes precedence over `sortField`/`sortDirection`/`sorters`. */
24
27
  comparer?: ((a: any, b: any) => number) | null;
28
+ /**
29
+ * Sort groups by a single field resolved against the group's `key`, `aggregates` and `name`.
30
+ * Use an aggregate alias (e.g. `"total"`) to sort by an aggregate, or a key field to sort by key.
31
+ */
32
+ sortField?: string;
33
+ /** Sort direction used together with `sortField`. Defaults to `"ASC"`. */
34
+ sortDirection?: SortDirection;
35
+ /** Multi-field group sorting. Each sorter's `field` is resolved against the group's `key`, `aggregates` and `name`. */
36
+ sorters?: Sorter[];
25
37
  header?: any;
26
38
  footer?: any;
27
39
  }
@@ -50,6 +62,8 @@ export interface GroupAdapterConfig extends ArrayAdapterConfig {
50
62
  groupRecordsName?: string;
51
63
  groupings?: GroupingConfig[] | null;
52
64
  groupName?: string;
65
+ /** When enabled, the active record sorters also reorder groups (resolved against each group's key/aggregates/name). */
66
+ sortGroupsBySorters?: boolean;
53
67
  }
54
68
 
55
69
  export class GroupAdapter<T = any> extends ArrayAdapter<T> {
@@ -58,11 +72,41 @@ export class GroupAdapter<T = any> extends ArrayAdapter<T> {
58
72
  declare public groupRecordsName?: string;
59
73
  declare public groupings?: ResolvedGrouping[] | null;
60
74
  declare public groupName: string;
75
+ declare public sortGroupsBySorters?: boolean;
76
+
77
+ /**
78
+ * Per-level comparer derived from the active record sorters, used to reorder groups when
79
+ * `sortGroupsBySorters` is set. A level's entry is `null` when no active sorter maps to that
80
+ * level's key/aggregate, so the grouping's own configured order is kept.
81
+ */
82
+ protected groupSortComparers?: (((a: any, b: any) => number) | null)[];
61
83
 
62
84
  constructor(config?: GroupAdapterConfig) {
63
85
  super(config);
64
86
  }
65
87
 
88
+ public sort(sorters?: ExtendedSorter[]): void {
89
+ super.sort(sorters);
90
+
91
+ // When enabled, derive a group comparer from the active record sorters so that interactive
92
+ // column sorting also reorders the groups. A column only reorders groups at levels where its
93
+ // field is a group key or aggregate; other levels keep their configured order. The record-level
94
+ // value selector is ignored — the field is resolved against the group's key/aggregates/name.
95
+ if (this.sortGroupsBySorters && this.groupings) {
96
+ const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
97
+ const colSorters: ExtendedSorter[] = isNonEmptyArray(sorters)
98
+ ? sorters.map((s) => ({ field: s.field, direction: s.direction, comparer: s.comparer }))
99
+ : [];
100
+
101
+ this.groupSortComparers = this.groupings.map((g) => {
102
+ if (colSorters.length === 0) return null;
103
+ const fields = groupFieldNames(g, this.aggregates);
104
+ const applicable = colSorters.filter((s) => s.field && fields.has(s.field));
105
+ return applicable.length > 0 ? buildGroupComparer(applicable, cultureComparer) : null;
106
+ });
107
+ }
108
+ }
109
+
66
110
  public init(): void {
67
111
  super.init();
68
112
 
@@ -116,8 +160,13 @@ export class GroupAdapter<T = any> extends ArrayAdapter<T> {
116
160
  grouper.processAll(records);
117
161
  let results = grouper.getResults();
118
162
 
119
- if (grouping.comparer && !this.preserveOrder) {
120
- results.sort(grouping.comparer);
163
+ // An active column sort that maps to this level's key/aggregate (when `sortGroupsBySorters` is
164
+ // enabled) takes over; otherwise the grouping's configured comparer (or implicit key order) applies.
165
+ const dynamicComparer = this.sortGroupsBySorters ? this.groupSortComparers?.[level] : undefined;
166
+ const comparer = dynamicComparer ?? grouping.comparer;
167
+
168
+ if (comparer && !this.preserveOrder) {
169
+ results.sort(comparer);
121
170
  }
122
171
 
123
172
  results.forEach((gr) => {
@@ -208,15 +257,22 @@ export class GroupAdapter<T = any> extends ArrayAdapter<T> {
208
257
  g.text,
209
258
  );
210
259
 
260
+ const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
261
+
262
+ // Sort groups by an aggregate/key/name through `sortField`/`sortDirection` or a `sorters` array.
263
+ let sortSorters: Sorter[] | null = null;
264
+ if (isNonEmptyArray(g.sorters)) sortSorters = g.sorters!;
265
+ else if (g.sortField) sortSorters = [{ field: g.sortField, direction: g.sortDirection ?? "ASC" }];
266
+
267
+ // `comparer`, `sortField`/`sorters` and the implicit key order are equivalent alternatives; an
268
+ // explicit `comparer` wins, then declarative sorters, then sorting by key in declaration order.
211
269
  const comparer =
212
270
  g.comparer ??
213
- (groupSorters.length > 0
214
- ? getComparer(
215
- groupSorters,
216
- (x: any) => x.key,
217
- this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined,
218
- )
219
- : null);
271
+ (sortSorters
272
+ ? buildGroupComparer(sortSorters, cultureComparer)
273
+ : groupSorters.length > 0
274
+ ? getComparer(groupSorters, (x: any) => x.key, cultureComparer)
275
+ : null);
220
276
 
221
277
  return {
222
278
  ...g,
@@ -233,6 +289,43 @@ export class GroupAdapter<T = any> extends ArrayAdapter<T> {
233
289
 
234
290
  GroupAdapter.prototype.groupName = "$group";
235
291
  GroupAdapter.prototype.preserveOrder = false;
292
+ GroupAdapter.prototype.sortGroupsBySorters = false;
293
+
294
+ // The set of fields a group can be sorted by at a given level: its key fields, its aggregate aliases
295
+ // and the group name. Used to decide whether an active column sort applies to that grouping level.
296
+ function groupFieldNames(grouping: ResolvedGrouping, adapterAggregates?: StructuredProp): Set<string> {
297
+ const names = new Set<string>();
298
+ for (const k of grouping.grouper.keys) names.add(k.name);
299
+ const aggregates = { ...adapterAggregates, ...grouping.aggregates };
300
+ for (const a in aggregates) names.add(a);
301
+ names.add("name");
302
+ names.add("$name");
303
+ return names;
304
+ }
305
+
306
+ // Reads a sort field straight from the GroupResult — first its key fields, then its aggregates, then
307
+ // its name. Returns a plain selector so groups can be sorted without cloning the result per comparison.
308
+ function groupFieldSelector(field: string): (gr: any) => any {
309
+ if (field === "name" || field === "$name") return (gr) => gr?.name;
310
+ return (gr) => {
311
+ if (gr?.key && field in gr.key) return gr.key[field];
312
+ if (gr?.aggregates && field in gr.aggregates) return gr.aggregates[field];
313
+ return undefined;
314
+ };
315
+ }
316
+
317
+ // Builds a group comparer from a list of sorters. Each sorter resolves by its explicit `value`
318
+ // selector, or by `field` looked up against the group's key/aggregates/name.
319
+ function buildGroupComparer(
320
+ sorters: ExtendedSorter[],
321
+ cultureComparer?: (a: any, b: any) => number,
322
+ ): (a: any, b: any) => number {
323
+ return getComparer(
324
+ sorters.map((s) => (isDefined(s.value) || !s.field ? s : { ...s, value: groupFieldSelector(s.field) })),
325
+ undefined,
326
+ cultureComparer,
327
+ );
328
+ }
236
329
 
237
330
  function serializeKey(data: any): string {
238
331
  if (isDataRecord(data)) {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { Widget, VDOM } from "../../ui/Widget";
4
4
  import { ContainerBase, ContainerConfig, StyledContainerBase, StyledContainerConfig } from "../../ui/Container";
5
- import { ddMouseDown, ddDetect, ddMouseUp, initiateDragDrop, isDragHandleEvent } from "./ops";
5
+ import { ddMouseDown, ddDetect, ddMouseUp, initiateDragDrop, isDragHandleEvent, DragEndEvent } from "./ops";
6
6
  import { preventFocus } from "../../ui/FocusManager";
7
7
  import { parseStyle } from "../../util/parseStyle";
8
8
  import { Instance } from "../../ui/Instance";
@@ -30,7 +30,7 @@ export interface DragSourceConfig extends StyledContainerConfig {
30
30
 
31
31
  onDragStart?: (e: React.MouseEvent | React.TouchEvent, instance: Instance) => any;
32
32
 
33
- onDragEnd?: (e: React.MouseEvent | React.TouchEvent, instance: Instance) => void;
33
+ onDragEnd?: (e: DragEndEvent, instance: Instance) => void;
34
34
 
35
35
  id?: StringProp;
36
36
 
@@ -64,7 +64,7 @@ export class DragSource extends StyledContainerBase<DragSourceConfig, DragSource
64
64
  declare cloneStyle: any;
65
65
  declare draggedStyle: any;
66
66
  declare onDragStart?: (e: React.MouseEvent | React.TouchEvent, instance: DragSourceInstance) => any;
67
- declare onDragEnd?: (e: React.MouseEvent | React.TouchEvent, instance: DragSourceInstance) => void;
67
+ declare onDragEnd?: (e: DragEndEvent, instance: DragSourceInstance) => void;
68
68
 
69
69
  constructor(config?: DragSourceConfig) {
70
70
  super(config);
@@ -3,7 +3,7 @@
3
3
  import { Widget, VDOM } from "../../ui/Widget";
4
4
  import { ContainerBase, ContainerConfig, StyledContainerBase, StyledContainerConfig } from "../../ui/Container";
5
5
  import { parseStyle } from "../../util/parseStyle";
6
- import { registerDropZone, DragDropContext, DragEvent } from "./ops";
6
+ import { registerDropZone, DragDropContext, DragEvent, DragEndEvent } from "./ops";
7
7
  import { findScrollableParent } from "../../util/findScrollableParent";
8
8
  import { isNumber } from "../../util/isNumber";
9
9
  import { getTopLevelBoundingClientRect } from "../../util/getTopLevelBoundingClientRect";
@@ -90,8 +90,8 @@ export interface DropZoneConfig extends StyledContainerConfig {
90
90
  /** A callback method invoked when at the beginning of the drag & drop operation. */
91
91
  onDragStart?: string | ((event: DragEvent, instance: Instance) => void);
92
92
 
93
- /** A callback method invoked when at the end of the drag & drop operation. */
94
- onDragEnd?: string | ((event: DragEvent, instance: Instance) => void);
93
+ /** A callback method invoked when at the end of the drag & drop operation. `event.cancelled` is `true` when the operation was cancelled (e.g. via Esc) instead of dropped. */
94
+ onDragEnd?: string | ((event: DragEndEvent, instance: Instance) => void);
95
95
 
96
96
  /** Match height of the item being dragged */
97
97
  matchHeight?: boolean;
@@ -126,7 +126,7 @@ export class DropZone extends StyledContainerBase<DropZoneConfig, DropZoneInstan
126
126
  declare onDragEnter?: string | ((event?: DragEvent, instance?: Instance) => void);
127
127
  declare onDragLeave?: string | ((event?: DragEvent, instance?: Instance) => void);
128
128
  declare onDragStart?: string | ((event?: DragEvent, instance?: Instance) => void);
129
- declare onDragEnd?: string | ((event?: DragEvent, instance?: Instance) => void);
129
+ declare onDragEnd?: string | ((event?: DragEndEvent, instance?: Instance) => void);
130
130
 
131
131
  constructor(config?: DropZoneConfig) {
132
132
  super(config);
@@ -342,7 +342,7 @@ class DropZoneComponent extends VDOM.Component<DropZoneComponentProps, DropZoneC
342
342
  if (this.state.state == "over" && widget.onDrop) instance.invoke("onDrop", e, instance);
343
343
  }
344
344
 
345
- onDragEnd(e: DragEvent) {
345
+ onDragEnd(e: DragEndEvent) {
346
346
  this.setState({
347
347
  state: false,
348
348
  style: null,
@@ -21,6 +21,18 @@ export interface DragEvent {
21
21
  result?: any;
22
22
  }
23
23
 
24
+ export interface DragEndEvent {
25
+ type: "dragend";
26
+ /** The triggering pointer event, or `null` when the operation was cancelled via the keyboard. */
27
+ event: DragEvent["event"] | null;
28
+ /** Cursor position, or `null` when the operation was cancelled via the keyboard. */
29
+ cursor: CursorPosition | null;
30
+ source: DragEvent["source"];
31
+ /** `true` when the operation was cancelled (e.g. by pressing Esc) instead of dropped. */
32
+ cancelled: boolean;
33
+ result?: any;
34
+ }
35
+
24
36
  export interface DragDropOptions {
25
37
  sourceEl?: Element | null;
26
38
  clone?: any;
@@ -38,7 +50,7 @@ export interface IDropZone {
38
50
  onDragStart?: DragEventHandler;
39
51
  onDragAway?: DragEventHandler;
40
52
  onDragNear?: DragEventHandler;
41
- onDragEnd?: DragEventHandler;
53
+ onDragEnd?: (e: DragEndEvent) => void;
42
54
  onDragMeasure?: (
43
55
  e: DragEvent,
44
56
  operation: DragDropOperationContext,
@@ -62,7 +74,6 @@ import { getTopLevelBoundingClientRect } from "../../util/getTopLevelBoundingCli
62
74
  import { VDOM } from "../../ui/VDOM";
63
75
  import { Container } from "../../ui/Container";
64
76
  import { Console } from "../../util";
65
- import { WidgetConfig } from "../../ui";
66
77
 
67
78
  interface Puppet {
68
79
  deltaX: number;
@@ -70,7 +81,7 @@ interface Puppet {
70
81
  el: HTMLDivElement;
71
82
  clone: any;
72
83
  source: any;
73
- onDragEnd?: (e?: DragEvent) => void;
84
+ onDragEnd?: (e?: DragEndEvent) => void;
74
85
  stop?: () => void;
75
86
  }
76
87
 
@@ -82,6 +93,7 @@ let puppet: Puppet | null = null;
82
93
  let scrollTimer: number | null = null;
83
94
  let vscrollParent: Element | null = null;
84
95
  let hscrollParent: Element | null = null;
96
+ let releaseCapture: (() => void) | null = null;
85
97
 
86
98
  export function registerDropZone(dropZone: IDropZone): UnregisterFunction {
87
99
  dropZones.push(dropZone);
@@ -94,7 +106,7 @@ export function registerDropZone(dropZone: IDropZone): UnregisterFunction {
94
106
  export function initiateDragDrop(
95
107
  e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent,
96
108
  options: DragDropOptions = {},
97
- onDragEnd?: (e?: DragEvent) => void,
109
+ onDragEnd?: (e?: DragEndEvent) => void,
98
110
  ): void {
99
111
  if (puppet) {
100
112
  //last operation didn't finish properly
@@ -187,7 +199,16 @@ export function initiateDragDrop(
187
199
 
188
200
  notifyDragMove(e, null);
189
201
 
190
- captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
202
+ releaseCapture = captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
203
+ document.addEventListener("keydown", onDragKeyDown, true);
204
+ }
205
+
206
+ function onDragKeyDown(e: KeyboardEvent): void {
207
+ if (!puppet || e.key !== "Escape") return;
208
+ //cancel the operation without dropping; there is no pointer event to report
209
+ e.preventDefault();
210
+ e.stopPropagation();
211
+ notifyDragDrop(null, true);
191
212
  }
192
213
 
193
214
  function notifyDragMove(e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent, _captureData: any): void {
@@ -321,29 +342,50 @@ function clearScrollTimer() {
321
342
  }
322
343
  }
323
344
 
324
- function notifyDragDrop(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent): void {
345
+ function notifyDragDrop(
346
+ e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent | null,
347
+ cancelled: boolean = false,
348
+ ): void {
349
+ if (!puppet) return;
350
+
325
351
  clearScrollTimer();
352
+ document.removeEventListener("keydown", onDragKeyDown, true);
326
353
 
327
- let event = getDragEvent(e, "dragdrop");
354
+ if (puppet.stop) puppet.stop();
328
355
 
329
- if (puppet!.stop) puppet!.stop();
356
+ //a keyboard cancellation has no pointer event, so the drop and away notifications are skipped
357
+ let dropEvent = !cancelled && e ? getDragEvent(e, "dragdrop") : null;
330
358
 
331
- if (activeZone && activeZone.onDrop) event.result = activeZone.onDrop(event);
359
+ let result: any;
360
+ if (dropEvent && activeZone && activeZone.onDrop) result = activeZone.onDrop(dropEvent);
361
+
362
+ let endEvent: DragEndEvent = {
363
+ type: "dragend",
364
+ event: e,
365
+ cursor: e ? getCursorPos(e) : null,
366
+ source: puppet.source,
367
+ cancelled,
368
+ result,
369
+ };
332
370
 
333
371
  dropZones.forEach((zone) => {
334
- if (nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(event);
372
+ if (dropEvent && nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(dropEvent);
335
373
 
336
374
  if (!dragStartedZones!.has(zone)) return;
337
375
 
338
- if (zone.onDragEnd) zone.onDragEnd(event);
376
+ if (zone.onDragEnd) zone.onDragEnd(endEvent);
339
377
  });
340
378
 
341
- if (puppet!.onDragEnd) puppet!.onDragEnd(event);
379
+ if (puppet.onDragEnd) puppet.onDragEnd(endEvent);
380
+
381
+ //tear down the mouse/touch capture when cancelling; on a normal drop the capture ends itself
382
+ if (cancelled && releaseCapture) releaseCapture();
342
383
 
343
384
  nearZones = null;
344
385
  activeZone = null;
345
386
  puppet = null;
346
387
  dragStartedZones = null;
388
+ releaseCapture = null;
347
389
  }
348
390
 
349
391
  function getDragEvent(
@@ -1,5 +1,10 @@
1
1
  import { createAccessorModelProxy } from "../../data/createAccessorModelProxy";
2
2
  import { LookupField } from "./LookupField";
3
+ import { Store } from "../../data/Store";
4
+ import { ValidationGroup } from "./ValidationGroup";
5
+ import { bind } from "../../ui/bind";
6
+ import { createTestRenderer } from "../../util/test/createTestRenderer";
7
+ import assert from "assert";
3
8
 
4
9
  interface User {
5
10
  id: number;
@@ -90,4 +95,148 @@ describe("LookupField", () => {
90
95
  </cx>
91
96
  );
92
97
  });
98
+
99
+ describe("validateOptionExists", () => {
100
+ const options = [
101
+ { id: 1, text: "One" },
102
+ { id: 2, text: "Two" },
103
+ ];
104
+
105
+ it("reports an error when the selected value is missing from options", async () => {
106
+ let widget = (
107
+ <cx>
108
+ <ValidationGroup errors={bind("errors")}>
109
+ <LookupField
110
+ value={bind("value")}
111
+ text={bind("text")}
112
+ options={options}
113
+ validateOptionExists
114
+ />
115
+ </ValidationGroup>
116
+ </cx>
117
+ );
118
+
119
+ let store = new Store();
120
+ store.set("value", 99);
121
+ store.set("text", "Stale");
122
+
123
+ await createTestRenderer(store, widget);
124
+
125
+ let errors = store.get("errors");
126
+ assert.equal(errors.length, 1);
127
+ assert.equal(errors[0].message, "The selected option is no longer available.");
128
+ });
129
+
130
+ it("does not report an error when the selected value matches an option", async () => {
131
+ let widget = (
132
+ <cx>
133
+ <ValidationGroup errors={bind("errors")}>
134
+ <LookupField
135
+ value={bind("value")}
136
+ text={bind("text")}
137
+ options={options}
138
+ validateOptionExists
139
+ />
140
+ </ValidationGroup>
141
+ </cx>
142
+ );
143
+
144
+ let store = new Store();
145
+ store.set("value", 1);
146
+ store.set("text", "One");
147
+
148
+ await createTestRenderer(store, widget);
149
+
150
+ let errors = store.get("errors");
151
+ assert.equal(errors.length, 0);
152
+ });
153
+
154
+ it("does not report an error when the field is empty", async () => {
155
+ let widget = (
156
+ <cx>
157
+ <ValidationGroup errors={bind("errors")}>
158
+ <LookupField
159
+ value={bind("value")}
160
+ text={bind("text")}
161
+ options={options}
162
+ validateOptionExists
163
+ />
164
+ </ValidationGroup>
165
+ </cx>
166
+ );
167
+
168
+ let store = new Store();
169
+
170
+ await createTestRenderer(store, widget);
171
+
172
+ let errors = store.get("errors");
173
+ assert.equal(errors.length, 0);
174
+ });
175
+
176
+ it("does not report an error when options are not provided (server-side mode)", async () => {
177
+ let widget = (
178
+ <cx>
179
+ <ValidationGroup errors={bind("errors")}>
180
+ <LookupField
181
+ value={bind("value")}
182
+ text={bind("text")}
183
+ onQuery={() => []}
184
+ validateOptionExists
185
+ />
186
+ </ValidationGroup>
187
+ </cx>
188
+ );
189
+
190
+ let store = new Store();
191
+ store.set("value", 99);
192
+ store.set("text", "Stale");
193
+
194
+ await createTestRenderer(store, widget);
195
+
196
+ let errors = store.get("errors");
197
+ assert.equal(errors.length, 0);
198
+ });
199
+
200
+ it("reports an error in multiple mode when some ids are not in options", async () => {
201
+ let widget = (
202
+ <cx>
203
+ <ValidationGroup errors={bind("errors")}>
204
+ <LookupField
205
+ multiple
206
+ values={bind("values")}
207
+ options={options}
208
+ validateOptionExists
209
+ />
210
+ </ValidationGroup>
211
+ </cx>
212
+ );
213
+
214
+ let store = new Store();
215
+ store.set("values", [1, 99]);
216
+
217
+ await createTestRenderer(store, widget);
218
+
219
+ let errors = store.get("errors");
220
+ assert.equal(errors.length, 1);
221
+ });
222
+
223
+ it("does not validate by default (back-compat)", async () => {
224
+ let widget = (
225
+ <cx>
226
+ <ValidationGroup errors={bind("errors")}>
227
+ <LookupField value={bind("value")} text={bind("text")} options={options} />
228
+ </ValidationGroup>
229
+ </cx>
230
+ );
231
+
232
+ let store = new Store();
233
+ store.set("value", 99);
234
+ store.set("text", "Stale");
235
+
236
+ await createTestRenderer(store, widget);
237
+
238
+ let errors = store.get("errors");
239
+ assert.equal(errors.length, 0);
240
+ });
241
+ });
93
242
  });
@@ -117,6 +117,12 @@ interface LookupFieldBaseConfig<TOption = any> extends FieldConfig {
117
117
  /** Error message displayed if server query throws an exception. */
118
118
  queryErrorText?: string;
119
119
 
120
+ /** Set to `true` to report a validation error when the selected value is not present in `options`. Only applies when `options` is an array. Default is `false`. */
121
+ validateOptionExists?: boolean;
122
+
123
+ /** Error message displayed when the selected value is not present in `options`. */
124
+ invalidOptionText?: string;
125
+
120
126
  /** Message to be displayed if no entries match the user query. */
121
127
  noResultsText?: string;
122
128
 
@@ -292,6 +298,8 @@ export class LookupField<TOption = any, TRecord = any> extends Field<
292
298
  declare public minOptionsForSearchField: number;
293
299
  declare public loadingText: string;
294
300
  declare public queryErrorText: string;
301
+ declare public validateOptionExists: boolean;
302
+ declare public invalidOptionText: string;
295
303
  declare public noResultsText: string;
296
304
  declare public optionIdField: string;
297
305
  declare public optionTextField: string;
@@ -501,6 +509,22 @@ export class LookupField<TOption = any, TRecord = any> extends Field<
501
509
 
502
510
  (instance as DropdownInstance).lastDropdown = context.lastDropdown;
503
511
 
512
+ if (
513
+ this.validateOptionExists &&
514
+ isArray(data.options) &&
515
+ !this.isEmpty(data)
516
+ ) {
517
+ let invalid = this.multiple
518
+ ? isArray(data.values) && data.records!.length < data.values.length
519
+ : !data.options.some(($option) =>
520
+ areKeysEqual(
521
+ getOptionKey(this.keyBindings!, { $option }),
522
+ data.selectedKeys[0],
523
+ ),
524
+ );
525
+ if (invalid) data.error = this.invalidOptionText;
526
+ }
527
+
504
528
  super.prepareData(context, instance);
505
529
  }
506
530
 
@@ -604,6 +628,9 @@ LookupField.prototype.minOptionsForSearchField = 7;
604
628
  LookupField.prototype.loadingText = "Loading...";
605
629
  LookupField.prototype.queryErrorText =
606
630
  "Error occurred while querying for lookup data.";
631
+ LookupField.prototype.validateOptionExists = false;
632
+ LookupField.prototype.invalidOptionText =
633
+ "The selected option is no longer available.";
607
634
  LookupField.prototype.noResultsText = "No results found.";
608
635
  LookupField.prototype.optionIdField = "id";
609
636
  LookupField.prototype.optionTextField = "text";
@@ -42,6 +42,10 @@
42
42
  .#{$state}icon > & {
43
43
  padding-left: cx-calc(cx-top($padding), $cx-default-input-left-tool-size, $cx-default-input-left-tool-spacing);
44
44
  }
45
+
46
+ .#{$state}clear > & {
47
+ padding-right: cx-calc(cx-top($padding), $cx-default-clear-size, $cx-default-clear-spacing);
48
+ }
45
49
  }
46
50
 
47
51
  .#{$element}#{$name}-tool {
@@ -312,6 +312,7 @@ class Input extends VDOM.Component<InputProps, InputState> {
312
312
  visited: state.visited,
313
313
  focus: this.state.focus,
314
314
  icon: !!icon,
315
+ clear: insideButton != null,
315
316
  empty: empty && !data.placeholder,
316
317
  error:
317
318
  data.error &&
@@ -184,6 +184,12 @@
184
184
  content: counter(cx-row-number);
185
185
  }
186
186
 
187
+ // counter-set (not counter-reset) updates the existing row-number counter in place; a
188
+ // counter-reset here would create a nested counter that the following data rows ignore.
189
+ .#{$element}#{$name}-group-caption.#{$state}reset-row-numbers {
190
+ counter-set: cx-row-number 0;
191
+ }
192
+
187
193
  .#{$element}#{$name}-fixed-header {
188
194
  overflow: hidden;
189
195
  position: absolute;