@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.158.0",
3
+ "version": "0.158.3",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -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.right > window.innerWidth) {
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 (dropdownBounds.bottom > window.innerHeight) {
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 (dropdownBounds.left < MIN_LEFT && !bumpedLeft) {
242
- const shift = MIN_LEFT - dropdownBounds.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.json.status',
90
+ '@webhook.status',
91
91
  {
92
- type: 'has_text',
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
  }
@@ -226,7 +226,9 @@ export class TabPane extends RapidElement {
226
226
  }
227
227
  }
228
228
  this.options = tabs;
229
- this.index = 0;
229
+ if (this.index < 0 || this.index >= tabs.length) {
230
+ this.index = 0;
231
+ }
230
232
  }
231
233
 
232
234
  public firstUpdated(
@@ -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
+ }