@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.170.0",
3
+ "version": "0.171.0",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -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 activityTimer: number | null = null;
204
- private activityInterval = 100; // Start with 100ms interval for fast initial load
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
- // Start fetching activity data when definition is loaded
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 startActivityFetching(): void {
1657
- // Don't start if simulator is active
1658
- if (this.simulatorActive) {
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
- // Fetch immediately
1662
- this.fetchActivityData();
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
- private stopActivityFetching(): void {
1666
- if (this.activityTimer !== null) {
1667
- clearTimeout(this.activityTimer);
1668
- this.activityTimer = null;
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
- private fetchActivityData(): void {
1673
- if (!this.definition?.uuid) {
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
- // Don't fetch if simulator is active
1678
- if (this.simulatorActive) {
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
- const state = store.getState();
1688
- state.fetchActivity(activityEndpoint).then(() => {
1689
- // Guard against responses arriving after the editor is disconnected
1690
- if (!this.isConnected) {
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
- // Schedule next fetch with exponential backoff (max 5 minutes)
1695
- this.activityInterval = Math.min(60000 * 5, this.activityInterval + 100);
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
- if (this.activityTimer !== null) {
1698
- clearTimeout(this.activityTimer);
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
- this.activityTimer = window.setTimeout(() => {
1702
- this.fetchActivityData();
1703
- }, this.activityInterval);
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
+ };