@nyaruka/temba-components 0.167.0 → 0.168.0

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/locales/es.js +3 -1
  3. package/dist/locales/es.js.map +1 -1
  4. package/dist/locales/fr.js +3 -1
  5. package/dist/locales/fr.js.map +1 -1
  6. package/dist/locales/pt.js +3 -1
  7. package/dist/locales/pt.js.map +1 -1
  8. package/dist/temba-components.js +2791 -1668
  9. package/dist/temba-components.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/Icons.ts +2 -0
  12. package/src/display/Button.ts +8 -6
  13. package/src/display/Chat.ts +541 -191
  14. package/src/display/Dropdown.ts +40 -17
  15. package/src/display/Label.ts +25 -4
  16. package/src/display/TembaDate.ts +11 -2
  17. package/src/display/Tip.ts +157 -18
  18. package/src/events/eventRenderers.ts +678 -268
  19. package/src/events.ts +16 -1
  20. package/src/flow/NodeEditor.ts +82 -9
  21. package/src/flow/actions/add_contact_urn.ts +1 -0
  22. package/src/flow/actions/play_audio.ts +1 -0
  23. package/src/flow/actions/send_broadcast.ts +17 -1
  24. package/src/flow/actions/send_msg.ts +12 -1
  25. package/src/flow/actions/set_run_result.ts +1 -0
  26. package/src/flow/actions/start_session.ts +10 -1
  27. package/src/flow/nodes/shared-rules.ts +2 -0
  28. package/src/flow/nodes/split_by_airtime.ts +6 -0
  29. package/src/flow/nodes/split_by_expression.ts +1 -0
  30. package/src/flow/nodes/split_by_llm.ts +2 -0
  31. package/src/flow/nodes/split_by_llm_categorize.ts +3 -1
  32. package/src/flow/nodes/split_by_random.ts +2 -1
  33. package/src/flow/nodes/split_by_resthook.ts +4 -1
  34. package/src/flow/nodes/split_by_webhook.ts +9 -1
  35. package/src/flow/types.ts +3 -0
  36. package/src/flow/utils.ts +49 -0
  37. package/src/form/DatePicker.ts +2 -1
  38. package/src/layout/Card.ts +9 -0
  39. package/src/layout/CardLayout.ts +426 -21
  40. package/src/layout/CardStack.ts +4 -1
  41. package/src/list/ContactList.ts +22 -17
  42. package/src/list/ContentList.ts +632 -29
  43. package/src/list/MsgList.ts +4 -2
  44. package/src/list/SortableList.ts +5 -1
  45. package/src/list/TembaMenu.ts +67 -6
  46. package/src/live/ContactChat.ts +655 -336
  47. package/src/live/ContactDetails.ts +961 -88
  48. package/src/live/ContactFieldEditor.ts +46 -11
  49. package/src/live/ContactFields.ts +57 -16
  50. package/src/live/ContactStoreElement.ts +15 -8
  51. package/src/locales/es.ts +3 -2
  52. package/src/locales/fr.ts +3 -2
  53. package/src/locales/pt.ts +3 -2
  54. package/src/simulator/Simulator.ts +28 -0
  55. package/src/styles/pillVariants.ts +13 -1
  56. package/web-test-runner.config.mjs +2 -0
  57. package/xliff/es.xlf +6 -3
  58. package/xliff/fr.xlf +6 -3
  59. package/xliff/pt.xlf +6 -3
package/src/events.ts CHANGED
@@ -11,6 +11,18 @@ export interface ContactLanguageChangedEvent extends ContactEvent {
11
11
  language: string;
12
12
  }
13
13
 
14
+ // ephemeral state event - arrives over the socket but is never persisted,
15
+ // updates the contact's current flow rather than recording history
16
+ export interface ContactFlowChangedEvent extends ContactEvent {
17
+ flow: ObjectReference | null;
18
+ }
19
+
20
+ // ephemeral state event - arrives over the socket but is never persisted,
21
+ // updates the contact's last seen rather than recording history
22
+ export interface ContactLastSeenChangedEvent extends ContactEvent {
23
+ last_seen_on: string;
24
+ }
25
+
14
26
  export interface ContactStatusChangedEvent extends ContactEvent {
15
27
  status: string;
16
28
  }
@@ -62,6 +74,7 @@ export interface TicketEvent extends ContactEvent {
62
74
  // ticket_opened
63
75
  uuid: string;
64
76
  topic?: ObjectReference;
77
+ assignee?: User;
65
78
  };
66
79
  ticket_uuid?: string; // all other event types
67
80
  assignee?: User;
@@ -75,7 +88,9 @@ export interface NameChangedEvent extends ContactEvent {
75
88
 
76
89
  export interface UpdateFieldEvent extends ContactEvent {
77
90
  field: { key: string; name: string };
78
- value: { text: string };
91
+ // engine field values always carry text; typed representations are
92
+ // present when the value parses as that type (see goflow's Value)
93
+ value: { text: string; datetime?: string; number?: string };
79
94
  }
80
95
 
81
96
  export interface ContactGroupsEvent extends ContactEvent {
@@ -1269,17 +1269,17 @@ export class NodeEditor extends RapidElement {
1269
1269
  // array/object handling, an over-long value here is emitted into the definition
1270
1270
  // and rejected by the backend (goflow caps result names at 64 and quick replies
1271
1271
  // at 1000 chars).
1272
+ const itemLength = (item: any): number => {
1273
+ if (typeof item === 'string') return item.length;
1274
+ if (item && typeof item === 'object') {
1275
+ const text = item.value ?? item.name;
1276
+ return typeof text === 'string' ? text.length : 0;
1277
+ }
1278
+ return 0;
1279
+ };
1280
+
1272
1281
  const maxLength = (fieldConfig as any).maxLength;
1273
1282
  if (maxLength) {
1274
- const itemLength = (item: any): number => {
1275
- if (typeof item === 'string') return item.length;
1276
- if (item && typeof item === 'object') {
1277
- const text = item.value ?? item.name;
1278
- return typeof text === 'string' ? text.length : 0;
1279
- }
1280
- return 0;
1281
- };
1282
-
1283
1283
  const exceedsMax = Array.isArray(value)
1284
1284
  ? value.some((item) => itemLength(item) > maxLength)
1285
1285
  : itemLength(value) > maxLength;
@@ -1290,6 +1290,79 @@ export class NodeEditor extends RapidElement {
1290
1290
  } must be no more than ${maxLength} characters`;
1291
1291
  }
1292
1292
  }
1293
+
1294
+ // Check maxLength declared on array item sub-fields (e.g. rule categories,
1295
+ // case arguments) - these live in itemConfig which the top-level check
1296
+ // above doesn't reach, so without this an over-long value is emitted into
1297
+ // the definition and rejected by the backend.
1298
+ if (
1299
+ fieldConfig.type === 'array' &&
1300
+ fieldConfig.itemConfig &&
1301
+ Array.isArray(value)
1302
+ ) {
1303
+ Object.entries(fieldConfig.itemConfig).forEach(
1304
+ ([subFieldName, subFieldConfig]) => {
1305
+ const subMaxLength = (subFieldConfig as any).maxLength;
1306
+ if (!subMaxLength || errors[fieldName]) {
1307
+ return;
1308
+ }
1309
+ const exceeds = value.some((item: any) => {
1310
+ // skip sub-fields hidden for this item (e.g. a rule value for
1311
+ // an operator that takes no operands) - they aren't emitted
1312
+ const visible = (subFieldConfig as any).conditions?.visible;
1313
+ if (visible && !visible(item)) {
1314
+ return false;
1315
+ }
1316
+ // measure like the top-level check so option-object values
1317
+ // (e.g. a select or tag input inside an array) are covered
1318
+ const subValue = item?.[subFieldName];
1319
+ return Array.isArray(subValue)
1320
+ ? subValue.some((sub) => itemLength(sub) > subMaxLength)
1321
+ : itemLength(subValue) > subMaxLength;
1322
+ });
1323
+ if (exceeds) {
1324
+ errors[fieldName] = `${
1325
+ (subFieldConfig as any).label || subFieldName
1326
+ } must be no more than ${subMaxLength} characters`;
1327
+ }
1328
+ }
1329
+ );
1330
+ }
1331
+
1332
+ // Check key-value fields against their declared limits (e.g. goflow caps
1333
+ // webhook headers at 100 entries, names at 100 chars and values at 1000).
1334
+ if (fieldConfig.type === 'key-value' && Array.isArray(value)) {
1335
+ const rows = value.filter(
1336
+ (item: any) =>
1337
+ ((item?.key || '') as string).trim() !== '' ||
1338
+ ((item?.value || '') as string).trim() !== ''
1339
+ );
1340
+ const label = (fieldConfig as any).label || fieldName;
1341
+ if (fieldConfig.maxItems && rows.length > fieldConfig.maxItems) {
1342
+ errors[fieldName] =
1343
+ `${label} can have no more than ${fieldConfig.maxItems} entries`;
1344
+ } else if (
1345
+ fieldConfig.keyMaxLength &&
1346
+ rows.some(
1347
+ (item: any) =>
1348
+ (item.key || '').length > fieldConfig.keyMaxLength
1349
+ )
1350
+ ) {
1351
+ errors[fieldName] = `${
1352
+ fieldConfig.keyPlaceholder || 'Key'
1353
+ } must be no more than ${fieldConfig.keyMaxLength} characters`;
1354
+ } else if (
1355
+ fieldConfig.valueMaxLength &&
1356
+ rows.some(
1357
+ (item: any) =>
1358
+ (item.value || '').length > fieldConfig.valueMaxLength
1359
+ )
1360
+ ) {
1361
+ errors[fieldName] = `${
1362
+ fieldConfig.valuePlaceholder || 'Value'
1363
+ } must be no more than ${fieldConfig.valueMaxLength} characters`;
1364
+ }
1365
+ }
1293
1366
  });
1294
1367
  }
1295
1368
 
@@ -66,6 +66,7 @@ export const add_contact_urn: ActionConfig = {
66
66
  label: 'URN Value',
67
67
  helpText: 'Enter the URN value (e.g., phone number, Facebook ID, etc.)',
68
68
  required: true,
69
+ maxLength: 1000,
69
70
  placeholder: 'Enter the URN value...',
70
71
  evaluated: true
71
72
  }
@@ -25,6 +25,7 @@ export const play_audio: ActionConfig = {
25
25
  type: 'text',
26
26
  label: 'Recording URL',
27
27
  required: true,
28
+ maxLength: 8192,
28
29
  evaluated: true
29
30
  }
30
31
  },
@@ -4,7 +4,10 @@ import { Node, SendBroadcast } from '../../store/flow-definition';
4
4
  import {
5
5
  renderMixedList,
6
6
  renderClamped,
7
- renderHighlightedText
7
+ renderHighlightedText,
8
+ validateRecipients,
9
+ validateTemplateVariables,
10
+ validateWith
8
11
  } from '../utils';
9
12
  import { Icon } from '../../Icons';
10
13
  import { CustomEventType } from '../../interfaces';
@@ -182,6 +185,19 @@ export const send_broadcast: ActionConfig = {
182
185
  formData.text = formData.text.trim();
183
186
  }
184
187
  },
188
+ validate: validateWith((formData, errors) => {
189
+ const recipientsError = validateRecipients(formData.recipients || []);
190
+ if (recipientsError) {
191
+ errors.recipients = recipientsError;
192
+ }
193
+
194
+ const templateVariablesError = validateTemplateVariables(
195
+ formData.template_variables || []
196
+ );
197
+ if (templateVariablesError) {
198
+ errors.template = templateVariablesError;
199
+ }
200
+ }),
185
201
  localizable: ['text', 'attachments'],
186
202
  toLocalizationFormData: (
187
203
  action: SendBroadcast,
@@ -8,7 +8,11 @@ import {
8
8
  } from '../types';
9
9
  import { Node, SendMsg } from '../../store/flow-definition';
10
10
  import { titleCase } from '../../utils';
11
- import { renderClamped, renderHighlightedText } from '../utils';
11
+ import {
12
+ renderClamped,
13
+ renderHighlightedText,
14
+ validateTemplateVariables
15
+ } from '../utils';
12
16
  import { splitSMS } from '../../display/sms';
13
17
 
14
18
  const ATTACHMENT_TYPE_NAMES: Record<string, string> = {
@@ -268,6 +272,13 @@ export const send_msg: ActionConfig = {
268
272
  }
269
273
  }
270
274
 
275
+ const templateVariablesError = validateTemplateVariables(
276
+ formData.template_variables || []
277
+ );
278
+ if (templateVariablesError) {
279
+ errors.template = templateVariablesError;
280
+ }
281
+
271
282
  return {
272
283
  valid: Object.keys(errors).length === 0,
273
284
  errors
@@ -51,6 +51,7 @@ export const set_run_result: ActionConfig = {
51
51
  helpText: 'The value to save for this result (can use expressions)',
52
52
  required: false,
53
53
  evaluated: true,
54
+ maxLength: 10000,
54
55
  placeholder: 'Enter value...'
55
56
  },
56
57
  category: {
@@ -10,7 +10,8 @@ import { Node, StartSession } from '../../store/flow-definition';
10
10
  import {
11
11
  renderMixedList,
12
12
  renderFlowLinks,
13
- renderHighlightedText
13
+ renderHighlightedText,
14
+ validateRecipients
14
15
  } from '../utils';
15
16
  import { Icon } from '../../Icons';
16
17
  import { CustomEventType } from '../../interfaces';
@@ -158,6 +159,7 @@ export const start_session: ActionConfig = {
158
159
  evaluated: true,
159
160
  label: 'Contact Query',
160
161
  helpText: 'Only one matching contact will be started',
162
+ maxLength: 10000,
161
163
  placeholder: 'household_id = @fields.household_id',
162
164
  conditions: {
163
165
  visible: (formData: FormData) => {
@@ -194,6 +196,13 @@ export const start_session: ActionConfig = {
194
196
  errors.recipients = 'At least one contact or group must be selected';
195
197
  }
196
198
 
199
+ if (startType === 'manual') {
200
+ const recipientsError = validateRecipients(formData.recipients || []);
201
+ if (recipientsError) {
202
+ errors.recipients = recipientsError;
203
+ }
204
+ }
205
+
197
206
  // Check if query has a query string
198
207
  if (
199
208
  startType === 'query' &&
@@ -172,6 +172,7 @@ export const createRulesItemConfig = () => ({
172
172
  type: 'text' as const,
173
173
  placeholder: value1Placeholder,
174
174
  evaluated: true,
175
+ maxLength: 10000,
175
176
  flavor: 'xsmall' as const,
176
177
  conditions: {
177
178
  visible: value1VisibilityCondition
@@ -181,6 +182,7 @@ export const createRulesItemConfig = () => ({
181
182
  type: 'text' as const,
182
183
  placeholder: value2Placeholder,
183
184
  evaluated: true,
185
+ maxLength: 10000,
184
186
  flavor: 'xsmall' as const,
185
187
  conditions: {
186
188
  visible: value2VisibilityCondition
@@ -87,6 +87,7 @@ export const split_by_airtime: NodeConfig = {
87
87
 
88
88
  if (duplicates.length > 0) {
89
89
  errors.amounts = `Duplicate currencies found: ${duplicates.join(', ')}`;
90
+ return;
90
91
  }
91
92
 
92
93
  for (const item of validAmounts) {
@@ -95,6 +96,11 @@ export const split_by_airtime: NodeConfig = {
95
96
  errors.amounts = 'All amounts must be valid positive numbers';
96
97
  return;
97
98
  }
99
+ // the engine caps amounts at 1e15
100
+ if (Number(amount) > 1e15) {
101
+ errors.amounts = 'Amounts must be 1,000,000,000,000,000 or less';
102
+ return;
103
+ }
98
104
  }
99
105
  }),
100
106
  render: (node: Node) => {
@@ -32,6 +32,7 @@ export const split_by_expression: NodeConfig = {
32
32
  helpText: 'The expression to evaluate and split on',
33
33
  required: true,
34
34
  evaluated: true,
35
+ maxLength: 10000,
35
36
  placeholder: '@fields.age'
36
37
  },
37
38
  rules: createRulesArrayConfig(
@@ -57,6 +57,7 @@ export const split_by_llm: NodeConfig = {
57
57
  helpText: 'The input the AI will process',
58
58
  required: true,
59
59
  evaluated: true,
60
+ maxLength: 10000,
60
61
  placeholder: '@input'
61
62
  },
62
63
  instructions: {
@@ -66,6 +67,7 @@ export const split_by_llm: NodeConfig = {
66
67
  'Tell the AI what to do with the input. The result can be referenced as **`@locals._llm_output`**',
67
68
  required: true,
68
69
  evaluated: true,
70
+ maxLength: 10000,
69
71
  placeholder: 'Enter instructions for the AI model...',
70
72
  minHeight: 130
71
73
  }
@@ -33,6 +33,7 @@ export const split_by_llm_categorize: NodeConfig = {
33
33
  helpText: 'The input to categorize (usually @input)',
34
34
  required: true,
35
35
  evaluated: true,
36
+ maxLength: 10000,
36
37
  placeholder: '@input'
37
38
  },
38
39
  categories: {
@@ -50,7 +51,8 @@ export const split_by_llm_categorize: NodeConfig = {
50
51
  name: {
51
52
  type: 'text',
52
53
  placeholder: 'Category name',
53
- required: true
54
+ required: true,
55
+ maxLength: 36
54
56
  }
55
57
  }
56
58
  },
@@ -24,7 +24,8 @@ export const split_by_random: NodeConfig = {
24
24
  name: {
25
25
  type: 'text',
26
26
  placeholder: 'Bucket name',
27
- required: true
27
+ required: true,
28
+ maxLength: 36
28
29
  }
29
30
  }
30
31
  }
@@ -87,7 +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.status',
90
+ // default() guards against @webhook being null (e.g. the call was
91
+ // skipped because the request exceeded the engine's size limit) which
92
+ // would otherwise produce lookup errors when the router evaluates
93
+ '@(default(webhook.status, 0))',
91
94
  {
92
95
  type: 'has_number_between',
93
96
  arguments: ['200', '299']
@@ -79,6 +79,7 @@ export const split_by_webhook: NodeConfig = {
79
79
  type: 'text',
80
80
  required: true,
81
81
  evaluated: true,
82
+ maxLength: 8192,
82
83
  placeholder: 'https://example.com/webhook'
83
84
  },
84
85
  headers: {
@@ -86,6 +87,9 @@ export const split_by_webhook: NodeConfig = {
86
87
  sortable: true,
87
88
  keyPlaceholder: 'Header name',
88
89
  valuePlaceholder: 'Header value',
90
+ maxItems: 100,
91
+ keyMaxLength: 100,
92
+ valueMaxLength: 8192,
89
93
  minRows: 0,
90
94
  dependsOn: ['method'],
91
95
  computeValue: (
@@ -108,6 +112,7 @@ export const split_by_webhook: NodeConfig = {
108
112
  body: {
109
113
  type: 'textarea',
110
114
  evaluated: true,
115
+ maxLength: 20000,
111
116
  placeholder: 'Request body content (JSON, XML, etc.)',
112
117
  minHeight: 200,
113
118
  dependsOn: ['method'],
@@ -248,7 +253,10 @@ export const split_by_webhook: NodeConfig = {
248
253
  const existingCases = originalNode.router?.cases || [];
249
254
 
250
255
  const { router, exits } = createSuccessFailureRouter(
251
- '@webhook.status',
256
+ // default() guards against @webhook being null (e.g. the call was
257
+ // skipped because the request exceeded the engine's size limit) which
258
+ // would otherwise produce lookup errors when the router evaluates
259
+ '@(default(webhook.status, 0))',
252
260
  {
253
261
  type: 'has_number_between',
254
262
  arguments: ['200', '299']
package/src/flow/types.ts CHANGED
@@ -257,6 +257,9 @@ export interface KeyValueFieldConfig extends BaseFieldConfig {
257
257
  valuePlaceholder?: string;
258
258
  minRows?: number;
259
259
  readOnlyKeys?: boolean;
260
+ maxItems?: number;
261
+ keyMaxLength?: number;
262
+ valueMaxLength?: number;
260
263
  }
261
264
 
262
265
  export interface ArrayFieldConfig extends BaseFieldConfig {
package/src/flow/utils.ts CHANGED
@@ -28,6 +28,55 @@ export function validateWith(
28
28
  };
29
29
  }
30
30
 
31
+ /**
32
+ * Validates a mixed recipients list (contacts, groups and expressions) against
33
+ * the engine's limits: at most 100 of each kind and expressions at most 1000
34
+ * characters. Returns an error message or null if the list is valid. The type
35
+ * predicates mirror how fromFormData partitions the list into the action's
36
+ * contacts / groups / legacy_vars.
37
+ */
38
+ export function validateRecipients(recipients: any[]): string | null {
39
+ const contacts = recipients.filter(
40
+ (r: any) => r.type === 'contact' || (!r.type && !r.expression && r.id)
41
+ );
42
+ const groups = recipients.filter((r: any) => r.type === 'group');
43
+ const expressions = recipients.filter(
44
+ (r: any) => r.type === 'expression' || r.expression
45
+ );
46
+
47
+ if (contacts.length > 100) {
48
+ return 'You can only add up to 100 contacts';
49
+ }
50
+ if (groups.length > 100) {
51
+ return 'You can only add up to 100 groups';
52
+ }
53
+ if (expressions.length > 100) {
54
+ return 'You can only add up to 100 expressions';
55
+ }
56
+ const tooLong = expressions.some((e: any) => {
57
+ const value = e.value || e.name || e.id || '';
58
+ return typeof value === 'string' && value.length > 1000;
59
+ });
60
+ if (tooLong) {
61
+ return 'Expressions must be no more than 1000 characters';
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Validates template variables against the engine's limit of 10000 characters
68
+ * per variable. Returns an error message or null if they're valid.
69
+ */
70
+ export function validateTemplateVariables(variables: any[]): string | null {
71
+ const tooLong = variables.some(
72
+ (v: any) => typeof v === 'string' && v.length > 10000
73
+ );
74
+ if (tooLong) {
75
+ return 'Template variables must be no more than 10000 characters';
76
+ }
77
+ return null;
78
+ }
79
+
31
80
  function makeTextToLocalizationFormData(fields: readonly string[]) {
32
81
  return (
33
82
  action: { uuid: string },
@@ -29,7 +29,7 @@ export class DatePicker extends FieldElement {
29
29
 
30
30
  .input-wrapper {
31
31
  padding: var(--temba-textinput-padding);
32
- flex-grow: 1;
32
+ flex-grow: 999;
33
33
  }
34
34
 
35
35
  .tz {
@@ -65,6 +65,7 @@ export class DatePicker extends FieldElement {
65
65
  flex-direction: row;
66
66
  align-items: center;
67
67
  padding: 0.4em 0em;
68
+ flex-grow: 1;
68
69
  }
69
70
 
70
71
  .container:focus-within {
@@ -18,6 +18,11 @@ export class Card extends RapidElement {
18
18
 
19
19
  :host {
20
20
  display: block;
21
+ /* as a grid/flex item (cards flatten into the card stack's
22
+ grid through its slots) the automatic minimum is
23
+ min-content — allow shrinking so long unbreakable content
24
+ inside ellipsizes instead of widening the card */
25
+ min-width: 0;
21
26
  }
22
27
 
23
28
  /* an empty panel drops its card entirely — in tab (plain) mode the
@@ -115,6 +120,10 @@ export class Card extends RapidElement {
115
120
  .body {
116
121
  display: grid;
117
122
  grid-template-rows: 1fr;
123
+ /* the implicit column track is auto-sized and would grow to
124
+ the content's max-content (one long unbreakable line widens
125
+ the card instead of ellipsizing) — pin it to the card width */
126
+ grid-template-columns: minmax(0, 1fr);
118
127
  transition: grid-template-rows 200ms ease;
119
128
  }
120
129