@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.
- package/CHANGELOG.md +23 -0
- package/dist/temba-components.js +1032 -204
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Dropdown.ts +17 -5
- package/src/flow/nodes/split_by_resthook.ts +3 -3
- package/src/interfaces.ts +4 -1
- package/src/layout/TabPane.ts +3 -1
- package/src/list/ContactList.ts +225 -0
- package/src/list/ContentList.ts +1298 -0
- package/src/list/FlowList.ts +251 -0
- package/src/list/MsgList.ts +144 -0
- package/static/api/flow-labels.json +31 -0
- package/temba-modules.ts +8 -0
- package/web-dev-server.config.mjs +156 -0
|
@@ -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) => {
|