@nyaruka/temba-components 0.158.3 → 0.159.1
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 +7 -4
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +1475 -602
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -2
- package/src/Icons.ts +8 -1
- package/src/display/Button.ts +24 -14
- package/src/display/Thumbnail.ts +1 -1
- package/src/events/eventRenderers.ts +29 -0
- package/src/events.ts +22 -0
- package/src/interfaces.ts +46 -2
- package/src/layout/PageHeader.ts +338 -0
- package/src/list/ContactList.ts +68 -52
- package/src/list/ContentList.ts +1472 -346
- package/src/list/FlowList.ts +20 -26
- package/src/list/MsgList.ts +169 -71
- package/src/live/ContactEvents.ts +880 -0
- package/src/styles/designTokens.ts +5 -2
- package/src/styles/pillVariants.ts +21 -6
- package/static/css/design-system.css +769 -0
- package/static/css/temba-components.css +16 -77
- package/static/svg/index.svg +1 -1
- package/static/svg/work/traced/chevron-down-double.svg +1 -0
- package/static/svg/work/traced/chevron-up-double.svg +1 -0
- package/static/svg/work/used/chevron-down-double.svg +3 -0
- package/static/svg/work/used/chevron-up-double.svg +3 -0
- package/temba-modules.ts +4 -2
- package/web-dev-server.config.mjs +9 -0
- package/src/live/ContactPending.ts +0 -247
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
import { css, html, PropertyValueMap, TemplateResult } from 'lit';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
|
+
import {
|
|
4
|
+
CustomEventType,
|
|
5
|
+
ObjectReference,
|
|
6
|
+
ScheduledEvent,
|
|
7
|
+
ScheduledEventType
|
|
8
|
+
} from '../interfaces';
|
|
9
|
+
import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
|
|
10
|
+
import { Icon } from '../Icons';
|
|
11
|
+
|
|
12
|
+
interface EventsResponse {
|
|
13
|
+
now: string;
|
|
14
|
+
campaigns: ObjectReference[];
|
|
15
|
+
future_count: number;
|
|
16
|
+
future: ScheduledEvent[];
|
|
17
|
+
past: ScheduledEvent[];
|
|
18
|
+
next_before: string | null;
|
|
19
|
+
next_after: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// four distinct primary hues assigned per campaign - chosen for maximum spread
|
|
23
|
+
// so the first handful of campaigns read clearly apart. Purple is reserved for
|
|
24
|
+
// broadcasts and green is reserved for triggers (matches the flow pill hue).
|
|
25
|
+
// The trailing four entries are darker shades of the primaries for the rare
|
|
26
|
+
// case where a contact belongs to more than four campaigns at once.
|
|
27
|
+
const CAMPAIGN_COLORS = [
|
|
28
|
+
'#1a86d0', // blue
|
|
29
|
+
'#e8843f', // orange
|
|
30
|
+
'#ec407a', // pink
|
|
31
|
+
'#2bb2a6', // teal
|
|
32
|
+
'#0a5290', // deep blue
|
|
33
|
+
'#a25320', // deep orange
|
|
34
|
+
'#a02153', // deep pink
|
|
35
|
+
'#176a61' // deep teal
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// shared "broadcast purple" used elsewhere for broadcasts
|
|
39
|
+
const BROADCAST_COLOR = '#8e5ea7';
|
|
40
|
+
|
|
41
|
+
// triggers use the same green as the flow pill
|
|
42
|
+
const TRIGGER_COLOR = '#16a34a';
|
|
43
|
+
|
|
44
|
+
export class ContactEvents extends EndpointMonitorElement {
|
|
45
|
+
@property({ type: String })
|
|
46
|
+
contact: string;
|
|
47
|
+
|
|
48
|
+
@property({ type: Object, attribute: false })
|
|
49
|
+
data: EventsResponse;
|
|
50
|
+
|
|
51
|
+
@property({ type: String })
|
|
52
|
+
lang_now = 'Now';
|
|
53
|
+
|
|
54
|
+
@property({ type: String })
|
|
55
|
+
lang_show_older = 'Show older events';
|
|
56
|
+
|
|
57
|
+
@property({ type: String })
|
|
58
|
+
lang_show_more = 'Show more upcoming events';
|
|
59
|
+
|
|
60
|
+
@property({ type: String })
|
|
61
|
+
lang_more = 'Show more';
|
|
62
|
+
|
|
63
|
+
@property({ type: String })
|
|
64
|
+
lang_campaigns_label = 'Campaigns';
|
|
65
|
+
|
|
66
|
+
@property({ type: String })
|
|
67
|
+
lang_empty = 'No events for this contact yet.';
|
|
68
|
+
|
|
69
|
+
@property({ type: String })
|
|
70
|
+
lang_projected_info =
|
|
71
|
+
'This is a projected timeline based on current schedules. Past entries may not reflect what actually happened, and upcoming entries are limited to the next year.';
|
|
72
|
+
|
|
73
|
+
// older past events accumulated through pagination
|
|
74
|
+
@state()
|
|
75
|
+
private olderEvents: ScheduledEvent[] = [];
|
|
76
|
+
|
|
77
|
+
// further-upcoming events accumulated through pagination
|
|
78
|
+
@state()
|
|
79
|
+
private newerEvents: ScheduledEvent[] = [];
|
|
80
|
+
|
|
81
|
+
// cursor for paging further back, null when there's nothing older
|
|
82
|
+
@state()
|
|
83
|
+
private nextBefore: string | null = null;
|
|
84
|
+
|
|
85
|
+
// cursor for paging further forward, null when there's nothing further out
|
|
86
|
+
@state()
|
|
87
|
+
private nextAfter: string | null = null;
|
|
88
|
+
|
|
89
|
+
@state()
|
|
90
|
+
private loadingMore = false;
|
|
91
|
+
|
|
92
|
+
@state()
|
|
93
|
+
private loadingMoreFuture = false;
|
|
94
|
+
|
|
95
|
+
// stable campaign uuid -> color assignments
|
|
96
|
+
private campaignColors: { [uuid: string]: string } = {};
|
|
97
|
+
|
|
98
|
+
static get styles() {
|
|
99
|
+
return css`
|
|
100
|
+
:host {
|
|
101
|
+
display: block;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.empty {
|
|
105
|
+
padding: 4em 1em;
|
|
106
|
+
text-align: center;
|
|
107
|
+
color: var(--text-color);
|
|
108
|
+
opacity: 0.55;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* row of campaign pills the contact is currently a member of */
|
|
112
|
+
.campaigns {
|
|
113
|
+
display: flex;
|
|
114
|
+
flex-wrap: wrap;
|
|
115
|
+
align-items: center;
|
|
116
|
+
gap: 0.4em;
|
|
117
|
+
padding: 0 0.5em 0.5em;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.campaigns-label {
|
|
121
|
+
font-size: 0.75em;
|
|
122
|
+
font-weight: 500;
|
|
123
|
+
text-transform: uppercase;
|
|
124
|
+
letter-spacing: 0.05em;
|
|
125
|
+
color: var(--text-color);
|
|
126
|
+
opacity: 0.55;
|
|
127
|
+
margin-right: 0.25em;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/* each pill is colored with its campaign's hue - background, border
|
|
131
|
+
and text all derived from --pill-hue. read-only badges, not links */
|
|
132
|
+
.campaign-pill {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
gap: 6px;
|
|
136
|
+
font-size: 0.78em;
|
|
137
|
+
line-height: 1.5;
|
|
138
|
+
padding: 0.05em 0.65em;
|
|
139
|
+
border-radius: 999px;
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
background: color-mix(
|
|
142
|
+
in srgb,
|
|
143
|
+
var(--pill-hue) 12%,
|
|
144
|
+
var(--color-widget-bg, #fff)
|
|
145
|
+
);
|
|
146
|
+
border: 1px solid
|
|
147
|
+
color-mix(in srgb, var(--pill-hue) 25%, var(--color-widget-bg, #fff));
|
|
148
|
+
color: var(--pill-hue);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* status-badge dot leading each campaign pill, in the same hue */
|
|
152
|
+
.campaign-dot {
|
|
153
|
+
display: inline-block;
|
|
154
|
+
width: 7px;
|
|
155
|
+
height: 7px;
|
|
156
|
+
border-radius: 50%;
|
|
157
|
+
background: var(--pill-hue);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.timeline {
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-direction: column;
|
|
163
|
+
padding: 0.5em 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.row {
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: stretch;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* relative time, vertically centered on its dot. mute via color
|
|
172
|
+
(not opacity) so the hovered date tooltip renders at full alpha
|
|
173
|
+
and doesn't share a stacking context with the column */
|
|
174
|
+
.time {
|
|
175
|
+
width: 7em;
|
|
176
|
+
flex-shrink: 0;
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
justify-content: flex-end;
|
|
180
|
+
padding-right: 0.65em;
|
|
181
|
+
font-size: 0.8em;
|
|
182
|
+
color: var(--text-3, #7b8593);
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.time temba-tip {
|
|
187
|
+
cursor: default;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* the subway rail */
|
|
191
|
+
.track {
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-direction: column;
|
|
194
|
+
align-items: center;
|
|
195
|
+
width: 1.75em;
|
|
196
|
+
flex-shrink: 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.line {
|
|
200
|
+
width: 2px;
|
|
201
|
+
flex: 1;
|
|
202
|
+
background: var(--color-borders, rgba(0, 0, 0, 0.12));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.line.hidden {
|
|
206
|
+
background: transparent;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.dot {
|
|
210
|
+
width: 18px;
|
|
211
|
+
height: 18px;
|
|
212
|
+
border-radius: 50%;
|
|
213
|
+
box-sizing: border-box;
|
|
214
|
+
flex-shrink: 0;
|
|
215
|
+
z-index: 1;
|
|
216
|
+
/* same border on past and future - only the fill differs */
|
|
217
|
+
border: 2px solid var(--dot-color);
|
|
218
|
+
display: flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
justify-content: center;
|
|
221
|
+
/* icons inside the dot take the dot's hue */
|
|
222
|
+
--icon-color: var(--dot-color);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* past events are filled with a lighter tint of the same hue and keep
|
|
226
|
+
the solid border - they're settled, whether real or projected */
|
|
227
|
+
.dot.past {
|
|
228
|
+
background: color-mix(
|
|
229
|
+
in srgb,
|
|
230
|
+
var(--dot-color) 25%,
|
|
231
|
+
var(--color-widget-bg, #fff)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* future events haven't occurred yet - signal that with a dotted
|
|
236
|
+
border and the faintest tint of the hue inside */
|
|
237
|
+
.dot.future {
|
|
238
|
+
border-style: dotted;
|
|
239
|
+
background: color-mix(
|
|
240
|
+
in srgb,
|
|
241
|
+
var(--dot-color) 8%,
|
|
242
|
+
var(--color-widget-bg, #fff)
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* the now dot and label both paint with the SPA content background
|
|
247
|
+
color - they look transparent but actually mask the rail and divider
|
|
248
|
+
behind them, so the "Now" marker reads as a clean break */
|
|
249
|
+
.dot.now {
|
|
250
|
+
border: 2px solid var(--border-strong, #9ca3af);
|
|
251
|
+
background: var(--color-widget-bg, #fff);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* round button on the rail that pages further back / forward when
|
|
255
|
+
clicked - up arrow above the past, down arrow below the future */
|
|
256
|
+
.pager-dot {
|
|
257
|
+
width: 18px;
|
|
258
|
+
height: 18px;
|
|
259
|
+
border-radius: 50%;
|
|
260
|
+
box-sizing: border-box;
|
|
261
|
+
border: 2px solid var(--border-strong, #9ca3af);
|
|
262
|
+
background: var(--color-widget-bg, #fff);
|
|
263
|
+
display: flex;
|
|
264
|
+
align-items: center;
|
|
265
|
+
justify-content: center;
|
|
266
|
+
padding: 0;
|
|
267
|
+
cursor: pointer;
|
|
268
|
+
flex-shrink: 0;
|
|
269
|
+
z-index: 1;
|
|
270
|
+
--icon-color: var(--border-strong, #9ca3af);
|
|
271
|
+
transition:
|
|
272
|
+
border-color 0.15s ease,
|
|
273
|
+
color 0.15s ease;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.pager-dot:hover {
|
|
277
|
+
border-color: var(--color-link-primary);
|
|
278
|
+
--icon-color: var(--color-link-primary);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.pager-dot.loading {
|
|
282
|
+
cursor: default;
|
|
283
|
+
opacity: 0.5;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* "More" label next to the pager dot - shares the event-row spacing
|
|
287
|
+
and font size so the pager row sits on the same vertical rhythm */
|
|
288
|
+
.pager-label {
|
|
289
|
+
display: flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
padding: 0.4em 0.75em;
|
|
292
|
+
margin: 0.1em 0;
|
|
293
|
+
color: var(--border-strong, #9ca3af);
|
|
294
|
+
cursor: pointer;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.pager-label:hover {
|
|
298
|
+
color: var(--color-link-primary);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/* hovering either the dot or the label highlights both so they read
|
|
302
|
+
as one interactive element - but the empty space of the row stays
|
|
303
|
+
inert (no hover, no click) */
|
|
304
|
+
.row.pager-row:has(.pager-dot:hover) .pager-label,
|
|
305
|
+
.row.pager-row:has(.pager-label:hover) .pager-label {
|
|
306
|
+
color: var(--color-link-primary);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.row.pager-row:has(.pager-label:hover) .pager-dot,
|
|
310
|
+
.row.pager-row:has(.pager-dot:hover) .pager-dot {
|
|
311
|
+
border-color: var(--color-link-primary);
|
|
312
|
+
--icon-color: var(--color-link-primary);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.row.pager-row.loading .pager-dot,
|
|
316
|
+
.row.pager-row.loading .pager-label,
|
|
317
|
+
.row.pager-row.loading:has(.pager-dot:hover) .pager-dot,
|
|
318
|
+
.row.pager-row.loading:has(.pager-dot:hover) .pager-label,
|
|
319
|
+
.row.pager-row.loading:has(.pager-label:hover) .pager-dot,
|
|
320
|
+
.row.pager-row.loading:has(.pager-label:hover) .pager-label {
|
|
321
|
+
cursor: default;
|
|
322
|
+
opacity: 0.5;
|
|
323
|
+
color: var(--border-strong, #9ca3af);
|
|
324
|
+
border-color: var(--border-strong, #9ca3af);
|
|
325
|
+
--icon-color: var(--border-strong, #9ca3af);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* the "now" divider - a rule running the full width */
|
|
329
|
+
.row.now-row {
|
|
330
|
+
position: relative;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.row.now-row::after {
|
|
334
|
+
content: '';
|
|
335
|
+
position: absolute;
|
|
336
|
+
left: 0;
|
|
337
|
+
right: 0;
|
|
338
|
+
top: 50%;
|
|
339
|
+
border-top: 1px solid var(--color-borders, rgba(0, 0, 0, 0.2));
|
|
340
|
+
z-index: 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/* notice that campaign timelines are projections - sits below the
|
|
344
|
+
timeline, bordered with the same dotted style as past campaign dots */
|
|
345
|
+
.projection-note {
|
|
346
|
+
max-width: 500px;
|
|
347
|
+
margin: 0.5em 0.75em;
|
|
348
|
+
padding: 0.5em 0.75em;
|
|
349
|
+
border: 2px dotted var(--color-borders, rgba(0, 0, 0, 0.3));
|
|
350
|
+
border-radius: var(--curvature);
|
|
351
|
+
font-size: 0.8em;
|
|
352
|
+
line-height: 1.45;
|
|
353
|
+
color: var(--text-color);
|
|
354
|
+
opacity: 0.7;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.now-label {
|
|
358
|
+
z-index: 1;
|
|
359
|
+
display: flex;
|
|
360
|
+
align-items: center;
|
|
361
|
+
margin: 1.5em 0;
|
|
362
|
+
padding: 0 0.6em;
|
|
363
|
+
background: var(--color-widget-bg, #fff);
|
|
364
|
+
font-size: 0.75em;
|
|
365
|
+
font-weight: 600;
|
|
366
|
+
text-transform: uppercase;
|
|
367
|
+
letter-spacing: 0.08em;
|
|
368
|
+
color: var(--text-color);
|
|
369
|
+
opacity: 0.5;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* flat event entry - no card chrome. column layout stacks the title
|
|
373
|
+
above any secondary metadata like the repeat schedule */
|
|
374
|
+
.event {
|
|
375
|
+
flex-grow: 1;
|
|
376
|
+
min-width: 0;
|
|
377
|
+
display: flex;
|
|
378
|
+
flex-direction: column;
|
|
379
|
+
gap: 0.15em;
|
|
380
|
+
padding: 0.4em 0.75em;
|
|
381
|
+
margin: 0.1em 0;
|
|
382
|
+
border-radius: var(--curvature);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.event.clickable {
|
|
386
|
+
cursor: pointer;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.event.clickable:hover {
|
|
390
|
+
background: var(--color-selection, rgba(0, 0, 0, 0.04));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.title {
|
|
394
|
+
min-width: 0;
|
|
395
|
+
display: flex;
|
|
396
|
+
align-items: center;
|
|
397
|
+
gap: 0.45em;
|
|
398
|
+
color: var(--text-color);
|
|
399
|
+
line-height: 1.4;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.title-text {
|
|
403
|
+
flex: 1;
|
|
404
|
+
min-width: 0;
|
|
405
|
+
display: -webkit-box;
|
|
406
|
+
-webkit-line-clamp: 2;
|
|
407
|
+
-webkit-box-orient: vertical;
|
|
408
|
+
overflow: hidden;
|
|
409
|
+
}
|
|
410
|
+
`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// the endpoint returns a custom {future, past, next_before} payload rather than
|
|
414
|
+
// the paginated {results: [...]} shape that the store's fetch machinery expects,
|
|
415
|
+
// so we fetch and assign data ourselves instead of relying on a monitored url
|
|
416
|
+
private async loadEvents(): Promise<void> {
|
|
417
|
+
if (!this.contact) {
|
|
418
|
+
this.data = null;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// capture the contact at request time so a slower in-flight response for a
|
|
423
|
+
// previous contact can't overwrite data for the contact we're now showing
|
|
424
|
+
const requestedContact = this.contact;
|
|
425
|
+
try {
|
|
426
|
+
const response = await this.store.getUrl(
|
|
427
|
+
`/contact/events/${encodeURIComponent(this.contact)}/`,
|
|
428
|
+
{ force: true }
|
|
429
|
+
);
|
|
430
|
+
if (this.contact !== requestedContact) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
this.data = response.json;
|
|
434
|
+
} catch {
|
|
435
|
+
// on failure leave the prior data in place so a transient blip doesn't
|
|
436
|
+
// wipe the timeline; data is only cleared when the contact changes
|
|
437
|
+
// (handled in updated()) or when there's no contact at all
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
public refresh(): void {
|
|
442
|
+
this.loadEvents();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
protected updated(
|
|
446
|
+
changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
|
447
|
+
): void {
|
|
448
|
+
super.updated(changes);
|
|
449
|
+
|
|
450
|
+
if (changes.has('contact')) {
|
|
451
|
+
// colors are assigned per-uuid in order of first appearance, so a switch
|
|
452
|
+
// to a new contact must drop the previous assignments to avoid drift
|
|
453
|
+
this.campaignColors = {};
|
|
454
|
+
// blank the timeline immediately on a contact switch so the previous
|
|
455
|
+
// contact's events aren't briefly visible while the new fetch is in flight
|
|
456
|
+
this.data = null;
|
|
457
|
+
// in-flight pager requests for the prior contact will bail on stale
|
|
458
|
+
// contact, so reset the loading flags so the new contact's pagers aren't
|
|
459
|
+
// blocked by the previous contact's still-resolving request
|
|
460
|
+
this.loadingMore = false;
|
|
461
|
+
this.loadingMoreFuture = false;
|
|
462
|
+
this.loadEvents();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (changes.has('data')) {
|
|
466
|
+
// a fresh first page resets any paged-in events in both directions
|
|
467
|
+
this.olderEvents = [];
|
|
468
|
+
this.newerEvents = [];
|
|
469
|
+
this.nextBefore = this.data ? (this.data.next_before ?? null) : null;
|
|
470
|
+
this.nextAfter = this.data ? (this.data.next_after ?? null) : null;
|
|
471
|
+
|
|
472
|
+
// eagerly assign campaign colors for everything in the first page so
|
|
473
|
+
// render() can be a pure read of the map. Pager handlers extend the map
|
|
474
|
+
// when later pages introduce campaigns not in the first page.
|
|
475
|
+
if (this.data) {
|
|
476
|
+
this.assignCampaignColors([
|
|
477
|
+
...(this.data.past || []),
|
|
478
|
+
...(this.data.future || [])
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// only notify consumers when we actually have data - skip the null state
|
|
483
|
+
// between contact switch and first response (and any transient errors)
|
|
484
|
+
// so listeners don't see a spurious count:0
|
|
485
|
+
if (this.data !== null && this.data !== undefined) {
|
|
486
|
+
// the badge reflects the total count of upcoming events, not just
|
|
487
|
+
// what's currently visible on this page
|
|
488
|
+
const count =
|
|
489
|
+
typeof this.data.future_count === 'number'
|
|
490
|
+
? this.data.future_count
|
|
491
|
+
: Array.isArray(this.data.future)
|
|
492
|
+
? this.data.future.length
|
|
493
|
+
: 0;
|
|
494
|
+
this.fireCustomEvent(CustomEventType.DetailsChanged, { count });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// assign stable per-uuid colors for any campaigns seen in `events` that
|
|
500
|
+
// aren't already in the map. Called from updated() on data change and from
|
|
501
|
+
// pager handlers after a page is appended, so render() never mutates state.
|
|
502
|
+
private assignCampaignColors(events: ScheduledEvent[]): void {
|
|
503
|
+
for (const event of events) {
|
|
504
|
+
if (event.type !== ScheduledEventType.CampaignEvent || !event.campaign) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const uuid = event.campaign.uuid;
|
|
508
|
+
if (!this.campaignColors[uuid]) {
|
|
509
|
+
const used = Object.keys(this.campaignColors).length;
|
|
510
|
+
this.campaignColors[uuid] =
|
|
511
|
+
CAMPAIGN_COLORS[used % CAMPAIGN_COLORS.length];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private getCampaignColor(uuid: string): string {
|
|
517
|
+
return this.campaignColors[uuid] || CAMPAIGN_COLORS[0];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private getColor(event: ScheduledEvent): string {
|
|
521
|
+
if (event.type === ScheduledEventType.ScheduledTrigger) {
|
|
522
|
+
return TRIGGER_COLOR;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (event.type !== ScheduledEventType.CampaignEvent || !event.campaign) {
|
|
526
|
+
return BROADCAST_COLOR;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return this.getCampaignColor(event.campaign.uuid);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// broadcasts and triggers carry their type icon inside the dot;
|
|
533
|
+
// campaign events use a plain dot since the campaign pill carries the context
|
|
534
|
+
private getDotIcon(event: ScheduledEvent): string | null {
|
|
535
|
+
if (event.type === ScheduledEventType.ScheduledTrigger) {
|
|
536
|
+
return Icon.trigger;
|
|
537
|
+
}
|
|
538
|
+
if (event.type !== ScheduledEventType.CampaignEvent) {
|
|
539
|
+
return Icon.broadcast;
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
public handleEventClicked(event: ScheduledEvent) {
|
|
545
|
+
this.fireCustomEvent(CustomEventType.Selection, event);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// clicking a campaign or flow pill navigates to that entity's read page
|
|
549
|
+
// instead of bubbling up to the row's event-read navigation
|
|
550
|
+
public handlePillClicked(e: Event, ref: ObjectReference) {
|
|
551
|
+
e.stopPropagation();
|
|
552
|
+
this.fireCustomEvent(CustomEventType.Selection, ref);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// activate a non-button row on Enter/Space (matches native button behavior)
|
|
556
|
+
// so keyboard users can reach clickable timeline rows and pager labels
|
|
557
|
+
private handleActivationKey(e: KeyboardEvent, action: () => void) {
|
|
558
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
559
|
+
e.preventDefault();
|
|
560
|
+
action();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
public async handleShowOlder() {
|
|
565
|
+
if (this.loadingMore || !this.nextBefore) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
this.loadingMore = true;
|
|
570
|
+
// capture the contact at request time so a paged response that returns
|
|
571
|
+
// after the user has switched contacts can't append onto the new timeline
|
|
572
|
+
const requestedContact = this.contact;
|
|
573
|
+
const url = `/contact/events/${encodeURIComponent(
|
|
574
|
+
this.contact
|
|
575
|
+
)}/?before=${encodeURIComponent(this.nextBefore)}`;
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const response = await this.store.getUrl(url, { force: true });
|
|
579
|
+
if (this.contact !== requestedContact) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const page = response.json as EventsResponse;
|
|
583
|
+
const newPast = page.past || [];
|
|
584
|
+
this.assignCampaignColors(newPast);
|
|
585
|
+
this.olderEvents = [...this.olderEvents, ...newPast];
|
|
586
|
+
this.nextBefore = page.next_before ?? null;
|
|
587
|
+
} catch {
|
|
588
|
+
if (this.contact !== requestedContact) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// on failure leave the accumulated events in place but stop offering
|
|
592
|
+
// the same failing page - clearing the cursor hides the pager
|
|
593
|
+
this.nextBefore = null;
|
|
594
|
+
} finally {
|
|
595
|
+
// only clear the flag if we're still on the original contact - the
|
|
596
|
+
// contact-change handler in updated() already cleared it, and clearing
|
|
597
|
+
// again here would unblock the new contact's in-flight pager request
|
|
598
|
+
// and allow a duplicate fetch of the same cursor
|
|
599
|
+
if (this.contact === requestedContact) {
|
|
600
|
+
this.loadingMore = false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
public async handleShowMore() {
|
|
606
|
+
if (this.loadingMoreFuture || !this.nextAfter) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
this.loadingMoreFuture = true;
|
|
611
|
+
const requestedContact = this.contact;
|
|
612
|
+
const url = `/contact/events/${encodeURIComponent(
|
|
613
|
+
this.contact
|
|
614
|
+
)}/?after=${encodeURIComponent(this.nextAfter)}`;
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
const response = await this.store.getUrl(url, { force: true });
|
|
618
|
+
if (this.contact !== requestedContact) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const page = response.json as EventsResponse;
|
|
622
|
+
const newFuture = page.future || [];
|
|
623
|
+
this.assignCampaignColors(newFuture);
|
|
624
|
+
this.newerEvents = [...this.newerEvents, ...newFuture];
|
|
625
|
+
this.nextAfter = page.next_after ?? null;
|
|
626
|
+
} catch {
|
|
627
|
+
if (this.contact !== requestedContact) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
this.nextAfter = null;
|
|
631
|
+
} finally {
|
|
632
|
+
if (this.contact === requestedContact) {
|
|
633
|
+
this.loadingMoreFuture = false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private renderTime(event: ScheduledEvent): TemplateResult {
|
|
639
|
+
// anchor durations to the server's "now" so they stay tolerant of clock
|
|
640
|
+
// skew and don't silently drift when the page is left open
|
|
641
|
+
return html`
|
|
642
|
+
<temba-tip text=${this.store.formatDate(event.scheduled)} position="top">
|
|
643
|
+
${this.store.getShortDurationFromIso(event.scheduled, this.data?.now)}
|
|
644
|
+
</temba-tip>
|
|
645
|
+
`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private renderEvent(event: ScheduledEvent): TemplateResult {
|
|
649
|
+
const clickable = event.type !== ScheduledEventType.SentBroadcast;
|
|
650
|
+
const isMessage = !!event.message;
|
|
651
|
+
|
|
652
|
+
// message events display the message text; flow-bearing events show a
|
|
653
|
+
// clickable flow pill linking to the flow (the "start" action is implied)
|
|
654
|
+
const body = isMessage
|
|
655
|
+
? html`<div class="title-text">${event.message}</div>`
|
|
656
|
+
: event.flow
|
|
657
|
+
? html`<temba-label
|
|
658
|
+
type="flow"
|
|
659
|
+
clickable
|
|
660
|
+
@click=${(e: Event) => this.handlePillClicked(e, event.flow)}
|
|
661
|
+
>${event.flow.name}</temba-label
|
|
662
|
+
>`
|
|
663
|
+
: html``;
|
|
664
|
+
|
|
665
|
+
return html`
|
|
666
|
+
<div
|
|
667
|
+
class="event ${clickable ? 'clickable' : ''}"
|
|
668
|
+
role=${clickable ? 'button' : 'presentation'}
|
|
669
|
+
tabindex=${clickable ? '0' : '-1'}
|
|
670
|
+
@click=${clickable ? () => this.handleEventClicked(event) : null}
|
|
671
|
+
@keydown=${clickable
|
|
672
|
+
? (e: KeyboardEvent) =>
|
|
673
|
+
this.handleActivationKey(e, () => this.handleEventClicked(event))
|
|
674
|
+
: null}
|
|
675
|
+
>
|
|
676
|
+
<div class="title">${body}</div>
|
|
677
|
+
</div>
|
|
678
|
+
`;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private renderRow(
|
|
682
|
+
time: TemplateResult,
|
|
683
|
+
dot: TemplateResult,
|
|
684
|
+
content: TemplateResult,
|
|
685
|
+
first: boolean,
|
|
686
|
+
last: boolean
|
|
687
|
+
): TemplateResult {
|
|
688
|
+
return html`
|
|
689
|
+
<div class="row">
|
|
690
|
+
<div class="time">${time}</div>
|
|
691
|
+
<div class="track">
|
|
692
|
+
<div class="line ${first ? 'hidden' : ''}"></div>
|
|
693
|
+
${dot}
|
|
694
|
+
<div class="line ${last ? 'hidden' : ''}"></div>
|
|
695
|
+
</div>
|
|
696
|
+
${content}
|
|
697
|
+
</div>
|
|
698
|
+
`;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
private renderCampaigns(campaigns: ObjectReference[]): TemplateResult | null {
|
|
702
|
+
if (campaigns.length === 0) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
return html`
|
|
706
|
+
<div class="campaigns">
|
|
707
|
+
<div class="campaigns-label">${this.lang_campaigns_label}</div>
|
|
708
|
+
${campaigns.map(
|
|
709
|
+
(campaign) =>
|
|
710
|
+
html`<div
|
|
711
|
+
class="campaign-pill"
|
|
712
|
+
style="--pill-hue:${this.getCampaignColor(campaign.uuid)}"
|
|
713
|
+
>
|
|
714
|
+
<span class="campaign-dot"></span>${campaign.name}
|
|
715
|
+
</div>`
|
|
716
|
+
)}
|
|
717
|
+
</div>
|
|
718
|
+
`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
public render(): TemplateResult {
|
|
722
|
+
if (!this.data) {
|
|
723
|
+
return html``;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// tolerate a malformed/empty response rather than throwing
|
|
727
|
+
const campaigns = Array.isArray(this.data.campaigns)
|
|
728
|
+
? this.data.campaigns
|
|
729
|
+
: [];
|
|
730
|
+
const future = [
|
|
731
|
+
...(Array.isArray(this.data.future) ? this.data.future : []),
|
|
732
|
+
...this.newerEvents
|
|
733
|
+
];
|
|
734
|
+
const pastDescending = [
|
|
735
|
+
...(Array.isArray(this.data.past) ? this.data.past : []),
|
|
736
|
+
...this.olderEvents
|
|
737
|
+
];
|
|
738
|
+
|
|
739
|
+
if (
|
|
740
|
+
campaigns.length === 0 &&
|
|
741
|
+
future.length === 0 &&
|
|
742
|
+
pastDescending.length === 0
|
|
743
|
+
) {
|
|
744
|
+
return html`<div class="empty">
|
|
745
|
+
<slot name="empty">${this.lang_empty}</slot>
|
|
746
|
+
</div>`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// the timeline reads oldest to newest, top to bottom: an "older" pager,
|
|
750
|
+
// then past events, the "now" divider, upcoming events, and a "show more"
|
|
751
|
+
// pager at the bottom
|
|
752
|
+
type Row = {
|
|
753
|
+
kind: 'event' | 'now' | 'more-past' | 'more-future';
|
|
754
|
+
event?: ScheduledEvent;
|
|
755
|
+
past?: boolean;
|
|
756
|
+
};
|
|
757
|
+
const rows: Row[] = [];
|
|
758
|
+
|
|
759
|
+
if (this.nextBefore) {
|
|
760
|
+
rows.push({ kind: 'more-past' });
|
|
761
|
+
}
|
|
762
|
+
[...pastDescending]
|
|
763
|
+
.reverse()
|
|
764
|
+
.forEach((event) => rows.push({ kind: 'event', event, past: true }));
|
|
765
|
+
rows.push({ kind: 'now' });
|
|
766
|
+
future.forEach((event) => rows.push({ kind: 'event', event, past: false }));
|
|
767
|
+
if (this.nextAfter) {
|
|
768
|
+
rows.push({ kind: 'more-future' });
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return html`
|
|
772
|
+
${this.renderCampaigns(campaigns)}
|
|
773
|
+
<div class="timeline">
|
|
774
|
+
${rows.map((row, idx) => {
|
|
775
|
+
const first = idx === 0;
|
|
776
|
+
const last = idx === rows.length - 1;
|
|
777
|
+
|
|
778
|
+
if (row.kind === 'now') {
|
|
779
|
+
return html`
|
|
780
|
+
<div class="row now-row">
|
|
781
|
+
<div class="time"></div>
|
|
782
|
+
<div class="track">
|
|
783
|
+
<div class="line ${first ? 'hidden' : ''}"></div>
|
|
784
|
+
<div class="dot now"></div>
|
|
785
|
+
<div class="line ${last ? 'hidden' : ''}"></div>
|
|
786
|
+
</div>
|
|
787
|
+
<div class="now-label">${this.lang_now}</div>
|
|
788
|
+
</div>
|
|
789
|
+
`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (row.kind === 'more-past') {
|
|
793
|
+
return html`
|
|
794
|
+
<div class="row pager-row ${this.loadingMore ? 'loading' : ''}">
|
|
795
|
+
<div class="time"></div>
|
|
796
|
+
<div class="track">
|
|
797
|
+
<div class="line ${first ? 'hidden' : ''}"></div>
|
|
798
|
+
<button
|
|
799
|
+
class="pager-dot ${this.loadingMore ? 'loading' : ''}"
|
|
800
|
+
@click=${this.handleShowOlder}
|
|
801
|
+
aria-label=${this.lang_show_older}
|
|
802
|
+
title=${this.lang_show_older}
|
|
803
|
+
>
|
|
804
|
+
<temba-icon name=${Icon.up_double} size="1"></temba-icon>
|
|
805
|
+
</button>
|
|
806
|
+
<div class="line ${last ? 'hidden' : ''}"></div>
|
|
807
|
+
</div>
|
|
808
|
+
<div
|
|
809
|
+
class="pager-label"
|
|
810
|
+
role="button"
|
|
811
|
+
tabindex="0"
|
|
812
|
+
aria-label=${this.lang_show_older}
|
|
813
|
+
@click=${this.handleShowOlder}
|
|
814
|
+
@keydown=${(e: KeyboardEvent) =>
|
|
815
|
+
this.handleActivationKey(e, () => this.handleShowOlder())}
|
|
816
|
+
title=${this.lang_show_older}
|
|
817
|
+
>
|
|
818
|
+
${this.lang_more}
|
|
819
|
+
</div>
|
|
820
|
+
</div>
|
|
821
|
+
`;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
if (row.kind === 'more-future') {
|
|
825
|
+
return html`
|
|
826
|
+
<div
|
|
827
|
+
class="row pager-row ${this.loadingMoreFuture ? 'loading' : ''}"
|
|
828
|
+
>
|
|
829
|
+
<div class="time"></div>
|
|
830
|
+
<div class="track">
|
|
831
|
+
<div class="line ${first ? 'hidden' : ''}"></div>
|
|
832
|
+
<button
|
|
833
|
+
class="pager-dot ${this.loadingMoreFuture ? 'loading' : ''}"
|
|
834
|
+
@click=${this.handleShowMore}
|
|
835
|
+
aria-label=${this.lang_show_more}
|
|
836
|
+
title=${this.lang_show_more}
|
|
837
|
+
>
|
|
838
|
+
<temba-icon name=${Icon.down_double} size="1"></temba-icon>
|
|
839
|
+
</button>
|
|
840
|
+
<div class="line ${last ? 'hidden' : ''}"></div>
|
|
841
|
+
</div>
|
|
842
|
+
<div
|
|
843
|
+
class="pager-label"
|
|
844
|
+
role="button"
|
|
845
|
+
tabindex="0"
|
|
846
|
+
aria-label=${this.lang_show_more}
|
|
847
|
+
@click=${this.handleShowMore}
|
|
848
|
+
@keydown=${(e: KeyboardEvent) =>
|
|
849
|
+
this.handleActivationKey(e, () => this.handleShowMore())}
|
|
850
|
+
title=${this.lang_show_more}
|
|
851
|
+
>
|
|
852
|
+
${this.lang_more}
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
`;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const dotIcon = this.getDotIcon(row.event);
|
|
859
|
+
return this.renderRow(
|
|
860
|
+
this.renderTime(row.event),
|
|
861
|
+
html`<div
|
|
862
|
+
class="dot ${row.past ? 'past' : 'future'}"
|
|
863
|
+
style="--dot-color:${this.getColor(row.event)}"
|
|
864
|
+
>
|
|
865
|
+
${dotIcon
|
|
866
|
+
? html`<temba-icon name=${dotIcon} size="0.65"></temba-icon>`
|
|
867
|
+
: null}
|
|
868
|
+
</div>`,
|
|
869
|
+
this.renderEvent(row.event),
|
|
870
|
+
first,
|
|
871
|
+
last
|
|
872
|
+
);
|
|
873
|
+
})}
|
|
874
|
+
</div>
|
|
875
|
+
${future.length > 0
|
|
876
|
+
? html`<div class="projection-note">${this.lang_projected_info}</div>`
|
|
877
|
+
: null}
|
|
878
|
+
`;
|
|
879
|
+
}
|
|
880
|
+
}
|