@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.
@@ -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
+ }