@nyaruka/temba-components 0.156.14 → 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/CHANGELOG.md +18 -0
- package/dist/temba-components.js +157 -155
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/flow/AutoTranslate.ts +104 -36
- package/src/flow/Editor.ts +14 -18
- package/src/flow/RevisionsWindow.ts +142 -36
- package/src/flow/flow-translations.ts +24 -1
- package/src/flow/revision-summary.ts +62 -0
- package/src/flow/types.ts +1 -2
- package/src/layout/Dialog.ts +1 -4
- package/src/store/AppState.ts +2 -0
- package/src/store/Store.ts +4 -0
- package/web-test-runner.config.mjs +7 -7
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { css, PropertyValues } from 'lit';
|
|
|
3
3
|
import { property, state } from 'lit/decorators.js';
|
|
4
4
|
import { RapidElement } from '../RapidElement';
|
|
5
5
|
import { getStore } from '../store/Store';
|
|
6
|
-
import { zustand } from '../store/AppState';
|
|
6
|
+
import { AppState, fromStore, zustand } from '../store/AppState';
|
|
7
7
|
import { FlowDefinition } from '../store/flow-definition';
|
|
8
8
|
import { TranslationEntry, buildTranslationBundles } from './flow-translations';
|
|
9
9
|
import { getLanguageDisplayName } from './utils';
|
|
@@ -66,10 +66,6 @@ export class AutoTranslate extends RapidElement {
|
|
|
66
66
|
text-decoration: underline;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
.auto-translate-single-model {
|
|
70
|
-
font-size: 13px;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
69
|
.auto-translate-status {
|
|
74
70
|
display: flex;
|
|
75
71
|
align-items: center;
|
|
@@ -141,6 +137,9 @@ export class AutoTranslate extends RapidElement {
|
|
|
141
137
|
@property({ type: Boolean })
|
|
142
138
|
disabled = false;
|
|
143
139
|
|
|
140
|
+
@fromStore(zustand, (state: AppState) => state.brand)
|
|
141
|
+
private brand!: string;
|
|
142
|
+
|
|
144
143
|
// Reactive flag the host can read to show "translating" state in UI
|
|
145
144
|
// adjacent to this component (e.g. the toolbar button).
|
|
146
145
|
@property({ type: Boolean, reflect: true })
|
|
@@ -170,6 +169,9 @@ export class AutoTranslate extends RapidElement {
|
|
|
170
169
|
@state()
|
|
171
170
|
interrupt = false;
|
|
172
171
|
|
|
172
|
+
@state()
|
|
173
|
+
updateExisting = false;
|
|
174
|
+
|
|
173
175
|
// Tracks whether the dialog has ever opened so we can keep it mounted
|
|
174
176
|
// afterwards (so it sees its own close transition) without paying
|
|
175
177
|
// for an empty hidden dialog before that.
|
|
@@ -192,6 +194,7 @@ export class AutoTranslate extends RapidElement {
|
|
|
192
194
|
this.error = null;
|
|
193
195
|
this.errorExpanded = false;
|
|
194
196
|
this.selectedModel = null;
|
|
197
|
+
this.updateExisting = false;
|
|
195
198
|
this.dialogOpen = true;
|
|
196
199
|
await this.loadModels();
|
|
197
200
|
|
|
@@ -228,6 +231,11 @@ export class AutoTranslate extends RapidElement {
|
|
|
228
231
|
this.selectedModel = next ? { uuid: next.uuid, name: next.name } : null;
|
|
229
232
|
}
|
|
230
233
|
|
|
234
|
+
private handleUpdateExistingChange(event: Event): void {
|
|
235
|
+
const checkbox = event.target as any;
|
|
236
|
+
this.updateExisting = !!checkbox?.checked;
|
|
237
|
+
}
|
|
238
|
+
|
|
231
239
|
private confirmTranslate(): void {
|
|
232
240
|
if (!this.selectedModel) {
|
|
233
241
|
return;
|
|
@@ -258,7 +266,10 @@ export class AutoTranslate extends RapidElement {
|
|
|
258
266
|
* preTranslated for direct application), and dedupes pending entries
|
|
259
267
|
* sharing identical source arrays so each unique array is sent at most
|
|
260
268
|
* once (duplicates are returned in duplicateKeyToCanonical for
|
|
261
|
-
* propagation when the canonical key's translation lands).
|
|
269
|
+
* propagation when the canonical key's translation lands). When
|
|
270
|
+
* updateExisting is true, already-translated entries are also included
|
|
271
|
+
* and the pre-translation reuse path is skipped so all localizable
|
|
272
|
+
* entries are sent to the LLM.
|
|
262
273
|
*/
|
|
263
274
|
private buildTranslationBatches(): {
|
|
264
275
|
keyToEntries: Map<string, TranslationEntry[]>;
|
|
@@ -276,53 +287,71 @@ export class AutoTranslate extends RapidElement {
|
|
|
276
287
|
}
|
|
277
288
|
|
|
278
289
|
const bundles = buildTranslationBundles(this.definition, this.languageCode);
|
|
290
|
+
const updateExisting = this.updateExisting;
|
|
279
291
|
|
|
280
292
|
// Map source-content -> already-stored translation array, built from
|
|
281
293
|
// entries that have a translation in the live localization map. Reading
|
|
282
|
-
// the stored array (not entry.to) preserves the original shape.
|
|
294
|
+
// the stored array (not entry.to) preserves the original shape. Skipped
|
|
295
|
+
// in update mode so we always re-translate via the LLM.
|
|
283
296
|
const existingByContent = new Map<string, string[]>();
|
|
284
297
|
const localization =
|
|
285
298
|
this.definition.localization?.[this.languageCode] || {};
|
|
286
299
|
|
|
287
|
-
|
|
288
|
-
for (const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
300
|
+
if (!updateExisting) {
|
|
301
|
+
for (const bundle of bundles) {
|
|
302
|
+
for (const entry of bundle.translations) {
|
|
303
|
+
// Skip entries with no real translation: an empty array OR an
|
|
304
|
+
// array of only empty/whitespace strings shouldn't seed reuse,
|
|
305
|
+
// or we'd silently propagate a blank to other matching sources.
|
|
306
|
+
if (
|
|
307
|
+
!entry.toValues ||
|
|
308
|
+
!entry.toValues.some((v) => v && v.trim().length > 0)
|
|
309
|
+
) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (!entry.fromValues || entry.fromValues.length === 0) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const stored = localization[entry.uuid]?.[entry.attribute];
|
|
316
|
+
if (!Array.isArray(stored) || stored.length === 0) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const contentKey = JSON.stringify(entry.fromValues);
|
|
320
|
+
if (!existingByContent.has(contentKey)) {
|
|
321
|
+
existingByContent.set(contentKey, stored);
|
|
322
|
+
}
|
|
302
323
|
}
|
|
303
324
|
}
|
|
304
325
|
}
|
|
305
326
|
|
|
306
|
-
// Collect pending entries
|
|
307
|
-
//
|
|
308
|
-
//
|
|
327
|
+
// Collect pending entries preserving order. Each unique key (uuid +
|
|
328
|
+
// attribute) maps to its source array (entry.fromValues), preserving
|
|
329
|
+
// multi-item structure for attributes like router-case `arguments`.
|
|
330
|
+
// In update mode we include entries that already have a translation.
|
|
331
|
+
// Invariant: findTranslations produces at most one entry per
|
|
332
|
+
// (uuid, attribute) pair, so the first fromValues is authoritative
|
|
333
|
+
// and any later entry with the same key is treated as a duplicate.
|
|
309
334
|
const valuesByKey = new Map<string, string[]>();
|
|
310
335
|
|
|
311
336
|
for (const bundle of bundles) {
|
|
312
337
|
for (const entry of bundle.translations) {
|
|
313
|
-
if (
|
|
338
|
+
if (
|
|
339
|
+
!updateExisting &&
|
|
340
|
+
entry.toValues &&
|
|
341
|
+
entry.toValues.some((v) => v && v.trim().length > 0)
|
|
342
|
+
) {
|
|
314
343
|
continue;
|
|
315
344
|
}
|
|
316
|
-
if (!entry.
|
|
345
|
+
if (!entry.fromValues || entry.fromValues.length === 0) {
|
|
317
346
|
continue;
|
|
318
347
|
}
|
|
319
348
|
const key = `${entry.uuid}:${entry.attribute}`;
|
|
320
349
|
const list = keyToEntries.get(key) || [];
|
|
321
350
|
list.push(entry);
|
|
322
351
|
keyToEntries.set(key, list);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
352
|
+
if (!valuesByKey.has(key)) {
|
|
353
|
+
valuesByKey.set(key, entry.fromValues);
|
|
354
|
+
}
|
|
326
355
|
}
|
|
327
356
|
}
|
|
328
357
|
|
|
@@ -507,8 +536,39 @@ export class AutoTranslate extends RapidElement {
|
|
|
507
536
|
const liveDefinition = zustand.getState().flowDefinition;
|
|
508
537
|
const existing =
|
|
509
538
|
liveDefinition?.localization?.[this.languageCode]?.[uuid] || {};
|
|
539
|
+
const existingValues = Array.isArray(existing[attribute])
|
|
540
|
+
? (existing[attribute] as string[])
|
|
541
|
+
: null;
|
|
542
|
+
|
|
543
|
+
// Never replace an existing non-empty translation with an empty
|
|
544
|
+
// value: when re-translating already-localized content the LLM may
|
|
545
|
+
// return blanks for some entries; keep the prior translation in
|
|
546
|
+
// those slots. Only safe when the arrays align by index — if the
|
|
547
|
+
// source array length has changed since the last translation, the
|
|
548
|
+
// old values aren't tied to the same items anymore, so skip the
|
|
549
|
+
// per-item preserve and let the LLM result through unchanged.
|
|
550
|
+
const canPreservePerItem =
|
|
551
|
+
!!existingValues && existingValues.length === values.length;
|
|
552
|
+
const mergedValues = values.map((v, i) => {
|
|
553
|
+
const isEmpty =
|
|
554
|
+
v === null ||
|
|
555
|
+
v === undefined ||
|
|
556
|
+
(typeof v === 'string' && v.trim().length === 0);
|
|
557
|
+
if (isEmpty && canPreservePerItem && existingValues![i]) {
|
|
558
|
+
return existingValues![i];
|
|
559
|
+
}
|
|
560
|
+
return v;
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
const hasContent = mergedValues.some(
|
|
564
|
+
(v) => v && (typeof v !== 'string' || v.trim().length > 0)
|
|
565
|
+
);
|
|
566
|
+
if (!hasContent) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
510
570
|
const merged = updatesByUuid.get(uuid) || { ...existing };
|
|
511
|
-
merged[attribute] =
|
|
571
|
+
merged[attribute] = mergedValues;
|
|
512
572
|
updatesByUuid.set(uuid, merged);
|
|
513
573
|
}
|
|
514
574
|
|
|
@@ -642,11 +702,15 @@ export class AutoTranslate extends RapidElement {
|
|
|
642
702
|
|
|
643
703
|
const selected = this.selectedModel ? [this.selectedModel] : [];
|
|
644
704
|
const languageName = getLanguageDisplayName(this.languageCode);
|
|
705
|
+
const aiClause = this.brand
|
|
706
|
+
? html`${this.brand} uses AI for automatic translation, which can make
|
|
707
|
+
mistakes,`
|
|
708
|
+
: html`Automatic translation uses AI, which can make mistakes,`;
|
|
645
709
|
return html`
|
|
646
710
|
<p>
|
|
647
711
|
All remaining text for <strong>${languageName}</strong> will be
|
|
648
|
-
translated automatically.
|
|
649
|
-
|
|
712
|
+
translated automatically. ${aiClause} so it is important to review all
|
|
713
|
+
of your translations to verify they are correct.
|
|
650
714
|
</p>
|
|
651
715
|
${this.models.length > 1
|
|
652
716
|
? html`<temba-select
|
|
@@ -660,9 +724,13 @@ export class AutoTranslate extends RapidElement {
|
|
|
660
724
|
placeholder="Select an AI model"
|
|
661
725
|
@change=${this.handleModelChange}
|
|
662
726
|
></temba-select>`
|
|
663
|
-
:
|
|
664
|
-
|
|
665
|
-
|
|
727
|
+
: ''}
|
|
728
|
+
<temba-checkbox
|
|
729
|
+
class="auto-translate-update-existing"
|
|
730
|
+
label="Update existing translations"
|
|
731
|
+
?checked=${this.updateExisting}
|
|
732
|
+
@change=${this.handleUpdateExistingChange}
|
|
733
|
+
></temba-checkbox>
|
|
666
734
|
`;
|
|
667
735
|
}
|
|
668
736
|
|
package/src/flow/Editor.ts
CHANGED
|
@@ -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 (
|
|
1761
|
+
if (this.viewingRevision) {
|
|
1764
1762
|
return;
|
|
1765
1763
|
}
|
|
1766
1764
|
const at = this.querySelector('temba-auto-translate') as any;
|
|
@@ -3690,11 +3688,11 @@ export class Editor extends RapidElement {
|
|
|
3690
3688
|
})
|
|
3691
3689
|
];
|
|
3692
3690
|
|
|
3691
|
+
// shown whenever the active language has any localizable content, even
|
|
3692
|
+
// at 100% — the dialog's "update existing" option lets users re-run
|
|
3693
|
+
// translation on already-translated entries.
|
|
3693
3694
|
const hasPendingTranslations =
|
|
3694
|
-
|
|
3695
|
-
Boolean(activeLanguage) &&
|
|
3696
|
-
progress.total > 0 &&
|
|
3697
|
-
progress.localized < progress.total;
|
|
3695
|
+
Boolean(activeLanguage) && progress.total > 0;
|
|
3698
3696
|
|
|
3699
3697
|
return html`
|
|
3700
3698
|
<temba-editor-toolbar
|
|
@@ -3928,14 +3926,12 @@ export class Editor extends RapidElement {
|
|
|
3928
3926
|
@temba-revision-reverted=${this.handleRevisionReverted}
|
|
3929
3927
|
@temba-revisions-closed=${this.handleRevisionsClosed}
|
|
3930
3928
|
></temba-revisions-window>
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
></temba-auto-translate>`
|
|
3938
|
-
: ''}
|
|
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>
|
|
3939
3935
|
<div id="editor-container">
|
|
3940
3936
|
${this.renderToolbarElement()}
|
|
3941
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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=${
|
|
88
|
-
.maxHeight=${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
${
|
|
134
|
-
? html`<
|
|
135
|
-
class="
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
</
|
|
143
|
-
:
|
|
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
|
-
|
|
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.
|
|
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) {
|
|
@@ -10,6 +10,12 @@ export interface TranslationEntry {
|
|
|
10
10
|
attribute: string;
|
|
11
11
|
from: string;
|
|
12
12
|
to: string | null;
|
|
13
|
+
// Original array form of the source/target. For attributes whose value
|
|
14
|
+
// is an array (e.g. router case `arguments`), these preserve the per-item
|
|
15
|
+
// structure that `from`/`to` would otherwise lose to comma-joining. For
|
|
16
|
+
// scalar attributes the arrays contain a single item.
|
|
17
|
+
fromValues: string[];
|
|
18
|
+
toValues: string[] | null;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export interface TranslationBundle {
|
|
@@ -97,12 +103,29 @@ export function findTranslations(
|
|
|
97
103
|
|
|
98
104
|
const toValue = to ? formatTranslationValue(to) : null;
|
|
99
105
|
|
|
106
|
+
const toArray = (value: any): string[] => {
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
return value.map((v) =>
|
|
109
|
+
v === null || v === undefined ? '' : String(v)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (value === null || value === undefined) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return [String(value)];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const fromValues = toArray(from);
|
|
119
|
+
const toValues = to !== null && to !== undefined ? toArray(to) : null;
|
|
120
|
+
|
|
100
121
|
translations.push({
|
|
101
122
|
uuid,
|
|
102
123
|
type,
|
|
103
124
|
attribute,
|
|
104
125
|
from: fromValue,
|
|
105
|
-
to: toValue
|
|
126
|
+
to: toValue,
|
|
127
|
+
fromValues,
|
|
128
|
+
toValues
|
|
106
129
|
});
|
|
107
130
|
});
|
|
108
131
|
|
|
@@ -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
|
+
}
|