@nyaruka/temba-components 0.163.0 → 0.164.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,1094 @@
1
+ import { css, html, PropertyValueMap, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import {
4
+ CampaignScheduleEvent,
5
+ CustomEventType,
6
+ ObjectReference
7
+ } from '../interfaces';
8
+ import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
9
+ import { Icon } from '../Icons';
10
+ import { designTokens } from '../styles/designTokens';
11
+
12
+ interface CampaignEventsResponse {
13
+ events: CampaignScheduleEvent[];
14
+ campaign?: ObjectReference;
15
+ can_edit?: boolean;
16
+ can_delete?: boolean;
17
+ }
18
+
19
+ // one row in the recent-contacts popup
20
+ interface RecentFire {
21
+ contact: ObjectReference;
22
+ time: string;
23
+ }
24
+
25
+ // distinct hues assigned per relative-to field so each field's "line" reads
26
+ // clearly apart - the same palette (and assignment scheme) the contact
27
+ // timeline uses for campaigns, so colors mean "grouping" in both places
28
+ const FIELD_COLORS = [
29
+ '#1a86d0', // blue
30
+ '#e8843f', // orange
31
+ '#ec407a', // pink
32
+ '#2bb2a6', // teal
33
+ '#0a5290', // deep blue
34
+ '#a25320', // deep orange
35
+ '#a02153', // deep pink
36
+ '#176a61' // deep teal
37
+ ];
38
+
39
+ // uppercase the first letter of a localized display string - offset-0
40
+ // events lead the detail schedule with "On <field>"
41
+ const capitalize = (text: string): string =>
42
+ text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
43
+
44
+ // a section is one subway line: all the events anchored to the same date
45
+ // field, split into those firing before the field's date and those firing
46
+ // on or after it
47
+ interface Section {
48
+ key: string;
49
+ name: string;
50
+ system: boolean;
51
+ before: CampaignScheduleEvent[];
52
+ onAfter: CampaignScheduleEvent[];
53
+ }
54
+
55
+ export class CampaignEvents extends EndpointMonitorElement {
56
+ @property({ type: String })
57
+ campaign: string;
58
+
59
+ // optional endpoint override, defaults to the campaign's events endpoint
60
+ @property({ type: String })
61
+ endpoint: string;
62
+
63
+ // title shown in the page header; a `title` slot overrides it
64
+ @property({ type: String, attribute: 'header-title' })
65
+ headerTitle = '';
66
+
67
+ // subtitle under the title; a `subtitle` slot overrides it
68
+ @property({ type: String })
69
+ subtitle = '';
70
+
71
+ // GET endpoint for the page's content menu (rapidpro's content-menu view);
72
+ // menu clicks fire temba-selection with the item for the host to dispatch
73
+ @property({ type: String, attribute: 'content-menu-endpoint' })
74
+ contentMenuEndpoint = '';
75
+
76
+ @property({ type: Object, attribute: false })
77
+ data: CampaignEventsResponse;
78
+
79
+ @property({ type: String })
80
+ lang_scheduling = 'Scheduling';
81
+
82
+ @property({ type: String })
83
+ lang_edit = 'Edit';
84
+
85
+ @property({ type: String })
86
+ lang_delete = 'Delete';
87
+
88
+ @property({ type: String })
89
+ lang_recent_contacts = 'Recent Contacts';
90
+
91
+ @property({ type: String })
92
+ lang_scheduled = 'scheduled';
93
+
94
+ @property({ type: String })
95
+ lang_scheduled_contacts = 'Contacts scheduled for this event';
96
+
97
+ @property({ type: String })
98
+ lang_okay = 'Okay';
99
+
100
+ @property({ type: String })
101
+ lang_send_message = 'Send Message';
102
+
103
+ @property({ type: String })
104
+ lang_start_flow = 'Start Flow';
105
+
106
+ @property({ type: String })
107
+ lang_empty = 'No events';
108
+
109
+ @property({ type: String })
110
+ lang_empty_help =
111
+ 'Events send a message or start a flow for each contact in the group, offset from a date field on the contact.';
112
+
113
+ // the event whose detail modal is open
114
+ @state()
115
+ private detailEvent: CampaignScheduleEvent = null;
116
+
117
+ // the detail modal's recent contacts - null while the fetch is in flight
118
+ @state()
119
+ private fires: RecentFire[] = null;
120
+
121
+ static get styles() {
122
+ return css`
123
+ ${designTokens}
124
+
125
+ /* The page-header bar at the top, then one card per anchor field
126
+ in a padded region below. */
127
+ :host {
128
+ display: flex;
129
+ flex-direction: column;
130
+ font-family: var(--font);
131
+ color: var(--text-1);
132
+ font-size: 13.5px;
133
+ }
134
+
135
+ /* The header is the same flush surface bar the fill-window lists
136
+ render — full width, the lists' 12px inset, no card chrome —
137
+ so a list page and this read page share one header treatment. */
138
+ .header-panel {
139
+ background: var(--surface);
140
+ padding: 0 12px;
141
+ border-bottom: 1px solid var(--border);
142
+ }
143
+
144
+ /* Card panel — the same surface treatment as the content lists so
145
+ the read page reads as part of the same family. The anchor dots
146
+ and tint mixes paint with the widget background, so inside a
147
+ card that must be the card surface. */
148
+ .panel {
149
+ background: var(--surface);
150
+ border-radius: var(--r);
151
+ overflow: hidden;
152
+ box-shadow: var(--shadow-1);
153
+ --color-widget-bg: var(--surface);
154
+ }
155
+
156
+ /* badges (group, archived) get their own row between the header and
157
+ the cards. Slotted links shouldn't pick up anchor styling - the
158
+ labels inside them carry the look. */
159
+ .badges {
160
+ display: flex;
161
+ align-items: center;
162
+ gap: 8px;
163
+ padding: 12px 12px 0;
164
+ }
165
+
166
+ .badges ::slotted(*) {
167
+ display: flex;
168
+ align-items: center;
169
+ gap: 8px;
170
+ text-decoration: none;
171
+ }
172
+
173
+ /* the per-field cards stack in a padded region below the header */
174
+ .content {
175
+ display: flex;
176
+ flex-direction: column;
177
+ gap: 12px;
178
+ padding: 12px;
179
+ }
180
+
181
+ /* the section cards sit inside the events wrapper, which stays out
182
+ of layout so the cards participate in the content's column gap */
183
+ .events {
184
+ display: contents;
185
+ }
186
+
187
+ /* empty state follows the list design system: centered icon, a short
188
+ title and muted explanatory copy */
189
+ .empty {
190
+ display: flex;
191
+ flex-direction: column;
192
+ align-items: center;
193
+ text-align: center;
194
+ padding: 4em 1em;
195
+ color: var(--text-color);
196
+ }
197
+
198
+ .empty temba-icon {
199
+ margin-bottom: 0.75em;
200
+ --icon-color: var(--text-3, #7b8593);
201
+ }
202
+
203
+ .empty-title {
204
+ font-weight: 600;
205
+ margin-bottom: 0.4em;
206
+ }
207
+
208
+ .empty-help {
209
+ font-size: 0.875em;
210
+ line-height: 1.5;
211
+ max-width: 22em;
212
+ margin-bottom: 1em;
213
+ color: var(--text-3, #7b8593);
214
+ }
215
+
216
+ /* one subway line per anchor field, each in its own card */
217
+ .section {
218
+ display: flex;
219
+ flex-direction: column;
220
+ padding: 0.9em 12px 1em;
221
+ }
222
+
223
+ .row {
224
+ display: flex;
225
+ align-items: stretch;
226
+ }
227
+
228
+ /* the schedule offset ("3 days before"), vertically centered on its
229
+ dot. mute via color (not opacity) so the hovered delivery-hour
230
+ tooltip renders at full alpha */
231
+ .time {
232
+ width: 9em;
233
+ flex-shrink: 0;
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: flex-end;
237
+ padding-right: 0.65em;
238
+ font-size: 0.8em;
239
+ color: var(--text-3, #7b8593);
240
+ white-space: nowrap;
241
+ }
242
+
243
+ .time temba-tip {
244
+ cursor: default;
245
+ }
246
+
247
+ /* the subway rail */
248
+ .track {
249
+ display: flex;
250
+ flex-direction: column;
251
+ align-items: center;
252
+ width: 1.75em;
253
+ flex-shrink: 0;
254
+ }
255
+
256
+ .line {
257
+ width: 2px;
258
+ flex: 1;
259
+ background: var(--color-borders, rgba(0, 0, 0, 0.12));
260
+ }
261
+
262
+ .line.hidden {
263
+ background: transparent;
264
+ }
265
+
266
+ .dot {
267
+ width: 18px;
268
+ height: 18px;
269
+ border-radius: 50%;
270
+ box-sizing: border-box;
271
+ flex-shrink: 0;
272
+ z-index: 1;
273
+ border: 2px solid var(--dot-color);
274
+ display: flex;
275
+ align-items: center;
276
+ justify-content: center;
277
+ /* icons inside the dot take the dot's hue */
278
+ --icon-color: var(--dot-color);
279
+ background: color-mix(
280
+ in srgb,
281
+ var(--dot-color) 12%,
282
+ var(--color-widget-bg, #fff)
283
+ );
284
+ }
285
+
286
+ /* the anchor marks the field's date itself - painted with the widget
287
+ background so it masks the rail behind it and reads as the station
288
+ the line is built around */
289
+ .dot.anchor {
290
+ background: var(--color-widget-bg, #fff);
291
+ }
292
+
293
+ /* system fields aren't real contact fields - dashed and muted */
294
+ .system-pill {
295
+ display: inline-flex;
296
+ align-items: center;
297
+ gap: 5px;
298
+ font-size: 0.85em;
299
+ line-height: 1.5;
300
+ padding: 0.05em 0.65em;
301
+ border-radius: 999px;
302
+ white-space: nowrap;
303
+ border: 1px dashed var(--border-strong, #9ca3af);
304
+ color: var(--text-3, #7b8593);
305
+ --icon-color: var(--text-3, #7b8593);
306
+ }
307
+
308
+ .anchor-label {
309
+ display: flex;
310
+ align-items: center;
311
+ margin: 0.5em 0;
312
+ padding: 0 0.6em;
313
+ }
314
+
315
+ /* flat event entry - no card chrome */
316
+ .event {
317
+ flex-grow: 1;
318
+ min-width: 0;
319
+ display: flex;
320
+ align-items: center;
321
+ gap: 12px;
322
+ padding: 0.4em 0.75em;
323
+ margin: 0.1em 0;
324
+ border-radius: var(--curvature);
325
+ /* transparent by default so the hover border doesn't shift layout */
326
+ border: 1px solid transparent;
327
+ }
328
+
329
+ .count {
330
+ flex-shrink: 0;
331
+ margin-left: auto;
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 0.35em;
335
+ font-size: 0.8em;
336
+ color: var(--text-3, #7b8593);
337
+ white-space: nowrap;
338
+ --icon-color: var(--text-3, #7b8593);
339
+ }
340
+
341
+ .event.clickable {
342
+ cursor: pointer;
343
+ }
344
+
345
+ .event.clickable:hover {
346
+ border-color: var(--border, #e4e7ec);
347
+ }
348
+
349
+ .title {
350
+ flex: 1;
351
+ min-width: 0;
352
+ display: flex;
353
+ align-items: center;
354
+ gap: 0.45em;
355
+ color: var(--text-color);
356
+ line-height: 1.4;
357
+ }
358
+
359
+ .title-text {
360
+ flex: 1;
361
+ min-width: 0;
362
+ display: -webkit-box;
363
+ -webkit-line-clamp: 2;
364
+ -webkit-box-orient: vertical;
365
+ overflow: hidden;
366
+ }
367
+
368
+ /* the detail modal stands in for an event read page - page-like, no
369
+ colored header bar. The header and gutter pin to the top and bottom
370
+ with full-bleed rules; the body between them scrolls when needed. */
371
+ .detail {
372
+ display: flex;
373
+ flex-direction: column;
374
+ }
375
+
376
+ .detail-body {
377
+ display: flex;
378
+ flex-direction: column;
379
+ gap: 1.25em;
380
+ overflow-y: auto;
381
+ max-height: calc(100vh - 300px);
382
+ padding: 1.25em 1.75em;
383
+ }
384
+
385
+ /* header and gutter share uniform padding on all sides, with the
386
+ title vertically centered against the action buttons */
387
+ .detail-header {
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 12px;
391
+ padding: 1em;
392
+ border-bottom: 1px solid var(--border, #e4e7ec);
393
+ }
394
+
395
+ .detail-title {
396
+ flex: 1;
397
+ min-width: 0;
398
+ display: flex;
399
+ flex-direction: column;
400
+ gap: 4px;
401
+ }
402
+
403
+ .detail-campaign {
404
+ font-size: 15.5px;
405
+ font-weight: var(--w-semibold, 600);
406
+ color: var(--text-1);
407
+ white-space: nowrap;
408
+ overflow: hidden;
409
+ text-overflow: ellipsis;
410
+ }
411
+
412
+ /* the schedule line leads the body - the offset, anchor field and
413
+ delivery hour on the left, the labeled scheduled count on the right */
414
+ .detail-schedule-row {
415
+ display: flex;
416
+ align-items: center;
417
+ justify-content: space-between;
418
+ gap: 12px;
419
+ }
420
+
421
+ .detail-schedule {
422
+ display: flex;
423
+ align-items: center;
424
+ gap: 8px;
425
+ font-size: 0.9em;
426
+ color: var(--text-3, #7b8593);
427
+ }
428
+
429
+ .detail-count {
430
+ flex-shrink: 0;
431
+ display: flex;
432
+ align-items: center;
433
+ gap: 0.35em;
434
+ font-size: 0.85em;
435
+ color: var(--text-3, #7b8593);
436
+ --icon-color: var(--text-3, #7b8593);
437
+ }
438
+
439
+ .detail-actions {
440
+ flex-shrink: 0;
441
+ display: flex;
442
+ align-items: center;
443
+ gap: 8px;
444
+ }
445
+
446
+ /* match the page header's content-menu buttons so the modal's actions
447
+ read like page actions */
448
+ .menu-button {
449
+ display: inline-flex;
450
+ align-items: center;
451
+ gap: 6px;
452
+ box-sizing: border-box;
453
+ height: 26px;
454
+ padding: 0 10px;
455
+ border: 1px solid var(--border-strong, #9ca3af);
456
+ border-radius: var(--r-sm);
457
+ font-size: 12.5px;
458
+ font-weight: var(--w-regular, 400);
459
+ cursor: pointer;
460
+ user-select: none;
461
+ background: var(--surface);
462
+ color: var(--text-1);
463
+ white-space: nowrap;
464
+ }
465
+
466
+ .menu-button:hover {
467
+ background: var(--sunken);
468
+ }
469
+
470
+ .menu-button.destructive {
471
+ color: #dc2626;
472
+ }
473
+
474
+ .menu-button.destructive:hover {
475
+ border-color: #dc2626;
476
+ background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
477
+ }
478
+
479
+ /* the event's action, contained with a leading type label. Children
480
+ hug their content so a flow pill doesn't stretch the box's width. */
481
+ .detail-action {
482
+ display: flex;
483
+ flex-direction: column;
484
+ align-items: flex-start;
485
+ gap: 0.6em;
486
+ padding: 0.9em 1em 1em;
487
+ background: color-mix(
488
+ in srgb,
489
+ var(--sunken, #f1f3f5) 45%,
490
+ var(--surface, #fff)
491
+ );
492
+ border: 1px solid var(--border, #e4e7ec);
493
+ border-radius: var(--r);
494
+ line-height: 1.5;
495
+ }
496
+
497
+ /* our own footer so the Okay button follows the same horizontal
498
+ padding as the header - its right edge lines up with Delete. A
499
+ full-bleed rule mirrors the header's. */
500
+ .detail-footer {
501
+ display: flex;
502
+ justify-content: flex-end;
503
+ padding: 1em;
504
+ border-top: 1px solid var(--border, #e4e7ec);
505
+ }
506
+
507
+ .detail-section-title {
508
+ margin-top: 0.25em;
509
+ font-size: 0.75em;
510
+ font-weight: 600;
511
+ text-transform: uppercase;
512
+ letter-spacing: 0.05em;
513
+ color: var(--text-3, #7b8593);
514
+ }
515
+
516
+ /* recent contacts - a simple name/time table */
517
+ .fires {
518
+ min-height: 2em;
519
+ margin: -0.25em 0 0.5em;
520
+ }
521
+
522
+ .fire-row {
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: space-between;
526
+ gap: 1em;
527
+ padding: 0.4em 0.5em;
528
+ border-radius: var(--r-sm);
529
+ cursor: pointer;
530
+ }
531
+
532
+ .fire-row:hover {
533
+ background: var(--color-selection, rgba(0, 0, 0, 0.04));
534
+ }
535
+
536
+ .fire-name {
537
+ min-width: 0;
538
+ overflow: hidden;
539
+ text-overflow: ellipsis;
540
+ white-space: nowrap;
541
+ }
542
+
543
+ .fire-row temba-date {
544
+ flex-shrink: 0;
545
+ font-size: 0.85em;
546
+ color: var(--text-3, #7b8593);
547
+ }
548
+ `;
549
+ }
550
+
551
+ // the endpoint returns a custom {events: [...]} payload rather than the
552
+ // paginated {results: [...]} shape that the store's fetch machinery
553
+ // expects, so we fetch and assign data ourselves. The base class's
554
+ // inherited `url` property is unsupported here - setting it would race
555
+ // its monitored fetch against ours.
556
+ private getEndpoint(): string | null {
557
+ if (this.endpoint) {
558
+ return this.endpoint;
559
+ }
560
+ return this.campaign
561
+ ? `/campaign/events/${encodeURIComponent(this.campaign)}/`
562
+ : null;
563
+ }
564
+
565
+ private async loadEvents(): Promise<void> {
566
+ // capture the endpoint at request time so a slower in-flight response for
567
+ // a previous campaign can't overwrite data for the one we're now showing
568
+ const requestedEndpoint = this.getEndpoint();
569
+ if (!requestedEndpoint) {
570
+ this.data = null;
571
+ return;
572
+ }
573
+
574
+ try {
575
+ const response = await this.store.getUrl(requestedEndpoint, {
576
+ force: true
577
+ });
578
+ if (this.getEndpoint() !== requestedEndpoint) {
579
+ return;
580
+ }
581
+ this.data = response.json;
582
+ } catch {
583
+ // on failure leave the prior data in place so a transient blip doesn't
584
+ // wipe the schedule; data is only cleared when the campaign changes
585
+ }
586
+ }
587
+
588
+ public refresh(): void {
589
+ this.loadEvents();
590
+ }
591
+
592
+ protected updated(
593
+ changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
594
+ ): void {
595
+ super.updated(changes);
596
+
597
+ if (changes.has('campaign') || changes.has('endpoint')) {
598
+ // blank immediately on a campaign switch so the previous campaign's
599
+ // events aren't briefly visible while the new fetch is in flight
600
+ this.data = null;
601
+ this.loadEvents();
602
+ }
603
+
604
+ if (changes.has('data') && this.data) {
605
+ const events = Array.isArray(this.data.events) ? this.data.events : [];
606
+ this.fireCustomEvent(CustomEventType.DetailsChanged, {
607
+ count: events.length
608
+ });
609
+ }
610
+ }
611
+
612
+ // events arrive sorted by field name then offset; group them into one
613
+ // section per field, split around the field's date
614
+ private getSections(events: CampaignScheduleEvent[]): Section[] {
615
+ const sections: Section[] = [];
616
+ const byKey: { [key: string]: Section } = {};
617
+
618
+ for (const event of events) {
619
+ const key = event.relative_to?.key || '';
620
+ let section = byKey[key];
621
+ if (!section) {
622
+ section = {
623
+ key,
624
+ name: event.relative_to?.name || '',
625
+ system: !!event.relative_to?.system,
626
+ before: [],
627
+ onAfter: []
628
+ };
629
+ byKey[key] = section;
630
+ sections.push(section);
631
+ }
632
+ if (event.offset < 0) {
633
+ section.before.push(event);
634
+ } else {
635
+ section.onAfter.push(event);
636
+ }
637
+ }
638
+ return sections;
639
+ }
640
+
641
+ // whether this event can be edited - scheduling-state events are locked
642
+ // until mailroom finishes rescheduling them
643
+ private canEdit(event: CampaignScheduleEvent): boolean {
644
+ return !!this.data?.can_edit && event.status === 'ready';
645
+ }
646
+
647
+ private canDelete(): boolean {
648
+ return !!this.data?.can_delete;
649
+ }
650
+
651
+ // in place of an event read page, clicking an event opens its detail
652
+ // modal, which also loads who the event recently fired for
653
+ public handleEventClicked(event: CampaignScheduleEvent) {
654
+ this.detailEvent = event;
655
+ this.loadFires(event);
656
+ }
657
+
658
+ private async loadFires(event: CampaignScheduleEvent): Promise<void> {
659
+ this.fires = null;
660
+ try {
661
+ const response = await this.store.getUrl(event.fires_url, {
662
+ force: true
663
+ });
664
+ if (this.detailEvent !== event) {
665
+ return;
666
+ }
667
+ this.fires = Array.isArray(response.json?.fires)
668
+ ? response.json.fires
669
+ : [];
670
+ } catch {
671
+ if (this.detailEvent === event) {
672
+ this.fires = [];
673
+ }
674
+ }
675
+ }
676
+
677
+ // edit / delete close the detail modal before the host opens the edit or
678
+ // delete-confirm modal in its place - modals never stack
679
+ public handleEditClicked() {
680
+ const event = this.detailEvent;
681
+ this.detailEvent = null;
682
+ this.fireCustomEvent(CustomEventType.Selection, {
683
+ action: 'edit_event',
684
+ event
685
+ });
686
+ }
687
+
688
+ public handleDeleteClicked() {
689
+ const event = this.detailEvent;
690
+ this.detailEvent = null;
691
+ this.fireCustomEvent(CustomEventType.Selection, {
692
+ action: 'delete_event',
693
+ event
694
+ });
695
+ }
696
+
697
+ // clicking a flow pill navigates to the flow instead of opening the row's
698
+ // detail modal (and closes the modal when clicked from inside it)
699
+ public handlePillClicked(e: Event, event: CampaignScheduleEvent) {
700
+ e.stopPropagation();
701
+ this.detailEvent = null;
702
+ this.fireCustomEvent(CustomEventType.Selection, event.flow);
703
+ }
704
+
705
+ // navigating to a contact from the detail modal closes it first
706
+ public handleFireClicked(fire: RecentFire) {
707
+ this.detailEvent = null;
708
+ this.fireCustomEvent(CustomEventType.Selection, fire.contact);
709
+ }
710
+
711
+ // the dialog closes itself on its Close button / ESC (firing only
712
+ // temba-button-clicked) and on mask clicks (temba-dialog-hidden) - our
713
+ // open state must reset on every path or the next open is a no-op change
714
+ // and the modal never reopens
715
+ private handleDetailClosed = () => {
716
+ this.detailEvent = null;
717
+ };
718
+
719
+ // activate a non-button row on Enter/Space (matches native button behavior)
720
+ private handleActivationKey(e: KeyboardEvent, action: () => void) {
721
+ if (e.key === 'Enter' || e.key === ' ') {
722
+ e.preventDefault();
723
+ action();
724
+ }
725
+ }
726
+
727
+ // system fields (Created On, Last Seen On, ...) aren't editable contact
728
+ // fields, so they get a dashed, muted pill instead of the field pill
729
+ private renderFieldPill(field: {
730
+ name?: string;
731
+ system?: boolean;
732
+ }): TemplateResult {
733
+ if (field?.system) {
734
+ return html`<span class="system-pill">
735
+ <temba-icon name="fields" size="0.9"></temba-icon>
736
+ ${field?.name}
737
+ </span>`;
738
+ }
739
+ return html`<temba-label type="field" icon="fields"
740
+ >${field?.name}</temba-label
741
+ >`;
742
+ }
743
+
744
+ private renderTime(event: CampaignScheduleEvent): TemplateResult {
745
+ // when the event fires at a specific hour, surface it on hover
746
+ if (event.delivery_hour_display) {
747
+ return html`
748
+ <temba-tip text=${event.delivery_hour_display} position="top">
749
+ ${event.offset_display}
750
+ </temba-tip>
751
+ `;
752
+ }
753
+ return html`${event.offset_display}`;
754
+ }
755
+
756
+ // the scheduled count (or scheduling state), shown inside the event's
757
+ // selectable outline and on the detail modal
758
+ private renderCount(event: CampaignScheduleEvent): TemplateResult {
759
+ if (event.status === 'scheduling') {
760
+ return html`<div class="count">${this.lang_scheduling}</div>`;
761
+ }
762
+ return html`
763
+ <div class="count" title=${this.lang_scheduled_contacts}>
764
+ ${(event.count || 0).toLocaleString()}
765
+ <temba-icon name=${Icon.contact} size="0.9"></temba-icon>
766
+ </div>
767
+ `;
768
+ }
769
+
770
+ private renderEvent(event: CampaignScheduleEvent): TemplateResult {
771
+ // message events display the message text; flow events show a clickable
772
+ // flow pill linking to the flow (the "start" action is implied)
773
+ const body =
774
+ event.type === 'message'
775
+ ? html`<div class="title-text">
776
+ <temba-expression-highlight
777
+ >${event.message}</temba-expression-highlight
778
+ >
779
+ </div>`
780
+ : event.flow
781
+ ? html`<temba-label
782
+ type="flow"
783
+ clickable
784
+ @click=${(e: Event) => this.handlePillClicked(e, event)}
785
+ >${event.flow.name}</temba-label
786
+ >`
787
+ : html``;
788
+
789
+ // in place of an event read page, opening a row shows its detail modal
790
+ return html`
791
+ <div
792
+ class="event clickable"
793
+ role="button"
794
+ tabindex="0"
795
+ @click=${() => this.handleEventClicked(event)}
796
+ @keydown=${(e: KeyboardEvent) =>
797
+ this.handleActivationKey(e, () => this.handleEventClicked(event))}
798
+ >
799
+ <div class="title">${body}</div>
800
+ ${this.renderCount(event)}
801
+ </div>
802
+ `;
803
+ }
804
+
805
+ private renderEventRow(
806
+ event: CampaignScheduleEvent,
807
+ color: string,
808
+ first: boolean,
809
+ last: boolean
810
+ ): TemplateResult {
811
+ return html`
812
+ <div class="row">
813
+ <div class="time">${this.renderTime(event)}</div>
814
+ <div class="track">
815
+ <div class="line ${first ? 'hidden' : ''}"></div>
816
+ <div class="dot" style="--dot-color:${color}">
817
+ <temba-icon
818
+ name=${event.type === 'message' ? Icon.message : Icon.flow}
819
+ size="0.65"
820
+ ></temba-icon>
821
+ </div>
822
+ <div class="line ${last ? 'hidden' : ''}"></div>
823
+ </div>
824
+ ${this.renderEvent(event)}
825
+ </div>
826
+ `;
827
+ }
828
+
829
+ // the anchor row marks the field's date itself - events before it sit
830
+ // above, events on or after it sit below
831
+ private renderAnchorRow(
832
+ section: Section,
833
+ color: string,
834
+ first: boolean,
835
+ last: boolean
836
+ ): TemplateResult {
837
+ return html`
838
+ <div class="row anchor-row">
839
+ <div class="time"></div>
840
+ <div class="track">
841
+ <div class="line ${first ? 'hidden' : ''}"></div>
842
+ <div class="dot anchor" style="--dot-color:${color}"></div>
843
+ <div class="line ${last ? 'hidden' : ''}"></div>
844
+ </div>
845
+ <div class="anchor-label">${this.renderFieldPill(section)}</div>
846
+ </div>
847
+ `;
848
+ }
849
+
850
+ // each field's line takes its hue from the section's position, so colors
851
+ // are a pure function of the current payload
852
+ private renderSection(section: Section, index: number): TemplateResult {
853
+ const color = FIELD_COLORS[index % FIELD_COLORS.length];
854
+ const total = section.before.length + 1 + section.onAfter.length;
855
+
856
+ let idx = 0;
857
+ const rows: TemplateResult[] = [];
858
+ for (const event of section.before) {
859
+ rows.push(this.renderEventRow(event, color, idx === 0, false));
860
+ idx++;
861
+ }
862
+ rows.push(
863
+ this.renderAnchorRow(section, color, idx === 0, idx === total - 1)
864
+ );
865
+ idx++;
866
+ for (const event of section.onAfter) {
867
+ rows.push(this.renderEventRow(event, color, false, idx === total - 1));
868
+ idx++;
869
+ }
870
+
871
+ return html`<div class="panel section">${rows}</div>`;
872
+ }
873
+
874
+ // the page header — title + content menu — is temba-page-header, the
875
+ // same header the content lists embed, so a list page and this read
876
+ // page share one header treatment. Slot presence is probed at render
877
+ // time (not reactive) - fine while hosts slot content declaratively.
878
+ private renderHeader(): TemplateResult | null {
879
+ const hasSubtitle =
880
+ this.subtitle || this.querySelector('[slot="subtitle"]');
881
+ if (!this.headerTitle && !this.contentMenuEndpoint && !hasSubtitle) {
882
+ return null;
883
+ }
884
+ return html`
885
+ <div class="header-panel">
886
+ <temba-page-header content-menu-endpoint=${this.contentMenuEndpoint}>
887
+ <slot name="title" slot="title">${this.headerTitle}</slot>
888
+ ${hasSubtitle
889
+ ? html`<slot name="subtitle" slot="subtitle"
890
+ >${this.subtitle}</slot
891
+ >`
892
+ : null}
893
+ </temba-page-header>
894
+ </div>
895
+ `;
896
+ }
897
+
898
+ private renderSchedule(): TemplateResult {
899
+ if (!this.data) {
900
+ return html``;
901
+ }
902
+
903
+ // tolerate a malformed/empty response rather than throwing
904
+ const events = Array.isArray(this.data.events) ? this.data.events : [];
905
+
906
+ if (events.length === 0) {
907
+ return html`<div class="content">
908
+ <div class="panel">
909
+ <div class="empty">
910
+ <slot name="empty">
911
+ <temba-icon name=${Icon.campaign} size="2"></temba-icon>
912
+ <div class="empty-title">${this.lang_empty}</div>
913
+ <div class="empty-help">${this.lang_empty_help}</div>
914
+ </slot>
915
+ </div>
916
+ </div>
917
+ </div>`;
918
+ }
919
+
920
+ // each field's line is its own card
921
+ return html`
922
+ <div class="content">
923
+ <div class="events">
924
+ ${this.getSections(events).map((section, index) =>
925
+ this.renderSection(section, index)
926
+ )}
927
+ </div>
928
+ </div>
929
+ `;
930
+ }
931
+
932
+ // badges (the campaign's group, archived state) get their own row
933
+ // between the header and the cards. Same render-time slot probe as the
934
+ // header - late-slotted badges won't toggle the row.
935
+ private renderBadges(): TemplateResult | null {
936
+ if (!this.querySelector('[slot="badges"]')) {
937
+ return null;
938
+ }
939
+ return html`<div class="badges"><slot name="badges"></slot></div>`;
940
+ }
941
+
942
+ // the recent contacts section - omitted entirely when the event hasn't
943
+ // fired for anyone
944
+ private renderFires(): TemplateResult | null {
945
+ if (!this.fires || this.fires.length === 0) {
946
+ return null;
947
+ }
948
+ return html`
949
+ <div class="detail-section-title">${this.lang_recent_contacts}</div>
950
+ <div class="fires">
951
+ ${this.fires.map(
952
+ (fire) => html`
953
+ <div
954
+ class="fire-row"
955
+ role="button"
956
+ tabindex="0"
957
+ @click=${() => this.handleFireClicked(fire)}
958
+ @keydown=${(e: KeyboardEvent) =>
959
+ this.handleActivationKey(e, () => this.handleFireClicked(fire))}
960
+ >
961
+ <div class="fire-name">${fire.contact.name}</div>
962
+ <temba-date value=${fire.time} display="timedate"></temba-date>
963
+ </div>
964
+ `
965
+ )}
966
+ </div>
967
+ `;
968
+ }
969
+
970
+ // modal detail view for an event, standing in for an event read page -
971
+ // page-like (no colored header bar) with the schedule as its title, edit
972
+ // and delete actions, the content, and the recent contacts
973
+ private renderDetail(): TemplateResult {
974
+ const event = this.detailEvent;
975
+
976
+ return html`
977
+ <temba-dialog
978
+ size="xlarge"
979
+ variant="flat"
980
+ primaryButtonName=""
981
+ cancelButtonName=""
982
+ hideOnClick
983
+ ?open=${!!event}
984
+ @temba-dialog-hidden=${this.handleDetailClosed}
985
+ @keyup=${(e: KeyboardEvent) => {
986
+ if (e.key === 'Escape') {
987
+ this.handleDetailClosed();
988
+ }
989
+ }}
990
+ >
991
+ ${event
992
+ ? html`
993
+ <div class="detail">
994
+ <div class="detail-header">
995
+ <div class="detail-title">
996
+ <div class="detail-campaign">
997
+ ${this.data?.campaign?.name || this.headerTitle}
998
+ </div>
999
+ </div>
1000
+ ${this.canEdit(event) || this.canDelete()
1001
+ ? html`<div class="detail-actions">
1002
+ ${this.canEdit(event)
1003
+ ? html`<button
1004
+ class="menu-button"
1005
+ @click=${this.handleEditClicked}
1006
+ >
1007
+ ${this.lang_edit}
1008
+ </button>`
1009
+ : null}
1010
+ ${this.canDelete()
1011
+ ? html`<button
1012
+ class="menu-button destructive"
1013
+ @click=${this.handleDeleteClicked}
1014
+ >
1015
+ ${this.lang_delete}
1016
+ </button>`
1017
+ : null}
1018
+ </div>`
1019
+ : null}
1020
+ </div>
1021
+
1022
+ <div class="detail-body">
1023
+ <div class="detail-schedule-row">
1024
+ <div class="detail-schedule">
1025
+ <span
1026
+ >${event.offset === 0
1027
+ ? capitalize(event.offset_display)
1028
+ : event.offset_display}</span
1029
+ >
1030
+ ${this.renderFieldPill(event.relative_to)}
1031
+ ${event.delivery_hour_display
1032
+ ? html`<span>${event.delivery_hour_display}</span>`
1033
+ : null}
1034
+ </div>
1035
+ <div
1036
+ class="detail-count"
1037
+ title=${event.status === 'scheduling'
1038
+ ? ''
1039
+ : this.lang_scheduled_contacts}
1040
+ >
1041
+ ${event.status === 'scheduling'
1042
+ ? html`${this.lang_scheduling}`
1043
+ : html`<temba-icon
1044
+ name=${Icon.contact}
1045
+ size="0.9"
1046
+ ></temba-icon>
1047
+ ${(event.count || 0).toLocaleString()}
1048
+ ${this.lang_scheduled}`}
1049
+ </div>
1050
+ </div>
1051
+
1052
+ <div class="detail-action">
1053
+ <div class="detail-section-title">
1054
+ ${event.type === 'message'
1055
+ ? this.lang_send_message
1056
+ : this.lang_start_flow}
1057
+ </div>
1058
+ ${event.type === 'message'
1059
+ ? html`<temba-expression-highlight
1060
+ >${event.message}</temba-expression-highlight
1061
+ >`
1062
+ : event.flow
1063
+ ? html`<temba-label
1064
+ type="flow"
1065
+ clickable
1066
+ @click=${(e: Event) =>
1067
+ this.handlePillClicked(e, event)}
1068
+ >${event.flow.name}</temba-label
1069
+ >`
1070
+ : null}
1071
+ </div>
1072
+
1073
+ ${this.renderFires()}
1074
+ </div>
1075
+
1076
+ <div class="detail-footer">
1077
+ <temba-button
1078
+ primary
1079
+ name=${this.lang_okay}
1080
+ @click=${this.handleDetailClosed}
1081
+ ></temba-button>
1082
+ </div>
1083
+ </div>
1084
+ `
1085
+ : null}
1086
+ </temba-dialog>
1087
+ `;
1088
+ }
1089
+
1090
+ public render(): TemplateResult {
1091
+ return html`${this.renderHeader()} ${this.renderBadges()}
1092
+ ${this.renderSchedule()} ${this.renderDetail()}`;
1093
+ }
1094
+ }