@nyaruka/temba-components 0.156.15 → 0.156.16

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.156.15",
3
+ "version": "0.156.16",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -40,7 +40,6 @@ import type { RevisionsWindow } from './RevisionsWindow';
40
40
  import {
41
41
  ACTION_GROUP_METADATA,
42
42
  CONTEXT_MENU_SHORTCUTS,
43
- Features,
44
43
  FlowType,
45
44
  FlowTypes
46
45
  } from './types';
@@ -200,10 +199,6 @@ export class Editor extends RapidElement {
200
199
  @property({ type: Array })
201
200
  public features: string[] = [];
202
201
 
203
- private get autoTranslateEnabled(): boolean {
204
- return this.features?.includes(Features.AUTO_TRANSLATE) ?? false;
205
- }
206
-
207
202
  private activityTimer: number | null = null;
208
203
  private activityInterval = 100; // Start with 100ms interval for fast initial load
209
204
 
@@ -1601,6 +1596,9 @@ export class Editor extends RapidElement {
1601
1596
  }
1602
1597
 
1603
1598
  getStore().getState().setDirtyDate(null);
1599
+
1600
+ // Refresh the revisions list if it's currently open.
1601
+ this.getRevisionsWindow()?.refresh();
1604
1602
  })
1605
1603
  .catch((error) => {
1606
1604
  console.error('Failed to save flow:', error);
@@ -1760,7 +1758,7 @@ export class Editor extends RapidElement {
1760
1758
  }
1761
1759
 
1762
1760
  private handleAutoTranslateClick(): void {
1763
- if (!this.autoTranslateEnabled || this.viewingRevision) {
1761
+ if (this.viewingRevision) {
1764
1762
  return;
1765
1763
  }
1766
1764
  const at = this.querySelector('temba-auto-translate') as any;
@@ -3694,9 +3692,7 @@ export class Editor extends RapidElement {
3694
3692
  // at 100% — the dialog's "update existing" option lets users re-run
3695
3693
  // translation on already-translated entries.
3696
3694
  const hasPendingTranslations =
3697
- this.autoTranslateEnabled &&
3698
- Boolean(activeLanguage) &&
3699
- progress.total > 0;
3695
+ Boolean(activeLanguage) && progress.total > 0;
3700
3696
 
3701
3697
  return html`
3702
3698
  <temba-editor-toolbar
@@ -3930,14 +3926,12 @@ export class Editor extends RapidElement {
3930
3926
  @temba-revision-reverted=${this.handleRevisionReverted}
3931
3927
  @temba-revisions-closed=${this.handleRevisionsClosed}
3932
3928
  ></temba-revisions-window>
3933
- ${this.autoTranslateEnabled
3934
- ? html`<temba-auto-translate
3935
- .definition=${this.definition}
3936
- language-code=${this.languageCode}
3937
- ?disabled=${this.viewingRevision}
3938
- @temba-auto-translate-changed=${this.handleAutoTranslateChanged}
3939
- ></temba-auto-translate>`
3940
- : ''}
3929
+ <temba-auto-translate
3930
+ .definition=${this.definition}
3931
+ language-code=${this.languageCode}
3932
+ ?disabled=${this.viewingRevision}
3933
+ @temba-auto-translate-changed=${this.handleAutoTranslateChanged}
3934
+ ></temba-auto-translate>
3941
3935
  <div id="editor-container">
3942
3936
  ${this.renderToolbarElement()}
3943
3937
  <div id="editor">
@@ -7,18 +7,28 @@ import { getStore } from '../store/Store';
7
7
  import { FlowDefinition } from '../store/flow-definition';
8
8
  import { fetchResults } from '../utils';
9
9
  import { FLOW_SPEC_VERSION } from '../store/AppState';
10
+ import {
11
+ labelsFor,
12
+ RevisionChanges,
13
+ summarizeChanges
14
+ } from './revision-summary';
15
+
16
+ const GROUP_WINDOW_MS = 15 * 60 * 1000;
17
+ const MAX_GROUP_LABELS = 3;
10
18
 
11
19
  export interface Revision {
12
20
  id: number;
13
21
  user: {
14
- id: number;
15
- username: string;
16
- first_name: string;
17
- last_name: string;
22
+ id?: number;
23
+ email?: string;
24
+ username?: string;
25
+ first_name?: string;
26
+ last_name?: string;
18
27
  name?: string;
19
28
  };
20
29
  created_on: string;
21
30
  comment?: string;
31
+ changes?: RevisionChanges | null;
22
32
  }
23
33
 
24
34
  export class RevisionsWindow extends RapidElement {
@@ -53,11 +63,18 @@ export class RevisionsWindow extends RapidElement {
53
63
  dirtyDate: Date | null;
54
64
  } | null = null;
55
65
  private browseLanguageCode: string | null = null;
66
+ private fetchRequestId = 0;
56
67
 
57
68
  public get isViewingRevision(): boolean {
58
69
  return this.viewingRevision !== null;
59
70
  }
60
71
 
72
+ public refresh(): void {
73
+ if (!this.hidden) {
74
+ this.fetchRevisions();
75
+ }
76
+ }
77
+
61
78
  protected updated(changes: PropertyValues): void {
62
79
  super.updated(changes);
63
80
  if (
@@ -84,8 +101,8 @@ export class RevisionsWindow extends RapidElement {
84
101
  name="revisions"
85
102
  header="Revisions"
86
103
  icon="revisions"
87
- .width=${240}
88
- .maxHeight=${400}
104
+ .width=${340}
105
+ .maxHeight=${500}
89
106
  .top=${120}
90
107
  color="rgb(142, 94, 167)"
91
108
  .saving=${this.saving}
@@ -99,11 +116,15 @@ export class RevisionsWindow extends RapidElement {
99
116
  >
100
117
  ${this.isLoading && !this.revisions.length
101
118
  ? html`<temba-loading></temba-loading>`
102
- : this.revisions.map((rev) => {
119
+ : this.revisions.map((rev, index) => {
120
+ const isCurrent = index === 0;
103
121
  const isSelected = this.viewingRevision?.id === rev.id;
122
+ const summary = summarizeChanges(rev.changes);
104
123
  return html`
105
124
  <div
106
- class="revision-item ${isSelected ? 'selected' : ''}"
125
+ class="revision-item ${isSelected
126
+ ? 'selected'
127
+ : ''} ${isCurrent ? 'current' : ''}"
107
128
  style="padding:8px; border-radius:4px; cursor:pointer; background:${isSelected
108
129
  ? '#f0f6ff'
109
130
  : '#f9fafb'}; border:1px solid ${isSelected
@@ -111,36 +132,46 @@ export class RevisionsWindow extends RapidElement {
111
132
  : '#e5e7eb'}; transition: all 0.2s ease;"
112
133
  @click=${() => this.handleRevisionClick(rev)}
113
134
  >
135
+ ${summary
136
+ ? html`<div
137
+ class="revision-summary"
138
+ style="font-size:13px; color:#111827; line-height:1.3;"
139
+ >
140
+ ${summary}
141
+ </div>`
142
+ : ''}
114
143
  <div
115
- style="display:flex; justify-content:space-between; align-items:center;"
144
+ class="revision-meta"
145
+ style="display:flex; justify-content:space-between; align-items:center; gap:8px; min-height:20px; font-size:11px; color:#6b7280; margin-top:${summary
146
+ ? '2px'
147
+ : '0'};"
116
148
  >
117
- <div
118
- class="revision-header"
119
- style="margin-bottom: 2px;"
120
- >
121
- <div
122
- style="font-weight:600; font-size:13px; color:#111827;"
123
- >
124
- <temba-date
125
- value=${rev.created_on}
126
- display="duration"
127
- ></temba-date>
128
- </div>
129
- <div style="font-size:11px; color:#6b7280;">
130
- ${rev.user.name || rev.user.username}
131
- </div>
149
+ <div style="flex:1; min-width:0;">
150
+ <temba-date
151
+ value=${rev.created_on}
152
+ display="duration"
153
+ ></temba-date>
154
+ · ${rev.user.name || rev.user.username}
132
155
  </div>
133
- ${isSelected
134
- ? html`<button
135
- class="revert-button"
136
- @click=${(e: Event) => {
137
- e.stopPropagation();
138
- this.handleRevertClick();
139
- }}
156
+ ${isCurrent
157
+ ? html`<div
158
+ class="current-label"
159
+ style="font-size:10px; font-weight:600; text-transform:uppercase; color:#6b7280; background:#e5e7eb; padding:2px 6px; border-radius:10px; letter-spacing:0.5px; flex-shrink:0;"
140
160
  >
141
- Revert
142
- </button>`
143
- : html``}
161
+ Current
162
+ </div>`
163
+ : isSelected
164
+ ? html`<button
165
+ class="revert-button"
166
+ style="font-size:10px; font-weight:600; text-transform:uppercase; color:#1e3a8a; background:#a4cafe; padding:2px 6px; border-radius:10px; letter-spacing:0.5px; border:none; cursor:pointer; flex-shrink:0;"
167
+ @click=${(e: Event) => {
168
+ e.stopPropagation();
169
+ this.handleRevertClick();
170
+ }}
171
+ >
172
+ Revert
173
+ </button>`
174
+ : html``}
144
175
  </div>
145
176
 
146
177
  ${rev.comment
@@ -162,17 +193,92 @@ export class RevisionsWindow extends RapidElement {
162
193
  // --- Private ---
163
194
 
164
195
  private async fetchRevisions() {
196
+ const requestId = ++this.fetchRequestId;
165
197
  this.isLoading = true;
166
198
  try {
167
199
  const results = await fetchResults(
168
200
  `/flow/revisions/${this.flow}/?version=${FLOW_SPEC_VERSION}`
169
201
  );
170
- this.revisions = results.slice(1);
202
+ if (requestId !== this.fetchRequestId) return;
203
+ this.revisions = this.collapseRevisions(results);
171
204
  } catch (e) {
205
+ if (requestId !== this.fetchRequestId) return;
172
206
  console.error('Error fetching revisions', e);
173
207
  } finally {
174
- this.isLoading = false;
208
+ if (requestId === this.fetchRequestId) {
209
+ this.isLoading = false;
210
+ }
211
+ }
212
+ }
213
+
214
+ // Lump revisions made in a continuous editing session (within 15 minutes
215
+ // of each other, by the same author) onto their most recent member,
216
+ // merging the tag sets so the summary covers everything that happened in
217
+ // the window. The merged revision is capped at three distinct displayed
218
+ // labels — once a fourth would be introduced we break out into a new row.
219
+ private collapseRevisions(revisions: Revision[]): Revision[] {
220
+ // The API returns newest-first today; sort defensively so the head/window
221
+ // logic stays correct if that ever changes.
222
+ const sorted = [...revisions].sort(
223
+ (a, b) =>
224
+ new Date(b.created_on).getTime() - new Date(a.created_on).getTime()
225
+ );
226
+ const result: Revision[] = [];
227
+ let group: Revision[] = [];
228
+ let groupLabels = new Set<string>();
229
+
230
+ const flush = () => {
231
+ if (group.length === 0) return;
232
+ const head = group[0];
233
+ const tagSet = new Set<string>();
234
+ let anyKnown = false;
235
+ for (const r of group) {
236
+ if (r.changes) {
237
+ anyKnown = true;
238
+ for (const tag of r.changes.tags || []) tagSet.add(tag);
239
+ }
240
+ }
241
+ result.push({
242
+ ...head,
243
+ changes: anyKnown ? { tags: Array.from(tagSet) } : null
244
+ });
245
+ group = [];
246
+ groupLabels = new Set();
247
+ };
248
+
249
+ for (const rev of sorted) {
250
+ if (group.length === 0) {
251
+ group.push(rev);
252
+ groupLabels = labelsFor(rev.changes);
253
+ continue;
254
+ }
255
+ const head = group[0];
256
+ const headTime = new Date(head.created_on).getTime();
257
+ const revTime = new Date(rev.created_on).getTime();
258
+ const withinWindow = headTime - revTime < GROUP_WINDOW_MS;
259
+ // Compare on whichever identifier the server provides — real data
260
+ // arrives with `email`, while test fixtures use `username`. Falling
261
+ // back through the chain keeps both shapes working.
262
+ const headId = head.user?.email ?? head.user?.username;
263
+ const revId = rev.user?.email ?? rev.user?.username;
264
+ const sameAuthor = headId === revId;
265
+ const prospective = new Set([
266
+ ...groupLabels,
267
+ ...labelsFor(rev.changes)
268
+ ]);
269
+ const fitsLabelCap = prospective.size <= MAX_GROUP_LABELS;
270
+
271
+ if (withinWindow && sameAuthor && fitsLabelCap) {
272
+ group.push(rev);
273
+ groupLabels = prospective;
274
+ } else {
275
+ flush();
276
+ group.push(rev);
277
+ groupLabels = labelsFor(rev.changes);
278
+ }
175
279
  }
280
+ flush();
281
+ return result;
176
282
  }
177
283
 
178
284
  private async handleRevisionClick(revision: Revision) {
@@ -0,0 +1,62 @@
1
+ export interface RevisionChanges {
2
+ tags: string[];
3
+ }
4
+
5
+ const TAG_LABELS: Record<string, { label: string; order: number }> = {
6
+ metadata: { label: 'metadata', order: 0 },
7
+ nodes: { label: 'nodes', order: 1 },
8
+ routing: { label: 'routing', order: 2 },
9
+ actions: { label: 'actions', order: 3 },
10
+ stickies: { label: 'stickies', order: 5 },
11
+ layout: { label: 'layout', order: 6 }
12
+ };
13
+
14
+ function tagToLabel(tag: string): { label: string; order: number } | null {
15
+ if (Object.prototype.hasOwnProperty.call(TAG_LABELS, tag)) {
16
+ return TAG_LABELS[tag];
17
+ }
18
+ if (tag.startsWith('localization:')) {
19
+ return { label: 'translations', order: 4 };
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function labelsFor(
25
+ changes: RevisionChanges | null | undefined
26
+ ): Set<string> {
27
+ const result = new Set<string>();
28
+ for (const tag of changes?.tags || []) {
29
+ const entry = tagToLabel(tag);
30
+ if (entry) result.add(entry.label);
31
+ }
32
+ return result;
33
+ }
34
+
35
+ export function summarizeChanges(
36
+ changes: RevisionChanges | null | undefined
37
+ ): string {
38
+ if (!changes) return '';
39
+ const tags = changes.tags || [];
40
+ if (tags.length === 0) return '';
41
+
42
+ const orders = new Map<string, number>();
43
+ for (const tag of tags) {
44
+ const entry = tagToLabel(tag);
45
+ if (entry && !orders.has(entry.label)) {
46
+ orders.set(entry.label, entry.order);
47
+ }
48
+ }
49
+ if (orders.size === 0) return '';
50
+
51
+ const labels = Array.from(orders.entries())
52
+ .sort((a, b) => a[1] - b[1])
53
+ .map(([label]) => label);
54
+
55
+ return `Changed ${joinNaturally(labels)}`;
56
+ }
57
+
58
+ function joinNaturally(parts: string[]): string {
59
+ if (parts.length <= 1) return parts.join('');
60
+ if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
61
+ return `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`;
62
+ }
package/src/flow/types.ts CHANGED
@@ -50,8 +50,7 @@ export const CONTEXT_MENU_SHORTCUTS: Record<FlowType, ContextMenuShortcut[]> = {
50
50
  export const Features = {
51
51
  AI: 'ai',
52
52
  AIRTIME: 'airtime',
53
- LOCATIONS: 'locations',
54
- AUTO_TRANSLATE: 'auto_translate'
53
+ LOCATIONS: 'locations'
55
54
  } as const;
56
55
 
57
56
  export type Feature = (typeof Features)[keyof typeof Features];
@@ -140,13 +140,13 @@ const checkScreenshot = async (filename, excluded, threshold) => {
140
140
  });
141
141
  };
142
142
 
143
- const wireScreenshots = async (page, context, wait, replaceScreenshots) => {
144
- // clear out any past tests
145
- const diffs = path.resolve(SCREENSHOTS, DIFF);
146
- const tests = path.resolve(SCREENSHOTS, TEST);
143
+ // clear out any past tests once per process — clearing per-page would race
144
+ // with other concurrent pages that have already written test screenshots and
145
+ // are about to read them back, causing intermittent ENOENT failures.
146
+ rimraf.sync(path.resolve(SCREENSHOTS, DIFF));
147
+ rimraf.sync(path.resolve(SCREENSHOTS, TEST));
147
148
 
148
- rimraf.sync(diffs);
149
- rimraf.sync(tests);
149
+ const wireScreenshots = async (page, context, wait, replaceScreenshots) => {
150
150
 
151
151
  await page.exposeFunction(
152
152
  'matchPageSnapshot',
@@ -335,7 +335,7 @@ const wireScreenshots = async (page, context, wait, replaceScreenshots) => {
335
335
 
336
336
  export default {
337
337
  rootDir: './',
338
- files: '**/test/**/*.test.ts',
338
+ files: ['**/test/**/*.test.ts', '!**/test/utils.test.ts'],
339
339
  nodeResolve: true,
340
340
  concurrency: 4,
341
341
  filterBrowserLogs(log) {