@nyaruka/temba-components 0.159.3 → 0.159.4

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.159.3",
3
+ "version": "0.159.4",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -88,6 +88,15 @@ export class Checkbox extends FieldElement {
88
88
  cursor: not-allowed;
89
89
  --icon-color: #ccc;
90
90
  }
91
+
92
+ /* While a change driven by this checkbox is in flight, the glyph
93
+ is swapped for spinning arrows to signal work is underway, and the
94
+ white checkbox backing is hidden so the circular arrows aren't
95
+ framed by a square. Clicks are ignored in handleClick so the user
96
+ can't re-fire the same toggle. */
97
+ .checkbox-container.busy .checkbox-background {
98
+ display: none;
99
+ }
91
100
  `;
92
101
  }
93
102
 
@@ -103,6 +112,12 @@ export class Checkbox extends FieldElement {
103
112
  @property({ type: Boolean })
104
113
  disabled = false;
105
114
 
115
+ // When set, the checkbox glyph is replaced by spinning arrows to indicate
116
+ // a change it drives is in flight, and clicks are ignored until the host
117
+ // clears it.
118
+ @property({ type: Boolean })
119
+ busy = false;
120
+
106
121
  @property({ type: Number })
107
122
  size = 1.2;
108
123
 
@@ -154,7 +169,7 @@ export class Checkbox extends FieldElement {
154
169
  }
155
170
 
156
171
  private handleClick(): void {
157
- if (!this.disabled) {
172
+ if (!this.disabled && !this.busy) {
158
173
  this.checked = !this.checked;
159
174
  }
160
175
  }
@@ -165,22 +180,33 @@ export class Checkbox extends FieldElement {
165
180
  }
166
181
 
167
182
  protected renderWidget(): TemplateResult {
168
- const icon = html`<temba-icon
169
- name="${this.checked
170
- ? Icon.checkbox_checked
171
- : this.partial
172
- ? Icon.checkbox_partial
173
- : Icon.checkbox}"
174
- size="${this.size}"
175
- animatechange="${this.animateChange}"
176
- ></temba-icon>`;
183
+ const icon = this.busy
184
+ ? html`<temba-icon
185
+ name="${Icon.progress_spinner}"
186
+ size="${this.size}"
187
+ spin
188
+ ></temba-icon>`
189
+ : html`<temba-icon
190
+ name="${this.checked
191
+ ? Icon.checkbox_checked
192
+ : this.partial
193
+ ? Icon.checkbox_partial
194
+ : Icon.checkbox}"
195
+ size="${this.size}"
196
+ animatechange="${this.animateChange}"
197
+ ></temba-icon>`;
177
198
 
178
199
  return html`
179
200
  <div
180
201
  class="wrapper ${this.label ? 'label' : ''}"
181
202
  @click=${this.handleClick}
182
203
  >
183
- <div class="checkbox-container ${this.disabled ? 'disabled' : ''}">
204
+ <div
205
+ class="checkbox-container ${this.disabled ? 'disabled' : ''} ${this
206
+ .busy
207
+ ? 'busy'
208
+ : ''}"
209
+ >
184
210
  <div class="checkbox-background"></div>
185
211
  ${icon}
186
212
 
@@ -46,28 +46,44 @@ export class PageHeader extends RapidElement {
46
46
  'tnum' 0;
47
47
  }
48
48
 
49
- /* Title on the left, actions + content menu on the right. */
49
+ /* One row: the title/subtitle block on the left and the
50
+ actions/content-menu on the right, vertically centered against
51
+ each other. The title block is the flexing column so the
52
+ actions hold their size. The vertical padding matches the
53
+ horizontal inset the host supplies (the list panel's 20px) so
54
+ the whole header is wrapped in even, consistent padding. */
50
55
  .header {
51
56
  display: flex;
52
- align-items: flex-start;
57
+ align-items: center;
53
58
  gap: var(--gap);
54
- padding: 20px 0 16px 0;
59
+ padding: 12px 0;
55
60
  }
56
- .titles {
61
+ /* Title + subtitle stacked tight, sharing the left column. It
62
+ flexes and clips so a long subtitle truncates against the
63
+ actions rather than pushing them off the row. */
64
+ .title-block {
57
65
  flex: 1 1 auto;
58
66
  min-width: 0;
67
+ display: flex;
68
+ flex-direction: column;
69
+ gap: 1px;
59
70
  }
60
71
  .title {
61
72
  font-size: 15.5px;
62
73
  font-weight: var(--w-semibold);
63
74
  color: var(--text-1);
64
- line-height: 1.3;
75
+ line-height: 1.25;
76
+ white-space: nowrap;
77
+ overflow: hidden;
78
+ text-overflow: ellipsis;
65
79
  }
66
80
  .subtitle {
67
81
  font-size: 12.5px;
68
82
  color: var(--text-3);
69
- line-height: 1.3;
70
- margin-top: 1px;
83
+ line-height: 1.25;
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
71
87
  }
72
88
  .actions {
73
89
  flex: 0 0 auto;
@@ -91,7 +107,7 @@ export class PageHeader extends RapidElement {
91
107
  same way .ds * does in the styleguide — otherwise the
92
108
  border adds 2px and the button computes 2px taller. */
93
109
  box-sizing: border-box;
94
- height: 28px;
110
+ height: 26px;
95
111
  padding: 0 10px;
96
112
  border: 1px solid var(--border-strong);
97
113
  border-radius: var(--r-sm);
@@ -133,9 +149,12 @@ export class PageHeader extends RapidElement {
133
149
  .menu-toggle {
134
150
  display: inline-flex;
135
151
  align-items: center;
152
+ justify-content: center;
136
153
  cursor: pointer;
137
154
  user-select: none;
138
- padding: 6px;
155
+ height: 26px;
156
+ box-sizing: border-box;
157
+ padding: 0 5px;
139
158
  border-radius: var(--r-sm);
140
159
  color: var(--text-2);
141
160
  --icon-color: currentColor;
@@ -314,16 +333,19 @@ export class PageHeader extends RapidElement {
314
333
  }
315
334
 
316
335
  public render(): TemplateResult {
317
- const hasSubtitle =
318
- this.subtitle || this.querySelector('[slot="subtitle"]');
336
+ const slotted = this.querySelector('[slot="subtitle"]');
337
+ const hasSubtitle = this.subtitle || slotted;
338
+ // Full subtitle text for the hover tooltip — the bar truncates a
339
+ // long subtitle, so the native title surfaces the rest on hover.
340
+ const subtitleText = (this.subtitle || slotted?.textContent || '').trim();
319
341
  return html`
320
342
  <div class="header">
321
- <div class="titles">
343
+ <div class="title-block">
322
344
  <div class="title">
323
345
  <slot name="title">${this.headerTitle}</slot>
324
346
  </div>
325
347
  ${hasSubtitle
326
- ? html`<div class="subtitle">
348
+ ? html`<div class="subtitle" title=${subtitleText}>
327
349
  <slot name="subtitle">${this.subtitle}</slot>
328
350
  </div>`
329
351
  : null}
@@ -7,6 +7,9 @@ import { getUrl } from '../utils';
7
7
 
8
8
  const FIELD_PREFIX = 'field:';
9
9
 
10
+ /** Placeholder shown in any cell whose value is empty. */
11
+ const EMPTY = '--';
12
+
10
13
  /**
11
14
  * Contact CRUDL list — drop-in replacement for the rapidpro
12
15
  * `contacts/contact_list.html` table. Each row carries a contact
@@ -60,6 +63,16 @@ export class ContactList extends ContentList<Contact> {
60
63
  this.bulkActions = [
61
64
  { key: 'send', label: 'Send', icon: Icon.compose },
62
65
  { key: 'flow', label: 'Start flow', icon: Icon.flow },
66
+ // Group toggle — a dropdown of the workspace's static (manual)
67
+ // groups to add/remove the selection to/from, like the message
68
+ // list's label dropdown.
69
+ {
70
+ key: 'label',
71
+ label: 'Group',
72
+ icon: Icon.group,
73
+ labelsEndpoint: '/api/v2/groups.json?manual_only=1',
74
+ labelsKey: 'groups'
75
+ },
63
76
  { key: 'archive', label: 'Archive', icon: Icon.archive },
64
77
  { key: 'delete', label: 'Delete', icon: Icon.delete, destructive: true }
65
78
  ];
@@ -117,15 +130,16 @@ export class ContactList extends ContentList<Contact> {
117
130
  }
118
131
  }
119
132
 
120
- /** Columns: name, urn, the featured fields, then last seen.
133
+ /** Columns: name, urn, the featured fields, then last seen and
134
+ * created on.
121
135
  *
122
- * Name + URN lead and Last-seen trails, with the workspace's
123
- * custom fields filling the middle. Every column sizes to its
124
- * content between min/max bounds — none are hard-fixed — so the
136
+ * Name leads, with URN, the workspace's custom fields, and the
137
+ * last-seen / created-on dates trailing it. Every column sizes to
138
+ * its content between min/max bounds — none are hard-fixed — so the
125
139
  * table stays compact and overflows into a horizontal scroll only
126
- * when the field set is genuinely wide. Name + URN are pinned to
127
- * the left edge and Last-seen to the right, so identity and
128
- * recency stay anchored while the fields scroll between them.
140
+ * when the field set is genuinely wide. Only Name is pinned to the
141
+ * left edge, so identity stays anchored while everything else
142
+ * scrolls.
129
143
  *
130
144
  * There is deliberately no group-membership column — contacts
131
145
  * routinely belong to dozens of groups, so a groups cell is
@@ -158,8 +172,7 @@ export class ContactList extends ContentList<Contact> {
158
172
  key: 'urn',
159
173
  label: 'URN',
160
174
  minWidth: '120px',
161
- maxWidth: '190px',
162
- pinned: true
175
+ maxWidth: '190px'
163
176
  },
164
177
  ...fieldColumns,
165
178
  {
@@ -168,8 +181,15 @@ export class ContactList extends ContentList<Contact> {
168
181
  sortable: true,
169
182
  minWidth: '96px',
170
183
  maxWidth: '150px',
171
- align: 'right',
172
- pinned: 'right'
184
+ align: 'right'
185
+ },
186
+ {
187
+ key: 'created_on',
188
+ label: 'Created on',
189
+ sortable: true,
190
+ minWidth: '96px',
191
+ maxWidth: '150px',
192
+ align: 'right'
173
193
  }
174
194
  ];
175
195
  }
@@ -189,11 +209,16 @@ export class ContactList extends ContentList<Contact> {
189
209
  if (column.key.startsWith(FIELD_PREFIX)) {
190
210
  const fieldKey = column.key.substring(FIELD_PREFIX.length);
191
211
  const raw = item.fields?.[fieldKey];
192
- if (raw == null || raw === '') return '';
193
- // Date/time fields render as a relative duration, matching the
194
- // Last-seen column never a raw timestamp string.
212
+ if (raw == null || raw === '') return EMPTY;
213
+ // Location values are stored as a full hierarchy path
214
+ // (e.g. "Nigeria > Yobe > Nguru > Dabule"); show only the leaf.
215
+ if (this.isLocationField(fieldKey)) {
216
+ const path = String(raw);
217
+ return html`<span title=${path}>${this.locationLeaf(path)}</span>`;
218
+ }
219
+ // Date/time fields render via the timedate format.
195
220
  if (this.isDateField(fieldKey)) {
196
- return html`<temba-date value=${raw} display="duration"></temba-date>`;
221
+ return html`<temba-date value=${raw} display="timedate"></temba-date>`;
197
222
  }
198
223
  const value = String(raw);
199
224
  return html`<span title=${value}>${value}</span>`;
@@ -201,19 +226,26 @@ export class ContactList extends ContentList<Contact> {
201
226
  switch (column.key) {
202
227
  case 'name':
203
228
  return html`<span class="contact-name" title=${item.name || ''}
204
- >${item.name || '—'}</span
229
+ >${item.name || EMPTY}</span
205
230
  >`;
206
231
  case 'urn':
207
232
  return html`<span class="contact-urn"
208
- >${this.primaryUrn(item) || ''}</span
233
+ >${this.primaryUrn(item) || EMPTY}</span
209
234
  >`;
210
235
  case 'last_seen_on':
211
236
  return item.last_seen_on
212
237
  ? html`<temba-date
213
238
  value=${item.last_seen_on}
214
- display="duration"
239
+ display="timedate"
215
240
  ></temba-date>`
216
- : '';
241
+ : EMPTY;
242
+ case 'created_on':
243
+ return item.created_on
244
+ ? html`<temba-date
245
+ value=${item.created_on}
246
+ display="timedate"
247
+ ></temba-date>`
248
+ : EMPTY;
217
249
  default:
218
250
  return super.renderCell(item, column);
219
251
  }
@@ -229,6 +261,24 @@ export class ContactList extends ContentList<Contact> {
229
261
  return type === 'datetime' || type === 'date';
230
262
  }
231
263
 
264
+ /** True when a featured field stores a location value (state /
265
+ * district / ward) — those are serialized as a full hierarchy path
266
+ * and we render only the leaf. */
267
+ private isLocationField(fieldKey: string): boolean {
268
+ const field = (this.featuredFields || []).find(
269
+ (f: any) => f.key === fieldKey
270
+ );
271
+ const type = field?.value_type;
272
+ return type === 'state' || type === 'district' || type === 'ward';
273
+ }
274
+
275
+ /** The last segment of a location hierarchy path, e.g.
276
+ * "Nigeria > Yobe > Nguru > Dabule" → "Dabule". */
277
+ private locationLeaf(path: string): string {
278
+ const parts = path.split('>');
279
+ return parts[parts.length - 1].trim();
280
+ }
281
+
232
282
  private primaryUrn(item: Contact): string {
233
283
  const i = item as any;
234
284
  if (i.urn) return i.urn;