@nyaruka/temba-components 0.158.0 → 0.158.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,251 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { ContentList, ContentListColumn } from './ContentList';
3
+ import { Icon } from '../Icons';
4
+
5
+ /**
6
+ * Flow CRUDL list — drop-in replacement for the rapidpro
7
+ * `flows/flow_list.html` table. Each row's leading icon reflects
8
+ * the flow type (messaging / voice / background / surveyor).
9
+ * Columns: name, runs, ongoing, completion bar, status, modified.
10
+ */
11
+ export class FlowList extends ContentList<any> {
12
+ static get styles() {
13
+ return css`
14
+ ${ContentList.styles}
15
+ .flow-name {
16
+ color: inherit;
17
+ font-weight: var(--w-medium);
18
+ overflow: hidden;
19
+ text-overflow: ellipsis;
20
+ white-space: nowrap;
21
+ display: inline-flex;
22
+ align-items: center;
23
+ gap: 6px;
24
+ }
25
+ .flow-labels {
26
+ display: inline-flex;
27
+ align-items: center;
28
+ gap: 4px;
29
+ margin-left: 4px;
30
+ }
31
+ .issue-icon {
32
+ --icon-color: var(--warning);
33
+ }
34
+ .num {
35
+ font-variant-numeric: tabular-nums;
36
+ color: inherit;
37
+ }
38
+ .completion-bar {
39
+ display: inline-flex;
40
+ align-items: center;
41
+ gap: 8px;
42
+ justify-content: flex-end;
43
+ }
44
+ .completion-bar .bar {
45
+ width: 50px;
46
+ height: 6px;
47
+ border-radius: 999px;
48
+ background: var(--sunken);
49
+ overflow: hidden;
50
+ }
51
+ .completion-bar .fill {
52
+ height: 100%;
53
+ background: var(--success);
54
+ border-radius: 999px;
55
+ }
56
+ .completion-bar .pct {
57
+ font-variant-numeric: tabular-nums;
58
+ color: var(--text-2);
59
+ min-width: 36px;
60
+ text-align: right;
61
+ }
62
+ .sparkline {
63
+ display: inline-block;
64
+ vertical-align: middle;
65
+ }
66
+ .sparkline .line {
67
+ fill: none;
68
+ stroke: var(--accent-600);
69
+ stroke-width: 1.25;
70
+ stroke-linejoin: round;
71
+ stroke-linecap: round;
72
+ }
73
+ .sparkline .area {
74
+ fill: var(--accent-100);
75
+ opacity: 0.7;
76
+ }
77
+ `;
78
+ }
79
+
80
+ constructor() {
81
+ super();
82
+ this.valueKey = 'uuid';
83
+ this.emptyMessage = 'No flows';
84
+ this.searchPlaceholder = 'Search flows';
85
+ this.columns = [
86
+ { key: 'name', label: 'Name', sortable: true, grow: 3 },
87
+ {
88
+ key: 'status',
89
+ label: 'Status',
90
+ width: '110px',
91
+ grow: 0
92
+ },
93
+ {
94
+ key: 'runs',
95
+ label: 'Runs',
96
+ sortable: true,
97
+ width: '80px',
98
+ grow: 0,
99
+ align: 'right'
100
+ },
101
+ {
102
+ key: 'ongoing',
103
+ label: 'Ongoing',
104
+ sortable: true,
105
+ width: '80px',
106
+ grow: 0,
107
+ align: 'right'
108
+ },
109
+ {
110
+ key: 'completion',
111
+ label: 'Completion',
112
+ width: '120px',
113
+ grow: 0,
114
+ align: 'right'
115
+ },
116
+ {
117
+ key: 'activity',
118
+ label: 'Activity',
119
+ width: '120px',
120
+ grow: 0,
121
+ align: 'right'
122
+ }
123
+ ];
124
+ this.bulkActions = [
125
+ {
126
+ key: 'label',
127
+ label: 'Label',
128
+ icon: Icon.label,
129
+ labelsEndpoint: '/api/v2/flow-labels.json'
130
+ },
131
+ { key: 'export', label: 'Export results', icon: Icon.export },
132
+ { key: 'archive', label: 'Archive', icon: Icon.archive }
133
+ ];
134
+ }
135
+
136
+ protected getRowIcon(item: any): string | null {
137
+ switch (item?.type) {
138
+ case 'voice':
139
+ case 'ivr':
140
+ return Icon.flow_ivr;
141
+ case 'background':
142
+ return Icon.flow_background;
143
+ case 'survey':
144
+ return Icon.flow_surveyor;
145
+ default:
146
+ return Icon.flow_message;
147
+ }
148
+ }
149
+
150
+ protected getRowHref(item: any): string | null {
151
+ return item?.uuid ? `/flow/editor/${item.uuid}/` : null;
152
+ }
153
+
154
+ protected renderCell(
155
+ item: any,
156
+ column: ContentListColumn
157
+ ): TemplateResult | string {
158
+ switch (column.key) {
159
+ case 'name': {
160
+ const labels = item.labels || [];
161
+ return html`<span class="flow-name"
162
+ >${item.name || ''}
163
+ ${item.has_issues
164
+ ? html`<temba-icon
165
+ class="issue-icon"
166
+ name=${Icon.issue}
167
+ size="0.9"
168
+ ></temba-icon>`
169
+ : null}
170
+ ${labels.length > 0
171
+ ? html`<span class="flow-labels">
172
+ ${labels.map(
173
+ (l: any) =>
174
+ html`<temba-label type="label" icon=${Icon.label}
175
+ >${l.name}</temba-label
176
+ >`
177
+ )}
178
+ </span>`
179
+ : null}
180
+ </span>`;
181
+ }
182
+ case 'runs':
183
+ return html`<span class="num"
184
+ >${(item.runs ?? 0).toLocaleString()}</span
185
+ >`;
186
+ case 'ongoing':
187
+ return html`<span class="num">${item.ongoing ?? 0}</span>`;
188
+ case 'completion':
189
+ return this.renderCompletion(item);
190
+ case 'activity':
191
+ return this.renderSparkline(item.activity || []);
192
+ case 'status':
193
+ return this.renderFlowStatus(item);
194
+ default:
195
+ return super.renderCell(item, column);
196
+ }
197
+ }
198
+
199
+ private renderCompletion(item: any): TemplateResult {
200
+ const value = typeof item.completion === 'number' ? item.completion : 0;
201
+ const pct = Math.round(value * 100);
202
+ return html`
203
+ <div class="completion-bar">
204
+ <div class="bar">
205
+ <div class="fill" style="width: ${pct}%"></div>
206
+ </div>
207
+ <span class="pct">${pct}%</span>
208
+ </div>
209
+ `;
210
+ }
211
+
212
+ /** Render a tiny line+area sparkline from a numeric array.
213
+ * Values are normalized to the column's pixel viewBox; absolute
214
+ * magnitude isn't preserved — the goal is shape, not scale. */
215
+ private renderSparkline(values: number[]): TemplateResult {
216
+ if (!values || values.length < 2) return html``;
217
+ const w = 90;
218
+ const h = 22;
219
+ const max = Math.max(...values, 1);
220
+ const step = w / (values.length - 1);
221
+ const pts = values.map((v, i) => {
222
+ const x = i * step;
223
+ const y = h - (v / max) * (h - 2) - 1;
224
+ return [x.toFixed(1), y.toFixed(1)] as const;
225
+ });
226
+ const line = pts.map(([x, y]) => `${x},${y}`).join(' ');
227
+ const areaCmds = pts
228
+ .map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x},${y}`)
229
+ .join(' ');
230
+ const area = `${areaCmds} L${w},${h} L0,${h} Z`;
231
+ return html`
232
+ <svg
233
+ class="sparkline"
234
+ width=${w}
235
+ height=${h}
236
+ viewBox="0 0 ${w} ${h}"
237
+ preserveAspectRatio="none"
238
+ >
239
+ <path class="area" d=${area}></path>
240
+ <polyline class="line" points=${line}></polyline>
241
+ </svg>
242
+ `;
243
+ }
244
+
245
+ private renderFlowStatus(item: any): TemplateResult {
246
+ const status = (item.status || 'active').toString().toLowerCase();
247
+ const kind = status === 'archived' ? 'archived' : 'active';
248
+ const label = status === 'archived' ? 'Archived' : 'Active';
249
+ return this.renderStatusPill(kind, label);
250
+ }
251
+ }
@@ -0,0 +1,144 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { ContentList, ContentListColumn } from './ContentList';
3
+ import { Icon } from '../Icons';
4
+
5
+ /**
6
+ * 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.
10
+ */
11
+ export class MsgList extends ContentList<any> {
12
+ static get styles() {
13
+ return css`
14
+ ${ContentList.styles}
15
+ .msg-meta {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 6px;
19
+ flex-wrap: wrap;
20
+ font-size: 12px;
21
+ color: var(--text-3);
22
+ }
23
+ .msg-text {
24
+ display: block;
25
+ color: inherit;
26
+ font-weight: var(--w-regular);
27
+ overflow: hidden;
28
+ text-overflow: ellipsis;
29
+ white-space: nowrap;
30
+ }
31
+ .msg-row {
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 2px;
35
+ }
36
+ .attachment-pill {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 3px;
40
+ padding: 0 6px 0 4px;
41
+ border-radius: 999px;
42
+ background: var(--sunken);
43
+ color: var(--text-3);
44
+ --icon-color: var(--text-3);
45
+ font-size: 11px;
46
+ }
47
+ `;
48
+ }
49
+
50
+ constructor() {
51
+ super();
52
+ this.valueKey = 'id';
53
+ this.emptyMessage = 'No messages';
54
+ this.searchPlaceholder = 'Search messages';
55
+ this.columns = [
56
+ { key: 'contact', label: 'Contact', width: '180px', grow: 0 },
57
+ { key: 'text', label: 'Message', grow: 2 },
58
+ {
59
+ key: 'created_on',
60
+ label: 'Sent',
61
+ width: '110px',
62
+ grow: 0,
63
+ align: 'right'
64
+ }
65
+ ];
66
+ this.bulkActions = [
67
+ {
68
+ key: 'label',
69
+ label: 'Label',
70
+ icon: Icon.label,
71
+ labelsEndpoint: '/api/v2/labels.json'
72
+ },
73
+ { key: 'archive', label: 'Archive', icon: Icon.archive },
74
+ { key: 'delete', label: 'Delete', icon: Icon.delete, destructive: true }
75
+ ];
76
+ }
77
+
78
+ protected renderCell(
79
+ item: any,
80
+ column: ContentListColumn
81
+ ): TemplateResult | string {
82
+ switch (column.key) {
83
+ case 'contact': {
84
+ const contact = item.contact || {};
85
+ return html`<span class="msg-text"
86
+ >${contact.name || contact.urn || ''}</span
87
+ >`;
88
+ }
89
+ case 'text':
90
+ return this.renderMessageBody(item);
91
+ case 'created_on':
92
+ return html`<temba-date
93
+ value=${item.created_on}
94
+ display="duration"
95
+ ></temba-date>`;
96
+ default:
97
+ return super.renderCell(item, column);
98
+ }
99
+ }
100
+
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';
105
+
106
+ 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
112
+ ? 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>
139
+ `
140
+ : null}
141
+ </div>
142
+ `;
143
+ }
144
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "next": null,
3
+ "previous": null,
4
+ "results": [
5
+ {
6
+ "uuid": "fl-001",
7
+ "name": "Active Campaigns",
8
+ "count": 12
9
+ },
10
+ {
11
+ "uuid": "fl-002",
12
+ "name": "Surveys",
13
+ "count": 8
14
+ },
15
+ {
16
+ "uuid": "fl-003",
17
+ "name": "Onboarding",
18
+ "count": 4
19
+ },
20
+ {
21
+ "uuid": "fl-004",
22
+ "name": "Internal",
23
+ "count": 2
24
+ },
25
+ {
26
+ "uuid": "fl-005",
27
+ "name": "Archived",
28
+ "count": 6
29
+ }
30
+ ]
31
+ }
package/temba-modules.ts CHANGED
@@ -47,6 +47,10 @@ import { ColorPicker } from './src/form/ColorPicker';
47
47
  import { Resizer } from './src/layout/Resizer';
48
48
  import { Thumbnail } from './src/display/Thumbnail';
49
49
  import { NotificationList } from './src/list/NotificationList';
50
+ import { ContentList } from './src/list/ContentList';
51
+ import { MsgList } from './src/list/MsgList';
52
+ import { ContactList } from './src/list/ContactList';
53
+ import { FlowList } from './src/list/FlowList';
50
54
  import { WebChat } from './src/webchat/WebChat';
51
55
  import { ImagePicker } from './src/form/ImagePicker';
52
56
  import { Mask } from './src/layout/Mask';
@@ -127,6 +131,10 @@ addCustomElement('temba-contact-chat', ContactChat);
127
131
  addCustomElement('temba-contact-details', ContactDetails);
128
132
  addCustomElement('temba-ticket-list', TicketList);
129
133
  addCustomElement('temba-notification-list', NotificationList);
134
+ addCustomElement('temba-content-list', ContentList);
135
+ addCustomElement('temba-msg-list', MsgList);
136
+ addCustomElement('temba-contact-list', ContactList);
137
+ addCustomElement('temba-flow-list', FlowList);
130
138
  addCustomElement('temba-list', TembaList);
131
139
  addCustomElement('temba-sortable-list', SortableList);
132
140
  addCustomElement('temba-run-list', RunList);
@@ -223,6 +223,72 @@ function generateFlowMetadata(flowDefinition) {
223
223
  return generateFlowInfo(flowDefinition);
224
224
  }
225
225
 
226
+ // In-memory state for the content-list demo so the labeling flow
227
+ // (label, refresh, recheck) is exercisable end-to-end. The first
228
+ // GET / POST loads from the on-disk fixture; subsequent ops mutate
229
+ // the in-memory copy. The on-disk fixture is never written — state
230
+ // resets on dev-server restart, which is the right default for a
231
+ // throwaway demo.
232
+ const demoState = {
233
+ messages: null,
234
+ flows: null,
235
+ labels: null,
236
+ flowLabels: null
237
+ };
238
+
239
+ function loadDemoJson(stateKey, filePath) {
240
+ if (demoState[stateKey] === null) {
241
+ demoState[stateKey] = JSON.parse(
242
+ fs.readFileSync(path.resolve(filePath), 'utf-8')
243
+ );
244
+ }
245
+ return demoState[stateKey];
246
+ }
247
+
248
+ /** Filter the in-memory demo items for a content-list endpoint.
249
+ *
250
+ * Supports `?label=<uuid>` (only items carrying that label) so the
251
+ * demo can exercise the filtered-view → label-removed → row-drops-
252
+ * out → recheck-selection lifecycle. */
253
+ function getFilteredDemoItems(data, url) {
254
+ const labelFilter = url.searchParams.get('label');
255
+ let results = data.results;
256
+ if (labelFilter) {
257
+ results = results.filter((item) =>
258
+ (item.labels || []).some((l) => l.uuid === labelFilter)
259
+ );
260
+ }
261
+ return { ...data, count: results.length, results };
262
+ }
263
+
264
+ /** Apply a label-toggle to a list of in-memory items, mirroring
265
+ * smartmin's BulkActionMixin behavior. Body is x-www-form-urlencoded
266
+ * (params: action, objects[], label, add). The `idKey` is what each
267
+ * item is matched against (messages use numeric `id`, flows use
268
+ * string `uuid`). */
269
+ function applyDemoListAction(body, items, labels, idKey) {
270
+ const params = new URLSearchParams(body);
271
+ const action = params.get('action');
272
+ if (action !== 'label') return;
273
+ const labelUuid = params.get('label');
274
+ const add = params.get('add') !== 'false';
275
+ const objectIds = params.getAll('objects');
276
+ const label = (labels.results || []).find((l) => l.uuid === labelUuid);
277
+ if (!label) return;
278
+ objectIds.forEach((idStr) => {
279
+ const lookup = idKey === 'id' ? parseInt(idStr, 10) : idStr;
280
+ const item = items.results.find((i) => i[idKey] === lookup);
281
+ if (!item) return;
282
+ item.labels = item.labels || [];
283
+ const idx = item.labels.findIndex((l) => l.uuid === labelUuid);
284
+ if (add && idx < 0) {
285
+ item.labels.push({ uuid: label.uuid, name: label.name });
286
+ } else if (!add && idx >= 0) {
287
+ item.labels.splice(idx, 1);
288
+ }
289
+ });
290
+ }
291
+
226
292
  export default {
227
293
  nodeResolve: true,
228
294
  plugins: [
@@ -244,6 +310,7 @@ export default {
244
310
  const apiMappings = {
245
311
  '/api/v2/groups.json': 'groups.json',
246
312
  '/api/v2/labels.json': 'labels.json',
313
+ '/api/v2/flow-labels.json': 'flow-labels.json',
247
314
  '/api/v2/fields.json': 'fields.json',
248
315
  '/api/v2/globals.json': 'globals.json',
249
316
  '/api/v2/resthooks.json': 'resthooks.json',
@@ -363,6 +430,95 @@ export default {
363
430
  return;
364
431
  }
365
432
 
433
+ // Serve the content-list demo messages from in-memory state
434
+ // so a labeling POST is reflected on the next refresh. Honors
435
+ // an optional `?label=<uuid>` filter for testing the filtered-
436
+ // view drop-out lifecycle.
437
+ if (
438
+ context.request.method === 'GET' &&
439
+ context.path === '/demo/components/content-list/data/messages.json'
440
+ ) {
441
+ const reqUrl = new URL(context.request.url, 'http://localhost');
442
+ const data = loadDemoJson(
443
+ 'messages',
444
+ './demo/components/content-list/data/messages.json'
445
+ );
446
+ context.contentType = 'application/json';
447
+ context.body = JSON.stringify(getFilteredDemoItems(data, reqUrl));
448
+ return;
449
+ }
450
+
451
+ // Same lifecycle for the flows list — labels carried on
452
+ // each flow live in flows.json; the labels themselves come
453
+ // from /api/v2/flow-labels.json.
454
+ if (
455
+ context.request.method === 'GET' &&
456
+ context.path === '/demo/components/content-list/data/flows.json'
457
+ ) {
458
+ const reqUrl = new URL(context.request.url, 'http://localhost');
459
+ const data = loadDemoJson(
460
+ 'flows',
461
+ './demo/components/content-list/data/flows.json'
462
+ );
463
+ context.contentType = 'application/json';
464
+ context.body = JSON.stringify(getFilteredDemoItems(data, reqUrl));
465
+ return;
466
+ }
467
+
468
+ // Bulk-action POSTs for the content-list demo: mutate the
469
+ // in-memory items so the subsequent refresh actually shows
470
+ // the labeling change. Body is form-urlencoded as sent by
471
+ // ContentList.toggleLabel().
472
+ if (
473
+ context.request.method === 'POST' &&
474
+ context.path === '/demo/components/content-list/list-action'
475
+ ) {
476
+ return new Promise((resolve) => {
477
+ let body = '';
478
+ context.req.on('data', (chunk) => { body += chunk.toString(); });
479
+ context.req.on('end', () => {
480
+ const messages = loadDemoJson(
481
+ 'messages',
482
+ './demo/components/content-list/data/messages.json'
483
+ );
484
+ const labels = loadDemoJson(
485
+ 'labels',
486
+ './static/api/labels.json'
487
+ );
488
+ applyDemoListAction(body, messages, labels, 'id');
489
+ context.contentType = 'application/json';
490
+ context.status = 200;
491
+ context.body = JSON.stringify({ status: 'success' });
492
+ resolve();
493
+ });
494
+ });
495
+ }
496
+
497
+ if (
498
+ context.request.method === 'POST' &&
499
+ context.path === '/demo/components/content-list/flow-list-action'
500
+ ) {
501
+ return new Promise((resolve) => {
502
+ let body = '';
503
+ context.req.on('data', (chunk) => { body += chunk.toString(); });
504
+ context.req.on('end', () => {
505
+ const flows = loadDemoJson(
506
+ 'flows',
507
+ './demo/components/content-list/data/flows.json'
508
+ );
509
+ const labels = loadDemoJson(
510
+ 'flowLabels',
511
+ './static/api/flow-labels.json'
512
+ );
513
+ applyDemoListAction(body, flows, labels, 'uuid');
514
+ context.contentType = 'application/json';
515
+ context.status = 200;
516
+ context.body = JSON.stringify({ status: 'success' });
517
+ resolve();
518
+ });
519
+ });
520
+ }
521
+
366
522
  // Handle contact chat POST (send message) - return a mock event
367
523
  if (context.request.method === 'POST' && context.path.match(/^\/contact\/chat\/[^/]+\/$/)) {
368
524
  return new Promise((resolve) => {