cx 26.6.0 → 26.7.1
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.
- package/build/data/ExposedValueView.js +1 -1
- package/build/data/comparer.d.ts +6 -8
- package/build/data/comparer.d.ts.map +1 -1
- package/build/ui/Cx.d.ts +5 -0
- package/build/ui/Cx.d.ts.map +1 -1
- package/build/ui/Cx.js +34 -3
- package/build/ui/Prop.d.ts +1 -1
- package/build/ui/Prop.d.ts.map +1 -1
- package/build/ui/Restate.d.ts +6 -0
- package/build/ui/Restate.d.ts.map +1 -1
- package/build/ui/Restate.js +2 -1
- package/build/ui/adapter/ArrayAdapter.d.ts +4 -6
- package/build/ui/adapter/ArrayAdapter.d.ts.map +1 -1
- package/build/ui/adapter/GroupAdapter.d.ts +22 -2
- package/build/ui/adapter/GroupAdapter.d.ts.map +1 -1
- package/build/ui/adapter/GroupAdapter.js +80 -5
- package/build/widgets/DocumentTitle.d.ts.map +1 -1
- package/build/widgets/DocumentTitle.js +2 -0
- package/build/widgets/drag-drop/DragSource.d.ts +3 -2
- package/build/widgets/drag-drop/DragSource.d.ts.map +1 -1
- package/build/widgets/drag-drop/DropZone.d.ts +4 -4
- package/build/widgets/drag-drop/DropZone.d.ts.map +1 -1
- package/build/widgets/drag-drop/ops.d.ts +13 -2
- package/build/widgets/drag-drop/ops.d.ts.map +1 -1
- package/build/widgets/drag-drop/ops.js +36 -9
- package/build/widgets/form/NumberField.js +1 -0
- package/build/widgets/grid/Grid.d.ts +19 -3
- package/build/widgets/grid/Grid.d.ts.map +1 -1
- package/build/widgets/grid/Grid.js +2 -0
- package/build/widgets/grid/TreeNode.d.ts +2 -0
- package/build/widgets/grid/TreeNode.d.ts.map +1 -1
- package/build/widgets/overlay/captureMouse.d.ts +4 -4
- package/build/widgets/overlay/captureMouse.d.ts.map +1 -1
- package/build/widgets/overlay/captureMouse.js +8 -3
- package/dist/data.js +1 -1
- package/dist/manifest.js +866 -866
- package/dist/ui.js +130 -12
- package/dist/widgets.css +4 -0
- package/dist/widgets.js +43 -10
- package/package.json +1 -1
- package/src/data/ExposedValueView.spec.ts +57 -0
- package/src/data/ExposedValueView.ts +1 -1
- package/src/data/comparer.ts +6 -7
- package/src/ui/Cx.tsx +44 -3
- package/src/ui/DataProxy.ts +55 -55
- package/src/ui/Prop.ts +1 -1
- package/src/ui/Rescope.ts +50 -50
- package/src/ui/Restate.tsx +9 -0
- package/src/ui/adapter/ArrayAdapter.ts +6 -8
- package/src/ui/adapter/GroupAdapter.spec.ts +75 -0
- package/src/ui/adapter/GroupAdapter.ts +103 -10
- package/src/ui/exprHelpers.ts +96 -96
- package/src/widgets/DocumentTitle.ts +2 -0
- package/src/widgets/Sandbox.ts +104 -104
- package/src/widgets/drag-drop/DragSource.tsx +3 -3
- package/src/widgets/drag-drop/DropZone.tsx +5 -5
- package/src/widgets/drag-drop/ops.tsx +54 -12
- package/src/widgets/form/ColorField.scss +112 -112
- package/src/widgets/form/DateTimeField.scss +111 -111
- package/src/widgets/form/LookupField.maps.scss +26 -26
- package/src/widgets/form/MonthField.scss +113 -113
- package/src/widgets/form/NumberField.scss +4 -0
- package/src/widgets/form/NumberField.tsx +1 -0
- package/src/widgets/form/Select.scss +104 -104
- package/src/widgets/form/TextField.scss +66 -66
- package/src/widgets/grid/Grid.tsx +23 -2
- package/src/widgets/grid/TreeNode.tsx +3 -0
- package/src/widgets/nav/MenuItem.tsx +525 -525
- package/src/widgets/overlay/captureMouse.ts +14 -7
package/dist/ui.js
CHANGED
|
@@ -2094,6 +2094,30 @@ class LinkedListsNode {
|
|
|
2094
2094
|
}
|
|
2095
2095
|
}
|
|
2096
2096
|
|
|
2097
|
+
// Optional guard (opt in per-instance via the `limitSyncBursts` prop) against React's "Maximum update depth
|
|
2098
|
+
// exceeded" during very large synchronous render bursts. On the initial render of a large document the store
|
|
2099
|
+
// can be mutated thousands of times within a single React commit: every Instance.setState wraps store.notify()
|
|
2100
|
+
// in batchUpdates(), so isBatchingUpdates() stays true and a subscribed Cx takes its *synchronous* setState
|
|
2101
|
+
// branch on each notification. React counts those as nested render-phase updates and aborts past ~50
|
|
2102
|
+
// (surfacing as the recoverable "error during concurrent rendering"). When a Cx opts in, once back-to-back
|
|
2103
|
+
// updates exceed the limit it falls back to the coalesced setTimeout branch, which renders the latest store
|
|
2104
|
+
// data on the next tick. The counter is global (it mirrors React's global nested-update counter) and resets
|
|
2105
|
+
// after any idle gap. It is advanced on every update() of an opted-in Cx -- counting only the synchronous
|
|
2106
|
+
// branch would let the window reset whenever a deferred update lands between two synchronous ones, so the
|
|
2107
|
+
// counter would never reach the limit. performance.now() gives sub-ms resolution (Date.now() is the fallback;
|
|
2108
|
+
// Timing.now() can't be used here because it returns 0 in production).
|
|
2109
|
+
const SYNC_UPDATE_BURST_LIMIT = 30;
|
|
2110
|
+
const SYNC_UPDATE_BURST_RESET_MS = 10;
|
|
2111
|
+
const syncUpdateBurstNow =
|
|
2112
|
+
typeof performance !== "undefined" && performance.now ? () => performance.now() : () => Date.now();
|
|
2113
|
+
let syncUpdateBurstCount = 0;
|
|
2114
|
+
let syncUpdateBurstLastAt = 0;
|
|
2115
|
+
function trackSyncUpdateBurst() {
|
|
2116
|
+
let t = syncUpdateBurstNow();
|
|
2117
|
+
if (t - syncUpdateBurstLastAt > SYNC_UPDATE_BURST_RESET_MS) syncUpdateBurstCount = 0;
|
|
2118
|
+
syncUpdateBurstLastAt = t;
|
|
2119
|
+
return ++syncUpdateBurstCount > SYNC_UPDATE_BURST_LIMIT;
|
|
2120
|
+
}
|
|
2097
2121
|
class Cx extends VDOM.Component {
|
|
2098
2122
|
widget;
|
|
2099
2123
|
store;
|
|
@@ -2192,7 +2216,11 @@ class Cx extends VDOM.Component {
|
|
|
2192
2216
|
let data = this.store.getData();
|
|
2193
2217
|
debug(appDataFlag, data);
|
|
2194
2218
|
if (this.flags.preparing) this.flags.dirty = true;
|
|
2195
|
-
|
|
2219
|
+
// `immediate` always renders synchronously (its contract). Otherwise, while batching, a Cx that opts in
|
|
2220
|
+
// via `limitSyncBursts` defers once a synchronous burst gets too deep (avoids React's max-update-depth).
|
|
2221
|
+
// Without the flag the condition is unchanged -- trackSyncUpdateBurst() is never even called, so the
|
|
2222
|
+
// guard has zero effect on instances that don't opt in.
|
|
2223
|
+
else if (this.props.immediate || (isBatchingUpdates() && !(this.props.limitSyncBursts && trackSyncUpdateBurst()))) {
|
|
2196
2224
|
notifyBatchedUpdateStarting();
|
|
2197
2225
|
this.setState(
|
|
2198
2226
|
{
|
|
@@ -2201,14 +2229,17 @@ class Cx extends VDOM.Component {
|
|
|
2201
2229
|
notifyBatchedUpdateCompleted,
|
|
2202
2230
|
);
|
|
2203
2231
|
} else {
|
|
2204
|
-
//in standard mode sequential store commands are batched
|
|
2232
|
+
//in standard mode sequential store commands are batched -- as is any update that exceeds the
|
|
2233
|
+
//synchronous burst limit above, which falls through here to avoid React's max-update-depth abort
|
|
2205
2234
|
if (!this.pendingUpdateTimer) {
|
|
2206
2235
|
notifyBatchedUpdateStarting();
|
|
2207
2236
|
this.pendingUpdateTimer = setTimeout(() => {
|
|
2208
2237
|
delete this.pendingUpdateTimer;
|
|
2238
|
+
//for opted-in instances read fresh data at fire time so a deferred (coalesced) update isn't
|
|
2239
|
+
//left stale after a burst; otherwise keep the original captured-data behavior unchanged
|
|
2209
2240
|
this.setState(
|
|
2210
2241
|
{
|
|
2211
|
-
data: data,
|
|
2242
|
+
data: this.props.limitSyncBursts ? this.store.getData() : data,
|
|
2212
2243
|
},
|
|
2213
2244
|
notifyBatchedUpdateCompleted,
|
|
2214
2245
|
);
|
|
@@ -2662,6 +2693,7 @@ class Restate extends PureContainerBase {
|
|
|
2662
2693
|
deferredUntilIdle: instance.data.deferredUntilIdle,
|
|
2663
2694
|
idleTimeout: instance.data.idleTimeout,
|
|
2664
2695
|
immediate: this.immediate,
|
|
2696
|
+
limitSyncBursts: this.limitSyncBursts,
|
|
2665
2697
|
cultureInfo: instance.cultureInfo,
|
|
2666
2698
|
},
|
|
2667
2699
|
key,
|
|
@@ -2671,6 +2703,7 @@ class Restate extends PureContainerBase {
|
|
|
2671
2703
|
Restate.prototype.detached = false;
|
|
2672
2704
|
Restate.prototype.waitForIdle = false;
|
|
2673
2705
|
Restate.prototype.immediate = false;
|
|
2706
|
+
Restate.prototype.limitSyncBursts = false;
|
|
2674
2707
|
Restate.prototype.culture = null;
|
|
2675
2708
|
const PrivateStore = Restate;
|
|
2676
2709
|
class RestateStore extends Store {
|
|
@@ -3829,9 +3862,38 @@ function startHotAppLoop(appModule, element, store, widgets, options = {}) {
|
|
|
3829
3862
|
}
|
|
3830
3863
|
|
|
3831
3864
|
class GroupAdapter extends ArrayAdapter {
|
|
3865
|
+
/**
|
|
3866
|
+
* Per-level comparer derived from the active record sorters, used to reorder groups when
|
|
3867
|
+
* `sortGroupsBySorters` is set. A level's entry is `null` when no active sorter maps to that
|
|
3868
|
+
* level's key/aggregate, so the grouping's own configured order is kept.
|
|
3869
|
+
*/
|
|
3870
|
+
groupSortComparers;
|
|
3832
3871
|
constructor(config) {
|
|
3833
3872
|
super(config);
|
|
3834
3873
|
}
|
|
3874
|
+
sort(sorters) {
|
|
3875
|
+
super.sort(sorters);
|
|
3876
|
+
// When enabled, derive a group comparer from the active record sorters so that interactive
|
|
3877
|
+
// column sorting also reorders the groups. A column only reorders groups at levels where its
|
|
3878
|
+
// field is a group key or aggregate; other levels keep their configured order. The record-level
|
|
3879
|
+
// value selector is ignored — the field is resolved against the group's key/aggregates/name.
|
|
3880
|
+
if (this.sortGroupsBySorters && this.groupings) {
|
|
3881
|
+
const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
|
|
3882
|
+
const colSorters = isNonEmptyArray(sorters)
|
|
3883
|
+
? sorters.map((s) => ({
|
|
3884
|
+
field: s.field,
|
|
3885
|
+
direction: s.direction,
|
|
3886
|
+
comparer: s.comparer,
|
|
3887
|
+
}))
|
|
3888
|
+
: [];
|
|
3889
|
+
this.groupSortComparers = this.groupings.map((g) => {
|
|
3890
|
+
if (colSorters.length === 0) return null;
|
|
3891
|
+
const fields = groupFieldNames(g, this.aggregates);
|
|
3892
|
+
const applicable = colSorters.filter((s) => s.field && fields.has(s.field));
|
|
3893
|
+
return applicable.length > 0 ? buildGroupComparer(applicable, cultureComparer) : null;
|
|
3894
|
+
});
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3835
3897
|
init() {
|
|
3836
3898
|
super.init();
|
|
3837
3899
|
if (this.groupRecordsAlias) {
|
|
@@ -3866,8 +3928,12 @@ class GroupAdapter extends ArrayAdapter {
|
|
|
3866
3928
|
grouper.reset();
|
|
3867
3929
|
grouper.processAll(records);
|
|
3868
3930
|
let results = grouper.getResults();
|
|
3869
|
-
|
|
3870
|
-
|
|
3931
|
+
// An active column sort that maps to this level's key/aggregate (when `sortGroupsBySorters` is
|
|
3932
|
+
// enabled) takes over; otherwise the grouping's configured comparer (or implicit key order) applies.
|
|
3933
|
+
const dynamicComparer = this.sortGroupsBySorters ? this.groupSortComparers?.[level] : undefined;
|
|
3934
|
+
const comparer = dynamicComparer ?? grouping.comparer;
|
|
3935
|
+
if (comparer && !this.preserveOrder) {
|
|
3936
|
+
results.sort(comparer);
|
|
3871
3937
|
}
|
|
3872
3938
|
results.forEach((gr) => {
|
|
3873
3939
|
keys.push(gr.key);
|
|
@@ -3949,15 +4015,26 @@ class GroupAdapter extends ArrayAdapter {
|
|
|
3949
4015
|
(r) => r.store.getData(),
|
|
3950
4016
|
g.text,
|
|
3951
4017
|
);
|
|
4018
|
+
const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined;
|
|
4019
|
+
// Sort groups by an aggregate/key/name through `sortField`/`sortDirection` or a `sorters` array.
|
|
4020
|
+
let sortSorters = null;
|
|
4021
|
+
if (isNonEmptyArray(g.sorters)) sortSorters = g.sorters;
|
|
4022
|
+
else if (g.sortField)
|
|
4023
|
+
sortSorters = [
|
|
4024
|
+
{
|
|
4025
|
+
field: g.sortField,
|
|
4026
|
+
direction: g.sortDirection ?? "ASC",
|
|
4027
|
+
},
|
|
4028
|
+
];
|
|
4029
|
+
// `comparer`, `sortField`/`sorters` and the implicit key order are equivalent alternatives; an
|
|
4030
|
+
// explicit `comparer` wins, then declarative sorters, then sorting by key in declaration order.
|
|
3952
4031
|
const comparer =
|
|
3953
4032
|
g.comparer ??
|
|
3954
|
-
(
|
|
3955
|
-
?
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
)
|
|
3960
|
-
: null);
|
|
4033
|
+
(sortSorters
|
|
4034
|
+
? buildGroupComparer(sortSorters, cultureComparer)
|
|
4035
|
+
: groupSorters.length > 0
|
|
4036
|
+
? getComparer(groupSorters, (x) => x.key, cultureComparer)
|
|
4037
|
+
: null);
|
|
3961
4038
|
return {
|
|
3962
4039
|
...g,
|
|
3963
4040
|
key: resolvedKey,
|
|
@@ -3972,6 +4049,47 @@ class GroupAdapter extends ArrayAdapter {
|
|
|
3972
4049
|
}
|
|
3973
4050
|
GroupAdapter.prototype.groupName = "$group";
|
|
3974
4051
|
GroupAdapter.prototype.preserveOrder = false;
|
|
4052
|
+
GroupAdapter.prototype.sortGroupsBySorters = false;
|
|
4053
|
+
// The set of fields a group can be sorted by at a given level: its key fields, its aggregate aliases
|
|
4054
|
+
// and the group name. Used to decide whether an active column sort applies to that grouping level.
|
|
4055
|
+
function groupFieldNames(grouping, adapterAggregates) {
|
|
4056
|
+
const names = new Set();
|
|
4057
|
+
for (const k of grouping.grouper.keys) names.add(k.name);
|
|
4058
|
+
const aggregates = {
|
|
4059
|
+
...adapterAggregates,
|
|
4060
|
+
...grouping.aggregates,
|
|
4061
|
+
};
|
|
4062
|
+
for (const a in aggregates) names.add(a);
|
|
4063
|
+
names.add("name");
|
|
4064
|
+
names.add("$name");
|
|
4065
|
+
return names;
|
|
4066
|
+
}
|
|
4067
|
+
// Reads a sort field straight from the GroupResult — first its key fields, then its aggregates, then
|
|
4068
|
+
// its name. Returns a plain selector so groups can be sorted without cloning the result per comparison.
|
|
4069
|
+
function groupFieldSelector(field) {
|
|
4070
|
+
if (field === "name" || field === "$name") return (gr) => gr?.name;
|
|
4071
|
+
return (gr) => {
|
|
4072
|
+
if (gr?.key && field in gr.key) return gr.key[field];
|
|
4073
|
+
if (gr?.aggregates && field in gr.aggregates) return gr.aggregates[field];
|
|
4074
|
+
return undefined;
|
|
4075
|
+
};
|
|
4076
|
+
}
|
|
4077
|
+
// Builds a group comparer from a list of sorters. Each sorter resolves by its explicit `value`
|
|
4078
|
+
// selector, or by `field` looked up against the group's key/aggregates/name.
|
|
4079
|
+
function buildGroupComparer(sorters, cultureComparer) {
|
|
4080
|
+
return getComparer(
|
|
4081
|
+
sorters.map((s) =>
|
|
4082
|
+
isDefined(s.value) || !s.field
|
|
4083
|
+
? s
|
|
4084
|
+
: {
|
|
4085
|
+
...s,
|
|
4086
|
+
value: groupFieldSelector(s.field),
|
|
4087
|
+
},
|
|
4088
|
+
),
|
|
4089
|
+
undefined,
|
|
4090
|
+
cultureComparer,
|
|
4091
|
+
);
|
|
4092
|
+
}
|
|
3975
4093
|
function serializeKey(data) {
|
|
3976
4094
|
if (isDataRecord(data)) {
|
|
3977
4095
|
return Object.keys(data)
|
package/dist/widgets.css
CHANGED
package/dist/widgets.js
CHANGED
|
@@ -1124,6 +1124,7 @@ class DocumentTitle extends Widget {
|
|
|
1124
1124
|
super.explore(context, instance);
|
|
1125
1125
|
}
|
|
1126
1126
|
prepare(context, instance) {
|
|
1127
|
+
if (typeof document == "undefined") return;
|
|
1127
1128
|
if (context.documentTitle.activeInstance == instance) document.title = context.documentTitle.title;
|
|
1128
1129
|
}
|
|
1129
1130
|
render() {
|
|
@@ -2050,6 +2051,7 @@ function captureMouse2(e, options) {
|
|
|
2050
2051
|
}
|
|
2051
2052
|
}
|
|
2052
2053
|
e.stopPropagation();
|
|
2054
|
+
return tear;
|
|
2053
2055
|
function move(e) {
|
|
2054
2056
|
if (!active) {
|
|
2055
2057
|
tear();
|
|
@@ -2105,7 +2107,11 @@ function captureMouseOrTouch2(e, options) {
|
|
|
2105
2107
|
el.addEventListener("touchmove", move);
|
|
2106
2108
|
el.addEventListener("touchend", end);
|
|
2107
2109
|
e.stopPropagation();
|
|
2108
|
-
|
|
2110
|
+
return () => {
|
|
2111
|
+
el.removeEventListener("touchmove", move);
|
|
2112
|
+
el.removeEventListener("touchend", end);
|
|
2113
|
+
};
|
|
2114
|
+
} else return captureMouse2(e, options);
|
|
2109
2115
|
}
|
|
2110
2116
|
/**
|
|
2111
2117
|
* Legacy function for capturing mouse events with individual parameters
|
|
@@ -2116,7 +2122,7 @@ function captureMouseOrTouch2(e, options) {
|
|
|
2116
2122
|
* @param cursor - CSS cursor style for the capture surface
|
|
2117
2123
|
*/
|
|
2118
2124
|
function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
|
|
2119
|
-
captureMouse2(e, {
|
|
2125
|
+
return captureMouse2(e, {
|
|
2120
2126
|
onMouseMove,
|
|
2121
2127
|
onMouseUp,
|
|
2122
2128
|
captureData,
|
|
@@ -2132,7 +2138,7 @@ function captureMouse(e, onMouseMove, onMouseUp, captureData, cursor) {
|
|
|
2132
2138
|
* @param cursor - CSS cursor style for the capture surface
|
|
2133
2139
|
*/
|
|
2134
2140
|
function captureMouseOrTouch(e, onMouseMove, onMouseUp, captureData, cursor) {
|
|
2135
|
-
captureMouseOrTouch2(e, {
|
|
2141
|
+
return captureMouseOrTouch2(e, {
|
|
2136
2142
|
onMouseMove,
|
|
2137
2143
|
onMouseUp,
|
|
2138
2144
|
captureData,
|
|
@@ -2403,6 +2409,7 @@ let puppet = null;
|
|
|
2403
2409
|
let scrollTimer = null;
|
|
2404
2410
|
let vscrollParent = null;
|
|
2405
2411
|
let hscrollParent = null;
|
|
2412
|
+
let releaseCapture = null;
|
|
2406
2413
|
function registerDropZone(dropZone) {
|
|
2407
2414
|
dropZones.push(dropZone);
|
|
2408
2415
|
return () => {
|
|
@@ -2488,7 +2495,15 @@ function initiateDragDrop(e, options = {}, onDragEnd) {
|
|
|
2488
2495
|
dragStartedZones.set(zone, true);
|
|
2489
2496
|
});
|
|
2490
2497
|
notifyDragMove(e);
|
|
2491
|
-
captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
|
|
2498
|
+
releaseCapture = captureMouseOrTouch(e, notifyDragMove, notifyDragDrop);
|
|
2499
|
+
document.addEventListener("keydown", onDragKeyDown, true);
|
|
2500
|
+
}
|
|
2501
|
+
function onDragKeyDown(e) {
|
|
2502
|
+
if (!puppet || e.key !== "Escape") return;
|
|
2503
|
+
//cancel the operation without dropping; there is no pointer event to report
|
|
2504
|
+
e.preventDefault();
|
|
2505
|
+
e.stopPropagation();
|
|
2506
|
+
notifyDragDrop(null, true);
|
|
2492
2507
|
}
|
|
2493
2508
|
function notifyDragMove(e, _captureData) {
|
|
2494
2509
|
let event = getDragEvent(e, "dragmove");
|
|
@@ -2613,21 +2628,36 @@ function clearScrollTimer() {
|
|
|
2613
2628
|
scrollTimer = null;
|
|
2614
2629
|
}
|
|
2615
2630
|
}
|
|
2616
|
-
function notifyDragDrop(e) {
|
|
2631
|
+
function notifyDragDrop(e, cancelled = false) {
|
|
2632
|
+
if (!puppet) return;
|
|
2617
2633
|
clearScrollTimer();
|
|
2618
|
-
|
|
2634
|
+
document.removeEventListener("keydown", onDragKeyDown, true);
|
|
2619
2635
|
if (puppet.stop) puppet.stop();
|
|
2620
|
-
|
|
2636
|
+
//a keyboard cancellation has no pointer event, so the drop and away notifications are skipped
|
|
2637
|
+
let dropEvent = !cancelled && e ? getDragEvent(e, "dragdrop") : null;
|
|
2638
|
+
let result;
|
|
2639
|
+
if (dropEvent && activeZone && activeZone.onDrop) result = activeZone.onDrop(dropEvent);
|
|
2640
|
+
let endEvent = {
|
|
2641
|
+
type: "dragend",
|
|
2642
|
+
event: e,
|
|
2643
|
+
cursor: e ? getCursorPos(e) : null,
|
|
2644
|
+
source: puppet.source,
|
|
2645
|
+
cancelled,
|
|
2646
|
+
result,
|
|
2647
|
+
};
|
|
2621
2648
|
dropZones.forEach((zone) => {
|
|
2622
|
-
if (nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(
|
|
2649
|
+
if (dropEvent && nearZones != null && zone.onDragAway && nearZones.has(zone)) zone.onDragAway(dropEvent);
|
|
2623
2650
|
if (!dragStartedZones.has(zone)) return;
|
|
2624
|
-
if (zone.onDragEnd) zone.onDragEnd(
|
|
2651
|
+
if (zone.onDragEnd) zone.onDragEnd(endEvent);
|
|
2625
2652
|
});
|
|
2626
|
-
if (puppet.onDragEnd) puppet.onDragEnd(
|
|
2653
|
+
if (puppet.onDragEnd) puppet.onDragEnd(endEvent);
|
|
2654
|
+
//tear down the mouse/touch capture when cancelling; on a normal drop the capture ends itself
|
|
2655
|
+
if (cancelled && releaseCapture) releaseCapture();
|
|
2627
2656
|
nearZones = null;
|
|
2628
2657
|
activeZone = null;
|
|
2629
2658
|
puppet = null;
|
|
2630
2659
|
dragStartedZones = null;
|
|
2660
|
+
releaseCapture = null;
|
|
2631
2661
|
}
|
|
2632
2662
|
function getDragEvent(event, type) {
|
|
2633
2663
|
return {
|
|
@@ -9790,6 +9820,7 @@ let Input$1 = class Input extends VDOM.Component {
|
|
|
9790
9820
|
visited: state.visited,
|
|
9791
9821
|
focus: this.state.focus,
|
|
9792
9822
|
icon: !!icon,
|
|
9823
|
+
clear: insideButton != null,
|
|
9793
9824
|
empty: empty && !data.placeholder,
|
|
9794
9825
|
error: data.error && (state.visited || !suppressErrorsUntilVisited || !empty),
|
|
9795
9826
|
}),
|
|
@@ -15038,6 +15069,7 @@ class Grid extends ContainerBase {
|
|
|
15038
15069
|
sortOptions: this.sortOptions,
|
|
15039
15070
|
groupings: grouping,
|
|
15040
15071
|
preserveOrder: this.preserveGroupOrder,
|
|
15072
|
+
sortGroupsBySorters: this.sortGroups,
|
|
15041
15073
|
},
|
|
15042
15074
|
this.dataAdapter,
|
|
15043
15075
|
);
|
|
@@ -15983,6 +16015,7 @@ Grid.prototype.hoverChannel = "default";
|
|
|
15983
16015
|
Grid.prototype.focusable = null; // automatically resolved
|
|
15984
16016
|
Grid.prototype.allowsFileDrops = false;
|
|
15985
16017
|
Grid.prototype.preserveGroupOrder = false;
|
|
16018
|
+
Grid.prototype.sortGroups = false;
|
|
15986
16019
|
Widget.alias("grid", Grid);
|
|
15987
16020
|
Localization.registerPrototype("cx/widgets/Grid", Grid);
|
|
15988
16021
|
class GridComponent extends VDOM.Component {
|
package/package.json
CHANGED
|
@@ -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(
|
|
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);
|
package/src/data/comparer.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
66
|
+
sorters: ExtendedSorter[],
|
|
68
67
|
dataAccessor?: (x: any) => any,
|
|
69
68
|
compare?: (a: any, b: any) => number,
|
|
70
69
|
): (data: any[]) => any[] {
|
package/src/ui/Cx.tsx
CHANGED
|
@@ -21,6 +21,11 @@ export interface CxProps {
|
|
|
21
21
|
parentInstance?: Instance;
|
|
22
22
|
subscribe?: boolean;
|
|
23
23
|
immediate?: boolean;
|
|
24
|
+
/** Cap re-entrant synchronous updates: while batching, once a burst of back-to-back updates gets too
|
|
25
|
+
* deep, defer the excess to the coalesced (setTimeout) branch instead of updating synchronously. Avoids
|
|
26
|
+
* React's "Maximum update depth exceeded" on very large synchronous render bursts (e.g. the initial
|
|
27
|
+
* render of a huge document). Off by default; has no effect on instances that don't set it. */
|
|
28
|
+
limitSyncBursts?: boolean;
|
|
24
29
|
deferredUntilIdle?: boolean;
|
|
25
30
|
idleTimeout?: number;
|
|
26
31
|
options?: any;
|
|
@@ -35,6 +40,32 @@ export interface CxState {
|
|
|
35
40
|
data?: any;
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
// Optional guard (opt in per-instance via the `limitSyncBursts` prop) against React's "Maximum update depth
|
|
44
|
+
// exceeded" during very large synchronous render bursts. On the initial render of a large document the store
|
|
45
|
+
// can be mutated thousands of times within a single React commit: every Instance.setState wraps store.notify()
|
|
46
|
+
// in batchUpdates(), so isBatchingUpdates() stays true and a subscribed Cx takes its *synchronous* setState
|
|
47
|
+
// branch on each notification. React counts those as nested render-phase updates and aborts past ~50
|
|
48
|
+
// (surfacing as the recoverable "error during concurrent rendering"). When a Cx opts in, once back-to-back
|
|
49
|
+
// updates exceed the limit it falls back to the coalesced setTimeout branch, which renders the latest store
|
|
50
|
+
// data on the next tick. The counter is global (it mirrors React's global nested-update counter) and resets
|
|
51
|
+
// after any idle gap. It is advanced on every update() of an opted-in Cx -- counting only the synchronous
|
|
52
|
+
// branch would let the window reset whenever a deferred update lands between two synchronous ones, so the
|
|
53
|
+
// counter would never reach the limit. performance.now() gives sub-ms resolution (Date.now() is the fallback;
|
|
54
|
+
// Timing.now() can't be used here because it returns 0 in production).
|
|
55
|
+
const SYNC_UPDATE_BURST_LIMIT = 30;
|
|
56
|
+
const SYNC_UPDATE_BURST_RESET_MS = 10;
|
|
57
|
+
const syncUpdateBurstNow: () => number =
|
|
58
|
+
typeof performance !== "undefined" && performance.now ? () => performance.now() : () => Date.now();
|
|
59
|
+
let syncUpdateBurstCount = 0;
|
|
60
|
+
let syncUpdateBurstLastAt = 0;
|
|
61
|
+
|
|
62
|
+
function trackSyncUpdateBurst(): boolean {
|
|
63
|
+
let t = syncUpdateBurstNow();
|
|
64
|
+
if (t - syncUpdateBurstLastAt > SYNC_UPDATE_BURST_RESET_MS) syncUpdateBurstCount = 0;
|
|
65
|
+
syncUpdateBurstLastAt = t;
|
|
66
|
+
return ++syncUpdateBurstCount > SYNC_UPDATE_BURST_LIMIT;
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
export class Cx extends VDOM.Component<CxProps, CxState> {
|
|
39
70
|
widget: Widget;
|
|
40
71
|
store: View;
|
|
@@ -160,16 +191,26 @@ export class Cx extends VDOM.Component<CxProps, CxState> {
|
|
|
160
191
|
let data = this.store.getData();
|
|
161
192
|
debug(appDataFlag, data);
|
|
162
193
|
if (this.flags.preparing) this.flags.dirty = true;
|
|
163
|
-
|
|
194
|
+
// `immediate` always renders synchronously (its contract). Otherwise, while batching, a Cx that opts in
|
|
195
|
+
// via `limitSyncBursts` defers once a synchronous burst gets too deep (avoids React's max-update-depth).
|
|
196
|
+
// Without the flag the condition is unchanged -- trackSyncUpdateBurst() is never even called, so the
|
|
197
|
+
// guard has zero effect on instances that don't opt in.
|
|
198
|
+
else if (this.props.immediate || (isBatchingUpdates() && !(this.props.limitSyncBursts && trackSyncUpdateBurst()))) {
|
|
164
199
|
notifyBatchedUpdateStarting();
|
|
165
200
|
this.setState({ data: data }, notifyBatchedUpdateCompleted);
|
|
166
201
|
} else {
|
|
167
|
-
//in standard mode sequential store commands are batched
|
|
202
|
+
//in standard mode sequential store commands are batched -- as is any update that exceeds the
|
|
203
|
+
//synchronous burst limit above, which falls through here to avoid React's max-update-depth abort
|
|
168
204
|
if (!this.pendingUpdateTimer) {
|
|
169
205
|
notifyBatchedUpdateStarting();
|
|
170
206
|
this.pendingUpdateTimer = setTimeout(() => {
|
|
171
207
|
delete this.pendingUpdateTimer;
|
|
172
|
-
|
|
208
|
+
//for opted-in instances read fresh data at fire time so a deferred (coalesced) update isn't
|
|
209
|
+
//left stale after a burst; otherwise keep the original captured-data behavior unchanged
|
|
210
|
+
this.setState(
|
|
211
|
+
{ data: this.props.limitSyncBursts ? this.store.getData() : data },
|
|
212
|
+
notifyBatchedUpdateCompleted,
|
|
213
|
+
);
|
|
173
214
|
}, 0);
|
|
174
215
|
}
|
|
175
216
|
}
|
package/src/ui/DataProxy.ts
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
import { AccessorChain } from "../data";
|
|
2
|
-
import { NestedDataView } from "../data/NestedDataView";
|
|
3
|
-
import { StructuredProp, WritableProp } from "./Prop";
|
|
4
|
-
import { PureContainerBase, PureContainerConfig } from "./PureContainer";
|
|
5
|
-
import { StructuredInstanceDataAccessor } from "./StructuredInstanceDataAccessor";
|
|
6
|
-
|
|
7
|
-
export interface DataProxyConfig extends PureContainerConfig {
|
|
8
|
-
/** Data object with computed values to be exposed in the local store. */
|
|
9
|
-
data?: StructuredProp;
|
|
10
|
-
|
|
11
|
-
/** Binding to a value to be exposed under the `alias` name. */
|
|
12
|
-
value?: WritableProp<any>;
|
|
13
|
-
|
|
14
|
-
/** Alias name under which `value` is exposed in the local store. */
|
|
15
|
-
alias?: string | AccessorChain<any>;
|
|
16
|
-
|
|
17
|
-
/** Indicate that parent store data should not be mutated. */
|
|
18
|
-
immutable?: boolean;
|
|
19
|
-
|
|
20
|
-
/** Indicate that local store data should not be mutated. */
|
|
21
|
-
sealed?: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export class DataProxy extends PureContainerBase<DataProxyConfig> {
|
|
25
|
-
declare data?: any;
|
|
26
|
-
declare alias?: string;
|
|
27
|
-
declare value?: any;
|
|
28
|
-
declare immutable: boolean;
|
|
29
|
-
declare sealed: boolean;
|
|
30
|
-
|
|
31
|
-
init() {
|
|
32
|
-
if (!this.data) this.data = {};
|
|
33
|
-
|
|
34
|
-
if (this.alias) this.data[this.alias] = this.value;
|
|
35
|
-
|
|
36
|
-
super.init();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
initInstance(context: any, instance: any) {
|
|
40
|
-
instance.store = new NestedDataView({
|
|
41
|
-
store: instance.parentStore,
|
|
42
|
-
nestedData: new StructuredInstanceDataAccessor({ instance, data: this.data, useParentStore: true }),
|
|
43
|
-
immutable: this.immutable,
|
|
44
|
-
sealed: this.sealed,
|
|
45
|
-
});
|
|
46
|
-
super.initInstance(context, instance);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
applyParentStore(instance: any) {
|
|
50
|
-
instance.store.setStore(instance.parentStore);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
DataProxy.prototype.immutable = false;
|
|
55
|
-
DataProxy.prototype.sealed = false;
|
|
1
|
+
import { AccessorChain } from "../data";
|
|
2
|
+
import { NestedDataView } from "../data/NestedDataView";
|
|
3
|
+
import { StructuredProp, WritableProp } from "./Prop";
|
|
4
|
+
import { PureContainerBase, PureContainerConfig } from "./PureContainer";
|
|
5
|
+
import { StructuredInstanceDataAccessor } from "./StructuredInstanceDataAccessor";
|
|
6
|
+
|
|
7
|
+
export interface DataProxyConfig extends PureContainerConfig {
|
|
8
|
+
/** Data object with computed values to be exposed in the local store. */
|
|
9
|
+
data?: StructuredProp;
|
|
10
|
+
|
|
11
|
+
/** Binding to a value to be exposed under the `alias` name. */
|
|
12
|
+
value?: WritableProp<any>;
|
|
13
|
+
|
|
14
|
+
/** Alias name under which `value` is exposed in the local store. */
|
|
15
|
+
alias?: string | AccessorChain<any>;
|
|
16
|
+
|
|
17
|
+
/** Indicate that parent store data should not be mutated. */
|
|
18
|
+
immutable?: boolean;
|
|
19
|
+
|
|
20
|
+
/** Indicate that local store data should not be mutated. */
|
|
21
|
+
sealed?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DataProxy extends PureContainerBase<DataProxyConfig> {
|
|
25
|
+
declare data?: any;
|
|
26
|
+
declare alias?: string;
|
|
27
|
+
declare value?: any;
|
|
28
|
+
declare immutable: boolean;
|
|
29
|
+
declare sealed: boolean;
|
|
30
|
+
|
|
31
|
+
init() {
|
|
32
|
+
if (!this.data) this.data = {};
|
|
33
|
+
|
|
34
|
+
if (this.alias) this.data[this.alias] = this.value;
|
|
35
|
+
|
|
36
|
+
super.init();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
initInstance(context: any, instance: any) {
|
|
40
|
+
instance.store = new NestedDataView({
|
|
41
|
+
store: instance.parentStore,
|
|
42
|
+
nestedData: new StructuredInstanceDataAccessor({ instance, data: this.data, useParentStore: true }),
|
|
43
|
+
immutable: this.immutable,
|
|
44
|
+
sealed: this.sealed,
|
|
45
|
+
});
|
|
46
|
+
super.initInstance(context, instance);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
applyParentStore(instance: any) {
|
|
50
|
+
instance.store.setStore(instance.parentStore);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
DataProxy.prototype.immutable = false;
|
|
55
|
+
DataProxy.prototype.sealed = false;
|