@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.
@@ -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];
@@ -97,6 +97,7 @@ export class RichEditor extends FieldElement {
97
97
  overflow-y: auto;
98
98
  min-height: var(--textarea-min-height, 100px);
99
99
  resize: none;
100
+ position: relative;
100
101
  }
101
102
 
102
103
  :host(:not([textarea])) {
@@ -119,6 +120,12 @@ export class RichEditor extends FieldElement {
119
120
  content: attr(data-placeholder);
120
121
  color: var(--color-placeholder, #999);
121
122
  pointer-events: none;
123
+ /* Take the placeholder out of flow so the caret sits at offset 0
124
+ visually, not after the placeholder text (Firefox honors flow
125
+ strictly here while Chrome happens to overlay the caret). */
126
+ position: absolute;
127
+ inset: 0;
128
+ padding: inherit;
122
129
  }
123
130
 
124
131
  /* Token styles (shared) */
@@ -302,6 +309,11 @@ export class RichEditor extends FieldElement {
302
309
 
303
310
  if (this.disableCompletion) {
304
311
  div.textContent = text || '';
312
+ if (text && text.endsWith('\n')) {
313
+ const br = document.createElement('br');
314
+ br.setAttribute('data-sentinel', '');
315
+ div.appendChild(br);
316
+ }
305
317
  return;
306
318
  }
307
319
 
@@ -359,6 +371,15 @@ export class RichEditor extends FieldElement {
359
371
  // Ensure there's at least an empty text node for cursor placement
360
372
  if (!text || text === '') {
361
373
  div.appendChild(document.createTextNode(''));
374
+ } else if (text.endsWith('\n')) {
375
+ // A trailing "\n" text node is collapsed by Firefox in contenteditable,
376
+ // making the empty final line invisible. A sentinel <br> forces the
377
+ // line break to render. The data-sentinel attribute distinguishes it
378
+ // from browser-inserted <br>s (which represent real newlines and must
379
+ // be preserved by the caret/text utilities).
380
+ const br = document.createElement('br');
381
+ br.setAttribute('data-sentinel', '');
382
+ div.appendChild(br);
362
383
  }
363
384
  }
364
385
 
@@ -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) {