@nyaruka/temba-components 0.170.1 → 0.172.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.
@@ -0,0 +1,564 @@
1
+ import { html, css, CSSResultGroup, TemplateResult } from 'lit';
2
+ import { property, state, query } from 'lit/decorators.js';
3
+ import { styleMap } from 'lit/directives/style-map.js';
4
+ import { Icon } from '../Icons';
5
+ import { RapidElement } from '../RapidElement';
6
+
7
+ export interface ModalSearchResult {
8
+ // The label shown in the result's badge (e.g. "Send Message", a contact name)
9
+ typeName: string;
10
+ // Color for the badge background
11
+ color: string;
12
+ // Optional border color for the badge
13
+ borderColor?: string;
14
+ // Optional text color override for the badge
15
+ textColor?: string;
16
+ // The full text that was searched
17
+ fullText: string;
18
+ // The index of the match start in fullText
19
+ matchStart: number;
20
+ // The length of the match (0 renders the text as an unhighlighted preview)
21
+ matchLength: number;
22
+ }
23
+
24
+ /**
25
+ * Base class for modal search windows — a centered overlay with a search
26
+ * input, keyboard-navigable result list and match highlighting. Subclasses
27
+ * implement performSearch() to supply results, either synchronously as the
28
+ * user types (the default) or asynchronously when the user hits Enter (set
29
+ * searchOnEnter for searches that hit an endpoint).
30
+ */
31
+ export abstract class SearchModal<
32
+ T extends ModalSearchResult = ModalSearchResult
33
+ > extends RapidElement {
34
+ static styles: CSSResultGroup = css`
35
+ :host {
36
+ position: fixed;
37
+ top: 0;
38
+ left: 0;
39
+ right: 0;
40
+ bottom: 0;
41
+ z-index: 100000;
42
+ display: none;
43
+ font-family: var(--font-family, sans-serif);
44
+ }
45
+
46
+ :host([open]) {
47
+ display: flex;
48
+ align-items: flex-start;
49
+ justify-content: center;
50
+ }
51
+
52
+ .backdrop {
53
+ position: absolute;
54
+ top: 0;
55
+ left: 0;
56
+ right: 0;
57
+ bottom: 0;
58
+ background: rgba(0, 0, 0, 0.3);
59
+ }
60
+
61
+ .search-container {
62
+ position: relative;
63
+ margin-top: 80px;
64
+ width: 560px;
65
+ max-height: calc(100vh - 160px);
66
+ background: white;
67
+ border-radius: 14px;
68
+ box-shadow:
69
+ 0 24px 80px rgba(0, 0, 0, 0.2),
70
+ 0 0 0 1px rgba(0, 0, 0, 0.05);
71
+ display: flex;
72
+ flex-direction: column;
73
+ overflow: hidden;
74
+ animation: searchSlideIn 0.15s ease-out;
75
+ }
76
+
77
+ @keyframes searchSlideIn {
78
+ from {
79
+ opacity: 0;
80
+ transform: scale(0.98) translateY(-8px);
81
+ }
82
+ to {
83
+ opacity: 1;
84
+ transform: scale(1) translateY(0);
85
+ }
86
+ }
87
+
88
+ .search-input-row {
89
+ display: flex;
90
+ align-items: center;
91
+ padding: 14px 18px;
92
+ border-bottom: 1px solid #e5e7eb;
93
+ gap: 10px;
94
+ }
95
+
96
+ .search-icon {
97
+ color: #9ca3af;
98
+ flex-shrink: 0;
99
+ }
100
+
101
+ .search-icon temba-icon {
102
+ --icon-color: #9ca3af;
103
+ display: block;
104
+ }
105
+
106
+ input {
107
+ flex: 1;
108
+ border: none;
109
+ outline: none;
110
+ font-size: 16px;
111
+ background: transparent;
112
+ color: #111;
113
+ font-family: inherit;
114
+ }
115
+
116
+ input::placeholder {
117
+ color: #9ca3af;
118
+ }
119
+
120
+ .results {
121
+ overflow-y: auto;
122
+ max-height: 400px;
123
+ }
124
+
125
+ .result-item {
126
+ display: flex;
127
+ align-items: center;
128
+ padding: 10px 18px;
129
+ gap: 12px;
130
+ cursor: pointer;
131
+ border-bottom: 1px solid #f3f4f6;
132
+ transition: background 0.08s;
133
+ }
134
+
135
+ .result-item:last-child {
136
+ border-bottom: none;
137
+ }
138
+
139
+ .result-item:hover,
140
+ .result-item.highlighted {
141
+ background: #f0f4ff;
142
+ }
143
+
144
+ .result-type-badge {
145
+ flex-shrink: 0;
146
+ font-size: 11px;
147
+ font-weight: 600;
148
+ color: white;
149
+ padding: 2px 10px;
150
+ border-radius: 8px;
151
+ border: 2px solid transparent;
152
+ white-space: nowrap;
153
+ text-align: center;
154
+ box-sizing: border-box;
155
+ }
156
+
157
+ .result-text {
158
+ min-width: 0;
159
+ font-size: 14px;
160
+ color: #374151;
161
+ line-height: 1.4;
162
+ white-space: nowrap;
163
+ overflow: hidden;
164
+ text-overflow: ellipsis;
165
+ }
166
+
167
+ .result-text mark {
168
+ background: #fef08a;
169
+ color: inherit;
170
+ border-radius: 2px;
171
+ padding: 0 1px;
172
+ }
173
+
174
+ .no-results {
175
+ padding: 24px 18px;
176
+ text-align: center;
177
+ color: #9ca3af;
178
+ font-size: 14px;
179
+ }
180
+
181
+ .loading {
182
+ display: flex;
183
+ justify-content: center;
184
+ padding: 24px 18px;
185
+ }
186
+
187
+ .result-count {
188
+ padding: 8px 18px;
189
+ font-size: 12px;
190
+ color: #9ca3af;
191
+ border-top: 1px solid #e5e7eb;
192
+ text-align: right;
193
+ flex-shrink: 0;
194
+ }
195
+
196
+ .hint {
197
+ padding: 10px 18px;
198
+ font-size: 12px;
199
+ color: #9ca3af;
200
+ text-align: center;
201
+ }
202
+
203
+ kbd {
204
+ background: #f3f4f6;
205
+ border: 1px solid #d1d5db;
206
+ border-radius: 4px;
207
+ padding: 1px 5px;
208
+ font-size: 11px;
209
+ font-family: inherit;
210
+ }
211
+ `;
212
+
213
+ @property({ type: Boolean, reflect: true })
214
+ open = false;
215
+
216
+ // When set, searching waits for Enter instead of running as the user types.
217
+ // Use for searches that hit an endpoint and shouldn't fire on every keystroke.
218
+ @property({ type: Boolean })
219
+ searchOnEnter = false;
220
+
221
+ @state()
222
+ protected searchQuery = '';
223
+
224
+ @state()
225
+ protected results: T[] = [];
226
+
227
+ @state()
228
+ protected highlightedIndex = 0;
229
+
230
+ @state()
231
+ protected loading = false;
232
+
233
+ // In searchOnEnter mode, whether the query has changed since the last search
234
+ @state()
235
+ protected dirty = false;
236
+
237
+ // Set when an async performSearch rejects, so a failed lookup reads as a
238
+ // failure rather than as an empty result set
239
+ @state()
240
+ protected error = false;
241
+
242
+ // Bumped on each search (and on query edits in searchOnEnter mode) so
243
+ // in-flight async results that are no longer wanted get dropped
244
+ private searchGeneration = 0;
245
+
246
+ @query('input')
247
+ private inputEl!: HTMLInputElement;
248
+
249
+ /**
250
+ * Produce results for the given query. Synchronous implementations run on
251
+ * every keystroke; async implementations should be paired with searchOnEnter.
252
+ */
253
+ protected abstract performSearch(query: string): T[] | Promise<T[]>;
254
+
255
+ /**
256
+ * The label used for the dialog, placeholder and aria attributes.
257
+ */
258
+ protected abstract getSearchLabel(): string;
259
+
260
+ /**
261
+ * Called when a search is abandoned - the modal closed, or the query
262
+ * edited out from under it. Subclasses with an in-flight request should
263
+ * cancel it here.
264
+ */
265
+ protected cancelSearch(): void {
266
+ // nothing to do for synchronous searches
267
+ }
268
+
269
+ public show(): void {
270
+ this.open = true;
271
+ this.searchQuery = '';
272
+ this.results = [];
273
+ this.highlightedIndex = 0;
274
+ this.loading = false;
275
+ this.dirty = false;
276
+ this.error = false;
277
+ this.searchGeneration++;
278
+ this.updateComplete.then(() => {
279
+ this.inputEl?.focus();
280
+ this.inputEl?.select();
281
+ });
282
+ }
283
+
284
+ public hide(): void {
285
+ this.cancelSearch();
286
+ this.open = false;
287
+ this.searchQuery = '';
288
+ this.results = [];
289
+ this.loading = false;
290
+ this.dirty = false;
291
+ this.error = false;
292
+ this.searchGeneration++;
293
+ }
294
+
295
+ private handleInput(e: InputEvent): void {
296
+ const input = e.target as HTMLInputElement;
297
+ this.searchQuery = input.value;
298
+ this.highlightedIndex = 0;
299
+ this.error = false;
300
+ // whatever was in flight was for the old query
301
+ this.cancelSearch();
302
+ if (this.searchOnEnter) {
303
+ // invalidate any in-flight search and wait for Enter
304
+ this.searchGeneration++;
305
+ this.results = [];
306
+ this.loading = false;
307
+ this.dirty = true;
308
+ } else {
309
+ this.runSearch();
310
+ }
311
+ }
312
+
313
+ private handleKeyDown(e: KeyboardEvent): void {
314
+ if (e.key === 'Escape') {
315
+ e.preventDefault();
316
+ this.hide();
317
+ return;
318
+ }
319
+ if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
320
+ e.preventDefault();
321
+ if (this.results.length > 0) {
322
+ this.highlightedIndex =
323
+ (this.highlightedIndex + 1) % this.results.length;
324
+ }
325
+ return;
326
+ }
327
+ if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'p')) {
328
+ e.preventDefault();
329
+ if (this.results.length > 0) {
330
+ this.highlightedIndex =
331
+ (this.highlightedIndex - 1 + this.results.length) %
332
+ this.results.length;
333
+ }
334
+ return;
335
+ }
336
+ if (e.key === 'Enter') {
337
+ e.preventDefault();
338
+ if (this.searchOnEnter && this.dirty) {
339
+ this.runSearch();
340
+ return;
341
+ }
342
+ if (this.results.length > 0) {
343
+ this.selectResult(this.results[this.highlightedIndex]);
344
+ }
345
+ return;
346
+ }
347
+ }
348
+
349
+ private runSearch(): void {
350
+ const generation = ++this.searchGeneration;
351
+ this.dirty = false;
352
+ this.highlightedIndex = 0;
353
+ this.error = false;
354
+
355
+ if (!this.searchQuery.trim()) {
356
+ this.results = [];
357
+ this.loading = false;
358
+ return;
359
+ }
360
+
361
+ const outcome = this.performSearch(this.searchQuery);
362
+ if (outcome instanceof Promise) {
363
+ this.results = [];
364
+ this.loading = true;
365
+ outcome
366
+ .then((results) => {
367
+ if (generation === this.searchGeneration) {
368
+ this.results = results;
369
+ this.loading = false;
370
+ }
371
+ })
372
+ .catch((error) => {
373
+ // a superseded search has already been replaced by whatever
374
+ // superseded it - that includes searches we aborted ourselves,
375
+ // so those never surface as failures
376
+ if (generation === this.searchGeneration) {
377
+ console.error(
378
+ 'search failed',
379
+ error instanceof Response
380
+ ? `${error.status} ${error.url || ''}`.trim()
381
+ : error
382
+ );
383
+ this.results = [];
384
+ this.loading = false;
385
+ this.error = true;
386
+ // the query is still the one that failed, so leaving it dirty
387
+ // lets Enter run it again
388
+ this.dirty = this.searchOnEnter;
389
+ }
390
+ });
391
+ } else {
392
+ this.results = outcome;
393
+ }
394
+ }
395
+
396
+ private handleBackdropClick(): void {
397
+ this.hide();
398
+ }
399
+
400
+ protected selectResult(result: T): void {
401
+ this.hide();
402
+ this.dispatchEvent(
403
+ new CustomEvent('temba-search-result-selected', {
404
+ detail: result,
405
+ bubbles: true,
406
+ composed: true
407
+ })
408
+ );
409
+ }
410
+
411
+ protected renderMatchText(result: T): TemplateResult {
412
+ const { matchStart, matchLength } = result;
413
+ const matchEnd = matchStart + matchLength;
414
+
415
+ // Replace newlines with spaces for single-line display
416
+ const text = result.fullText.replace(/\n/g, ' ');
417
+
418
+ // Zero-length matches show an unhighlighted content preview
419
+ if (matchLength === 0) {
420
+ return html`${text}`;
421
+ }
422
+
423
+ // Show leading context before the match, then the match, then trailing.
424
+ // CSS text-overflow:ellipsis clips the trailing text, so we keep the match
425
+ // near the start. When the match is near the end of the text, show more
426
+ // leading context since there's less trailing text to display.
427
+ const afterMatch = text.length - matchEnd;
428
+ const contextBefore = afterMatch < 30 ? 50 : 20;
429
+ const start = Math.max(0, matchStart - contextBefore);
430
+ const prefix = start > 0 ? '…' : '';
431
+
432
+ const before = text.slice(start, matchStart);
433
+ const match = text.slice(matchStart, matchEnd);
434
+ const after = text.slice(matchEnd);
435
+
436
+ return html`${prefix}${before}<mark>${match}</mark>${after}`;
437
+ }
438
+
439
+ /**
440
+ * The content of a single result row. Subclasses can override to customize.
441
+ */
442
+ protected renderResultContent(result: T): TemplateResult {
443
+ const badgeStyles: { [key: string]: string } = {
444
+ background: result.color,
445
+ borderColor: result.borderColor || result.color
446
+ };
447
+ if (result.textColor) {
448
+ badgeStyles.color = result.textColor;
449
+ }
450
+
451
+ return html`
452
+ <div class="result-type-badge" style=${styleMap(badgeStyles)}>
453
+ ${result.typeName}
454
+ </div>
455
+ <div class="result-text">${this.renderMatchText(result)}</div>
456
+ `;
457
+ }
458
+
459
+ /**
460
+ * The message shown when a completed search produced no results. Subclasses
461
+ * can override to explain why the result set came back empty.
462
+ */
463
+ protected getNoResultsMessage(): string {
464
+ return 'No matches found';
465
+ }
466
+
467
+ /**
468
+ * The hint shown before any query has been entered.
469
+ */
470
+ protected renderHint(): TemplateResult {
471
+ return html`<div class="hint">
472
+ <kbd>↑</kbd> <kbd>↓</kbd> to navigate &nbsp; <kbd>Enter</kbd> to open
473
+ &nbsp; <kbd>Esc</kbd> to close
474
+ </div>`;
475
+ }
476
+
477
+ private renderBody(): TemplateResult {
478
+ if (!this.searchQuery.trim()) {
479
+ return this.renderHint();
480
+ }
481
+
482
+ // a failed search leaves the query dirty so Enter retries it, so the
483
+ // failure has to be reported ahead of the "Enter to search" hint
484
+ if (this.error) {
485
+ return html`<div class="no-results">Search failed - try again</div>`;
486
+ }
487
+
488
+ if (this.searchOnEnter && this.dirty) {
489
+ return html`<div class="hint"><kbd>Enter</kbd> to search</div>`;
490
+ }
491
+
492
+ if (this.loading) {
493
+ return html`<div class="loading">
494
+ <temba-loading units="6" size="8"></temba-loading>
495
+ </div>`;
496
+ }
497
+
498
+ if (this.results.length === 0) {
499
+ return html`<div class="no-results">${this.getNoResultsMessage()}</div>`;
500
+ }
501
+
502
+ return html`
503
+ <div class="results">
504
+ ${this.results.map(
505
+ (result, index) => html`
506
+ <div
507
+ class="result-item ${index === this.highlightedIndex
508
+ ? 'highlighted'
509
+ : ''}"
510
+ @click=${() => this.selectResult(result)}
511
+ @mouseenter=${() => {
512
+ this.highlightedIndex = index;
513
+ }}
514
+ >
515
+ ${this.renderResultContent(result)}
516
+ </div>
517
+ `
518
+ )}
519
+ </div>
520
+ <div class="result-count">
521
+ ${this.results.length} result${this.results.length !== 1 ? 's' : ''}
522
+ </div>
523
+ `;
524
+ }
525
+
526
+ updated(changedProps: Map<string, unknown>): void {
527
+ if (changedProps.has('highlightedIndex')) {
528
+ const highlighted = this.shadowRoot?.querySelector(
529
+ '.result-item.highlighted'
530
+ );
531
+ highlighted?.scrollIntoView({ block: 'nearest' });
532
+ }
533
+ }
534
+
535
+ render(): TemplateResult {
536
+ const searchLabel = this.getSearchLabel();
537
+
538
+ return html`
539
+ <div class="backdrop" @click=${this.handleBackdropClick}></div>
540
+ <div
541
+ class="search-container"
542
+ role="dialog"
543
+ aria-modal="true"
544
+ aria-label=${searchLabel}
545
+ @click=${(e: Event) => e.stopPropagation()}
546
+ >
547
+ <div class="search-input-row">
548
+ <div class="search-icon">
549
+ <temba-icon name=${Icon.search} size="1.5"></temba-icon>
550
+ </div>
551
+ <input
552
+ type="text"
553
+ placeholder="${searchLabel}..."
554
+ aria-label=${searchLabel}
555
+ .value=${this.searchQuery}
556
+ @input=${this.handleInput}
557
+ @keydown=${this.handleKeyDown}
558
+ />
559
+ </div>
560
+ ${this.renderBody()}
561
+ </div>
562
+ `;
563
+ }
564
+ }
@@ -1631,6 +1631,12 @@ export class ContentList<T = any> extends RapidElement {
1631
1631
  super();
1632
1632
  }
1633
1633
 
1634
+ /** Gives specialized lists a chance to normalize a fetched page before it
1635
+ * becomes visible. The default keeps the server response unchanged. */
1636
+ protected prepareItems(items: T[]): T[] {
1637
+ return items;
1638
+ }
1639
+
1634
1640
  protected willUpdate(changes: PropertyValues): void {
1635
1641
  super.willUpdate(changes);
1636
1642
  if (
@@ -2037,7 +2043,7 @@ export class ContentList<T = any> extends RapidElement {
2037
2043
  // empty results) with an `error` message — surface it over the
2038
2044
  // empty table rather than the plain empty-state copy.
2039
2045
  this.searchError = typeof data.error === 'string' ? data.error : '';
2040
- this.items = data.results || [];
2046
+ this.items = this.prepareItems(data.results || []);
2041
2047
  this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
2042
2048
  this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
2043
2049
  // Cursor mode is detected from the shape of next/previous,
@@ -2,6 +2,8 @@ import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
4
  import { CustomEventType, Flow, ObjectReference } from '../interfaces';
5
+ import { getStore, StoreAsset, StoreAssetChangedEvent } from '../store/Store';
6
+ import { RealtimeSubscription } from '../live/Realtime';
5
7
 
6
8
  /**
7
9
  * Flow CRUDL list — drop-in replacement for the rapidpro
@@ -11,6 +13,8 @@ import { CustomEventType, Flow, ObjectReference } from '../interfaces';
11
13
  * bar, activity sparkline.
12
14
  */
13
15
  export class FlowList extends ContentList<Flow> {
16
+ private assetWatch: RealtimeSubscription = null;
17
+
14
18
  static get styles() {
15
19
  return css`
16
20
  ${ContentList.styles}
@@ -149,6 +153,84 @@ export class FlowList extends ContentList<Flow> {
149
153
  ];
150
154
  }
151
155
 
156
+ public connectedCallback(): void {
157
+ super.connectedCallback();
158
+ // rows we already have (if any) - each load re-syncs from prepareItems
159
+ this.syncAssetWatch();
160
+ }
161
+
162
+ public disconnectedCallback(): void {
163
+ super.disconnectedCallback();
164
+ if (this.assetWatch) {
165
+ this.assetWatch.unsubscribe();
166
+ this.assetWatch = null;
167
+ }
168
+ }
169
+
170
+ private syncAssetWatch(): void {
171
+ if (!this.isConnected) {
172
+ return;
173
+ }
174
+ const store = getStore();
175
+ if (!store) {
176
+ return;
177
+ }
178
+ if (this.assetWatch) {
179
+ this.assetWatch.unsubscribe();
180
+ }
181
+ this.assetWatch = store.watchAssets(
182
+ (this.items || []).map((flow) => ({ type: 'flow', uuid: flow.uuid })),
183
+ (event: StoreAssetChangedEvent | null) => this.syncFlowNames(event?.asset)
184
+ );
185
+ }
186
+
187
+ private syncFlowNames(changed?: StoreAsset): void {
188
+ const items = this.withCanonicalFlowNames(this.items, changed);
189
+ if (items !== this.items) {
190
+ this.items = items;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * A freshly fetched page is authoritative, so it seeds the store cache
196
+ * rather than being overwritten by it - a cached name can predate this
197
+ * response (e.g. a rename missed while another page was on screen), and
198
+ * writing it back would make the stale name self-reinforcing. Cached names
199
+ * only ever move rows through syncFlowNames, driven by socket changes and
200
+ * the reconnect refresh, which are the paths that are actually newer.
201
+ */
202
+ protected prepareItems(items: Flow[]): Flow[] {
203
+ getStore()?.cacheAssets(
204
+ items
205
+ .filter((item) => !!item.uuid && typeof item.name === 'string')
206
+ .map((item) => ({
207
+ type: 'flow',
208
+ uuid: item.uuid,
209
+ name: item.name
210
+ }))
211
+ );
212
+ Promise.resolve().then(() => this.syncAssetWatch());
213
+ return items;
214
+ }
215
+
216
+ private withCanonicalFlowNames(items: Flow[], changed?: StoreAsset): Flow[] {
217
+ const store = getStore();
218
+ let updated = false;
219
+ const canonical = items.map((item) => {
220
+ const asset = changed
221
+ ? changed.uuid === item.uuid
222
+ ? changed
223
+ : null
224
+ : store?.getAsset('flow', item.uuid);
225
+ if (asset && asset.name !== item.name) {
226
+ updated = true;
227
+ return { ...item, name: asset.name };
228
+ }
229
+ return item;
230
+ });
231
+ return updated ? canonical : items;
232
+ }
233
+
152
234
  protected getRowIcon(item: Flow): string | null {
153
235
  switch (item?.type) {
154
236
  case 'voice':