@nyaruka/temba-components 0.157.1 → 0.158.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.
- package/CHANGELOG.md +16 -0
- package/dist/temba-components.js +1305 -530
- package/dist/temba-components.js.map +1 -1
- package/orca/setup.sh +81 -0
- package/orca.yaml +3 -0
- package/package.json +1 -1
- package/src/display/Dropdown.ts +17 -5
- package/src/events/eventRenderers.ts +4 -9
- package/src/flow/CanvasNode.ts +14 -6
- package/src/flow/DragManager.ts +4 -2
- package/src/flow/utils.ts +1 -0
- package/src/form/DatePicker.ts +2 -1
- package/src/interfaces.ts +4 -1
- package/src/layout/Tab.ts +0 -15
- package/src/layout/TabPane.ts +76 -164
- 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/src/live/ContactChat.ts +6 -2
- package/src/live/ContactDetails.ts +40 -35
- package/src/live/ContactFieldEditor.ts +35 -55
- package/src/live/ContactFields.ts +1 -2
- package/src/live/ContactNotepad.ts +9 -1
- package/src/live/ContactPending.ts +1 -0
- package/src/styles/designTokens.ts +2 -0
- package/static/api/flow-labels.json +31 -0
- package/static/css/temba-components.css +2 -0
- package/temba-modules.ts +8 -0
- package/web-dev-server.config.mjs +156 -0
|
@@ -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
|
+
}
|