@nyaruka/temba-components 0.159.2 → 0.159.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.159.2",
3
+ "version": "0.159.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",
@@ -47,6 +47,7 @@ export default {
47
47
  replace({
48
48
  preventAssignment: true,
49
49
  'process.env.NODE_ENV': JSON.stringify('development'),
50
+ '__TEMBA_DEV_SERVER__': JSON.stringify(false),
50
51
  '__TEMBA_COMPONENTS_VERSION__': JSON.stringify(TEMBA_COMPONENTS_VERSION)
51
52
  }),
52
53
 
@@ -802,6 +802,15 @@ export class Chat extends RapidElement {
802
802
  @property({ type: Boolean })
803
803
  avatars = false;
804
804
 
805
+ // identity of the contact this chat belongs to, used to render a
806
+ // name-based avatar for the contact's own incoming messages (which the
807
+ // backend does not attach a `_user` to)
808
+ @property({ type: String })
809
+ contactName: string;
810
+
811
+ @property({ type: String })
812
+ contactUuid: string;
813
+
805
814
  @property({ type: Boolean, attribute: false })
806
815
  endOfHistory = false;
807
816
 
@@ -1213,7 +1222,36 @@ export class Chat extends RapidElement {
1213
1222
  const showAvatar =
1214
1223
  this.avatars && ((isMessageType && this.agent) || !incoming);
1215
1224
 
1216
- const isSystem = !currentMsg._user?.uuid;
1225
+ // resolve the identity shown in the avatar: prefer the user attached to
1226
+ // the event (an agent or flow author), otherwise fall back to the contact
1227
+ // for their own incoming messages.
1228
+ //
1229
+ // contact fallback assumes `_user` is absent for `msg_received` (contact
1230
+ // messages carry no `_user`, so first_name/last_name aren't available and
1231
+ // getFullName falls back to `name`); the fallback only applies when there
1232
+ // is no `_user` on the event.
1233
+ const fromContact = currentMsg.type === 'msg_received' && !currentMsg._user;
1234
+ const avatarName = currentMsg._user
1235
+ ? currentMsg._user.name
1236
+ : fromContact
1237
+ ? this.contactName
1238
+ : undefined;
1239
+ const avatarUuid = currentMsg._user
1240
+ ? currentMsg._user.uuid
1241
+ : fromContact
1242
+ ? this.contactUuid
1243
+ : undefined;
1244
+
1245
+ // determine whether to fall back to the generic default (system) avatar.
1246
+ // when the event has a `_user`, preserve the original behavior exactly:
1247
+ // system iff that user has no uuid (a name-only flow author still gets the
1248
+ // default avatar). for a contact event with no `_user`, it's system only
1249
+ // when we have no contact identity at all.
1250
+ const isSystem = currentMsg._user
1251
+ ? !currentMsg._user.uuid
1252
+ : fromContact
1253
+ ? !this.contactUuid && !this.contactName
1254
+ : true;
1217
1255
 
1218
1256
  const reasonLabel = this.getReasonLabel(group.reason);
1219
1257
  const showReason = false; // reasonLabel && idx > 0;
@@ -1294,11 +1332,11 @@ export class Chat extends RapidElement {
1294
1332
  ${showAvatar
1295
1333
  ? html`<div class="avatar" style="align-self:flex-end">
1296
1334
  <temba-user
1297
- uuid=${currentMsg._user?.uuid}
1298
- name=${name}
1299
- first_name=${currentMsg._user?.first_name}
1300
- last_name=${currentMsg._user?.last_name}
1301
- avatar=${currentMsg._user?.avatar}
1335
+ uuid=${avatarUuid ?? nothing}
1336
+ name=${avatarName ?? nothing}
1337
+ first_name=${currentMsg._user?.first_name ?? nothing}
1338
+ last_name=${currentMsg._user?.last_name ?? nothing}
1339
+ avatar=${currentMsg._user?.avatar ?? nothing}
1302
1340
  ?system=${isSystem}
1303
1341
  >
1304
1342
  </temba-user>
@@ -4,6 +4,7 @@ import { property } from 'lit/decorators.js';
4
4
  import { colorHash, extractInitials } from '../utils';
5
5
 
6
6
  import { DEFAULT_AVATAR } from '../webchat/assets';
7
+ import { Icon } from '../Icons';
7
8
  import { RapidElement } from '../RapidElement';
8
9
 
9
10
  export const getFullName = (user: {
@@ -83,8 +84,14 @@ export class TembaUser extends RapidElement {
83
84
  public willUpdate(changed: PropertyValues): void {
84
85
  super.willUpdate(changed);
85
86
 
86
- if (changed.has('system') && this.system) {
87
- this.bgimage = `url('${DEFAULT_AVATAR}') center / contain no-repeat`;
87
+ // when system toggles, set the default avatar background while system, and
88
+ // clear it otherwise so a reused element doesn't keep a stale default that
89
+ // would suppress the initials/contact-icon branch. a real `avatar` below
90
+ // can still override this.
91
+ if (changed.has('system')) {
92
+ this.bgimage = this.system
93
+ ? `url('${DEFAULT_AVATAR}') center / contain no-repeat`
94
+ : null;
88
95
  }
89
96
 
90
97
  if (
@@ -136,13 +143,18 @@ export class TembaUser extends RapidElement {
136
143
  box-shadow: inset 0 0 0 3px rgba(0, 0, 0, 0.1);
137
144
  background:${this.bgimage || this.bgcolor};"
138
145
  >
139
- ${this.initials && !this.bgimage
140
- ? html` <div
141
- style="border: 0px solid red; display:flex; flex-direction: column; align-items:center;flex-grow:1"
142
- >
143
- <div style="border:0px solid blue;">${this.initials}</div>
144
- </div>`
145
- : null}
146
+ ${this.bgimage
147
+ ? null
148
+ : this.initials
149
+ ? html` <div
150
+ style="display:flex; flex-direction: column; align-items:center;flex-grow:1"
151
+ >
152
+ <div>${this.initials}</div>
153
+ </div>`
154
+ : html`<temba-icon
155
+ name="${Icon.contact}"
156
+ style="display:flex;flex-grow:1;justify-content:center;color:rgba(0,0,0,0.35)"
157
+ ></temba-icon>`}
146
158
  </div>
147
159
  ${this.showName
148
160
  ? html`<div
@@ -1129,11 +1129,16 @@ export class NodeEditor extends RapidElement {
1129
1129
  }
1130
1130
  }
1131
1131
 
1132
- // Check required fields (skip in localization mode since all fields are optional)
1132
+ // Check required fields (skip in localization mode since all fields are optional).
1133
+ // A whitespace-only string counts as empty here - otherwise it slips past this
1134
+ // check and is emitted (e.g. trimmed to "") into the definition, which the backend
1135
+ // then rejects (e.g. a dial wait with an empty phone).
1133
1136
  if (
1134
1137
  !this.isTranslating &&
1135
1138
  (fieldConfig as any).required &&
1136
- (!value || (Array.isArray(value) && value.length === 0))
1139
+ (!value ||
1140
+ (typeof value === 'string' && value.trim() === '') ||
1141
+ (Array.isArray(value) && value.length === 0))
1137
1142
  ) {
1138
1143
  errors[fieldName] = `${
1139
1144
  (fieldConfig as any).label || fieldName
@@ -1151,17 +1156,32 @@ export class NodeEditor extends RapidElement {
1151
1156
  } must be at least ${(fieldConfig as any).minLength} characters`;
1152
1157
  }
1153
1158
 
1154
- // Check maxLength for text fields
1155
- if (
1156
- typeof value === 'string' &&
1157
- (fieldConfig as any).maxLength &&
1158
- value.length > (fieldConfig as any).maxLength
1159
- ) {
1160
- errors[fieldName] = `${
1161
- (fieldConfig as any).label || fieldName
1162
- } must be no more than ${
1163
- (fieldConfig as any).maxLength
1164
- } characters`;
1159
+ // Check maxLength for text fields, as well as for the values of select/tag
1160
+ // items (e.g. result names, quick replies) which are stored as arrays of
1161
+ // option objects (or plain strings) rather than a single string. Without the
1162
+ // array/object handling, an over-long value here is emitted into the definition
1163
+ // and rejected by the backend (goflow caps result names at 64 and quick replies
1164
+ // at 1000 chars).
1165
+ const maxLength = (fieldConfig as any).maxLength;
1166
+ if (maxLength) {
1167
+ const itemLength = (item: any): number => {
1168
+ if (typeof item === 'string') return item.length;
1169
+ if (item && typeof item === 'object') {
1170
+ const text = item.value ?? item.name;
1171
+ return typeof text === 'string' ? text.length : 0;
1172
+ }
1173
+ return 0;
1174
+ };
1175
+
1176
+ const exceedsMax = Array.isArray(value)
1177
+ ? value.some((item) => itemLength(item) > maxLength)
1178
+ : itemLength(value) > maxLength;
1179
+
1180
+ if (exceedsMax) {
1181
+ errors[fieldName] = `${
1182
+ (fieldConfig as any).label || fieldName
1183
+ } must be no more than ${maxLength} characters`;
1184
+ }
1165
1185
  }
1166
1186
  });
1167
1187
  }
@@ -6,7 +6,10 @@ import { renderNamedObjects } from '../utils';
6
6
  export const add_input_labels: ActionConfig = {
7
7
  name: 'Add Input Labels',
8
8
  group: ACTION_GROUPS.save,
9
- flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
9
+ // Not allowed in background flows: goflow treats add_input_labels as an interactive
10
+ // action (messaging, messaging_offline, voice only), so offering it in a
11
+ // messaging_background flow produces a definition the backend rejects.
12
+ flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE],
10
13
  render: (_node: Node, action: AddInputLabels) => {
11
14
  return html`<div>${renderNamedObjects(action.labels, 'label')}</div>`;
12
15
  },
@@ -81,6 +81,7 @@ export const send_msg: ActionConfig = {
81
81
  searchable: true,
82
82
  placeholder: 'Add quick replies...',
83
83
  maxItems: 10,
84
+ maxLength: 1000,
84
85
  evaluated: true
85
86
  },
86
87
  template: {
@@ -21,6 +21,7 @@ export const set_run_result: ActionConfig = {
21
21
  label: 'Result Name',
22
22
  helpText: 'Select an existing result name or type a new one',
23
23
  required: true,
24
+ maxLength: 64,
24
25
  placeholder: 'Select or enter result name...',
25
26
  createArbitraryOption: (input, options) => {
26
27
  const exists = options.some(
@@ -41,6 +41,7 @@ export const split_by_ticket: NodeConfig = {
41
41
  type: 'textarea',
42
42
  label: 'Note',
43
43
  required: false,
44
+ evaluated: true,
44
45
  placeholder: 'Enter a note for the ticket (optional)',
45
46
  minHeight: 100
46
47
  }
@@ -1524,7 +1524,16 @@ export class ContentList<T = any> extends RapidElement {
1524
1524
  this.fireCustomEvent(CustomEventType.RowClick, { item });
1525
1525
  const href = this.getRowHref(item);
1526
1526
  if (href && this.isSafeHref(href)) {
1527
- window.location.href = href;
1527
+ // Meta/ctrl-click opens a new tab, matching ordinary links.
1528
+ if (event.metaKey || event.ctrlKey) {
1529
+ window.open(href, '_blank');
1530
+ return;
1531
+ }
1532
+ // Fire Redirected rather than assigning window.location so the
1533
+ // host SPA frame swaps content in place instead of doing a full
1534
+ // page reload — the frame listens for this event on document and
1535
+ // routes it through its in-app loader.
1536
+ this.fireCustomEvent(CustomEventType.Redirected, { url: href });
1528
1537
  }
1529
1538
  }
1530
1539
 
@@ -134,6 +134,14 @@ export class MsgList extends ContentList<Msg> {
134
134
  ];
135
135
  }
136
136
 
137
+ /** Rows navigate to the message's contact. Returning the href here
138
+ * also marks the row `clickable`, so it carries the pointer cursor on
139
+ * hover. */
140
+ protected getRowHref(item: Msg): string | null {
141
+ const uuid = item.contact?.uuid;
142
+ return uuid ? `/contact/read/${uuid}/` : null;
143
+ }
144
+
137
145
  protected renderCell(
138
146
  item: Msg,
139
147
  column: ContentListColumn
@@ -220,20 +228,37 @@ export class MsgList extends ContentList<Msg> {
220
228
  }
221
229
 
222
230
  /** Flow + label pills for a row, pushed to the trailing edge of
223
- * the message cell, or '' when the row carries none. */
231
+ * the message cell, or '' when the row carries none. The flow pill
232
+ * opens its editor and each label pill opens that label's filtered
233
+ * message view — matching the rapidpro msg list. `clickable` gives
234
+ * the hover affordance; `goto` routes the click through the SPA and
235
+ * stops propagation so the row's own contact navigation doesn't also
236
+ * fire. */
224
237
  private renderPills(item: Msg): TemplateResult | string {
225
238
  const labels = item.labels || [];
226
239
  if (!item.flow && !labels.length) return '';
227
240
  return html`
228
241
  <div class="cell-pills">
229
242
  ${item.flow
230
- ? html`<temba-label type="flow" icon=${Icon.flow}
243
+ ? html`<temba-label
244
+ type="flow"
245
+ icon=${Icon.flow}
246
+ href="/flow/editor/${item.flow.uuid}/"
247
+ onclick="goto(event)"
248
+ clickable
231
249
  >${item.flow.name}</temba-label
232
250
  >`
233
251
  : null}
234
252
  ${labels.map(
235
253
  (l) => html`
236
- <temba-label type="label" icon=${Icon.label}>${l.name}</temba-label>
254
+ <temba-label
255
+ type="label"
256
+ icon=${Icon.label}
257
+ href="/msg/filter/${l.uuid}/"
258
+ onclick="goto(event)"
259
+ clickable
260
+ >${l.name}</temba-label
261
+ >
237
262
  `
238
263
  )}
239
264
  </div>
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  css,
4
4
  html,
5
+ nothing,
5
6
  PropertyValueMap,
6
7
  PropertyValues,
7
8
  TemplateResult
@@ -1436,6 +1437,8 @@ export class ContactChat extends ContactStoreElement {
1436
1437
  @temba-scroll-threshold-bottom=${this.fetchNewerMessages}
1437
1438
  @temba-fetch-complete=${this.fetchComplete}
1438
1439
  avatar=${this.avatar}
1440
+ contactName=${this.currentContact?.name ?? nothing}
1441
+ contactUuid=${this.currentContact?.uuid ?? nothing}
1439
1442
  agent
1440
1443
  avatars
1441
1444
  ?hasFooter=${inFlow}
@@ -809,7 +809,7 @@ export class ContactTimeline extends EndpointMonitorElement {
809
809
  <temba-icon name=${Icon.schedule} size="2"></temba-icon>
810
810
  <div class="empty-title">${this.lang_empty}</div>
811
811
  <div class="empty-help">${this.lang_empty_help}</div>
812
- <a class="empty-link" href="/campaign/" onclick="goto(event)"
812
+ <a class="empty-link" href="/campaign/" onclick="goto(event, this)"
813
813
  >${this.lang_campaigns_link}</a
814
814
  >
815
815
  </slot>
@@ -41,6 +41,25 @@ export const getStore = () => {
41
41
  return document.querySelector('temba-store') as Store;
42
42
  };
43
43
 
44
+ declare const __TEMBA_DEV_SERVER__: boolean;
45
+
46
+ /**
47
+ * True only when running against the temba-components dev server, which
48
+ * replaces `__TEMBA_DEV_SERVER__` with `true` at serve time. The production
49
+ * rollup build and the test runner replace it with `false`; for any other
50
+ * consumer the token is undefined and the try/catch falls back to `false`.
51
+ * We deliberately do not key off `process.env.NODE_ENV` — the published IIFE
52
+ * bundle (rollup.components.mjs) hardcodes that to 'development', so it can't
53
+ * distinguish the dev server from a production consumer.
54
+ */
55
+ const isDevServer = (): boolean => {
56
+ try {
57
+ return __TEMBA_DEV_SERVER__ === true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ };
62
+
44
63
  export class Store extends RapidElement {
45
64
  public static get styles() {
46
65
  return css`
@@ -172,7 +191,7 @@ export class Store extends RapidElement {
172
191
  const fetches = [];
173
192
  if (this.completionEndpoint) {
174
193
  fetches.push(
175
- getUrl(this.completionEndpoint).then((response) => {
194
+ getUrl(this.getCompletionEndpoint()).then((response) => {
176
195
  this.schema = response.json['context'] as CompletionSchema;
177
196
  this.fnOptions = response.json['functions'] as CompletionOption[];
178
197
  })
@@ -338,6 +357,27 @@ export class Store extends RapidElement {
338
357
  }
339
358
  }
340
359
 
360
+ /**
361
+ * Resolves the completion endpoint to fetch. When running against the
362
+ * temba-components dev server we override the configured endpoint so the
363
+ * editor serves our own editor.json (static/mr/docs/en-us/editor.json)
364
+ * rather than the host application's mailroom completions. The dev-server
365
+ * origin is derived from import.meta.url so this works even when the
366
+ * components are loaded cross-origin (e.g. rapidpro on :8001 with the
367
+ * components dev server on :3011).
368
+ */
369
+ private getCompletionEndpoint(): string {
370
+ if (isDevServer()) {
371
+ try {
372
+ const origin = new URL(import.meta.url).origin;
373
+ return `${origin}/api/v2/completion.json`;
374
+ } catch {
375
+ // import.meta.url unavailable; fall back to the configured endpoint
376
+ }
377
+ }
378
+ return this.completionEndpoint;
379
+ }
380
+
341
381
  public getCompletionSchema(): CompletionSchema {
342
382
  return this.schema;
343
383
  }
@@ -295,8 +295,18 @@ export default {
295
295
  // Permissive CORS so this dev server can be loaded as a cross-origin
296
296
  // module source by a rapidpro instance running on a different localhost
297
297
  // port (e.g. Nautilus/run-pair.sh launching rapidpro:8001 + components:3011).
298
+ // The Store fetches completion.json with custom headers (X-CSRFToken,
299
+ // X-Temba-Workspace, X-Requested-With), which makes it a non-simple
300
+ // cross-origin request, so we must also answer the preflight (OPTIONS)
301
+ // with the allowed methods/headers or the browser blocks it.
298
302
  (ctx, next) => {
299
303
  ctx.set('Access-Control-Allow-Origin', '*');
304
+ ctx.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
305
+ ctx.set('Access-Control-Allow-Headers', '*');
306
+ if (ctx.method === 'OPTIONS') {
307
+ ctx.status = 204;
308
+ return;
309
+ }
300
310
  return next();
301
311
  },
302
312
  ],
@@ -304,6 +314,7 @@ export default {
304
314
  replacePlugin({
305
315
  preventAssignment: true,
306
316
  'process.env.NODE_ENV': JSON.stringify('development'),
317
+ '__TEMBA_DEV_SERVER__': JSON.stringify(true),
307
318
  '__TEMBA_COMPONENTS_VERSION__': JSON.stringify(TEMBA_COMPONENTS_VERSION),
308
319
  'process.env.MINIO_ENDPOINT': JSON.stringify('http://minio:9000'),
309
320
  'process.env.MINIO_PUBLIC_ENDPOINT': JSON.stringify('http://localhost:9000'),
@@ -454,6 +454,7 @@ export default {
454
454
  replacePlugin({
455
455
  preventAssignment: true,
456
456
  'process.env.NODE_ENV': JSON.stringify('test'),
457
+ __TEMBA_DEV_SERVER__: JSON.stringify(false),
457
458
  __TEMBA_COMPONENTS_VERSION__: JSON.stringify(TEMBA_COMPONENTS_VERSION)
458
459
  }),
459
460
  {