@nyaruka/temba-components 0.171.0 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/dist/temba-components.js +1015 -747
  3. package/dist/temba-components.js.map +1 -1
  4. package/package.json +1 -1
  5. package/src/RapidElement.ts +83 -41
  6. package/src/display/Button.ts +14 -2
  7. package/src/display/Chat.ts +41 -22
  8. package/src/display/Label.ts +18 -3
  9. package/src/events/eventRenderers.ts +4 -0
  10. package/src/flow/FlowSearch.ts +12 -392
  11. package/src/flow/actions/send_msg.ts +2 -1
  12. package/src/flow/actions/set_contact_field.ts +1 -0
  13. package/src/flow/actions/set_contact_name.ts +1 -0
  14. package/src/flow/nodes/split_by_ticket.ts +1 -0
  15. package/src/flow/nodes/wait_for_dial.ts +1 -0
  16. package/src/form/RangePicker.ts +10 -2
  17. package/src/form/select/Select.ts +15 -2
  18. package/src/layout/SearchModal.ts +564 -0
  19. package/src/list/BroadcastList.ts +17 -6
  20. package/src/list/ContentList.ts +29 -13
  21. package/src/list/FieldList.ts +8 -1
  22. package/src/list/TembaMenu.ts +71 -13
  23. package/src/live/CampaignEvents.ts +31 -11
  24. package/src/live/ContactChat.ts +276 -76
  25. package/src/live/ContactTimeline.ts +46 -24
  26. package/src/live/ContactWatch.ts +166 -63
  27. package/src/live/Realtime.ts +137 -8
  28. package/src/live/SocketService.ts +115 -10
  29. package/src/live/TicketSearch.ts +290 -0
  30. package/src/live/Watchers.ts +93 -0
  31. package/src/simulator/Simulator.ts +59 -36
  32. package/src/store/Store.ts +17 -34
  33. package/src/styles/designTokens.ts +57 -17
  34. package/src/styles/pillVariants.ts +42 -10
  35. package/static/css/design-system.css +47 -12
  36. package/static/css/temba-components.css +31 -9
  37. package/temba-modules.ts +2 -0
@@ -1,4 +1,5 @@
1
1
  import { Centrifuge, Subscription, SubscriptionState } from 'centrifuge';
2
+ import { Watchers } from './Watchers';
2
3
 
3
4
  /**
4
5
  * Access to our realtime messaging socket (centrifugo). The server lives
@@ -20,6 +21,11 @@ import { Centrifuge, Subscription, SubscriptionState } from 'centrifuge';
20
21
  * channel - the underlying centrifugo subscription is created on first use
21
22
  * and torn down when the last subscriber leaves. The connection itself stays
22
23
  * open for the life of the page. Each published event arrives as raw JSON.
24
+ *
25
+ * The connection's state is readable too, so a page can tell its user when it
26
+ * has gone quiet rather than silently showing stale data:
27
+ *
28
+ * window.sockets.onConnectionState((state) => { ... });
23
29
  */
24
30
 
25
31
  export interface SocketSubscription {
@@ -28,6 +34,19 @@ export interface SocketSubscription {
28
34
 
29
35
  export type PublicationHandler = (data: any) => void;
30
36
 
37
+ /**
38
+ * Where the shared connection is. Mirrors centrifugo's own client states -
39
+ * connecting covers the first attempt and every reconnect after a drop, so a
40
+ * page showing a "reconnecting" hint wants that one.
41
+ */
42
+ export enum ConnectionState {
43
+ Disconnected = 'disconnected',
44
+ Connecting = 'connecting',
45
+ Connected = 'connected'
46
+ }
47
+
48
+ export type ConnectionStateHandler = (state: ConnectionState) => void;
49
+
31
50
  export interface SocketProvider {
32
51
  subscribe(
33
52
  channel: string,
@@ -36,6 +55,10 @@ export interface SocketProvider {
36
55
  ): SocketSubscription;
37
56
 
38
57
  publish(channel: string, data: any): Promise<void>;
58
+
59
+ getConnectionState(): ConnectionState;
60
+
61
+ onConnectionState(handler: ConnectionStateHandler): SocketSubscription;
39
62
  }
40
63
 
41
64
  interface ChannelEntry {
@@ -48,6 +71,13 @@ export class SocketManager implements SocketProvider {
48
71
  private channels = new Map<string, ChannelEntry>();
49
72
  private createSocket: () => Centrifuge;
50
73
 
74
+ // no connection exists until something asks for one, so that is where we
75
+ // start rather than pretending we know the server is unreachable
76
+ private state = ConnectionState.Disconnected;
77
+ private stateHandlers = new Watchers<ConnectionStateHandler>(
78
+ 'socket connection handler'
79
+ );
80
+
51
81
  constructor(createSocket?: () => Centrifuge) {
52
82
  this.createSocket =
53
83
  createSocket ||
@@ -67,33 +97,98 @@ export class SocketManager implements SocketProvider {
67
97
  * and fan-out; a rejection means the publication was denied.
68
98
  */
69
99
  public publish(channel: string, data: any): Promise<void> {
70
- if (!this.socket) {
71
- this.socket = this.createSocket();
72
- }
100
+ const socket = this.ensureSocket();
73
101
 
74
102
  // prefer the channel's live subscription when we have one
75
103
  const entry = this.channels.get(channel);
76
104
  const published = entry
77
105
  ? entry.sub.publish(data)
78
- : this.socket.publish(channel, data);
106
+ : socket.publish(channel, data);
79
107
  return published.then(() => undefined);
80
108
  }
81
109
 
110
+ /**
111
+ * The shared connection, opened on first use. Its state is tracked from
112
+ * here on, seeded with whatever it is already in - creating it connects, so
113
+ * the first transition can happen before we are listening.
114
+ */
115
+ private ensureSocket(): Centrifuge {
116
+ if (!this.socket) {
117
+ this.socket = this.createSocket();
118
+ this.socket.on('connecting', () =>
119
+ this.setState(ConnectionState.Connecting)
120
+ );
121
+ this.socket.on('connected', () =>
122
+ this.setState(ConnectionState.Connected)
123
+ );
124
+ this.socket.on('disconnected', () =>
125
+ this.setState(ConnectionState.Disconnected)
126
+ );
127
+ this.setState(this.socket.state as unknown as ConnectionState);
128
+ }
129
+ return this.socket;
130
+ }
131
+
132
+ // counts transitions, so an initial delivery can tell whether one beat it
133
+ // to the handler
134
+ private stateSeq = 0;
135
+
136
+ private setState(state: ConnectionState): void {
137
+ if (!state || state === this.state) {
138
+ return;
139
+ }
140
+ this.state = state;
141
+ this.stateSeq++;
142
+ this.stateHandlers.each((handler) => handler(state));
143
+ }
144
+
145
+ /**
146
+ * Where the shared connection is right now. Disconnected until something
147
+ * subscribes or publishes, since that is what opens it.
148
+ */
149
+ public getConnectionState(): ConnectionState {
150
+ return this.state;
151
+ }
152
+
153
+ /**
154
+ * Watches the connection. The handler is called on every transition, and
155
+ * once up front with the current state so a caller never has to pair this
156
+ * with getConnectionState to render.
157
+ */
158
+ public onConnectionState(
159
+ handler: ConnectionStateHandler
160
+ ): SocketSubscription {
161
+ this.stateHandlers.add(handler);
162
+
163
+ // where we are now, rather than wherever we've got to by the time the
164
+ // prime lands - opening the connection in this same tick transitions us
165
+ // and delivers that to the handler ahead of it, and saying the same thing
166
+ // again is the repeat this is supposed to suppress
167
+ const initial = this.state;
168
+ const seq = this.stateSeq;
169
+ this.stateHandlers.prime(handler, () => {
170
+ if (this.stateSeq === seq) {
171
+ handler(initial);
172
+ }
173
+ });
174
+ return {
175
+ unsubscribe: () => {
176
+ this.stateHandlers.remove(handler);
177
+ }
178
+ };
179
+ }
180
+
82
181
  public subscribe(
83
182
  channel: string,
84
183
  onPublication: PublicationHandler,
85
184
  onSubscribed?: () => void
86
185
  ): SocketSubscription {
87
- if (!this.socket) {
88
- this.socket = this.createSocket();
89
- }
186
+ const socket = this.ensureSocket();
90
187
 
91
188
  let entry = this.channels.get(channel);
92
189
  if (!entry) {
93
190
  entry = {
94
- sub:
95
- this.socket.getSubscription(channel) ||
96
- this.socket.newSubscription(channel),
191
+ sub: socket.getSubscription(channel) || socket.newSubscription(channel),
97
192
  count: 0
98
193
  };
99
194
  this.channels.set(channel, entry);
@@ -174,6 +269,16 @@ export const publishToSocket = (channel: string, data: any): Promise<void> => {
174
269
  return (provider || getManager()).publish(channel, data);
175
270
  };
176
271
 
272
+ export const getSocketConnectionState = (): ConnectionState => {
273
+ return (provider || getManager()).getConnectionState();
274
+ };
275
+
276
+ export const onSocketConnectionState = (
277
+ handler: ConnectionStateHandler
278
+ ): SocketSubscription => {
279
+ return (provider || getManager()).onConnectionState(handler);
280
+ };
281
+
177
282
  // for tests to swap in a mock provider, returns the previous provider
178
283
  export const setSocketProvider = (newProvider: SocketProvider) => {
179
284
  const previous = provider;
@@ -0,0 +1,290 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { ModalSearchResult, SearchModal } from '../layout/SearchModal';
4
+ import { getUrl } from '../utils';
5
+
6
+ // The matched message event as the endpoint returns it
7
+ export interface TicketSearchEvent {
8
+ uuid: string;
9
+ type: string;
10
+ created_on: string;
11
+ ticket_uuid?: string;
12
+ msg?: { text?: string };
13
+ [key: string]: any;
14
+ }
15
+
16
+ export interface TicketSearchResult extends ModalSearchResult {
17
+ contact: { uuid: string; name: string };
18
+ ticket: { uuid: string; status: string };
19
+ event: TicketSearchEvent;
20
+ // The query that produced this result, so selection handlers can re-run
21
+ // the search within the ticket
22
+ query: string;
23
+ }
24
+
25
+ const BADGE_COLOR = '#6b7280';
26
+
27
+ // rough number of characters that fit on a result row - the snippet is
28
+ // windowed around the match so the match stays visible if CSS clips the tail
29
+ const SNIPPET_BUDGET = 60;
30
+
31
+ // the shortest prefix we'll fall back to when a query term doesn't appear
32
+ // literally - short enough to cover most inflections, long enough that the
33
+ // highlight still points at the word the user searched for
34
+ const MIN_PREFIX = 4;
35
+
36
+ // strips everything but letters and numbers, so punctuation in the query
37
+ // doesn't keep a term from matching the message text
38
+ const stripNonWord = (text: string) => text.replace(/[^\p{L}\p{N}]/gu, '');
39
+
40
+ // same, but only at the ends, so the words of a multi-word query stay apart
41
+ const stripOuterNonWord = (text: string) =>
42
+ text.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
43
+
44
+ // the candidates to look for, in priority order, without duplicates
45
+ const withoutDupes = (candidates: string[]) =>
46
+ candidates.filter(
47
+ (candidate, index) => !!candidate && candidates.indexOf(candidate) === index
48
+ );
49
+
50
+ /**
51
+ * Searches message text across all of an org's ticket chats. Searching hits
52
+ * an endpoint, so it waits for Enter rather than running on every keystroke.
53
+ */
54
+ export class TicketSearch extends SearchModal<TicketSearchResult> {
55
+ static styles = [
56
+ SearchModal.styles,
57
+ css`
58
+ .result-type-badge {
59
+ max-width: 35%;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ }
63
+
64
+ .result-text {
65
+ flex-grow: 1;
66
+ }
67
+
68
+ .result-date {
69
+ flex-shrink: 0;
70
+ font-size: 11px;
71
+ color: #9ca3af;
72
+ }
73
+ `
74
+ ];
75
+
76
+ @property({ type: String })
77
+ endpoint = '/ticket/search/';
78
+
79
+ // searching hits an endpoint, so wait for Enter
80
+ searchOnEnter = true;
81
+
82
+ // how many matches the last search found in tickets this user can't access,
83
+ // so an empty result set can say why it's empty
84
+ @state()
85
+ private dropped = 0;
86
+
87
+ // the in-flight request, aborted when it's superseded or the modal closes
88
+ private abortController: AbortController = null;
89
+
90
+ protected cancelSearch(): void {
91
+ this.abortSearch();
92
+ this.dropped = 0;
93
+ }
94
+
95
+ private abortSearch(): void {
96
+ if (this.abortController) {
97
+ this.abortController.abort();
98
+ this.abortController = null;
99
+ }
100
+ }
101
+
102
+ protected getSearchLabel(): string {
103
+ return 'Search tickets';
104
+ }
105
+
106
+ protected renderHint(): TemplateResult {
107
+ return html`<div class="hint">
108
+ <kbd>Enter</kbd> to search &nbsp; <kbd>Esc</kbd> to close
109
+ </div>`;
110
+ }
111
+
112
+ /**
113
+ * The endpoint filters matches down to the tickets this user can access
114
+ * after the search itself is capped, so a query that matched plenty can
115
+ * still come back empty. Say so rather than claiming nothing matched.
116
+ */
117
+ protected getNoResultsMessage(): string {
118
+ return this.dropped > 0
119
+ ? 'No matches in tickets you can access'
120
+ : super.getNoResultsMessage();
121
+ }
122
+
123
+ protected async performSearch(query: string): Promise<TicketSearchResult[]> {
124
+ this.abortSearch();
125
+ this.dropped = 0;
126
+
127
+ const controller = new AbortController();
128
+ this.abortController = controller;
129
+
130
+ const joiner = this.endpoint.includes('?') ? '&' : '?';
131
+ const response = await getUrl(
132
+ `${this.endpoint}${joiner}text=${encodeURIComponent(query)}`,
133
+ controller
134
+ );
135
+
136
+ if (this.abortController === controller) {
137
+ this.abortController = null;
138
+ }
139
+
140
+ const json = response.json as any;
141
+ this.dropped = json.dropped || 0;
142
+
143
+ const results = json.results || [];
144
+ return results.map((result: any) => this.toSearchResult(result, query));
145
+ }
146
+
147
+ /**
148
+ * Locates a term in the message text, falling back to progressively
149
+ * shorter prefixes of it. The backend matches on analyzed text, so a query
150
+ * term ("helping") can match a message that only contains a stemmed or
151
+ * inflected form of it ("helped") - highlighting the longest prefix that
152
+ * does appear keeps the match visible rather than showing nothing.
153
+ */
154
+ private findTerm(
155
+ lowerText: string,
156
+ term: string,
157
+ minLength: number
158
+ ): { start: number; length: number } | null {
159
+ for (let length = term.length; length >= minLength; length--) {
160
+ const idx = lowerText.indexOf(term.slice(0, length));
161
+ if (idx !== -1) {
162
+ return { start: idx, length };
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+
168
+ private toSearchResult(result: any, query: string): TicketSearchResult {
169
+ const text = result.event?.msg?.text || result.event?.text || '';
170
+ const lowerText = text.toLowerCase();
171
+ const lowerQuery = query.trim().toLowerCase();
172
+
173
+ let matchStart = -1;
174
+ let matchLength = 0;
175
+
176
+ // highlight the whole query if it appears verbatim (with or without any
177
+ // punctuation it's wrapped in), otherwise the first matching term - the
178
+ // backend matches terms independently, and against analyzed text, so a
179
+ // term is tried both as typed and stripped of its punctuation
180
+ const queries = withoutDupes([lowerQuery, stripOuterNonWord(lowerQuery)]);
181
+ for (const candidate of queries) {
182
+ const idx = lowerText.indexOf(candidate);
183
+ if (idx !== -1) {
184
+ matchStart = idx;
185
+ matchLength = candidate.length;
186
+ break;
187
+ }
188
+ }
189
+
190
+ const terms = withoutDupes(
191
+ lowerQuery.split(/\s+/).reduce((all: string[], term) => {
192
+ all.push(term, stripNonWord(term));
193
+ return all;
194
+ }, [])
195
+ );
196
+
197
+ // literal matches first, so an exact hit on a later term beats a
198
+ // prefix hit on an earlier one
199
+ for (const minLength of [Infinity, MIN_PREFIX]) {
200
+ if (matchStart !== -1) {
201
+ break;
202
+ }
203
+ for (const term of terms) {
204
+ const match = this.findTerm(
205
+ lowerText,
206
+ term,
207
+ Math.min(minLength, term.length)
208
+ );
209
+ if (match) {
210
+ matchStart = match.start;
211
+ matchLength = match.length;
212
+ break;
213
+ }
214
+ }
215
+ }
216
+
217
+ if (matchStart === -1) {
218
+ matchStart = 0;
219
+ matchLength = 0;
220
+ }
221
+
222
+ return {
223
+ typeName: result.contact?.name || 'Unknown',
224
+ color: BADGE_COLOR,
225
+ fullText: text,
226
+ matchStart,
227
+ matchLength,
228
+ contact: result.contact,
229
+ ticket: result.ticket,
230
+ event: result.event,
231
+ query: query.trim()
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Unlike the base (which anchors the match near the start and lets CSS
237
+ * clip the tail), message snippets are windowed to roughly center the
238
+ * match, with ellipses marking clipped context on either side.
239
+ */
240
+ protected renderMatchText(result: TicketSearchResult): TemplateResult {
241
+ const text = result.fullText.replace(/\n/g, ' ');
242
+ const { matchStart, matchLength } = result;
243
+
244
+ if (matchLength === 0) {
245
+ return html`${text}`;
246
+ }
247
+
248
+ const matchEnd = matchStart + matchLength;
249
+ const highlight = (start: number, end: number) =>
250
+ html`${start > 0 ? '…' : ''}${text.slice(
251
+ start,
252
+ matchStart
253
+ )}<mark>${text.slice(matchStart, matchEnd)}</mark>${text.slice(
254
+ matchEnd,
255
+ end
256
+ )}${end < text.length ? '…' : ''}`;
257
+
258
+ if (text.length <= SNIPPET_BUDGET) {
259
+ return highlight(0, text.length);
260
+ }
261
+
262
+ // center the match in the character budget, shifting the window back
263
+ // into range when the match sits near either end of the text
264
+ const context = Math.max(0, SNIPPET_BUDGET - matchLength);
265
+ let start = matchStart - Math.floor(context / 2);
266
+ let end = matchEnd + Math.ceil(context / 2);
267
+ if (start < 0) {
268
+ end = Math.min(text.length, end - start);
269
+ start = 0;
270
+ }
271
+ if (end > text.length) {
272
+ start = Math.max(0, start - (end - text.length));
273
+ end = text.length;
274
+ }
275
+
276
+ return highlight(start, end);
277
+ }
278
+
279
+ protected renderResultContent(result: TicketSearchResult): TemplateResult {
280
+ return html`
281
+ ${super.renderResultContent(result)}
282
+ <div class="result-date">
283
+ <temba-date
284
+ value=${result.event?.created_on}
285
+ display="duration"
286
+ ></temba-date>
287
+ </div>
288
+ `;
289
+ }
290
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * A list of subscribers with safe fan-out.
3
+ *
4
+ * Every registry we keep - contact watchers, the store's asset watchers,
5
+ * socket connection listeners - hands events to page components we don't
6
+ * control, so they all need the same three things: one subscriber throwing
7
+ * can't cost the others their delivery, a delivery iterates a snapshot so
8
+ * unsubscribing mid-fan-out doesn't shift the list underneath it, and
9
+ * unsubscribing twice does nothing the second time.
10
+ *
11
+ * The subscriber record and how an event reaches it belong to the caller -
12
+ * interests, payloads and arities differ - so this owns only the list and the
13
+ * delivery discipline.
14
+ */
15
+ export class Watchers<W> {
16
+ private watchers: W[] = [];
17
+
18
+ // names the subscriber when reporting one that threw
19
+ private label: string;
20
+
21
+ constructor(label: string) {
22
+ this.label = label;
23
+ }
24
+
25
+ public get size(): number {
26
+ return this.watchers.length;
27
+ }
28
+
29
+ public has(watcher: W): boolean {
30
+ return this.watchers.includes(watcher);
31
+ }
32
+
33
+ public some(predicate: (watcher: W) => boolean): boolean {
34
+ return this.watchers.some(predicate);
35
+ }
36
+
37
+ /** A snapshot, for callers that need to read across everyone registered. */
38
+ public all(): W[] {
39
+ return [...this.watchers];
40
+ }
41
+
42
+ public add(watcher: W): void {
43
+ this.watchers.push(watcher);
44
+ }
45
+
46
+ /** Removes it, reporting whether it was still registered. */
47
+ public remove(watcher: W): boolean {
48
+ const index = this.watchers.indexOf(watcher);
49
+ if (index < 0) {
50
+ return false;
51
+ }
52
+ this.watchers.splice(index, 1);
53
+ return true;
54
+ }
55
+
56
+ public clear(): void {
57
+ this.watchers.length = 0;
58
+ }
59
+
60
+ /** One delivery, with a subscriber's failure kept to itself. */
61
+ public deliver(watcher: W, deliver: (watcher: W) => void): void {
62
+ try {
63
+ deliver(watcher);
64
+ } catch (error) {
65
+ console.error(`${this.label} failed`, error);
66
+ }
67
+ }
68
+
69
+ /** Delivers to everyone registered now, or just those matching. */
70
+ public each(
71
+ deliver: (watcher: W) => void,
72
+ matches?: (watcher: W) => boolean
73
+ ): void {
74
+ for (const watcher of this.all()) {
75
+ if (!matches || matches(watcher)) {
76
+ this.deliver(watcher, deliver);
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * An initial delivery to one subscriber, off the current task so it lands
83
+ * the way a live one would rather than before they can use the handle they
84
+ * are being given. Skipped if they leave before it lands.
85
+ */
86
+ public prime(watcher: W, deliver: (watcher: W) => void): void {
87
+ Promise.resolve().then(() => {
88
+ if (this.has(watcher)) {
89
+ this.deliver(watcher, deliver);
90
+ }
91
+ });
92
+ }
93
+ }
@@ -11,28 +11,49 @@ import { CustomEventType } from '../interfaces';
11
11
  import { Chat, ContactEvent, MessageType } from '../display/Chat';
12
12
  import { Events, renderEvent } from '../events/eventRenderers';
13
13
 
14
- // test attachment URLs
15
- const TEST_IMAGES = [
16
- 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_a.jpg',
17
- 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_b.jpg',
18
- 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_c.jpg',
19
- 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_d.jpg'
20
- ];
21
-
22
- const TEST_VIDEOS = [
23
- 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_video_a.mp4'
24
- ];
25
-
26
- const TEST_AUDIO = [
27
- 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_audio_a.mp3'
28
- ];
29
-
30
- const TEST_LOCATIONS = [
31
- 'geo:47.6062,-122.3321', // Seattle
32
- 'geo:-0.1807,-78.4678', // Quito
33
- 'geo:-2.9001,-79.0059', // Cuenca
34
- 'geo:-1.9536,30.0606' // Kigali
35
- ];
14
+ export interface SampleMedia {
15
+ images: string[];
16
+ videos: string[];
17
+ audio: string[];
18
+ locations: string[];
19
+ }
20
+
21
+ // the sample media the simulator attaches, hosted alongside our other
22
+ // simulator assets
23
+ const HOSTED_SAMPLES: SampleMedia = {
24
+ images: [
25
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_a.jpg',
26
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_b.jpg',
27
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_c.jpg',
28
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_image_d.jpg'
29
+ ],
30
+ videos: [
31
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_video_a.mp4'
32
+ ],
33
+ audio: [
34
+ 'https://s3.amazonaws.com/floweditor-assets.temba.io/simulator/sim_audio_a.mp3'
35
+ ],
36
+ locations: [
37
+ 'geo:47.6062,-122.3321', // Seattle
38
+ 'geo:-0.1807,-78.4678', // Quito
39
+ 'geo:-2.9001,-79.0059', // Cuenca
40
+ 'geo:-1.9536,30.0606' // Kigali
41
+ ]
42
+ };
43
+
44
+ let samples: SampleMedia = HOSTED_SAMPLES;
45
+
46
+ /**
47
+ * Points the simulator's sample attachments somewhere else, returning the
48
+ * previous set so a caller can put it back. For tests, which shouldn't need
49
+ * our asset host to be reachable to run - the simulator itself always
50
+ * attaches the hosted samples above.
51
+ */
52
+ export const setSampleMedia = (media: Partial<SampleMedia>): SampleMedia => {
53
+ const previous = samples;
54
+ samples = { ...samples, ...media };
55
+ return previous;
56
+ };
36
57
 
37
58
  const truncateUrl = (url: string, maxLen = 50): string =>
38
59
  url && url.length > maxLen ? url.slice(0, maxLen) + '…' : url;
@@ -888,14 +909,15 @@ export class Simulator extends RapidElement {
888
909
  private boundClickOutsideHandler: ((event: MouseEvent) => void) | null = null;
889
910
 
890
911
  // attachment cycling indices - initialized randomly
891
- private imageIndex = Math.floor(Math.random() * TEST_IMAGES.length);
892
- private videoIndex = Math.floor(Math.random() * TEST_VIDEOS.length);
893
- private audioIndex = Math.floor(Math.random() * TEST_AUDIO.length);
894
- private locationIndex = Math.floor(Math.random() * TEST_LOCATIONS.length);
912
+ private imageIndex = Math.floor(Math.random() * samples.images.length);
913
+ private videoIndex = Math.floor(Math.random() * samples.videos.length);
914
+ private audioIndex = Math.floor(Math.random() * samples.audio.length);
915
+ private locationIndex = Math.floor(Math.random() * samples.locations.length);
895
916
 
896
- // method to reset attachment indices for testing
917
+ // starts the attachment cycling from the top, so a test gets the same
918
+ // sample every run rather than wherever the random start landed
897
919
  public resetAttachmentIndices() {
898
- this.imageIndex = 2;
920
+ this.imageIndex = 0;
899
921
  this.videoIndex = 0;
900
922
  this.audioIndex = 0;
901
923
  this.locationIndex = 0;
@@ -2176,20 +2198,21 @@ export class Simulator extends RapidElement {
2176
2198
  let attachment = '';
2177
2199
  switch (attachmentType) {
2178
2200
  case 'image':
2179
- attachment = `image/jpeg:${TEST_IMAGES[this.imageIndex]}`;
2180
- this.imageIndex = (this.imageIndex + 1) % TEST_IMAGES.length;
2201
+ attachment = `image/jpeg:${samples.images[this.imageIndex]}`;
2202
+ this.imageIndex = (this.imageIndex + 1) % samples.images.length;
2181
2203
  break;
2182
2204
  case 'video':
2183
- attachment = `video/mp4:${TEST_VIDEOS[this.videoIndex]}`;
2184
- this.videoIndex = (this.videoIndex + 1) % TEST_VIDEOS.length;
2205
+ attachment = `video/mp4:${samples.videos[this.videoIndex]}`;
2206
+ this.videoIndex = (this.videoIndex + 1) % samples.videos.length;
2185
2207
  break;
2186
2208
  case 'audio':
2187
- attachment = `audio/mp3:${TEST_AUDIO[this.audioIndex]}`;
2188
- this.audioIndex = (this.audioIndex + 1) % TEST_AUDIO.length;
2209
+ attachment = `audio/mp3:${samples.audio[this.audioIndex]}`;
2210
+ this.audioIndex = (this.audioIndex + 1) % samples.audio.length;
2189
2211
  break;
2190
2212
  case 'location':
2191
- attachment = TEST_LOCATIONS[this.locationIndex];
2192
- this.locationIndex = (this.locationIndex + 1) % TEST_LOCATIONS.length;
2213
+ attachment = samples.locations[this.locationIndex];
2214
+ this.locationIndex =
2215
+ (this.locationIndex + 1) % samples.locations.length;
2193
2216
  break;
2194
2217
  }
2195
2218