@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
package/package.json
CHANGED
package/src/display/Dropdown.ts
CHANGED
|
@@ -205,13 +205,22 @@ export class Dropdown extends RapidElement {
|
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
// Anchor the dropdown to the toggle's viewport coordinates.
|
|
209
|
+
// The dropdown is `position: fixed`, so viewport coords are
|
|
210
|
+
// the right reference frame. Without an explicit anchor the
|
|
211
|
+
// browser resolves `top: auto; left: auto` from the element's
|
|
212
|
+
// in-flow position, which mis-places the dropdown when the
|
|
213
|
+
// page is scrolled. Always setting top/left from
|
|
214
|
+
// getBoundingClientRect makes positioning scroll-invariant.
|
|
208
215
|
const dropdownStyle = {
|
|
209
216
|
border: '1px solid rgba(0,0,0,0.1)',
|
|
210
|
-
marginTop: '0.5em'
|
|
217
|
+
marginTop: '0.5em',
|
|
218
|
+
top: toggleBounds.bottom + 'px',
|
|
219
|
+
left: toggleBounds.left + 'px'
|
|
211
220
|
};
|
|
212
221
|
|
|
213
222
|
// if off the the right, bump it left
|
|
214
|
-
if (dropdownBounds.
|
|
223
|
+
if (toggleBounds.left + dropdownBounds.width > window.innerWidth) {
|
|
215
224
|
dropdownStyle['left'] =
|
|
216
225
|
toggleBounds.right - dropdownBounds.width + 'px';
|
|
217
226
|
delete dropdownStyle['right'];
|
|
@@ -219,7 +228,7 @@ export class Dropdown extends RapidElement {
|
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
// if off to the bottom, bump it up
|
|
222
|
-
if (
|
|
231
|
+
if (toggleBounds.bottom + dropdownBounds.height > window.innerHeight) {
|
|
223
232
|
dropdownStyle['top'] = toggleBounds.top - dropdownBounds.height + 'px';
|
|
224
233
|
dropdownStyle['marginTop'] = '-0.5em';
|
|
225
234
|
bumpedUp = true;
|
|
@@ -237,9 +246,12 @@ export class Dropdown extends RapidElement {
|
|
|
237
246
|
// anchored to far-left toggles (e.g. rail items) don't rub against
|
|
238
247
|
// the window edge. Shift the dropdown right and slide the arrow
|
|
239
248
|
// back the same amount so it still points at the toggle.
|
|
249
|
+
// Check against the intended `left` (toggleBounds.left) rather
|
|
250
|
+
// than the dropdown's currently-rendered bounds, since the new
|
|
251
|
+
// left is what we're about to set.
|
|
240
252
|
const MIN_LEFT = 8;
|
|
241
|
-
if (
|
|
242
|
-
const shift = MIN_LEFT -
|
|
253
|
+
if (toggleBounds.left < MIN_LEFT && !bumpedLeft) {
|
|
254
|
+
const shift = MIN_LEFT - toggleBounds.left;
|
|
243
255
|
dropdownStyle['left'] = MIN_LEFT + 'px';
|
|
244
256
|
arrowLeft -= shift;
|
|
245
257
|
}
|
|
@@ -87,10 +87,10 @@ export const split_by_resthook: NodeConfig = {
|
|
|
87
87
|
const existingCases = originalNode.router?.cases || [];
|
|
88
88
|
|
|
89
89
|
const { router, exits } = createSuccessFailureRouter(
|
|
90
|
-
'@webhook.
|
|
90
|
+
'@webhook.status',
|
|
91
91
|
{
|
|
92
|
-
type: '
|
|
93
|
-
arguments: []
|
|
92
|
+
type: 'has_number_between',
|
|
93
|
+
arguments: ['200', '299']
|
|
94
94
|
},
|
|
95
95
|
existingCategories,
|
|
96
96
|
existingExits,
|
package/src/interfaces.ts
CHANGED
|
@@ -323,5 +323,8 @@ export enum CustomEventType {
|
|
|
323
323
|
RevisionViewed = 'temba-revision-viewed',
|
|
324
324
|
RevisionCancelled = 'temba-revision-cancelled',
|
|
325
325
|
RevisionReverted = 'temba-revision-reverted',
|
|
326
|
-
RevisionsClosed = 'temba-revisions-closed'
|
|
326
|
+
RevisionsClosed = 'temba-revisions-closed',
|
|
327
|
+
RowClick = 'temba-row-click',
|
|
328
|
+
SelectionChange = 'temba-selection-change',
|
|
329
|
+
BulkAction = 'temba-bulk-action'
|
|
327
330
|
}
|
package/src/layout/TabPane.ts
CHANGED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
|
+
import { ContentList, ContentListColumn } from './ContentList';
|
|
4
|
+
import { Icon } from '../Icons';
|
|
5
|
+
import { Contact } from '../interfaces';
|
|
6
|
+
import { getUrl } from '../utils';
|
|
7
|
+
|
|
8
|
+
const FIELD_PREFIX = 'field:';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Contact CRUDL list — drop-in replacement for the rapidpro
|
|
12
|
+
* `contacts/contact_list.html` table. Each row carries a contact
|
|
13
|
+
* silhouette as the leading icon, name + URN, group pills, and a
|
|
14
|
+
* last-seen duration. Name + Last-seen are sortable.
|
|
15
|
+
*
|
|
16
|
+
* Featured contact fields from the workspace render as extra
|
|
17
|
+
* columns between URN and Groups. The component fetches them from
|
|
18
|
+
* {@link ContactList.fieldsEndpoint} on connect; cells read each
|
|
19
|
+
* contact's value out of `item.fields[<key>]`.
|
|
20
|
+
*/
|
|
21
|
+
export class ContactList extends ContentList<Contact> {
|
|
22
|
+
static get styles() {
|
|
23
|
+
return css`
|
|
24
|
+
${ContentList.styles}
|
|
25
|
+
.contact-name {
|
|
26
|
+
color: inherit;
|
|
27
|
+
font-weight: var(--w-medium);
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
text-overflow: ellipsis;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
}
|
|
32
|
+
.contact-urn {
|
|
33
|
+
color: var(--text-3);
|
|
34
|
+
font-size: 12.5px;
|
|
35
|
+
}
|
|
36
|
+
/* Featured-field values are concrete data (someone's age,
|
|
37
|
+
their state, etc.) — bold text, not a pill. Truncated
|
|
38
|
+
with a title tooltip so long entries don't blow up the
|
|
39
|
+
row width. */
|
|
40
|
+
.field-value {
|
|
41
|
+
font-weight: var(--w-semibold);
|
|
42
|
+
overflow: hidden;
|
|
43
|
+
text-overflow: ellipsis;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
display: block;
|
|
46
|
+
}
|
|
47
|
+
.group-list {
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 4px;
|
|
51
|
+
flex-wrap: wrap;
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Endpoint returning `{ results: ContactField[] }`. Fields where
|
|
57
|
+
* `featured: true` become extra columns. */
|
|
58
|
+
@property({ type: String, attribute: 'fields-endpoint' })
|
|
59
|
+
fieldsEndpoint = '/api/v2/fields.json';
|
|
60
|
+
|
|
61
|
+
@state()
|
|
62
|
+
private featuredFields: any[] = [];
|
|
63
|
+
|
|
64
|
+
private pendingFieldsController?: AbortController;
|
|
65
|
+
|
|
66
|
+
constructor() {
|
|
67
|
+
super();
|
|
68
|
+
this.valueKey = 'uuid';
|
|
69
|
+
this.emptyMessage = 'No contacts';
|
|
70
|
+
this.searchPlaceholder = 'Search contacts';
|
|
71
|
+
this.columns = this.buildColumns();
|
|
72
|
+
this.bulkActions = [
|
|
73
|
+
{ key: 'send', label: 'Send', icon: Icon.compose },
|
|
74
|
+
{ key: 'flow', label: 'Start flow', icon: Icon.flow },
|
|
75
|
+
{ key: 'archive', label: 'Archive', icon: Icon.archive },
|
|
76
|
+
{ key: 'delete', label: 'Delete', icon: Icon.delete, destructive: true }
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public connectedCallback(): void {
|
|
81
|
+
super.connectedCallback();
|
|
82
|
+
this.loadFields();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public disconnectedCallback(): void {
|
|
86
|
+
if (this.pendingFieldsController) {
|
|
87
|
+
this.pendingFieldsController.abort();
|
|
88
|
+
this.pendingFieldsController = undefined;
|
|
89
|
+
}
|
|
90
|
+
super.disconnectedCallback();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
protected updated(changes: PropertyValues): void {
|
|
94
|
+
super.updated(changes);
|
|
95
|
+
if (changes.has('fieldsEndpoint') && this.fieldsEndpoint) {
|
|
96
|
+
this.loadFields();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async loadFields(): Promise<void> {
|
|
101
|
+
if (!this.fieldsEndpoint) return;
|
|
102
|
+
// Abort any in-flight fields request so a stale response can't
|
|
103
|
+
// overwrite featuredFields/columns after a new endpoint is set
|
|
104
|
+
// or the component has disconnected.
|
|
105
|
+
if (this.pendingFieldsController) {
|
|
106
|
+
this.pendingFieldsController.abort();
|
|
107
|
+
}
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
this.pendingFieldsController = controller;
|
|
110
|
+
try {
|
|
111
|
+
const response = await getUrl(this.fieldsEndpoint, controller);
|
|
112
|
+
// If the controller has been swapped or cleared, the response
|
|
113
|
+
// is from a stale request — drop it on the floor.
|
|
114
|
+
if (this.pendingFieldsController !== controller) return;
|
|
115
|
+
const all = response.json?.results || [];
|
|
116
|
+
this.featuredFields = all
|
|
117
|
+
.filter((f: any) => f.featured)
|
|
118
|
+
.sort((a: any, b: any) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
119
|
+
this.columns = this.buildColumns();
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if ((err as DOMException)?.name !== 'AbortError') {
|
|
122
|
+
// eslint-disable-next-line no-console
|
|
123
|
+
console.error('failed to fetch contact fields', err);
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
if (this.pendingFieldsController === controller) {
|
|
127
|
+
this.pendingFieldsController = undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Columns: name, urn, <featured fields>, groups, last seen. */
|
|
133
|
+
private buildColumns(): ContentListColumn[] {
|
|
134
|
+
const fieldColumns: ContentListColumn[] = (this.featuredFields || []).map(
|
|
135
|
+
(f: any) => ({
|
|
136
|
+
key: FIELD_PREFIX + f.key,
|
|
137
|
+
label: f.name || f.label || f.key,
|
|
138
|
+
width: '110px',
|
|
139
|
+
grow: 0
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
return [
|
|
143
|
+
{ key: 'name', label: 'Name', sortable: true, grow: 2 },
|
|
144
|
+
{ key: 'urn', label: 'URN', width: '150px', grow: 0 },
|
|
145
|
+
...fieldColumns,
|
|
146
|
+
{ key: 'groups', label: 'Groups', grow: 1 },
|
|
147
|
+
{
|
|
148
|
+
key: 'last_seen_on',
|
|
149
|
+
label: 'Last seen',
|
|
150
|
+
sortable: true,
|
|
151
|
+
width: '110px',
|
|
152
|
+
grow: 0,
|
|
153
|
+
align: 'right'
|
|
154
|
+
}
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
protected getRowIcon(_item: Contact): string | null {
|
|
159
|
+
return Icon.contact;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
protected getRowHref(item: Contact): string | null {
|
|
163
|
+
return item?.uuid ? `/contact/read/${item.uuid}/` : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
protected renderCell(
|
|
167
|
+
item: Contact,
|
|
168
|
+
column: ContentListColumn
|
|
169
|
+
): TemplateResult | string {
|
|
170
|
+
if (column.key.startsWith(FIELD_PREFIX)) {
|
|
171
|
+
const fieldKey = column.key.substring(FIELD_PREFIX.length);
|
|
172
|
+
const raw = item.fields?.[fieldKey];
|
|
173
|
+
const value = raw == null || raw === '' ? '' : String(raw);
|
|
174
|
+
return value
|
|
175
|
+
? html`<span class="field-value" title=${value}>${value}</span>`
|
|
176
|
+
: '';
|
|
177
|
+
}
|
|
178
|
+
switch (column.key) {
|
|
179
|
+
case 'name':
|
|
180
|
+
return html`<span class="contact-name" title=${item.name || ''}
|
|
181
|
+
>${item.name || '—'}</span
|
|
182
|
+
>`;
|
|
183
|
+
case 'urn':
|
|
184
|
+
return html`<span class="contact-urn"
|
|
185
|
+
>${this.primaryUrn(item) || ''}</span
|
|
186
|
+
>`;
|
|
187
|
+
case 'groups':
|
|
188
|
+
return this.renderGroups(item);
|
|
189
|
+
case 'last_seen_on':
|
|
190
|
+
return item.last_seen_on
|
|
191
|
+
? html`<temba-date
|
|
192
|
+
value=${item.last_seen_on}
|
|
193
|
+
display="duration"
|
|
194
|
+
></temba-date>`
|
|
195
|
+
: '';
|
|
196
|
+
default:
|
|
197
|
+
return super.renderCell(item, column);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private primaryUrn(item: Contact): string {
|
|
202
|
+
const i = item as any;
|
|
203
|
+
if (i.urn) return i.urn;
|
|
204
|
+
if (Array.isArray(i.urns) && i.urns.length > 0) {
|
|
205
|
+
const u = i.urns[0];
|
|
206
|
+
return typeof u === 'string' ? u.split(':')[1] || u : u?.display || '';
|
|
207
|
+
}
|
|
208
|
+
return '';
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private renderGroups(item: Contact): TemplateResult {
|
|
212
|
+
const groups = item.groups || [];
|
|
213
|
+
if (groups.length === 0) return html``;
|
|
214
|
+
return html`
|
|
215
|
+
<div class="group-list">
|
|
216
|
+
${groups.map(
|
|
217
|
+
(g: any) =>
|
|
218
|
+
html`<temba-label type="group" icon=${Icon.group}
|
|
219
|
+
>${g.name}</temba-label
|
|
220
|
+
>`
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
`;
|
|
224
|
+
}
|
|
225
|
+
}
|