@nyaruka/temba-components 0.89.0 → 0.91.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.
Files changed (40) hide show
  1. package/.devcontainer/devcontainer.json +10 -2
  2. package/CHANGELOG.md +19 -0
  3. package/dist/temba-components.js +437 -443
  4. package/dist/temba-components.js.map +1 -1
  5. package/out-tsc/src/compose/Compose.js +21 -340
  6. package/out-tsc/src/compose/Compose.js.map +1 -1
  7. package/out-tsc/src/mediapicker/MediaPicker.js +312 -0
  8. package/out-tsc/src/mediapicker/MediaPicker.js.map +1 -0
  9. package/out-tsc/src/templates/TemplateEditor.js +74 -4
  10. package/out-tsc/src/templates/TemplateEditor.js.map +1 -1
  11. package/out-tsc/src/thumbnail/Thumbnail.js +31 -29
  12. package/out-tsc/src/thumbnail/Thumbnail.js.map +1 -1
  13. package/out-tsc/src/vectoricon/VectorIcon.js +0 -1
  14. package/out-tsc/src/vectoricon/VectorIcon.js.map +1 -1
  15. package/out-tsc/src/vectoricon/index.js +1 -0
  16. package/out-tsc/src/vectoricon/index.js.map +1 -1
  17. package/out-tsc/temba-modules.js +2 -0
  18. package/out-tsc/temba-modules.js.map +1 -1
  19. package/out-tsc/test/temba-compose.test.js +11 -12
  20. package/out-tsc/test/temba-compose.test.js.map +1 -1
  21. package/package.json +1 -1
  22. package/screenshots/truth/compose/attachments-with-all-files-and-click-send.png +0 -0
  23. package/screenshots/truth/compose/attachments-with-success-files-and-click-send.png +0 -0
  24. package/screenshots/truth/compose/chatbox-no-text-attachments-with-all-files-and-click-send.png +0 -0
  25. package/screenshots/truth/compose/chatbox-no-text-attachments-with-success-files-and-click-send.png +0 -0
  26. package/screenshots/truth/compose/chatbox-with-text-attachments-no-files-and-hit-enter.png +0 -0
  27. package/screenshots/truth/compose/chatbox-with-text-attachments-with-all-files-and-click-send.png +0 -0
  28. package/screenshots/truth/compose/chatbox-with-text-attachments-with-all-files-and-hit-enter.png +0 -0
  29. package/screenshots/truth/compose/chatbox-with-text-attachments-with-success-files-and-click-send.png +0 -0
  30. package/screenshots/truth/compose/chatbox-with-text-attachments-with-success-files-and-hit-enter.png +0 -0
  31. package/screenshots/truth/templates/default.png +0 -0
  32. package/screenshots/truth/templates/french.png +0 -0
  33. package/src/compose/Compose.ts +23 -378
  34. package/src/mediapicker/MediaPicker.ts +338 -0
  35. package/src/templates/TemplateEditor.ts +79 -4
  36. package/src/thumbnail/Thumbnail.ts +43 -39
  37. package/src/vectoricon/VectorIcon.ts +0 -1
  38. package/src/vectoricon/index.ts +1 -0
  39. package/temba-modules.ts +2 -0
  40. package/test/temba-compose.test.ts +13 -53
@@ -0,0 +1,338 @@
1
+ import { TemplateResult, css, html } from 'lit';
2
+ import { RapidElement } from '../RapidElement';
3
+ import { property } from 'lit/decorators.js';
4
+ import { Attachment } from '../interfaces';
5
+ import { Icon } from '../vectoricon';
6
+ import {
7
+ DEFAULT_MEDIA_ENDPOINT,
8
+ WebResponse,
9
+ getClasses,
10
+ isImageAttachment,
11
+ postFormData
12
+ } from '../utils';
13
+
14
+ const verifyAccept = (type: string, accept: string): boolean => {
15
+ if (accept) {
16
+ const allowed = accept.split(',').map((x) => x.trim());
17
+ return (
18
+ allowed.includes(type) || allowed.includes(type.split('/')[0] + '/*')
19
+ );
20
+ }
21
+ return true;
22
+ };
23
+
24
+ export class MediaPicker extends RapidElement {
25
+ static get styles() {
26
+ return css`
27
+ .drop-mask {
28
+ border-radius: var(--curvature-widget);
29
+ transition: opacity ease-in-out var(--transition-speed);
30
+ }
31
+
32
+ .highlight .drop-mask {
33
+ background: rgba(210, 243, 184, 0.8);
34
+ }
35
+
36
+ .drop-mask > div {
37
+ margin: auto;
38
+ border-radius: var(--curvature-widget);
39
+ font-weight: 400;
40
+ color: rgba(0, 0, 0, 0.5);
41
+ }
42
+
43
+ .attachments {
44
+ }
45
+
46
+ .attachments-list {
47
+ display: flex;
48
+ flex-direction: row;
49
+ flex-wrap: wrap;
50
+ padding: 0.2em;
51
+ }
52
+
53
+ .attachment-item {
54
+ padding: 0.4em;
55
+ padding-top: 1em;
56
+ }
57
+
58
+ .attachment-item.error {
59
+ background: #fff;
60
+ color: rgba(250, 0, 0, 0.75);
61
+ padding: 0.2em;
62
+ margin: 0.3em 0.5em;
63
+ border-radius: var(--curvature);
64
+ display: block;
65
+ }
66
+
67
+ .remove-item {
68
+ --icon-color: #ccc;
69
+ background: #fff;
70
+ border-radius: 99%;
71
+ transition: transform 200ms linear;
72
+ transform: scale(0);
73
+ display: block;
74
+ margin-bottom: -24px;
75
+ margin-left: 10px;
76
+ width: 1em;
77
+ height: 1em;
78
+ }
79
+
80
+ .attachment-item:hover .remove-item {
81
+ transform: scale(1);
82
+ }
83
+
84
+ .remove-item:hover {
85
+ --icon-color: #333;
86
+ cursor: pointer;
87
+ }
88
+
89
+ .attachment-name {
90
+ align-self: center;
91
+ font-size: 12px;
92
+ padding: 2px 8px;
93
+ }
94
+
95
+ #upload-input {
96
+ display: none;
97
+ }
98
+
99
+ .upload-label {
100
+ display: flex;
101
+ align-items: center;
102
+ }
103
+
104
+ .upload-icon {
105
+ color: rgb(102, 102, 102);
106
+ }
107
+
108
+ .add-attachment {
109
+ padding: 1em;
110
+ background-color: rgba(0, 0, 0, 0.05);
111
+ border-radius: var(--curvature);
112
+ color: #aaa;
113
+ margin: 0.5em;
114
+ }
115
+
116
+ .add-attachment:hover {
117
+ background-color: rgba(0, 0, 0, 0.07);
118
+ cursor: pointer;
119
+ }
120
+ `;
121
+ }
122
+
123
+ @property({ type: String, attribute: false })
124
+ endpoint = DEFAULT_MEDIA_ENDPOINT;
125
+
126
+ @property({ type: Boolean })
127
+ pendingDrop: boolean;
128
+
129
+ @property({ type: String })
130
+ icon = Icon.add;
131
+
132
+ @property({ type: String })
133
+ accept = ''; //e.g. ".xls,.xlsx"
134
+
135
+ @property({ type: Number })
136
+ max = 3;
137
+
138
+ @property({ type: Array })
139
+ attachments: Attachment[] = [];
140
+
141
+ @property({ type: Boolean, attribute: false })
142
+ uploading: boolean;
143
+
144
+ public updated(changes: Map<string, any>): void {
145
+ super.updated(changes);
146
+ if (changes.has('attachments')) {
147
+ // wait one cycle to fire change for tests
148
+ setTimeout(() => {
149
+ this.dispatchEvent(new Event('change'));
150
+ }, 0);
151
+ }
152
+ }
153
+
154
+ private getAcceptableFiles(evt: DragEvent): File[] {
155
+ const dt = evt.dataTransfer;
156
+ if (dt) {
157
+ const files = [...dt.files];
158
+ return files.filter((file) => verifyAccept(file.type, this.accept));
159
+ }
160
+ }
161
+
162
+ private handleDragEnter(evt: DragEvent): void {
163
+ this.highlight(evt);
164
+ }
165
+
166
+ private handleDragOver(evt: DragEvent): void {
167
+ this.highlight(evt);
168
+ }
169
+
170
+ private handleDragLeave(evt: DragEvent): void {
171
+ this.unhighlight(evt);
172
+ }
173
+
174
+ private handleDrop(evt: DragEvent): void {
175
+ this.unhighlight(evt);
176
+ if (this.canAcceptAttachments()) {
177
+ this.uploadFiles(this.getAcceptableFiles(evt));
178
+ }
179
+ }
180
+
181
+ public canAcceptAttachments() {
182
+ return this.attachments.length < this.max;
183
+ }
184
+
185
+ private highlight(evt: DragEvent): void {
186
+ evt.preventDefault();
187
+ evt.stopPropagation();
188
+ if (this.canAcceptAttachments()) {
189
+ this.pendingDrop = true;
190
+ }
191
+ }
192
+
193
+ private unhighlight(evt: DragEvent): void {
194
+ evt.preventDefault();
195
+ evt.stopPropagation();
196
+ this.pendingDrop = false;
197
+ }
198
+
199
+ private addCurrentAttachment(attachmentToAdd: any) {
200
+ this.attachments.push(attachmentToAdd);
201
+ this.requestUpdate('attachments');
202
+ }
203
+
204
+ private removeCurrentAttachment(attachmentToRemove: any) {
205
+ this.attachments = this.attachments.filter(
206
+ (currentAttachment) => currentAttachment !== attachmentToRemove
207
+ );
208
+ this.requestUpdate('attachments');
209
+ }
210
+
211
+ private handleRemoveFileClicked(evt: Event): void {
212
+ const target = evt.target as HTMLDivElement;
213
+ const currentAttachmentToRemove = this.attachments.find(
214
+ ({ url }) => url === target.id
215
+ );
216
+ if (currentAttachmentToRemove) {
217
+ this.removeCurrentAttachment(currentAttachmentToRemove);
218
+ }
219
+ }
220
+
221
+ private handleUploadFileInputChanged(evt: Event): void {
222
+ const target = evt.target as HTMLInputElement;
223
+ const files = target.files;
224
+ this.uploadFiles([...files]);
225
+ }
226
+
227
+ public uploadFiles(files: File[]): void {
228
+ let filesToUpload = [];
229
+
230
+ //remove duplicate files that have already been uploaded
231
+ filesToUpload = files.filter((file) => {
232
+ // check our file type against accepts
233
+ if (this.accept) {
234
+ if (!verifyAccept(file.type, this.accept)) {
235
+ return false;
236
+ }
237
+ }
238
+
239
+ const index = this.attachments.findIndex(
240
+ (value) => value.filename === file.name && value.size === file.size
241
+ );
242
+ if (index === -1) {
243
+ return file;
244
+ }
245
+ });
246
+
247
+ filesToUpload.map((fileToUpload) => {
248
+ this.uploadFile(fileToUpload);
249
+ });
250
+ }
251
+
252
+ private uploadFile(file: File): void {
253
+ this.uploading = true;
254
+
255
+ const url = this.endpoint;
256
+ const payload = new FormData();
257
+ payload.append('file', file);
258
+ postFormData(url, payload)
259
+ .then((response: WebResponse) => {
260
+ if (this.attachments.length < this.max) {
261
+ const attachment = response.json as Attachment;
262
+ if (attachment) {
263
+ this.addCurrentAttachment(attachment);
264
+ }
265
+ }
266
+ })
267
+ .catch((error: WebResponse) => {
268
+ let uploadError = '';
269
+ if (error.status === 400) {
270
+ uploadError = error.json.file[0];
271
+ } else {
272
+ uploadError = 'Server failure';
273
+ }
274
+ console.error(uploadError);
275
+ })
276
+ .finally(() => {
277
+ this.uploading = false;
278
+ });
279
+ }
280
+
281
+ private renderUploader(): TemplateResult {
282
+ if (this.uploading) {
283
+ return html`<temba-loading units="3" size="12"></temba-loading>`;
284
+ } else {
285
+ return this.attachments.length < this.max
286
+ ? html`<input
287
+ type="file"
288
+ id="upload-input"
289
+ ?multiple=${this.max > 1}
290
+ accept="${this.accept}"
291
+ @change="${this.handleUploadFileInputChanged}"
292
+ />
293
+ <label
294
+ id="upload-label"
295
+ class="actions-left upload-label"
296
+ for="upload-input"
297
+ >
298
+ <div class="add-attachment">
299
+ <temba-icon name="${this.icon}" size="1.5"></temba-icon>
300
+ </div>
301
+ </label>`
302
+ : null;
303
+ }
304
+ }
305
+
306
+ public render(): TemplateResult {
307
+ return html` <div
308
+ class=${getClasses({ container: true, highlight: this.pendingDrop })}
309
+ @dragenter="${this.handleDragEnter}"
310
+ @dragover="${this.handleDragOver}"
311
+ @dragleave="${this.handleDragLeave}"
312
+ @drop="${this.handleDrop}"
313
+ >
314
+ <div class="drop-mask">
315
+ <div class="attachments-list">
316
+ ${this.attachments.map((validAttachment) => {
317
+ return html`<div class="attachment-item">
318
+ <temba-icon
319
+ class="remove-item"
320
+ @click="${this.handleRemoveFileClicked}"
321
+ id="${validAttachment.url}"
322
+ name="${Icon.delete_small}"
323
+ ></temba-icon>
324
+ ${isImageAttachment(validAttachment)
325
+ ? html`<temba-thumbnail
326
+ url="${validAttachment.url}"
327
+ ></temba-thumbnail>`
328
+ : html`<temba-thumbnail
329
+ label="${validAttachment.content_type.split('/')[1]}"
330
+ ></temba-thumbnail>`}
331
+ </div>`;
332
+ })}
333
+ ${this.renderUploader()}
334
+ </div>
335
+ </div>
336
+ </div>`;
337
+ }
338
+ }
@@ -2,6 +2,7 @@ import { property } from 'lit/decorators.js';
2
2
  import { FormElement } from '../FormElement';
3
3
  import { TemplateResult, html, css, PropertyValueMap, LitElement } from 'lit';
4
4
  import { CustomEventType } from '../interfaces';
5
+ import { MediaPicker } from '../mediapicker/MediaPicker';
5
6
 
6
7
  interface Component {
7
8
  name: string;
@@ -41,6 +42,11 @@ export class TemplateEditor extends FormElement {
41
42
  padding: 1em;
42
43
  margin-top: 1em;
43
44
  }
45
+
46
+ .content {
47
+ margin-bottom: 1em;
48
+ }
49
+
44
50
  .picker {
45
51
  margin-bottom: 0.5em;
46
52
  display: block;
@@ -69,7 +75,6 @@ export class TemplateEditor extends FormElement {
69
75
  }
70
76
 
71
77
  .button-wrapper {
72
- margin-top: 1em;
73
78
  background: #f9f9f9;
74
79
  border-radius: var(--curvature);
75
80
  padding: 0.5em;
@@ -125,6 +130,9 @@ export class TemplateEditor extends FormElement {
125
130
  border: 1px solid var(--color-widget-border);
126
131
  padding: 1em;
127
132
  line-height: 2.2em;
133
+ max-height: 50vh;
134
+ overflow-y: auto;
135
+ overflow-x: hidden;
128
136
  }
129
137
  `;
130
138
  }
@@ -199,10 +207,31 @@ export class TemplateEditor extends FormElement {
199
207
  });
200
208
  }
201
209
 
210
+ private handleAttachmentsChanged(event: CustomEvent) {
211
+ const media = event.target as MediaPicker;
212
+ const index = parseInt(media.getAttribute('index'));
213
+
214
+ if (media.attachments.length === 0) {
215
+ this.variables[index] = '';
216
+ } else {
217
+ const attachment = media.attachments[0];
218
+ if (attachment.url && attachment.content_type) {
219
+ this.variables[index] = `${attachment.content_type}:${attachment.url}`;
220
+ } else {
221
+ this.variables[index] = ``;
222
+ }
223
+ }
224
+ this.fireContentChange();
225
+ }
226
+
202
227
  private handleVariableChanged(event: CustomEvent) {
203
228
  const target = event.target as HTMLInputElement;
204
229
  const variableIndex = parseInt(target.getAttribute('index'));
205
230
  this.variables[variableIndex] = target.value;
231
+ this.fireContentChange();
232
+ }
233
+
234
+ private fireContentChange() {
206
235
  this.fireCustomEvent(CustomEventType.ContentChanged, {
207
236
  template: this.selectedTemplate,
208
237
  translation: this.translation,
@@ -216,9 +245,11 @@ export class TemplateEditor extends FormElement {
216
245
  `{{(${Object.keys(component.variables || []).join('|')})}}`,
217
246
  'g'
218
247
  );
219
- const parts = component.content.split(variableRegex);
248
+
249
+ let variables = null;
250
+ const parts = component.content?.split(variableRegex) || [];
220
251
  if (parts.length > 0) {
221
- const variables = parts.map((part, index) => {
252
+ variables = parts.map((part, index) => {
222
253
  if (index % 2 === 0) {
223
254
  return html`<span class="text">${part}</span>`;
224
255
  }
@@ -235,8 +266,52 @@ export class TemplateEditor extends FormElement {
235
266
  placeholder="{{${part}}}"
236
267
  ></temba-completion>`;
237
268
  });
238
- return html`<div class="content">${variables}</div>`;
269
+ } else {
270
+ variables = Object.values(component.variables).map((variableIndex) => {
271
+ const variableSpec = this.translation.variables[variableIndex];
272
+ if (
273
+ variableSpec.type === 'image' ||
274
+ variableSpec.type === 'document' ||
275
+ variableSpec.type === 'audio' ||
276
+ variableSpec.type === 'video'
277
+ ) {
278
+ let attachments = [];
279
+ if (this.variables[variableIndex]) {
280
+ const parts = this.variables[variableIndex].split(':');
281
+ attachments = [{ url: parts[1], content_type: parts[0] }];
282
+ }
283
+
284
+ return html`<div
285
+ style="
286
+ display: flex;
287
+ align-items: center;
288
+ border-radius: var(--curvature);
289
+ ${attachments.length === 0
290
+ ? `background-color:rgba(255,0,0,.07);`
291
+ : ``}
292
+ "
293
+ >
294
+ <temba-media-picker
295
+ accept="${variableSpec.type === 'document'
296
+ ? 'application/pdf'
297
+ : variableSpec.type + '/*'}"
298
+ max="1"
299
+ index=${variableIndex}
300
+ icon="attachment_${variableSpec.type}"
301
+ attachments=${JSON.stringify(attachments)}
302
+ @change=${this.handleAttachmentsChanged.bind(this)}
303
+ ></temba-media-picker>
304
+ <div>
305
+ ${attachments.length == 0
306
+ ? html`Attach ${variableSpec.type} to continue`
307
+ : ''}
308
+ </div>
309
+ </div>`;
310
+ }
311
+ });
239
312
  }
313
+
314
+ return html`<div class="content">${variables}</div> `;
240
315
  }
241
316
 
242
317
  public renderComponents(components: Component[]): TemplateResult {
@@ -9,7 +9,7 @@ export class Thumbnail extends RapidElement {
9
9
  static get styles() {
10
10
  return css`
11
11
  :host {
12
- display: inline-block;
12
+ display: inline;
13
13
  }
14
14
 
15
15
  .zooming.wrapper {
@@ -54,6 +54,26 @@ export class Thumbnail extends RapidElement {
54
54
 
55
55
  public render() {
56
56
  if (this.zooming) {
57
+ const styles = {
58
+ backgroundColor: '#fafafa',
59
+ backgroundSize: 'contain',
60
+ backgroundPosition: 'center',
61
+ backgroundRepeat: 'no-repeat',
62
+ maxHeight: 'var(--thumb-size, 4em)',
63
+ height: 'var(--thumb-size, 4em)',
64
+ width: 'var(--thumb-size, 4em)',
65
+ borderRadius: '0',
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ justifyContent: 'center',
69
+ fontWeight: '400',
70
+ color: '#bbb'
71
+ };
72
+
73
+ if (this.url) {
74
+ styles['backgroundImage'] = `url(${this.url})`;
75
+ }
76
+
57
77
  return html`
58
78
  <div
59
79
  class="${getClasses({ wrapper: true })}"
@@ -63,30 +83,29 @@ export class Thumbnail extends RapidElement {
63
83
  boxShadow: 'var(--widget-box-shadow)'
64
84
  })}
65
85
  >
66
- <div
67
- class="thumb"
68
- style=${styleMap({
69
- backgroundImage: `url(${this.url})`,
70
- backgroundSize: 'contain',
71
- backgroundPosition: 'center',
72
- backgroundRepeat: 'no-repeat',
73
- maxHeight: 'var(--thumb-size, 4em)',
74
- height: 'var(--thumb-size, 4em)',
75
- width: 'var(--thumb-size, 4em)',
76
- borderRadius: '0',
77
-
78
- display: 'flex',
79
- alignItems: 'center',
80
- justifyContent: 'center',
81
- fontWeight: '400',
82
- color: '#bbb'
83
- })}
84
- >
85
- ${this.label}
86
- </div>
86
+ <div class="thumb" style=${styleMap(styles)}>${this.label}</div>
87
87
  </div>
88
88
  `;
89
89
  } else {
90
+ const styles = {
91
+ backgroundColor: '#fafafa',
92
+ backgroundSize: 'cover',
93
+ backgroundPosition: 'center',
94
+ maxHeight: 'var(--thumb-size, 4em)',
95
+ height: 'var(--thumb-size, 4em)',
96
+ width: 'var(--thumb-size, 4em)',
97
+ borderRadius: 'var(--curvature)',
98
+ display: 'flex',
99
+ alignItems: 'center',
100
+ justifyContent: 'center',
101
+ fontWeight: '400',
102
+ color: '#bbb'
103
+ };
104
+
105
+ if (this.url) {
106
+ styles['backgroundImage'] = `url(${this.url})`;
107
+ }
108
+
90
109
  return html`
91
110
  <div class="${getClasses({ wrapper: true })}" style=${styleMap({
92
111
  padding: 'var(--thumb-padding, 0.4em)',
@@ -94,24 +113,9 @@ export class Thumbnail extends RapidElement {
94
113
  borderRadius: 'var(--curvature)',
95
114
  boxShadow: 'var(--widget-box-shadow)'
96
115
  })}">
97
-
98
- <div class="thumb" style=${styleMap({
99
- backgroundImage: `url(${this.url})`,
100
- backgroundSize: 'cover',
101
- backgroundPosition: 'center',
102
- maxHeight: 'var(--thumb-size, 4em)',
103
- height: 'var(--thumb-size, 4em)',
104
- width: 'var(--thumb-size, 4em)',
105
- borderRadius: 'var(--curvature)',
106
-
107
- display: 'flex',
108
- alignItems: 'center',
109
- justifyContent: 'center',
110
- fontWeight: '400',
111
- color: '#bbb'
112
- })}>
116
+ <div class="thumb" style=${styleMap(styles)}>
113
117
  ${this.label}
114
- </div>
118
+ </div>
115
119
  </div>
116
120
  `;
117
121
  }
@@ -50,7 +50,6 @@ export class VectorIcon extends LitElement {
50
50
  static get styles() {
51
51
  return css`
52
52
  :host {
53
- display: flex-inline;
54
53
  align-items: center;
55
54
  align-self: center;
56
55
  }
@@ -186,6 +186,7 @@ export enum Icon {
186
186
  user = 'users-01',
187
187
  users = 'users-01',
188
188
  user_beta = 'shield-zap',
189
+ video = 'video-recorder',
189
190
  webhook = 'link-external-01',
190
191
  wit = 'wit',
191
192
  workspace = 'folder',
package/temba-modules.ts CHANGED
@@ -56,6 +56,7 @@ import { Mask } from './src/mask/Mask';
56
56
  import { TembaUser } from './src/user/TembaUser';
57
57
  import { TemplateEditor } from './src/templates/TemplateEditor';
58
58
  import { Toast } from './src/toast/Toast';
59
+ import { MediaPicker } from './src/mediapicker/MediaPicker';
59
60
 
60
61
  export function addCustomElement(name: string, comp: any) {
61
62
  if (!window.customElements.get(name)) {
@@ -122,3 +123,4 @@ addCustomElement('temba-mask', Mask);
122
123
  addCustomElement('temba-user', TembaUser);
123
124
  addCustomElement('temba-template-editor', TemplateEditor);
124
125
  addCustomElement('temba-toast', Toast);
126
+ addCustomElement('temba-media-picker', MediaPicker);