@nyaruka/temba-components 0.159.1 → 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/CHANGELOG.md +30 -2
- package/dist/temba-components.js +1117 -1040
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/rollup.components.mjs +1 -0
- package/src/display/Chat.ts +44 -6
- package/src/display/TembaUser.ts +21 -9
- package/src/flow/NodeEditor.ts +33 -13
- package/src/flow/actions/add_input_labels.ts +4 -1
- package/src/flow/actions/send_msg.ts +1 -0
- package/src/flow/actions/set_run_result.ts +1 -0
- package/src/flow/nodes/split_by_ticket.ts +1 -0
- package/src/interfaces.ts +1 -0
- package/src/list/ContentList.ts +10 -1
- package/src/list/MsgList.ts +28 -3
- package/src/live/ContactChat.ts +3 -0
- package/src/live/{ContactEvents.ts → ContactTimeline.ts} +79 -9
- package/src/store/Store.ts +41 -1
- package/temba-modules.ts +2 -2
- package/web-dev-server.config.mjs +11 -0
- package/web-test-runner.config.mjs +1 -0
package/package.json
CHANGED
package/rollup.components.mjs
CHANGED
package/src/display/Chat.ts
CHANGED
|
@@ -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
|
-
|
|
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=${
|
|
1298
|
-
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>
|
package/src/display/TembaUser.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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.
|
|
140
|
-
?
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
package/src/flow/NodeEditor.ts
CHANGED
|
@@ -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 ||
|
|
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
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
)
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
(
|
|
1164
|
-
|
|
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
|
-
|
|
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
|
},
|
|
@@ -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(
|
package/src/interfaces.ts
CHANGED
package/src/list/ContentList.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/list/MsgList.ts
CHANGED
|
@@ -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
|
|
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
|
|
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>
|
package/src/live/ContactChat.ts
CHANGED
|
@@ -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}
|
|
@@ -41,7 +41,7 @@ const BROADCAST_COLOR = '#8e5ea7';
|
|
|
41
41
|
// triggers use the same green as the flow pill
|
|
42
42
|
const TRIGGER_COLOR = '#16a34a';
|
|
43
43
|
|
|
44
|
-
export class
|
|
44
|
+
export class ContactTimeline extends EndpointMonitorElement {
|
|
45
45
|
@property({ type: String })
|
|
46
46
|
contact: string;
|
|
47
47
|
|
|
@@ -64,7 +64,14 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
64
64
|
lang_campaigns_label = 'Campaigns';
|
|
65
65
|
|
|
66
66
|
@property({ type: String })
|
|
67
|
-
lang_empty = 'No events
|
|
67
|
+
lang_empty = 'No upcoming events';
|
|
68
|
+
|
|
69
|
+
@property({ type: String })
|
|
70
|
+
lang_empty_help =
|
|
71
|
+
'Events appear here when a contact joins a campaign. Scheduled flows and messages will also show up here.';
|
|
72
|
+
|
|
73
|
+
@property({ type: String })
|
|
74
|
+
lang_campaigns_link = 'View campaigns';
|
|
68
75
|
|
|
69
76
|
@property({ type: String })
|
|
70
77
|
lang_projected_info =
|
|
@@ -101,11 +108,44 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
101
108
|
display: block;
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/* empty state follows the list design system: centered icon, a short
|
|
112
|
+
title, muted explanatory copy, and a single call-to-action link */
|
|
104
113
|
.empty {
|
|
105
|
-
|
|
114
|
+
display: flex;
|
|
115
|
+
flex-direction: column;
|
|
116
|
+
align-items: center;
|
|
106
117
|
text-align: center;
|
|
118
|
+
padding: 7em 1em 4em;
|
|
107
119
|
color: var(--text-color);
|
|
108
|
-
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.empty temba-icon {
|
|
123
|
+
margin-bottom: 0.75em;
|
|
124
|
+
--icon-color: var(--text-3, #7b8593);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.empty-title {
|
|
128
|
+
font-weight: 600;
|
|
129
|
+
margin-bottom: 0.4em;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.empty-help {
|
|
133
|
+
font-size: 0.875em;
|
|
134
|
+
line-height: 1.5;
|
|
135
|
+
max-width: 22em;
|
|
136
|
+
margin-bottom: 1em;
|
|
137
|
+
color: var(--text-3, #7b8593);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.empty-link {
|
|
141
|
+
font-size: 0.875em;
|
|
142
|
+
font-weight: 500;
|
|
143
|
+
color: var(--color-link-primary);
|
|
144
|
+
text-decoration: none;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.empty-link:hover {
|
|
148
|
+
text-decoration: underline;
|
|
109
149
|
}
|
|
110
150
|
|
|
111
151
|
/* row of campaign pills the contact is currently a member of */
|
|
@@ -128,7 +168,8 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
/* each pill is colored with its campaign's hue - background, border
|
|
131
|
-
and text all derived from --pill-hue.
|
|
171
|
+
and text all derived from --pill-hue. clickable links to the
|
|
172
|
+
campaign's read page */
|
|
132
173
|
.campaign-pill {
|
|
133
174
|
display: inline-flex;
|
|
134
175
|
align-items: center;
|
|
@@ -146,6 +187,21 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
146
187
|
border: 1px solid
|
|
147
188
|
color-mix(in srgb, var(--pill-hue) 25%, var(--color-widget-bg, #fff));
|
|
148
189
|
color: var(--pill-hue);
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
transition: background 100ms ease-in-out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.campaign-pill:hover {
|
|
195
|
+
background: color-mix(
|
|
196
|
+
in srgb,
|
|
197
|
+
var(--pill-hue) 22%,
|
|
198
|
+
var(--color-widget-bg, #fff)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.campaign-pill:focus-visible {
|
|
203
|
+
outline: 2px solid var(--pill-hue);
|
|
204
|
+
outline-offset: 1px;
|
|
149
205
|
}
|
|
150
206
|
|
|
151
207
|
/* status-badge dot leading each campaign pill, in the same hue */
|
|
@@ -424,7 +480,7 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
424
480
|
const requestedContact = this.contact;
|
|
425
481
|
try {
|
|
426
482
|
const response = await this.store.getUrl(
|
|
427
|
-
`/contact/
|
|
483
|
+
`/contact/timeline/${encodeURIComponent(this.contact)}/`,
|
|
428
484
|
{ force: true }
|
|
429
485
|
);
|
|
430
486
|
if (this.contact !== requestedContact) {
|
|
@@ -570,7 +626,7 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
570
626
|
// capture the contact at request time so a paged response that returns
|
|
571
627
|
// after the user has switched contacts can't append onto the new timeline
|
|
572
628
|
const requestedContact = this.contact;
|
|
573
|
-
const url = `/contact/
|
|
629
|
+
const url = `/contact/timeline/${encodeURIComponent(
|
|
574
630
|
this.contact
|
|
575
631
|
)}/?before=${encodeURIComponent(this.nextBefore)}`;
|
|
576
632
|
|
|
@@ -609,7 +665,7 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
609
665
|
|
|
610
666
|
this.loadingMoreFuture = true;
|
|
611
667
|
const requestedContact = this.contact;
|
|
612
|
-
const url = `/contact/
|
|
668
|
+
const url = `/contact/timeline/${encodeURIComponent(
|
|
613
669
|
this.contact
|
|
614
670
|
)}/?after=${encodeURIComponent(this.nextAfter)}`;
|
|
615
671
|
|
|
@@ -710,6 +766,13 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
710
766
|
html`<div
|
|
711
767
|
class="campaign-pill"
|
|
712
768
|
style="--pill-hue:${this.getCampaignColor(campaign.uuid)}"
|
|
769
|
+
role="button"
|
|
770
|
+
tabindex="0"
|
|
771
|
+
@click=${(e: Event) => this.handlePillClicked(e, campaign)}
|
|
772
|
+
@keydown=${(e: KeyboardEvent) =>
|
|
773
|
+
this.handleActivationKey(e, () =>
|
|
774
|
+
this.handlePillClicked(e, campaign)
|
|
775
|
+
)}
|
|
713
776
|
>
|
|
714
777
|
<span class="campaign-dot"></span>${campaign.name}
|
|
715
778
|
</div>`
|
|
@@ -742,7 +805,14 @@ export class ContactEvents extends EndpointMonitorElement {
|
|
|
742
805
|
pastDescending.length === 0
|
|
743
806
|
) {
|
|
744
807
|
return html`<div class="empty">
|
|
745
|
-
<slot name="empty"
|
|
808
|
+
<slot name="empty">
|
|
809
|
+
<temba-icon name=${Icon.schedule} size="2"></temba-icon>
|
|
810
|
+
<div class="empty-title">${this.lang_empty}</div>
|
|
811
|
+
<div class="empty-help">${this.lang_empty_help}</div>
|
|
812
|
+
<a class="empty-link" href="/campaign/" onclick="goto(event, this)"
|
|
813
|
+
>${this.lang_campaigns_link}</a
|
|
814
|
+
>
|
|
815
|
+
</slot>
|
|
746
816
|
</div>`;
|
|
747
817
|
}
|
|
748
818
|
|
package/src/store/Store.ts
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/temba-modules.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { ContactFields } from './src/live/ContactFields';
|
|
|
31
31
|
import { ContactFieldEditor } from './src/live/ContactFieldEditor';
|
|
32
32
|
|
|
33
33
|
import { ContactBadges } from './src/live/ContactBadges';
|
|
34
|
-
import {
|
|
34
|
+
import { ContactTimeline } from './src/live/ContactTimeline';
|
|
35
35
|
import { TembaSlider } from './src/form/TembaSlider';
|
|
36
36
|
import { RunList } from './src/list/RunList';
|
|
37
37
|
import { FlowStoreElement } from './src/store/FlowStoreElement';
|
|
@@ -149,7 +149,7 @@ addCustomElement('temba-dropdown', Dropdown);
|
|
|
149
149
|
addCustomElement('temba-tabs', TabPane);
|
|
150
150
|
addCustomElement('temba-tab', Tab);
|
|
151
151
|
addCustomElement('temba-contact-badges', ContactBadges);
|
|
152
|
-
addCustomElement('temba-contact-
|
|
152
|
+
addCustomElement('temba-contact-timeline', ContactTimeline);
|
|
153
153
|
addCustomElement('temba-slider', TembaSlider);
|
|
154
154
|
addCustomElement('temba-content-menu', ContentMenu);
|
|
155
155
|
addCustomElement('temba-compose', Compose);
|
|
@@ -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
|
{
|