@nyaruka/temba-components 0.158.1 → 0.159.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.
@@ -1,14 +1,16 @@
1
1
  import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
+ import { Flow, ObjectReference } from '../interfaces';
4
5
 
5
6
  /**
6
7
  * Flow CRUDL list — drop-in replacement for the rapidpro
7
8
  * `flows/flow_list.html` table. Each row's leading icon reflects
8
9
  * the flow type (messaging / voice / background / surveyor).
9
- * Columns: name, runs, ongoing, completion bar, status, modified.
10
+ * Columns: name, status, runs, ongoing, completion bar, activity
11
+ * sparkline.
10
12
  */
11
- export class FlowList extends ContentList<any> {
13
+ export class FlowList extends ContentList<Flow> {
12
14
  static get styles() {
13
15
  return css`
14
16
  ${ContentList.styles}
@@ -83,43 +85,35 @@ export class FlowList extends ContentList<any> {
83
85
  this.emptyMessage = 'No flows';
84
86
  this.searchPlaceholder = 'Search flows';
85
87
  this.columns = [
86
- { key: 'name', label: 'Name', sortable: true, grow: 3 },
87
88
  {
88
- key: 'status',
89
- label: 'Status',
90
- width: '110px',
91
- grow: 0
89
+ key: 'name',
90
+ label: 'Name',
91
+ sortable: true,
92
+ width: '280px',
93
+ pinned: true
92
94
  },
95
+ { key: 'status', label: 'Status', width: '110px' },
93
96
  {
94
97
  key: 'runs',
95
98
  label: 'Runs',
96
99
  sortable: true,
97
- width: '80px',
98
- grow: 0,
100
+ width: '90px',
99
101
  align: 'right'
100
102
  },
101
103
  {
102
104
  key: 'ongoing',
103
105
  label: 'Ongoing',
104
106
  sortable: true,
105
- width: '80px',
106
- grow: 0,
107
+ width: '90px',
107
108
  align: 'right'
108
109
  },
109
110
  {
110
111
  key: 'completion',
111
112
  label: 'Completion',
112
- width: '120px',
113
- grow: 0,
113
+ width: '130px',
114
114
  align: 'right'
115
115
  },
116
- {
117
- key: 'activity',
118
- label: 'Activity',
119
- width: '120px',
120
- grow: 0,
121
- align: 'right'
122
- }
116
+ { key: 'activity', label: 'Activity', width: '120px', align: 'right' }
123
117
  ];
124
118
  this.bulkActions = [
125
119
  {
@@ -133,7 +127,7 @@ export class FlowList extends ContentList<any> {
133
127
  ];
134
128
  }
135
129
 
136
- protected getRowIcon(item: any): string | null {
130
+ protected getRowIcon(item: Flow): string | null {
137
131
  switch (item?.type) {
138
132
  case 'voice':
139
133
  case 'ivr':
@@ -147,12 +141,12 @@ export class FlowList extends ContentList<any> {
147
141
  }
148
142
  }
149
143
 
150
- protected getRowHref(item: any): string | null {
144
+ protected getRowHref(item: Flow): string | null {
151
145
  return item?.uuid ? `/flow/editor/${item.uuid}/` : null;
152
146
  }
153
147
 
154
148
  protected renderCell(
155
- item: any,
149
+ item: Flow,
156
150
  column: ContentListColumn
157
151
  ): TemplateResult | string {
158
152
  switch (column.key) {
@@ -170,7 +164,7 @@ export class FlowList extends ContentList<any> {
170
164
  ${labels.length > 0
171
165
  ? html`<span class="flow-labels">
172
166
  ${labels.map(
173
- (l: any) =>
167
+ (l: ObjectReference) =>
174
168
  html`<temba-label type="label" icon=${Icon.label}
175
169
  >${l.name}</temba-label
176
170
  >`
@@ -196,7 +190,7 @@ export class FlowList extends ContentList<any> {
196
190
  }
197
191
  }
198
192
 
199
- private renderCompletion(item: any): TemplateResult {
193
+ private renderCompletion(item: Flow): TemplateResult {
200
194
  const value = typeof item.completion === 'number' ? item.completion : 0;
201
195
  const pct = Math.round(value * 100);
202
196
  return html`
@@ -242,7 +236,7 @@ export class FlowList extends ContentList<any> {
242
236
  `;
243
237
  }
244
238
 
245
- private renderFlowStatus(item: any): TemplateResult {
239
+ private renderFlowStatus(item: Flow): TemplateResult {
246
240
  const status = (item.status || 'active').toString().toLowerCase();
247
241
  const kind = status === 'archived' ? 'archived' : 'active';
248
242
  const label = status === 'archived' ? 'Archived' : 'Active';
@@ -1,48 +1,94 @@
1
1
  import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
+ import { Msg } from '../interfaces';
4
5
 
5
6
  /**
6
7
  * Message CRUDL list — drop-in replacement for the rapidpro
7
- * `msgs/msg_list.html` table. Reverse-chronological; rows carry
8
- * contact name, message text + attachments + labels + active-flow
9
- * pill, and a duration timestamp.
8
+ * `msgs/msg_list.html` table. Reverse-chronological; the message
9
+ * cell carries the body text with its attachment thumbnails right
10
+ * after it and the flow / label pills pushed to the trailing edge,
11
+ * with a duration timestamp closing the row.
10
12
  */
11
- export class MsgList extends ContentList<any> {
13
+ export class MsgList extends ContentList<Msg> {
12
14
  static get styles() {
13
15
  return css`
14
16
  ${ContentList.styles}
15
- .msg-meta {
17
+ /* The message cell holds the body text with its attachment
18
+ thumbnails right after it, and the flow / label pills pushed
19
+ to the trailing edge. The text sizes to content and
20
+ ellipsizes when squeezed — resolved per row, so a busy row
21
+ doesn't widen a column for all of them. */
22
+ .msg-cell {
16
23
  display: flex;
17
24
  align-items: center;
18
- gap: 6px;
19
- flex-wrap: wrap;
20
- font-size: 12px;
21
- color: var(--text-3);
25
+ gap: 12px;
22
26
  }
23
27
  .msg-text {
24
- display: block;
25
- color: inherit;
26
- font-weight: var(--w-regular);
28
+ flex: 0 1 auto;
29
+ /* A small floor so the message keeps a few words even on a
30
+ row whose meta is wide — the message yields most of its
31
+ width to the attachments and pills before they clip. */
32
+ min-width: 80px;
27
33
  overflow: hidden;
28
34
  text-overflow: ellipsis;
29
35
  white-space: nowrap;
30
36
  }
31
- .msg-row {
37
+ /* Attachment thumbnails sit immediately after the text. */
38
+ .msg-attachments {
39
+ flex: 0 0 auto;
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 6px;
43
+ }
44
+ /* Flow + label pills, pushed to the trailing edge of the cell. */
45
+ .cell-pills {
46
+ flex: 0 0 auto;
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 6px;
50
+ margin-left: auto;
51
+ }
52
+ /* Attachment thumbnails are sized well below the 44px row so
53
+ they never grow its height — a small square preview rather
54
+ than the chat history's full-size thumbnail. --thumb-icon-
55
+ padding shrinks temba-thumbnail's non-image fallback (the
56
+ document/video icon box) to match. The max-height clamp is
57
+ a backstop for any type that still carries its own larger
58
+ intrinsic size (e.g. a location map). */
59
+ .msg-thumb {
60
+ --thumb-size: 24px;
61
+ --thumb-padding: 2px;
62
+ --thumb-icon-padding: 4px;
63
+ max-height: 36px;
64
+ overflow: hidden;
65
+ }
66
+ /* Sent cell — the date with an optional channel-log icon to
67
+ its right. The cell stays right-aligned (the column's own
68
+ alignment) and uses a flex row so the icon sits flush
69
+ against the date with a small gap. */
70
+ .sent-cell {
32
71
  display: flex;
33
- flex-direction: column;
34
- gap: 2px;
72
+ align-items: center;
73
+ justify-content: flex-end;
74
+ gap: 6px;
35
75
  }
36
- .attachment-pill {
76
+ /* Channel-log icon link inside the sent cell. */
77
+ .msg-log {
78
+ flex: 0 0 auto;
37
79
  display: inline-flex;
38
80
  align-items: center;
39
- gap: 3px;
40
- padding: 0 6px 0 4px;
41
- border-radius: 999px;
42
- background: var(--sunken);
81
+ padding: 2px;
82
+ border-radius: var(--r-sm);
43
83
  color: var(--text-3);
44
- --icon-color: var(--text-3);
45
- font-size: 11px;
84
+ text-decoration: none;
85
+ }
86
+ .msg-log:hover {
87
+ background: var(--sunken);
88
+ color: var(--text-1);
89
+ }
90
+ .msg-log temba-icon {
91
+ --icon-color: currentColor;
46
92
  }
47
93
  `;
48
94
  }
@@ -52,15 +98,28 @@ export class MsgList extends ContentList<any> {
52
98
  this.valueKey = 'id';
53
99
  this.emptyMessage = 'No messages';
54
100
  this.searchPlaceholder = 'Search messages';
101
+ // Messages page 100 at a time, matching rapidpro's msg list.
102
+ this.pageSize = 100;
103
+ // Fixed layout so a long message ellipsis-truncates within its
104
+ // column instead of stretching the table; minTableWidth lets the
105
+ // list scroll horizontally once the container is too narrow to
106
+ // keep the columns usable, rather than clipping anything.
107
+ this.fixedLayout = true;
108
+ this.minTableWidth = '640px';
55
109
  this.columns = [
56
- { key: 'contact', label: 'Contact', width: '180px', grow: 0 },
57
- { key: 'text', label: 'Message', grow: 2 },
110
+ {
111
+ key: 'contact',
112
+ label: 'Contact',
113
+ width: '130px',
114
+ pinned: true
115
+ },
116
+ { key: 'text', label: 'Message', grow: true },
58
117
  {
59
118
  key: 'created_on',
60
119
  label: 'Sent',
61
- width: '110px',
62
- grow: 0,
63
- align: 'right'
120
+ width: '120px',
121
+ align: 'right',
122
+ pinned: 'right'
64
123
  }
65
124
  ];
66
125
  this.bulkActions = [
@@ -76,68 +135,107 @@ export class MsgList extends ContentList<any> {
76
135
  }
77
136
 
78
137
  protected renderCell(
79
- item: any,
138
+ item: Msg,
80
139
  column: ContentListColumn
81
140
  ): TemplateResult | string {
82
141
  switch (column.key) {
83
142
  case 'contact': {
84
143
  const contact = item.contact || {};
85
- return html`<span class="msg-text"
86
- >${contact.name || contact.urn || ''}</span
87
- >`;
144
+ return contact.name || contact.urn || '';
88
145
  }
89
146
  case 'text':
90
- return this.renderMessageBody(item);
147
+ return this.renderMessageCell(item);
91
148
  case 'created_on':
92
- return html`<temba-date
93
- value=${item.created_on}
94
- display="duration"
95
- ></temba-date>`;
149
+ return this.renderSentCell(item);
96
150
  default:
97
151
  return super.renderCell(item, column);
98
152
  }
99
153
  }
100
154
 
101
- private renderMessageBody(item: any): TemplateResult {
102
- const labels = item.labels || [];
103
- const attachments = item.attachments || [];
104
- const isOptin = item.msg_type === 'optin' || item.type === 'optin';
155
+ /** The message cell — body text, its attachment thumbnails, then
156
+ * the trailing flow / label pills. Each piece sizes to content, so
157
+ * the split is resolved independently for every row. */
158
+ private renderMessageCell(item: Msg): TemplateResult {
159
+ return html`
160
+ <div class="msg-cell">
161
+ <span class="msg-text">${this.renderMessageText(item)}</span>
162
+ ${this.renderAttachments(item)}${this.renderPills(item)}
163
+ </div>
164
+ `;
165
+ }
105
166
 
167
+ /** The sent cell — duration timestamp with an optional channel-log
168
+ * icon to its right. The icon is rendered when the server includes
169
+ * a logs_url on the row (permission- and retention-gated
170
+ * server-side). stopPropagation keeps the row's contact navigation
171
+ * from also firing when the icon is clicked. */
172
+ private renderSentCell(item: Msg): TemplateResult | string {
173
+ if (!item.created_on) return '';
106
174
  return html`
107
- <div class="msg-row">
108
- <span class="msg-text">
109
- ${item.text || (isOptin ? 'Opt-in request' : '')}
110
- </span>
111
- ${labels.length || attachments.length || item.flow || isOptin
175
+ <div class="sent-cell">
176
+ <temba-date value=${item.created_on} display="duration"></temba-date>
177
+ ${item.logs_url && this.isSafeHref(item.logs_url)
112
178
  ? html`
113
- <div class="msg-meta">
114
- ${isOptin ? this.renderStatusPill('pending', 'opt-in') : null}
115
- ${attachments.map(
116
- () => html`
117
- <span class="attachment-pill">
118
- <temba-icon
119
- name=${Icon.attachment}
120
- size="0.8"
121
- ></temba-icon>
122
- attachment
123
- </span>
124
- `
125
- )}
126
- ${item.flow
127
- ? html`<temba-label type="flow" icon=${Icon.flow}
128
- >${item.flow.name}</temba-label
129
- >`
130
- : null}
131
- ${labels.map(
132
- (l: any) => html`
133
- <temba-label type="label" icon=${Icon.label}
134
- >${l.name}</temba-label
135
- >
136
- `
137
- )}
138
- </div>
179
+ <a
180
+ class="msg-log"
181
+ href=${item.logs_url}
182
+ @click=${(e: MouseEvent) => e.stopPropagation()}
183
+ aria-label="Channel log"
184
+ >
185
+ <temba-icon name=${Icon.log} size="0.95"></temba-icon>
186
+ </a>
139
187
  `
188
+ : ''}
189
+ </div>
190
+ `;
191
+ }
192
+
193
+ /** The message body — plain text, or an opt-in pill when an
194
+ * opt-in request carries no text of its own. */
195
+ private renderMessageText(item: Msg): TemplateResult | string {
196
+ const isOptin = item.msg_type === 'optin' || item.type === 'optin';
197
+ if (!item.text && isOptin) {
198
+ return this.renderStatusPill('pending', 'opt-in request');
199
+ }
200
+ return item.text || '';
201
+ }
202
+
203
+ /** Attachment thumbnails for a row, sitting immediately after the
204
+ * message text, or '' when the row carries none. */
205
+ private renderAttachments(item: Msg): TemplateResult | string {
206
+ const attachments = item.attachments || [];
207
+ if (!attachments.length) return '';
208
+ return html`
209
+ <div class="msg-attachments">
210
+ ${attachments.map(
211
+ (a) => html`
212
+ <temba-thumbnail
213
+ class="msg-thumb"
214
+ attachment=${a}
215
+ ></temba-thumbnail>
216
+ `
217
+ )}
218
+ </div>
219
+ `;
220
+ }
221
+
222
+ /** Flow + label pills for a row, pushed to the trailing edge of
223
+ * the message cell, or '' when the row carries none. */
224
+ private renderPills(item: Msg): TemplateResult | string {
225
+ const labels = item.labels || [];
226
+ if (!item.flow && !labels.length) return '';
227
+ return html`
228
+ <div class="cell-pills">
229
+ ${item.flow
230
+ ? html`<temba-label type="flow" icon=${Icon.flow}
231
+ >${item.flow.name}</temba-label
232
+ >`
140
233
  : null}
234
+ ${labels.map(
235
+ (l) => html`
236
+ <temba-label type="label" icon=${Icon.label}>${l.name}</temba-label>
237
+ `
238
+ )}
141
239
  </div>
142
240
  `;
143
241
  }