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