@nyaruka/temba-components 0.156.9 → 0.156.11
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/CHANGELOG.md +18 -0
- package/dist/temba-components.js +587 -523
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +8 -8
- package/src/display/FloatingTab.ts +2 -2
- package/src/display/Options.ts +8 -2
- package/src/flow/CanvasMenu.ts +20 -25
- package/src/flow/CanvasNode.ts +16 -12
- package/src/flow/DragManager.ts +93 -33
- package/src/flow/Editor.ts +59 -54
- package/src/flow/EditorToolbar.ts +19 -20
- package/src/flow/FlowSearch.ts +9 -7
- package/src/flow/MessageTable.ts +181 -74
- package/src/flow/NodeEditor.ts +55 -72
- package/src/flow/RevisionsWindow.ts +2 -4
- package/src/flow/ZoomManager.ts +1 -2
- package/src/flow/actions/play_audio.ts +1 -28
- package/src/flow/actions/say_msg.ts +1 -40
- package/src/flow/actions/send_broadcast.ts +1 -2
- package/src/flow/actions/send_email.ts +5 -56
- package/src/flow/actions/send_msg.ts +10 -2
- package/src/flow/actions/start_session.ts +1 -2
- package/src/flow/categoryLocalization.ts +1 -5
- package/src/flow/categoryUtils.ts +139 -0
- package/src/flow/nodes/shared-rules.ts +6 -16
- package/src/flow/nodes/shared.ts +113 -6
- package/src/flow/nodes/split_by_airtime.ts +41 -63
- package/src/flow/nodes/split_by_contact_field.ts +8 -17
- package/src/flow/nodes/split_by_expression.ts +8 -17
- package/src/flow/nodes/split_by_groups.ts +34 -112
- package/src/flow/nodes/split_by_llm.ts +1 -7
- package/src/flow/nodes/split_by_llm_categorize.ts +27 -43
- package/src/flow/nodes/split_by_random.ts +39 -99
- package/src/flow/nodes/split_by_resthook.ts +5 -19
- package/src/flow/nodes/split_by_run_result.ts +8 -17
- package/src/flow/nodes/split_by_scheme.ts +39 -124
- package/src/flow/nodes/split_by_subflow.ts +1 -7
- package/src/flow/nodes/split_by_ticket.ts +1 -7
- package/src/flow/nodes/split_by_webhook.ts +2 -8
- package/src/flow/nodes/wait_for_audio.ts +1 -7
- package/src/flow/nodes/wait_for_dial.ts +2 -8
- package/src/flow/nodes/wait_for_digits.ts +5 -7
- package/src/flow/nodes/wait_for_menu.ts +5 -7
- package/src/flow/nodes/wait_for_response.ts +10 -18
- package/src/flow/types.ts +27 -0
- package/src/flow/utils.ts +111 -3
- package/src/form/Compose.ts +84 -7
- package/src/form/MessageEditor.ts +5 -3
- package/src/form/RichEditor.ts +3 -1
- package/src/form/TemplateEditor.ts +5 -1
- package/src/form/select/Select.ts +12 -10
- package/src/layout/AccordionSection.ts +9 -3
- package/src/layout/Modax.ts +1 -3
- package/src/live/ContactChat.ts +54 -46
- package/src/simulator/Simulator.ts +9 -3
- package/src/store/AppState.ts +1 -1
- package/src/utils.ts +21 -16
package/src/flow/ZoomManager.ts
CHANGED
|
@@ -524,8 +524,7 @@ export class ZoomManager {
|
|
|
524
524
|
const dx = canvasX - this.loupeCursorCanvas.x;
|
|
525
525
|
const dy = canvasY - this.loupeCursorCanvas.y;
|
|
526
526
|
const moved =
|
|
527
|
-
Math.abs(dx) > visibleRadius * 0.5 ||
|
|
528
|
-
Math.abs(dy) > visibleRadius * 0.5;
|
|
527
|
+
Math.abs(dx) > visibleRadius * 0.5 || Math.abs(dy) > visibleRadius * 0.5;
|
|
529
528
|
|
|
530
529
|
if (
|
|
531
530
|
!this.loupeClone ||
|
|
@@ -42,32 +42,5 @@ export const play_audio: ActionConfig = {
|
|
|
42
42
|
audio_url: (data.audio_url || '').trim()
|
|
43
43
|
} as PlayAudio;
|
|
44
44
|
},
|
|
45
|
-
localizable: ['audio_url']
|
|
46
|
-
toLocalizationFormData: (
|
|
47
|
-
action: PlayAudio,
|
|
48
|
-
localization: Record<string, any>
|
|
49
|
-
) => {
|
|
50
|
-
const formData: FormData = {
|
|
51
|
-
uuid: action.uuid
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
if (localization.audio_url && Array.isArray(localization.audio_url)) {
|
|
55
|
-
formData.audio_url = localization.audio_url[0] || '';
|
|
56
|
-
} else {
|
|
57
|
-
formData.audio_url = '';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return formData;
|
|
61
|
-
},
|
|
62
|
-
fromLocalizationFormData: (formData: FormData, action: PlayAudio) => {
|
|
63
|
-
const localization: Record<string, any> = {};
|
|
64
|
-
|
|
65
|
-
if (formData.audio_url && formData.audio_url.trim() !== '') {
|
|
66
|
-
if (formData.audio_url !== action.audio_url) {
|
|
67
|
-
localization.audio_url = [formData.audio_url];
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return localization;
|
|
72
|
-
}
|
|
45
|
+
localizable: ['audio_url']
|
|
73
46
|
};
|
|
@@ -63,44 +63,5 @@ export const say_msg: ActionConfig = {
|
|
|
63
63
|
formData.text = formData.text.trim();
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
|
-
localizable: ['text', 'audio_url']
|
|
67
|
-
toLocalizationFormData: (
|
|
68
|
-
action: SayMsg,
|
|
69
|
-
localization: Record<string, any>
|
|
70
|
-
) => {
|
|
71
|
-
const formData: FormData = {
|
|
72
|
-
uuid: action.uuid
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
if (localization.text && Array.isArray(localization.text)) {
|
|
76
|
-
formData.text = localization.text[0] || '';
|
|
77
|
-
} else {
|
|
78
|
-
formData.text = '';
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (localization.audio_url && Array.isArray(localization.audio_url)) {
|
|
82
|
-
formData.audio_url = localization.audio_url[0] || '';
|
|
83
|
-
} else {
|
|
84
|
-
formData.audio_url = '';
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return formData;
|
|
88
|
-
},
|
|
89
|
-
fromLocalizationFormData: (formData: FormData, action: SayMsg) => {
|
|
90
|
-
const localization: Record<string, any> = {};
|
|
91
|
-
|
|
92
|
-
if (formData.text && formData.text.trim() !== '') {
|
|
93
|
-
if (formData.text !== action.text) {
|
|
94
|
-
localization.text = [formData.text];
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (formData.audio_url && formData.audio_url.trim() !== '') {
|
|
99
|
-
if (formData.audio_url !== action.audio_url) {
|
|
100
|
-
localization.audio_url = [formData.audio_url];
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return localization;
|
|
105
|
-
}
|
|
66
|
+
localizable: ['text', 'audio_url']
|
|
106
67
|
};
|
|
@@ -220,8 +220,7 @@ export const send_broadcast: ActionConfig = {
|
|
|
220
220
|
|
|
221
221
|
if (attachments.length > 0) {
|
|
222
222
|
if (
|
|
223
|
-
JSON.stringify(attachments) !==
|
|
224
|
-
JSON.stringify(action.attachments || [])
|
|
223
|
+
JSON.stringify(attachments) !== JSON.stringify(action.attachments || [])
|
|
225
224
|
) {
|
|
226
225
|
localization.attachments = attachments;
|
|
227
226
|
}
|
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { html } from 'lit-html';
|
|
2
|
-
import {
|
|
3
|
-
ActionConfig,
|
|
4
|
-
ACTION_GROUPS,
|
|
5
|
-
FormData,
|
|
6
|
-
ValidationResult,
|
|
7
|
-
FlowTypes
|
|
8
|
-
} from '../types';
|
|
2
|
+
import { ActionConfig, ACTION_GROUPS, FormData, FlowTypes } from '../types';
|
|
9
3
|
import { Node, SendEmail } from '../../store/flow-definition';
|
|
10
4
|
import {
|
|
11
5
|
renderStringList,
|
|
12
6
|
renderClamped,
|
|
13
|
-
renderHighlightedText
|
|
7
|
+
renderHighlightedText,
|
|
8
|
+
validateWith
|
|
14
9
|
} from '../utils';
|
|
15
10
|
import { Icon } from '../../Icons';
|
|
16
11
|
|
|
@@ -67,55 +62,9 @@ export const send_email: ActionConfig = {
|
|
|
67
62
|
};
|
|
68
63
|
},
|
|
69
64
|
localizable: ['subject', 'body'],
|
|
70
|
-
|
|
71
|
-
action: SendEmail,
|
|
72
|
-
localization: Record<string, any>
|
|
73
|
-
) => {
|
|
74
|
-
const formData: FormData = {
|
|
75
|
-
uuid: action.uuid
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
if (localization.subject && Array.isArray(localization.subject)) {
|
|
79
|
-
formData.subject = localization.subject[0] || '';
|
|
80
|
-
} else {
|
|
81
|
-
formData.subject = '';
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (localization.body && Array.isArray(localization.body)) {
|
|
85
|
-
formData.body = localization.body[0] || '';
|
|
86
|
-
} else {
|
|
87
|
-
formData.body = '';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return formData;
|
|
91
|
-
},
|
|
92
|
-
fromLocalizationFormData: (formData: FormData, action: SendEmail) => {
|
|
93
|
-
const localization: Record<string, any> = {};
|
|
94
|
-
|
|
95
|
-
if (formData.subject && formData.subject.trim() !== '') {
|
|
96
|
-
if (formData.subject !== action.subject) {
|
|
97
|
-
localization.subject = [formData.subject];
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (formData.body && formData.body.trim() !== '') {
|
|
102
|
-
if (formData.body !== action.body) {
|
|
103
|
-
localization.body = [formData.body];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return localization;
|
|
108
|
-
},
|
|
109
|
-
validate: (formData: FormData): ValidationResult => {
|
|
110
|
-
const errors: { [key: string]: string } = {};
|
|
111
|
-
|
|
65
|
+
validate: validateWith((formData, errors) => {
|
|
112
66
|
if (!formData.addresses || formData.addresses.length === 0) {
|
|
113
67
|
errors.addresses = 'At least one recipient email address is required';
|
|
114
68
|
}
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
valid: Object.keys(errors).length === 0,
|
|
118
|
-
errors
|
|
119
|
-
};
|
|
120
|
-
}
|
|
69
|
+
})
|
|
121
70
|
};
|
|
@@ -172,7 +172,11 @@ export const send_msg: ActionConfig = {
|
|
|
172
172
|
|
|
173
173
|
if (!contentType.includes('/')) {
|
|
174
174
|
runtimeAttachments.push({
|
|
175
|
-
type: {
|
|
175
|
+
type: {
|
|
176
|
+
name:
|
|
177
|
+
ATTACHMENT_TYPE_NAMES[contentType] || titleCase(contentType),
|
|
178
|
+
value: contentType
|
|
179
|
+
},
|
|
176
180
|
expression: value
|
|
177
181
|
});
|
|
178
182
|
} else {
|
|
@@ -319,7 +323,11 @@ export const send_msg: ActionConfig = {
|
|
|
319
323
|
|
|
320
324
|
if (!contentType.includes('/')) {
|
|
321
325
|
runtimeAttachments.push({
|
|
322
|
-
type: {
|
|
326
|
+
type: {
|
|
327
|
+
name:
|
|
328
|
+
ATTACHMENT_TYPE_NAMES[contentType] || titleCase(contentType),
|
|
329
|
+
value: contentType
|
|
330
|
+
},
|
|
323
331
|
expression: value
|
|
324
332
|
});
|
|
325
333
|
} else {
|
|
@@ -236,8 +236,7 @@ export const start_session: ActionConfig = {
|
|
|
236
236
|
const recipients = formData.recipients || [];
|
|
237
237
|
action.contacts = recipients
|
|
238
238
|
.filter(
|
|
239
|
-
(r: any) =>
|
|
240
|
-
r.type === 'contact' || (!r.type && !r.expression && r.id)
|
|
239
|
+
(r: any) => r.type === 'contact' || (!r.type && !r.expression && r.id)
|
|
241
240
|
)
|
|
242
241
|
.map((c: any) => ({ uuid: c.id, name: c.name }));
|
|
243
242
|
action.groups = recipients
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { Category } from '../store/flow-definition';
|
|
2
2
|
import { NODE_CONFIG } from './config';
|
|
3
|
-
|
|
4
|
-
const SYSTEM_CATEGORIES_ALLOWED_FOR_TRANSLATION = new Set([
|
|
5
|
-
'Other',
|
|
6
|
-
'No Response'
|
|
7
|
-
]);
|
|
3
|
+
import { SYSTEM_CATEGORIES_ALLOWED_FOR_TRANSLATION } from './categoryUtils';
|
|
8
4
|
|
|
9
5
|
export function getTranslatableCategoriesForNode(
|
|
10
6
|
nodeType: string | undefined,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers and constants for router category handling.
|
|
3
|
+
*
|
|
4
|
+
* This module is the single source of truth for:
|
|
5
|
+
* - reserved category names (forbidden to users across all node types)
|
|
6
|
+
* - system category names (auto-generated; filtered from user-editable rules)
|
|
7
|
+
* - case-insensitive category name comparison and lookup
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Names reserved for system-generated categories. Users may not create
|
|
12
|
+
* categories whose names collide with these (matching is case-insensitive).
|
|
13
|
+
*/
|
|
14
|
+
export const RESERVED_CATEGORY_NAMES = [
|
|
15
|
+
'Other',
|
|
16
|
+
'All Responses',
|
|
17
|
+
'No Response',
|
|
18
|
+
'Failure',
|
|
19
|
+
'Success',
|
|
20
|
+
'Timeout'
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Categories that are auto-generated by the system and should be filtered out
|
|
25
|
+
* when converting router data back into user-editable form rules.
|
|
26
|
+
*/
|
|
27
|
+
export const SYSTEM_CATEGORY_NAMES = [
|
|
28
|
+
'Other',
|
|
29
|
+
'All Responses',
|
|
30
|
+
'No Response',
|
|
31
|
+
'Timeout'
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* System categories permitted to be localized even on nodes that otherwise
|
|
36
|
+
* block translation of system categories.
|
|
37
|
+
*/
|
|
38
|
+
export const SYSTEM_CATEGORIES_ALLOWED_FOR_TRANSLATION: ReadonlySet<string> =
|
|
39
|
+
new Set(['Other', 'No Response']);
|
|
40
|
+
|
|
41
|
+
const RESERVED_LOWER = new Set<string>(
|
|
42
|
+
RESERVED_CATEGORY_NAMES.map((n) => n.toLowerCase())
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const SYSTEM_LOWER = new Set<string>(
|
|
46
|
+
SYSTEM_CATEGORY_NAMES.map((n) => n.toLowerCase())
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const normalize = (name: string | undefined | null): string =>
|
|
50
|
+
(name || '').trim().toLowerCase();
|
|
51
|
+
|
|
52
|
+
/** Case-insensitive check for reserved category names. */
|
|
53
|
+
export const isReservedCategoryName = (name: string): boolean =>
|
|
54
|
+
RESERVED_LOWER.has(normalize(name));
|
|
55
|
+
|
|
56
|
+
/** Case-insensitive check for auto-generated system categories. */
|
|
57
|
+
export const isSystemCategory = (name: string): boolean =>
|
|
58
|
+
SYSTEM_LOWER.has(normalize(name));
|
|
59
|
+
|
|
60
|
+
/** Case-insensitive equality test for two category names. */
|
|
61
|
+
export const categoryNamesEqual = (a: string, b: string): boolean =>
|
|
62
|
+
normalize(a) === normalize(b);
|
|
63
|
+
|
|
64
|
+
/** Case-insensitive lookup of a category by name. */
|
|
65
|
+
export const findCategoryByName = <T extends { name: string }>(
|
|
66
|
+
categories: T[],
|
|
67
|
+
name: string
|
|
68
|
+
): T | undefined => {
|
|
69
|
+
const target = normalize(name);
|
|
70
|
+
return categories.find((cat) => normalize(cat.name) === target);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns the subset of the provided names that collide with a reserved
|
|
75
|
+
* category name. Originals (with their casing) are preserved in the output;
|
|
76
|
+
* duplicates are de-duplicated.
|
|
77
|
+
*/
|
|
78
|
+
export const findReservedNames = (names: string[]): string[] => {
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const result: string[] = [];
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const trimmed = (name || '').trim();
|
|
83
|
+
if (!trimmed) continue;
|
|
84
|
+
if (!isReservedCategoryName(trimmed)) continue;
|
|
85
|
+
const key = trimmed.toLowerCase();
|
|
86
|
+
if (seen.has(key)) continue;
|
|
87
|
+
seen.add(key);
|
|
88
|
+
result.push(trimmed);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Form fields whose entries become router category names. Kept in one place so
|
|
95
|
+
* the reserved-name validator can be applied uniformly across node types.
|
|
96
|
+
*
|
|
97
|
+
* - categories[].name (split_by_random)
|
|
98
|
+
* - groups[].name (split_by_groups — the group name becomes the
|
|
99
|
+
* category name, so a group literally named "Other"
|
|
100
|
+
* must be rejected)
|
|
101
|
+
* - rules[].category (split_by_expression, wait_for_response)
|
|
102
|
+
*/
|
|
103
|
+
const CATEGORY_FIELD_SHAPES: Array<{
|
|
104
|
+
fieldName: string;
|
|
105
|
+
getName: (item: any) => unknown;
|
|
106
|
+
}> = [
|
|
107
|
+
{ fieldName: 'categories', getName: (item) => item?.name },
|
|
108
|
+
{ fieldName: 'groups', getName: (item) => item?.name },
|
|
109
|
+
{ fieldName: 'rules', getName: (item) => item?.category }
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Scans form data for user-authored category names that collide with reserved
|
|
114
|
+
* system names, returning an `errors` map keyed by form field name. Checks all
|
|
115
|
+
* known category-bearing field shapes so callers don't have to know which one
|
|
116
|
+
* a given node uses.
|
|
117
|
+
*/
|
|
118
|
+
export const collectReservedCategoryErrors = (formData: {
|
|
119
|
+
[key: string]: any;
|
|
120
|
+
}): { [fieldName: string]: string } => {
|
|
121
|
+
const errors: { [fieldName: string]: string } = {};
|
|
122
|
+
|
|
123
|
+
for (const { fieldName, getName } of CATEGORY_FIELD_SHAPES) {
|
|
124
|
+
const value = formData[fieldName];
|
|
125
|
+
if (!Array.isArray(value)) continue;
|
|
126
|
+
|
|
127
|
+
const names = value
|
|
128
|
+
.map((item) => getName(item))
|
|
129
|
+
.filter((name): name is string => typeof name === 'string');
|
|
130
|
+
|
|
131
|
+
const reservedUsed = findReservedNames(names);
|
|
132
|
+
if (reservedUsed.length > 0) {
|
|
133
|
+
errors[fieldName] =
|
|
134
|
+
`Reserved category names cannot be used: ${reservedUsed.join(', ')}`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return errors;
|
|
139
|
+
};
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
operatorsToSelectOptions
|
|
5
5
|
} from '../operators';
|
|
6
6
|
import { generateDefaultCategoryName } from '../../utils';
|
|
7
|
+
import { isSystemCategory } from '../categoryUtils';
|
|
7
8
|
import { FormData } from '../types';
|
|
8
9
|
import { zustand } from '../../store/AppState';
|
|
9
10
|
|
|
@@ -301,15 +302,6 @@ export const casesToFormRules = (node: any) => {
|
|
|
301
302
|
return rules;
|
|
302
303
|
};
|
|
303
304
|
|
|
304
|
-
/**
|
|
305
|
-
* Helper to check if a category is a system category
|
|
306
|
-
*/
|
|
307
|
-
function isSystemCategory(categoryName: string): boolean {
|
|
308
|
-
return ['No Response', 'Other', 'All Responses', 'Timeout'].includes(
|
|
309
|
-
categoryName
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
305
|
/**
|
|
314
306
|
* Creates a complete rules array configuration for forms.
|
|
315
307
|
* This is the shared configuration used by both wait_for_response and split_by_expression.
|
|
@@ -340,13 +332,11 @@ export const createRulesArrayConfig = (
|
|
|
340
332
|
|
|
341
333
|
// Default to the last rule's non-location operator that has at least one operand,
|
|
342
334
|
// falling back to the first non-location operator option
|
|
343
|
-
const lastWithOperand = [...items]
|
|
344
|
-
.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return config && config.operands >= 1 && config.filter !== 'locations';
|
|
349
|
-
});
|
|
335
|
+
const lastWithOperand = [...items].reverse().find((item) => {
|
|
336
|
+
const opValue = getOperatorValue(item.operator);
|
|
337
|
+
const config = opValue ? getOperatorConfig(opValue) : undefined;
|
|
338
|
+
return config && config.operands >= 1 && config.filter !== 'locations';
|
|
339
|
+
});
|
|
350
340
|
|
|
351
341
|
const nonLocationOptions = currentOptions.filter((o: any) => {
|
|
352
342
|
const config = getOperatorConfig(o.value);
|
package/src/flow/nodes/shared.ts
CHANGED
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
AccordionLayoutConfig,
|
|
5
5
|
CheckboxFieldConfig
|
|
6
6
|
} from '../types';
|
|
7
|
-
import { Node } from '../../store/flow-definition';
|
|
7
|
+
import { Node, Category, Exit, Case } from '../../store/flow-definition';
|
|
8
8
|
import { getOperatorConfig } from '../operators';
|
|
9
|
+
import { generateUUID } from '../../utils';
|
|
10
|
+
import { categoryNamesEqual, findCategoryByName } from '../categoryUtils';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* Shared result_name field configuration for router nodes.
|
|
@@ -124,10 +126,9 @@ export function categoriesToLocalizationFormData(
|
|
|
124
126
|
rulesData[c.uuid] = {
|
|
125
127
|
operatorName,
|
|
126
128
|
originalArguments: [...c.arguments],
|
|
127
|
-
localizedArguments:
|
|
128
|
-
caseLocalization
|
|
129
|
-
|
|
130
|
-
: c.arguments.map(() => '')
|
|
129
|
+
localizedArguments: caseLocalization?.arguments
|
|
130
|
+
? [...caseLocalization.arguments]
|
|
131
|
+
: c.arguments.map(() => '')
|
|
131
132
|
};
|
|
132
133
|
});
|
|
133
134
|
|
|
@@ -173,7 +174,8 @@ export function localizationFormDataToCategories(
|
|
|
173
174
|
|
|
174
175
|
// Save if any argument differs from original and is non-empty
|
|
175
176
|
const hasLocalization = localized.some(
|
|
176
|
-
(arg: string, i: number) =>
|
|
177
|
+
(arg: string, i: number) =>
|
|
178
|
+
arg?.trim() && arg.trim() !== (original[i] || '')
|
|
177
179
|
);
|
|
178
180
|
|
|
179
181
|
if (hasLocalization) {
|
|
@@ -186,3 +188,108 @@ export function localizationFormDataToCategories(
|
|
|
186
188
|
|
|
187
189
|
return localizationData;
|
|
188
190
|
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Describes a category to build for a router node. When `case` is provided,
|
|
194
|
+
* a matching switch-router case is also built and linked to the category.
|
|
195
|
+
*/
|
|
196
|
+
export interface CategoryEntry {
|
|
197
|
+
name: string;
|
|
198
|
+
case?: {
|
|
199
|
+
type: string;
|
|
200
|
+
arguments: string[];
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Builds categories, exits, and (optionally) cases for a router node,
|
|
206
|
+
* preserving UUIDs and exit destinations from existing data when possible.
|
|
207
|
+
* Categories/exits are matched by category name; cases by their first argument.
|
|
208
|
+
*/
|
|
209
|
+
export function buildCategoriesExitsCases(
|
|
210
|
+
entries: CategoryEntry[],
|
|
211
|
+
existingCategories: Category[],
|
|
212
|
+
existingExits: Exit[],
|
|
213
|
+
existingCases: Case[] = []
|
|
214
|
+
): { categories: Category[]; exits: Exit[]; cases: Case[] } {
|
|
215
|
+
const categories: Category[] = [];
|
|
216
|
+
const exits: Exit[] = [];
|
|
217
|
+
const cases: Case[] = [];
|
|
218
|
+
|
|
219
|
+
entries.forEach((entry) => {
|
|
220
|
+
const existingCategory = findCategoryByName(existingCategories, entry.name);
|
|
221
|
+
const existingExit = existingCategory
|
|
222
|
+
? existingExits.find((exit) => exit.uuid === existingCategory.exit_uuid)
|
|
223
|
+
: null;
|
|
224
|
+
|
|
225
|
+
const exitUuid = existingExit?.uuid || generateUUID();
|
|
226
|
+
const categoryUuid = existingCategory?.uuid || generateUUID();
|
|
227
|
+
|
|
228
|
+
categories.push({
|
|
229
|
+
uuid: categoryUuid,
|
|
230
|
+
name: entry.name,
|
|
231
|
+
exit_uuid: exitUuid
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
exits.push({
|
|
235
|
+
uuid: exitUuid,
|
|
236
|
+
destination_uuid: existingExit?.destination_uuid || null
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (entry.case) {
|
|
240
|
+
const matchArg = entry.case.arguments[0];
|
|
241
|
+
const existingCase = existingCases.find(
|
|
242
|
+
(c) => c.arguments?.[0] === matchArg
|
|
243
|
+
);
|
|
244
|
+
cases.push({
|
|
245
|
+
uuid: existingCase?.uuid || generateUUID(),
|
|
246
|
+
type: entry.case.type,
|
|
247
|
+
arguments: entry.case.arguments,
|
|
248
|
+
category_uuid: categoryUuid
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return { categories, exits, cases };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Appends a default "Other" category and its exit to the given arrays,
|
|
258
|
+
* preserving the UUID/destination of an existing "Other" unless the user
|
|
259
|
+
* selected an item also named "Other" (in which case the existing one was
|
|
260
|
+
* already consumed by buildCategoriesExitsCases). Returns the Other category
|
|
261
|
+
* UUID for use as `default_category_uuid`.
|
|
262
|
+
*/
|
|
263
|
+
export function appendOtherCategory(
|
|
264
|
+
categories: Category[],
|
|
265
|
+
exits: Exit[],
|
|
266
|
+
existingCategories: Category[],
|
|
267
|
+
existingExits: Exit[],
|
|
268
|
+
userItemNames: string[]
|
|
269
|
+
): string {
|
|
270
|
+
const userHasOther = userItemNames.some((name) =>
|
|
271
|
+
categoryNamesEqual(name, 'Other')
|
|
272
|
+
);
|
|
273
|
+
const existingOther = userHasOther
|
|
274
|
+
? null
|
|
275
|
+
: findCategoryByName(existingCategories, 'Other');
|
|
276
|
+
const existingOtherExit = existingOther
|
|
277
|
+
? existingExits.find((exit) => exit.uuid === existingOther.exit_uuid)
|
|
278
|
+
: null;
|
|
279
|
+
|
|
280
|
+
const otherExitUuid = existingOtherExit?.uuid || generateUUID();
|
|
281
|
+
const otherCategoryUuid = existingOther?.uuid || generateUUID();
|
|
282
|
+
|
|
283
|
+
categories.push({
|
|
284
|
+
uuid: otherCategoryUuid,
|
|
285
|
+
name: 'Other',
|
|
286
|
+
exit_uuid: otherExitUuid
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
exits.push({
|
|
290
|
+
uuid: otherExitUuid,
|
|
291
|
+
destination_uuid: existingOtherExit?.destination_uuid || null
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return otherCategoryUuid;
|
|
295
|
+
}
|
|
@@ -7,13 +7,10 @@ import {
|
|
|
7
7
|
} from '../types';
|
|
8
8
|
import { TransferAirtime, Node } from '../../store/flow-definition';
|
|
9
9
|
import { generateUUID, createSuccessFailureRouter } from '../../utils';
|
|
10
|
+
import { validateWith } from '../utils';
|
|
10
11
|
import { html } from 'lit';
|
|
11
12
|
import { CURRENCY_OPTIONS, CURRENCIES } from '../currencies';
|
|
12
|
-
import {
|
|
13
|
-
resultNameField,
|
|
14
|
-
categoriesToLocalizationFormData,
|
|
15
|
-
localizationFormDataToCategories
|
|
16
|
-
} from './shared';
|
|
13
|
+
import { resultNameField } from './shared';
|
|
17
14
|
|
|
18
15
|
export const split_by_airtime: NodeConfig = {
|
|
19
16
|
type: 'split_by_airtime',
|
|
@@ -55,68 +52,51 @@ export const split_by_airtime: NodeConfig = {
|
|
|
55
52
|
result_name: resultNameField
|
|
56
53
|
},
|
|
57
54
|
layout: ['amounts', 'result_name'],
|
|
58
|
-
validate: (formData
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const validAmounts = formData.amounts.filter(
|
|
64
|
-
(item: any) =>
|
|
65
|
-
item?.currency && item?.amount && item.amount.trim() !== ''
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
if (validAmounts.length === 0) {
|
|
69
|
-
errors.amounts = 'At least one currency and amount is required';
|
|
70
|
-
return {
|
|
71
|
-
valid: false,
|
|
72
|
-
errors
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Check for duplicate currencies
|
|
77
|
-
const currencies = new Set();
|
|
78
|
-
const duplicates: string[] = [];
|
|
55
|
+
validate: validateWith((formData, errors) => {
|
|
56
|
+
if (!formData.amounts || !Array.isArray(formData.amounts)) {
|
|
57
|
+
errors.amounts = 'At least one currency and amount is required';
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
79
60
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
Array.isArray(item.currency) && item.currency.length > 0
|
|
84
|
-
? item.currency[0].value
|
|
85
|
-
: typeof item.currency === 'string'
|
|
86
|
-
? item.currency
|
|
87
|
-
: item.currency?.value;
|
|
61
|
+
const validAmounts = formData.amounts.filter(
|
|
62
|
+
(item: any) => item?.currency && item?.amount && item.amount.trim() !== ''
|
|
63
|
+
);
|
|
88
64
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
});
|
|
65
|
+
if (validAmounts.length === 0) {
|
|
66
|
+
errors.amounts = 'At least one currency and amount is required';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
95
69
|
|
|
96
|
-
|
|
97
|
-
|
|
70
|
+
const currencies = new Set();
|
|
71
|
+
const duplicates: string[] = [];
|
|
72
|
+
|
|
73
|
+
validAmounts.forEach((item: any) => {
|
|
74
|
+
const currencyCode =
|
|
75
|
+
Array.isArray(item.currency) && item.currency.length > 0
|
|
76
|
+
? item.currency[0].value
|
|
77
|
+
: typeof item.currency === 'string'
|
|
78
|
+
? item.currency
|
|
79
|
+
: item.currency?.value;
|
|
80
|
+
|
|
81
|
+
if (currencies.has(currencyCode)) {
|
|
82
|
+
duplicates.push(currencyCode);
|
|
83
|
+
} else {
|
|
84
|
+
currencies.add(currencyCode);
|
|
98
85
|
}
|
|
86
|
+
});
|
|
99
87
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const amount = item.amount.trim();
|
|
103
|
-
if (isNaN(Number(amount)) || Number(amount) <= 0) {
|
|
104
|
-
errors.amounts = 'All amounts must be valid positive numbers';
|
|
105
|
-
return {
|
|
106
|
-
valid: false,
|
|
107
|
-
errors
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
} else {
|
|
112
|
-
errors.amounts = 'At least one currency and amount is required';
|
|
88
|
+
if (duplicates.length > 0) {
|
|
89
|
+
errors.amounts = `Duplicate currencies found: ${duplicates.join(', ')}`;
|
|
113
90
|
}
|
|
114
91
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
92
|
+
for (const item of validAmounts) {
|
|
93
|
+
const amount = item.amount.trim();
|
|
94
|
+
if (isNaN(Number(amount)) || Number(amount) <= 0) {
|
|
95
|
+
errors.amounts = 'All amounts must be valid positive numbers';
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}),
|
|
120
100
|
render: (node: Node) => {
|
|
121
101
|
const transferAirtimeAction = node.actions?.find(
|
|
122
102
|
(action) => action.type === 'transfer_airtime'
|
|
@@ -254,7 +234,5 @@ export const split_by_airtime: NodeConfig = {
|
|
|
254
234
|
|
|
255
235
|
// Localization support for categories
|
|
256
236
|
localizable: 'categories',
|
|
257
|
-
nonTranslatableCategories: 'all'
|
|
258
|
-
toLocalizationFormData: categoriesToLocalizationFormData,
|
|
259
|
-
fromLocalizationFormData: localizationFormDataToCategories
|
|
237
|
+
nonTranslatableCategories: 'all'
|
|
260
238
|
};
|