@nyaruka/temba-components 0.170.0 → 0.171.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.
- package/CHANGELOG.md +8 -0
- package/dist/temba-components.js +766 -744
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/flow/Editor.ts +202 -42
- package/src/flow/dependencies.ts +115 -0
- package/src/form/RangePicker.ts +129 -97
- package/src/list/ContentList.ts +7 -1
- package/src/list/FlowList.ts +82 -0
- package/src/live/Realtime.ts +22 -0
- package/src/store/AppState.ts +97 -10
- package/src/store/Store.ts +495 -6
- package/src/store/identity.ts +28 -0
- package/src/utils.ts +10 -8
package/package.json
CHANGED
package/src/flow/Editor.ts
CHANGED
|
@@ -58,6 +58,8 @@ import { Dialog } from '../layout/Dialog';
|
|
|
58
58
|
import { CanvasMenu, CanvasMenuSelection } from './CanvasMenu';
|
|
59
59
|
import { NodeTypeSelector, NodeTypeSelection } from './NodeTypeSelector';
|
|
60
60
|
import { FlowSearch, SearchResult } from './FlowSearch';
|
|
61
|
+
import { RealtimeSubscription, subscribeToFlow } from '../live/Realtime';
|
|
62
|
+
import { FlowDependency } from './dependencies';
|
|
61
63
|
|
|
62
64
|
export function findNodeForExit(
|
|
63
65
|
definition: FlowDefinition,
|
|
@@ -105,6 +107,10 @@ const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
|
|
|
105
107
|
// Used in both the CSS animation and the JS setTimeout.
|
|
106
108
|
const PENDING_SAVE_DELAY = 5000;
|
|
107
109
|
|
|
110
|
+
// Shortest gap (in ms) between activity reads, however many changes the flow
|
|
111
|
+
// socket announces in that window.
|
|
112
|
+
const ACTIVITY_FETCH_INTERVAL = 3000;
|
|
113
|
+
|
|
108
114
|
/**
|
|
109
115
|
* Manages a timed "pending changes" card that lets users discard or auto-save
|
|
110
116
|
* after a delay. Used for both auto-layout and shift+drag copy.
|
|
@@ -200,8 +206,17 @@ export class Editor extends RapidElement {
|
|
|
200
206
|
@property({ type: Array })
|
|
201
207
|
public features: string[] = [];
|
|
202
208
|
|
|
203
|
-
private
|
|
204
|
-
private
|
|
209
|
+
private activityWatch: RealtimeSubscription = null;
|
|
210
|
+
private watchedActivityFlow: string = null;
|
|
211
|
+
private activityFetchTimer: number | null = null;
|
|
212
|
+
private activityFetchInFlight = false;
|
|
213
|
+
private activityFetchQueued = false;
|
|
214
|
+
private activityFetchToken = 0;
|
|
215
|
+
private assetWatch: RealtimeSubscription = null;
|
|
216
|
+
private watchedAssetStore: Store = null;
|
|
217
|
+
private flowInfoWatch: () => void = null;
|
|
218
|
+
private assetResolveVersion = 0;
|
|
219
|
+
private watchedAssetSignature = '';
|
|
205
220
|
|
|
206
221
|
@fromStore(zustand, (state: AppState) => state.flowDefinition)
|
|
207
222
|
public definition!: FlowDefinition;
|
|
@@ -1198,10 +1213,17 @@ export class Editor extends RapidElement {
|
|
|
1198
1213
|
this.zoomManager = new ZoomManager(this);
|
|
1199
1214
|
}
|
|
1200
1215
|
|
|
1216
|
+
connectedCallback(): void {
|
|
1217
|
+
super.connectedCallback();
|
|
1218
|
+
this.syncAssetWatch(zustand.getState().flowInfo);
|
|
1219
|
+
this.syncActivityWatch();
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1201
1222
|
protected firstUpdated(
|
|
1202
1223
|
changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
|
1203
1224
|
): void {
|
|
1204
1225
|
super.firstUpdated(changes);
|
|
1226
|
+
this.syncAssetWatch(zustand.getState().flowInfo);
|
|
1205
1227
|
this.plumber = new Plumber(this.querySelector('#canvas'), this);
|
|
1206
1228
|
this.plumber.zoom = this.zoom;
|
|
1207
1229
|
this.setupGlobalEventListeners();
|
|
@@ -1422,23 +1444,15 @@ export class Editor extends RapidElement {
|
|
|
1422
1444
|
// defer to avoid triggering a reactive canvasSize update during this cycle
|
|
1423
1445
|
setTimeout(() => this.updateCanvasSize(), 0);
|
|
1424
1446
|
|
|
1425
|
-
|
|
1426
|
-
if (this.definition?.uuid) {
|
|
1427
|
-
this.startActivityFetching();
|
|
1428
|
-
}
|
|
1447
|
+
this.syncActivityWatch();
|
|
1429
1448
|
}
|
|
1430
1449
|
|
|
1431
1450
|
if (changes.has('simulatorActive')) {
|
|
1432
1451
|
if (this.simulatorActive) {
|
|
1433
1452
|
// Close any open floating windows when simulator opens
|
|
1434
1453
|
this.closeFloatingWindows();
|
|
1435
|
-
// Stop polling when simulator becomes active
|
|
1436
|
-
this.stopActivityFetching();
|
|
1437
|
-
} else {
|
|
1438
|
-
// Resume polling and refresh activity when simulator closes
|
|
1439
|
-
this.activityInterval = 100; // Reset to fast initial interval
|
|
1440
|
-
this.startActivityFetching();
|
|
1441
1454
|
}
|
|
1455
|
+
this.syncActivityWatch();
|
|
1442
1456
|
}
|
|
1443
1457
|
|
|
1444
1458
|
if (changes.has('activityData')) {
|
|
@@ -1653,55 +1667,188 @@ export class Editor extends RapidElement {
|
|
|
1653
1667
|
});
|
|
1654
1668
|
}
|
|
1655
1669
|
|
|
1656
|
-
private
|
|
1657
|
-
|
|
1658
|
-
if (this.
|
|
1670
|
+
private syncActivityWatch(): void {
|
|
1671
|
+
const flow = this.simulatorActive ? null : this.definition?.uuid;
|
|
1672
|
+
if (this.activityWatch && flow === this.watchedActivityFlow) {
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
if (this.activityWatch) {
|
|
1677
|
+
this.activityWatch.unsubscribe();
|
|
1678
|
+
this.activityWatch = null;
|
|
1679
|
+
}
|
|
1680
|
+
this.clearActivityFetch();
|
|
1681
|
+
this.watchedActivityFlow = flow;
|
|
1682
|
+
if (!flow) {
|
|
1659
1683
|
return;
|
|
1660
1684
|
}
|
|
1661
|
-
|
|
1662
|
-
this.
|
|
1685
|
+
|
|
1686
|
+
this.activityWatch = subscribeToFlow(
|
|
1687
|
+
flow,
|
|
1688
|
+
(event) => {
|
|
1689
|
+
if (event?.type === 'activity') {
|
|
1690
|
+
this.scheduleActivityFetch(flow);
|
|
1691
|
+
}
|
|
1692
|
+
},
|
|
1693
|
+
// fires on every (re)subscribe, so catch up on anything published while
|
|
1694
|
+
// we were disconnected
|
|
1695
|
+
() => this.scheduleActivityFetch(flow)
|
|
1696
|
+
);
|
|
1697
|
+
|
|
1698
|
+
// the socket only tells us about *changes*, and it may never connect at
|
|
1699
|
+
// all, so the first read doesn't wait on it
|
|
1700
|
+
this.fetchActivityData(flow);
|
|
1663
1701
|
}
|
|
1664
1702
|
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1703
|
+
/**
|
|
1704
|
+
* Abandons any scheduled or in-flight read. The token makes the in-flight
|
|
1705
|
+
* one a no-op when it lands, so a read for the flow we've stopped watching
|
|
1706
|
+
* can neither hold up nor swallow the next flow's first read.
|
|
1707
|
+
*/
|
|
1708
|
+
private clearActivityFetch(): void {
|
|
1709
|
+
if (this.activityFetchTimer !== null) {
|
|
1710
|
+
clearTimeout(this.activityFetchTimer);
|
|
1711
|
+
this.activityFetchTimer = null;
|
|
1669
1712
|
}
|
|
1713
|
+
this.activityFetchQueued = false;
|
|
1714
|
+
this.activityFetchInFlight = false;
|
|
1715
|
+
this.activityFetchToken++;
|
|
1670
1716
|
}
|
|
1671
1717
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1718
|
+
/**
|
|
1719
|
+
* Coalesces activity publications into at most one request per window.
|
|
1720
|
+
* Mailroom publishes once per committed sprint batch, so a flow running
|
|
1721
|
+
* against a large group can otherwise announce many changes a second.
|
|
1722
|
+
*/
|
|
1723
|
+
private scheduleActivityFetch(flow: string): void {
|
|
1724
|
+
if (flow !== this.watchedActivityFlow || this.activityFetchTimer !== null) {
|
|
1674
1725
|
return;
|
|
1675
1726
|
}
|
|
1727
|
+
this.activityFetchTimer = window.setTimeout(() => {
|
|
1728
|
+
this.activityFetchTimer = null;
|
|
1729
|
+
this.fetchActivityData(flow);
|
|
1730
|
+
}, ACTIVITY_FETCH_INTERVAL);
|
|
1731
|
+
}
|
|
1676
1732
|
|
|
1677
|
-
|
|
1678
|
-
if (
|
|
1733
|
+
private fetchActivityData(flow: string): void {
|
|
1734
|
+
if (
|
|
1735
|
+
!this.isConnected ||
|
|
1736
|
+
this.simulatorActive ||
|
|
1737
|
+
flow !== this.watchedActivityFlow
|
|
1738
|
+
) {
|
|
1679
1739
|
return;
|
|
1680
1740
|
}
|
|
1681
1741
|
|
|
1682
|
-
const activityEndpoint = `/flow/activity/${this.definition.uuid}/`;
|
|
1683
1742
|
const store = getStore();
|
|
1684
1743
|
if (!store) {
|
|
1685
1744
|
return;
|
|
1686
1745
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1746
|
+
|
|
1747
|
+
// one request at a time, so responses can't land out of order and a burst
|
|
1748
|
+
// of publications collapses into a single follow-up read
|
|
1749
|
+
if (this.activityFetchInFlight) {
|
|
1750
|
+
this.activityFetchQueued = true;
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
this.activityFetchInFlight = true;
|
|
1754
|
+
const token = ++this.activityFetchToken;
|
|
1755
|
+
const done = () => {
|
|
1756
|
+
// a flow change (or teardown) since this read started has already
|
|
1757
|
+
// reset the bookkeeping for the flow we now care about
|
|
1758
|
+
if (token !== this.activityFetchToken) {
|
|
1691
1759
|
return;
|
|
1692
1760
|
}
|
|
1761
|
+
this.activityFetchInFlight = false;
|
|
1762
|
+
if (this.activityFetchQueued) {
|
|
1763
|
+
this.activityFetchQueued = false;
|
|
1764
|
+
this.scheduleActivityFetch(flow);
|
|
1765
|
+
}
|
|
1766
|
+
};
|
|
1767
|
+
Promise.resolve(
|
|
1768
|
+
store.getState().fetchActivity(`/flow/activity/${flow}/`)
|
|
1769
|
+
).then(done, done);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
private syncAssetNames(): void {
|
|
1773
|
+
const store = getStore();
|
|
1774
|
+
const state = zustand.getState();
|
|
1775
|
+
if (!store || !state.flowDefinition || !state.flowInfo) {
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1693
1778
|
|
|
1694
|
-
|
|
1695
|
-
|
|
1779
|
+
const canonical: FlowDependency[] = [];
|
|
1780
|
+
for (const dependency of state.flowInfo.dependencies || []) {
|
|
1781
|
+
const identity = dependency.uuid || dependency.key;
|
|
1782
|
+
if (!identity) {
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
const asset = store.getAsset(dependency.type, identity);
|
|
1786
|
+
if (asset) {
|
|
1787
|
+
canonical.push({ ...dependency, name: asset.name });
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
state.updateDependencyNames(canonical);
|
|
1791
|
+
}
|
|
1696
1792
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1793
|
+
private async resolveAssetNames(info: AppState['flowInfo']): Promise<void> {
|
|
1794
|
+
const store = getStore();
|
|
1795
|
+
if (!store || !info) {
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const version = ++this.assetResolveVersion;
|
|
1799
|
+
try {
|
|
1800
|
+
const canonical = await store.resolveAssets(info.dependencies || []);
|
|
1801
|
+
if (version === this.assetResolveVersion && this.isConnected) {
|
|
1802
|
+
zustand.getState().updateDependencyNames(canonical);
|
|
1699
1803
|
}
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
console.error('failed to resolve flow dependency assets', error);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1700
1808
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
}
|
|
1809
|
+
private syncAssetWatch(info: AppState['flowInfo']): void {
|
|
1810
|
+
if (!this.isConnected) {
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const store = getStore();
|
|
1814
|
+
if (!store) {
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
const interests = info?.dependencies || [];
|
|
1818
|
+
const signature = interests
|
|
1819
|
+
.map(
|
|
1820
|
+
(dependency) =>
|
|
1821
|
+
`${dependency.type}:${dependency.uuid || dependency.key || ''}`
|
|
1822
|
+
)
|
|
1823
|
+
.sort()
|
|
1824
|
+
.join('|');
|
|
1825
|
+
// the store is part of the key: a replaced <temba-store> leaves us
|
|
1826
|
+
// watching a dead one otherwise
|
|
1827
|
+
if (
|
|
1828
|
+
this.assetWatch &&
|
|
1829
|
+
store === this.watchedAssetStore &&
|
|
1830
|
+
signature === this.watchedAssetSignature
|
|
1831
|
+
) {
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (this.assetWatch) {
|
|
1835
|
+
this.assetWatch.unsubscribe();
|
|
1836
|
+
}
|
|
1837
|
+
this.watchedAssetStore = store;
|
|
1838
|
+
this.watchedAssetSignature = signature;
|
|
1839
|
+
// the store caches the changed asset before publishing, so applying
|
|
1840
|
+
// everything it has covers both a single change and a reconnect refresh
|
|
1841
|
+
this.assetWatch = store.watchAssets(interests, () => this.syncAssetNames());
|
|
1842
|
+
if (!this.flowInfoWatch) {
|
|
1843
|
+
this.flowInfoWatch = zustand.subscribe(
|
|
1844
|
+
(state) => state.flowInfo,
|
|
1845
|
+
(info) => {
|
|
1846
|
+
this.syncAssetWatch(info);
|
|
1847
|
+
this.resolveAssetNames(info);
|
|
1848
|
+
}
|
|
1849
|
+
);
|
|
1850
|
+
this.resolveAssetNames(zustand.getState().flowInfo);
|
|
1851
|
+
}
|
|
1705
1852
|
}
|
|
1706
1853
|
|
|
1707
1854
|
private handleLanguageChange(languageCode: string): void {
|
|
@@ -1957,6 +2104,23 @@ export class Editor extends RapidElement {
|
|
|
1957
2104
|
|
|
1958
2105
|
disconnectedCallback(): void {
|
|
1959
2106
|
super.disconnectedCallback();
|
|
2107
|
+
if (this.assetWatch) {
|
|
2108
|
+
this.assetWatch.unsubscribe();
|
|
2109
|
+
this.assetWatch = null;
|
|
2110
|
+
}
|
|
2111
|
+
if (this.activityWatch) {
|
|
2112
|
+
this.activityWatch.unsubscribe();
|
|
2113
|
+
this.activityWatch = null;
|
|
2114
|
+
}
|
|
2115
|
+
this.clearActivityFetch();
|
|
2116
|
+
this.watchedActivityFlow = null;
|
|
2117
|
+
this.watchedAssetStore = null;
|
|
2118
|
+
this.watchedAssetSignature = '';
|
|
2119
|
+
if (this.flowInfoWatch) {
|
|
2120
|
+
this.flowInfoWatch();
|
|
2121
|
+
this.flowInfoWatch = null;
|
|
2122
|
+
}
|
|
2123
|
+
this.assetResolveVersion++;
|
|
1960
2124
|
this.zoomManager.teardownLoupe();
|
|
1961
2125
|
getStore()?.getState().setFlushSave(null);
|
|
1962
2126
|
this.dragManager.teardownListeners();
|
|
@@ -1969,10 +2133,6 @@ export class Editor extends RapidElement {
|
|
|
1969
2133
|
clearTimeout(this.saveTimer);
|
|
1970
2134
|
this.saveTimer = null;
|
|
1971
2135
|
}
|
|
1972
|
-
if (this.activityTimer !== null) {
|
|
1973
|
-
clearTimeout(this.activityTimer);
|
|
1974
|
-
this.activityTimer = null;
|
|
1975
|
-
}
|
|
1976
2136
|
this.pendingTimer.clearTimer();
|
|
1977
2137
|
window.removeEventListener('resize', this.boundWindowResize);
|
|
1978
2138
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { produce } from 'immer';
|
|
2
|
+
import { FlowDefinition } from '../store/flow-definition';
|
|
3
|
+
import { normalizeUuid } from '../store/identity';
|
|
4
|
+
|
|
5
|
+
export interface FlowDependency {
|
|
6
|
+
type: string;
|
|
7
|
+
name: string;
|
|
8
|
+
uuid?: string;
|
|
9
|
+
key?: string;
|
|
10
|
+
missing?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const dependencyIdentity = (dependency: FlowDependency): string | null => {
|
|
14
|
+
const identity = dependency.uuid || dependency.key;
|
|
15
|
+
return identity ? `${dependency.type}:${identity}` : null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns a definition whose embedded reference names have been replaced by
|
|
20
|
+
* the canonical dependency names supplied by the server. References with a
|
|
21
|
+
* UUID are safe to recognize anywhere in the definition because flow-owned
|
|
22
|
+
* UUIDs don't appear in its dependency list. Field references use keys and
|
|
23
|
+
* only occur under a `field` property; scoping keyed matching there avoids
|
|
24
|
+
* confusing a field key with a result name or another keyed structure.
|
|
25
|
+
*/
|
|
26
|
+
export const resolveDependencyNames = (
|
|
27
|
+
definition: FlowDefinition,
|
|
28
|
+
dependencies: FlowDependency[] = []
|
|
29
|
+
): FlowDefinition => {
|
|
30
|
+
if (!definition || dependencies.length === 0) {
|
|
31
|
+
return definition;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const namesByUuid = new Map<string, string>();
|
|
35
|
+
const fieldNamesByKey = new Map<string, string>();
|
|
36
|
+
for (const dependency of dependencies) {
|
|
37
|
+
if (dependency.uuid && dependency.name != null) {
|
|
38
|
+
// normalized on both set and lookup so a reference embedded in the
|
|
39
|
+
// definition in a non-canonical form still matches, the same way the
|
|
40
|
+
// store's asset cache resolves it
|
|
41
|
+
namesByUuid.set(normalizeUuid(dependency.uuid), dependency.name);
|
|
42
|
+
} else if (
|
|
43
|
+
dependency.type === 'field' &&
|
|
44
|
+
dependency.key &&
|
|
45
|
+
dependency.name != null
|
|
46
|
+
) {
|
|
47
|
+
fieldNamesByKey.set(dependency.key, dependency.name);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return produce(definition, (draft: any) => {
|
|
52
|
+
const visit = (value: any, propertyName?: string) => {
|
|
53
|
+
if (!value || typeof value !== 'object') {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
// keep the owning property name so `field` scoping still applies to
|
|
58
|
+
// references nested in an array
|
|
59
|
+
value.forEach((item) => visit(item, propertyName));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof value.uuid === 'string' && 'name' in value) {
|
|
64
|
+
const canonical = namesByUuid.get(normalizeUuid(value.uuid));
|
|
65
|
+
if (canonical != null) {
|
|
66
|
+
value.name = canonical;
|
|
67
|
+
}
|
|
68
|
+
} else if (
|
|
69
|
+
propertyName === 'field' &&
|
|
70
|
+
typeof value.key === 'string' &&
|
|
71
|
+
'name' in value
|
|
72
|
+
) {
|
|
73
|
+
const canonical = fieldNamesByKey.get(value.key);
|
|
74
|
+
if (canonical != null) {
|
|
75
|
+
value.name = canonical;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [key, child] of Object.entries(value)) {
|
|
80
|
+
visit(child, key);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
visit(draft);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Replaces every matching dependency whose canonical name has changed. Assets
|
|
90
|
+
* the loaded flow isn't interested in are ignored.
|
|
91
|
+
*/
|
|
92
|
+
export const replaceDependencies = (
|
|
93
|
+
dependencies: FlowDependency[] = [],
|
|
94
|
+
changed: FlowDependency[]
|
|
95
|
+
): FlowDependency[] | null => {
|
|
96
|
+
const changedByIdentity = new Map<string, FlowDependency>();
|
|
97
|
+
for (const dependency of changed) {
|
|
98
|
+
const identity = dependencyIdentity(dependency);
|
|
99
|
+
if (identity) {
|
|
100
|
+
changedByIdentity.set(identity, dependency);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let replaced = false;
|
|
105
|
+
const updated = dependencies.map((dependency) => {
|
|
106
|
+
const identity = dependencyIdentity(dependency);
|
|
107
|
+
const replacement = identity ? changedByIdentity.get(identity) : null;
|
|
108
|
+
if (!replacement || replacement.name === dependency.name) {
|
|
109
|
+
return dependency;
|
|
110
|
+
}
|
|
111
|
+
replaced = true;
|
|
112
|
+
return { ...dependency, name: replacement.name };
|
|
113
|
+
});
|
|
114
|
+
return replaced ? updated : null;
|
|
115
|
+
};
|