@nyaruka/temba-components 0.156.15 → 0.156.17
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 +15 -0
- package/dist/temba-components.js +270 -259
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Options.ts +8 -0
- package/src/excellent/caret-utils.ts +179 -55
- package/src/flow/Editor.ts +11 -17
- package/src/flow/RevisionsWindow.ts +142 -36
- package/src/flow/revision-summary.ts +62 -0
- package/src/flow/types.ts +1 -2
- package/src/form/RichEditor.ts +21 -0
- package/web-test-runner.config.mjs +7 -7
package/package.json
CHANGED
package/src/display/Options.ts
CHANGED
|
@@ -562,6 +562,14 @@ export class Options extends RapidElement {
|
|
|
562
562
|
return;
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
// Don't intercept keys when the popup isn't visible to the user. The
|
|
566
|
+
// element can still be in the layout tree (so offsetParent is non-null)
|
|
567
|
+
// while being hidden via opacity/pointer-events, in which case handling
|
|
568
|
+
// keys would steal them from the underlying input.
|
|
569
|
+
if (!this.block && !this.visible) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
565
573
|
// Only intercept keys when the event originated within our owning component.
|
|
566
574
|
// Without this, any temba-options with populated options would swallow arrow
|
|
567
575
|
// keys document-wide, breaking cursor movement in unrelated text editors.
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Cursor management utilities for contenteditable
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// Cursor management utilities for contenteditable.
|
|
3
|
+
//
|
|
4
|
+
// The DOM produced by our renderer is a flat list of <span> tokens (newlines
|
|
5
|
+
// are "\n" characters inside <span class="tok-newline">) plus an optional
|
|
6
|
+
// trailing <br data-sentinel> that exists only so Firefox renders an empty
|
|
7
|
+
// final line. Browser-inserted structures (e.g. from yank/paste) are also
|
|
8
|
+
// possible: a bare <br> represents a real newline, and a <div> or <p> at the
|
|
9
|
+
// editable's root level represents a line block. Both are translated to "\n"
|
|
10
|
+
// when computing plain-text offsets so the rebuild after handleInput sees the
|
|
11
|
+
// correct value.
|
|
6
12
|
// ---------------------------------------------------------------------------
|
|
7
13
|
|
|
8
14
|
/** Gets the Selection object, handling shadow DOM. */
|
|
@@ -14,20 +20,51 @@ export function getSelectionFromRoot(element: HTMLElement): Selection | null {
|
|
|
14
20
|
return window.getSelection();
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
|
|
18
|
-
|
|
23
|
+
function isSentinelBr(node: Node): boolean {
|
|
24
|
+
return (
|
|
25
|
+
node.nodeName === 'BR' &&
|
|
26
|
+
typeof (node as Element).hasAttribute === 'function' &&
|
|
27
|
+
(node as Element).hasAttribute('data-sentinel')
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isBlockElement(node: Node): boolean {
|
|
32
|
+
return node.nodeName === 'DIV' || node.nodeName === 'P';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Plain-text contribution of a node and its descendants. */
|
|
36
|
+
function textOfNode(node: Node): string {
|
|
19
37
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
20
|
-
return node.textContent
|
|
38
|
+
return node.textContent || '';
|
|
21
39
|
}
|
|
22
|
-
// Ignore browser-added <br> artifacts
|
|
23
40
|
if (node.nodeName === 'BR') {
|
|
24
|
-
return
|
|
41
|
+
return isSentinelBr(node) ? '' : '\n';
|
|
25
42
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
len += nodeTextLength(child);
|
|
43
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
44
|
+
return '';
|
|
29
45
|
}
|
|
30
|
-
return
|
|
46
|
+
return textOfChildren(node);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Plain-text contribution of an element's children, with block-prefix \n. */
|
|
50
|
+
function textOfChildren(parent: Node): string {
|
|
51
|
+
let text = '';
|
|
52
|
+
let hasContent = false;
|
|
53
|
+
for (const child of Array.from(parent.childNodes)) {
|
|
54
|
+
if (
|
|
55
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
56
|
+
isBlockElement(child) &&
|
|
57
|
+
hasContent
|
|
58
|
+
) {
|
|
59
|
+
text += '\n';
|
|
60
|
+
}
|
|
61
|
+
const piece = textOfNode(child);
|
|
62
|
+
text += piece;
|
|
63
|
+
if (piece.length > 0) {
|
|
64
|
+
hasContent = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return text;
|
|
31
68
|
}
|
|
32
69
|
|
|
33
70
|
/** Converts a DOM selection position (container + offset) to a plain-text offset. */
|
|
@@ -36,38 +73,70 @@ function domPositionToTextOffset(
|
|
|
36
73
|
targetContainer: Node,
|
|
37
74
|
targetOffset: number
|
|
38
75
|
): number {
|
|
39
|
-
|
|
76
|
+
// Build the text from `root` up to (target, targetOffset) and return its
|
|
77
|
+
// length. This mirrors textOfChildren so block-prefix newlines and BR
|
|
78
|
+
// newlines are accounted for the same way when reading and writing.
|
|
79
|
+
let text = '';
|
|
80
|
+
let stopped = false;
|
|
81
|
+
|
|
82
|
+
const walk = (node: Node): void => {
|
|
83
|
+
if (stopped) return;
|
|
40
84
|
|
|
41
|
-
const walk = (node: Node): boolean => {
|
|
42
85
|
if (node === targetContainer) {
|
|
43
86
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
44
|
-
|
|
45
|
-
} else {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
87
|
+
text += (node.textContent || '').substring(0, targetOffset);
|
|
88
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
89
|
+
const children = Array.from(node.childNodes);
|
|
90
|
+
let hasContent = false;
|
|
91
|
+
for (let i = 0; i < Math.min(targetOffset, children.length); i++) {
|
|
92
|
+
const child = children[i];
|
|
93
|
+
if (
|
|
94
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
95
|
+
isBlockElement(child) &&
|
|
96
|
+
hasContent
|
|
97
|
+
) {
|
|
98
|
+
text += '\n';
|
|
99
|
+
}
|
|
100
|
+
const piece = textOfNode(child);
|
|
101
|
+
text += piece;
|
|
102
|
+
if (piece.length > 0) hasContent = true;
|
|
49
103
|
}
|
|
50
104
|
}
|
|
51
|
-
|
|
105
|
+
stopped = true;
|
|
106
|
+
return;
|
|
52
107
|
}
|
|
53
108
|
|
|
54
109
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
55
|
-
|
|
56
|
-
return
|
|
110
|
+
text += node.textContent || '';
|
|
111
|
+
return;
|
|
57
112
|
}
|
|
58
|
-
// Ignore browser-added <br> artifacts
|
|
59
113
|
if (node.nodeName === 'BR') {
|
|
60
|
-
|
|
114
|
+
if (!isSentinelBr(node)) text += '\n';
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
118
|
+
return;
|
|
61
119
|
}
|
|
62
120
|
|
|
121
|
+
let hasContent = false;
|
|
63
122
|
for (const child of Array.from(node.childNodes)) {
|
|
64
|
-
if (
|
|
123
|
+
if (stopped) break;
|
|
124
|
+
if (
|
|
125
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
126
|
+
isBlockElement(child) &&
|
|
127
|
+
hasContent
|
|
128
|
+
) {
|
|
129
|
+
text += '\n';
|
|
130
|
+
}
|
|
131
|
+
const before = text.length;
|
|
132
|
+
walk(child);
|
|
133
|
+
if (stopped) break;
|
|
134
|
+
if (text.length > before) hasContent = true;
|
|
65
135
|
}
|
|
66
|
-
return false;
|
|
67
136
|
};
|
|
68
137
|
|
|
69
138
|
walk(root);
|
|
70
|
-
return
|
|
139
|
+
return text.length;
|
|
71
140
|
}
|
|
72
141
|
|
|
73
142
|
/** Converts a plain-text offset to a DOM position (node + offset). */
|
|
@@ -76,49 +145,103 @@ function textOffsetToDomPosition(
|
|
|
76
145
|
targetOffset: number
|
|
77
146
|
): { node: Node; offset: number } | null {
|
|
78
147
|
let remaining = targetOffset;
|
|
148
|
+
// Track the last text node passed through so we can fall back to its end
|
|
149
|
+
// when the offset sits past all text content.
|
|
150
|
+
let lastTextNode: Node | null = null;
|
|
151
|
+
let lastTextOffset = 0;
|
|
152
|
+
let result: { node: Node; offset: number } | null = null;
|
|
153
|
+
|
|
154
|
+
const walk = (node: Node, parent: Node | null): void => {
|
|
155
|
+
if (result !== null) return;
|
|
79
156
|
|
|
80
|
-
const walk = (node: Node): { node: Node; offset: number } | null => {
|
|
81
157
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
82
|
-
|
|
83
|
-
|
|
158
|
+
const len = node.textContent.length;
|
|
159
|
+
// Use strict "<" so positions at the boundary between two text nodes
|
|
160
|
+
// resolve to the START of the next node rather than the END of the
|
|
161
|
+
// previous one. Firefox doesn't paint the caret reliably when it lands
|
|
162
|
+
// at the end of an inline span whose text is just "\n", but happily
|
|
163
|
+
// renders it at the start of the following node.
|
|
164
|
+
if (remaining < len) {
|
|
165
|
+
result = { node, offset: remaining };
|
|
166
|
+
return;
|
|
84
167
|
}
|
|
85
|
-
|
|
86
|
-
|
|
168
|
+
lastTextNode = node;
|
|
169
|
+
lastTextOffset = len;
|
|
170
|
+
remaining -= len;
|
|
171
|
+
return;
|
|
87
172
|
}
|
|
88
|
-
|
|
173
|
+
|
|
89
174
|
if (node.nodeName === 'BR') {
|
|
90
|
-
return
|
|
175
|
+
if (isSentinelBr(node)) return;
|
|
176
|
+
if (remaining === 0 && parent) {
|
|
177
|
+
const idx = Array.from(parent.childNodes).indexOf(node as ChildNode);
|
|
178
|
+
if (idx >= 0) {
|
|
179
|
+
result = { node: parent, offset: idx };
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
remaining -= 1;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
188
|
+
return;
|
|
91
189
|
}
|
|
92
190
|
|
|
191
|
+
let hasContent = false;
|
|
93
192
|
for (const child of Array.from(node.childNodes)) {
|
|
94
|
-
|
|
95
|
-
if (
|
|
193
|
+
if (result !== null) return;
|
|
194
|
+
if (
|
|
195
|
+
child.nodeType === Node.ELEMENT_NODE &&
|
|
196
|
+
isBlockElement(child) &&
|
|
197
|
+
hasContent
|
|
198
|
+
) {
|
|
199
|
+
if (remaining === 0) {
|
|
200
|
+
const idx = Array.from(node.childNodes).indexOf(child as ChildNode);
|
|
201
|
+
if (idx >= 0) {
|
|
202
|
+
result = { node, offset: idx };
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
remaining -= 1;
|
|
207
|
+
}
|
|
208
|
+
const before = remaining;
|
|
209
|
+
walk(child, node);
|
|
210
|
+
if (result !== null) return;
|
|
211
|
+
if (remaining < before) hasContent = true;
|
|
96
212
|
}
|
|
97
|
-
return null;
|
|
98
213
|
};
|
|
99
214
|
|
|
100
|
-
|
|
215
|
+
walk(root, null);
|
|
216
|
+
if (result) return result;
|
|
217
|
+
|
|
218
|
+
if (remaining === 0) {
|
|
219
|
+
// Past all content. If the editor ends in a <br>, position relative to it
|
|
220
|
+
// so the caret renders on the empty trailing line in Firefox.
|
|
221
|
+
const lastChild = root.lastChild;
|
|
222
|
+
if (lastChild && lastChild.nodeName === 'BR') {
|
|
223
|
+
// Sentinel: render before it. Non-sentinel (transient post-yank state):
|
|
224
|
+
// render after it so the caret sits on the empty line the BR produces.
|
|
225
|
+
const offset = isSentinelBr(lastChild)
|
|
226
|
+
? root.childNodes.length - 1
|
|
227
|
+
: root.childNodes.length;
|
|
228
|
+
return { node: root, offset };
|
|
229
|
+
}
|
|
230
|
+
if (lastTextNode) {
|
|
231
|
+
return { node: lastTextNode, offset: lastTextOffset };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
101
235
|
}
|
|
102
236
|
|
|
103
237
|
/**
|
|
104
|
-
* Extracts plain text from the contenteditable DOM
|
|
105
|
-
*
|
|
238
|
+
* Extracts plain text from the contenteditable DOM. Translates non-sentinel
|
|
239
|
+
* <br> elements to "\n" and inserts a "\n" before block-level (DIV/P) children
|
|
240
|
+
* that follow other content, so yank/paste-inserted DOM structures round-trip
|
|
241
|
+
* through handleInput correctly.
|
|
106
242
|
*/
|
|
107
243
|
export function getTextFromEditableDiv(element: HTMLElement): string {
|
|
108
|
-
|
|
109
|
-
for (const child of Array.from(element.childNodes)) {
|
|
110
|
-
if (child.nodeType === Node.TEXT_NODE) {
|
|
111
|
-
text += child.textContent;
|
|
112
|
-
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
|
113
|
-
// Skip browser-added <br> artifacts
|
|
114
|
-
if (child.nodeName === 'BR') {
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
// Recurse into spans and other elements
|
|
118
|
-
text += getTextFromEditableDiv(child as HTMLElement);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return text;
|
|
244
|
+
return textOfChildren(element);
|
|
122
245
|
}
|
|
123
246
|
|
|
124
247
|
/** Gets the caret (selection start) as a plain-text offset. */
|
|
@@ -171,3 +294,4 @@ export function setCaretRange(
|
|
|
171
294
|
selection.removeAllRanges();
|
|
172
295
|
selection.addRange(range);
|
|
173
296
|
}
|
|
297
|
+
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
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
|
|
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) {
|