@nyaruka/temba-components 0.163.0 → 0.165.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.
- package/CHANGELOG.md +27 -0
- package/DEV_DATA.md +11 -11
- package/README.md +4 -4
- package/TEST_OPTIMIZATION.md +8 -11
- package/dist/locales/es.js +4 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +4 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +4 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +5266 -3031
- package/dist/temba-components.js.map +1 -1
- package/orca/setup.sh +2 -2
- package/package.json +13 -18
- package/scripts/dev-data-sync.mjs +4 -4
- package/src/Icons.ts +3 -2
- package/src/display/Chat.ts +3 -2
- package/src/display/Lightbox.ts +57 -109
- package/src/display/ProgressBar.ts +15 -2
- package/src/display/Thumbnail.ts +34 -7
- package/src/form/Compose.ts +31 -1
- package/src/form/ContactSearch.ts +229 -126
- package/src/interfaces.ts +131 -2
- package/src/layout/Card.ts +336 -0
- package/src/layout/CardLayout.ts +425 -0
- package/src/layout/CardStack.ts +131 -0
- package/src/layout/Dialog.ts +33 -24
- package/src/layout/HeaderBar.ts +41 -0
- package/src/layout/PageHeader.ts +5 -6
- package/src/layout/Resizer.ts +20 -0
- package/src/list/BroadcastList.ts +912 -0
- package/src/list/CampaignList.ts +144 -0
- package/src/list/ContactList.ts +3 -1
- package/src/list/ContentList.ts +46 -10
- package/src/list/FieldList.ts +1057 -0
- package/src/list/FlowList.ts +31 -7
- package/src/list/MsgList.ts +18 -12
- package/src/list/SortableList.ts +52 -8
- package/src/list/TembaList.ts +8 -0
- package/src/list/TembaMenu.ts +5 -38
- package/src/list/TriggerList.ts +538 -0
- package/src/live/CampaignEvents.ts +1108 -0
- package/src/live/ContactChat.ts +7 -1
- package/src/live/ContactDetails.ts +20 -14
- package/src/live/ContactFieldEditor.ts +7 -3
- package/src/live/ContactFields.ts +1 -1
- package/src/live/ContactNameFetch.ts +5 -2
- package/src/live/ContactNotepad.ts +88 -2
- package/src/live/ContactStoreElement.ts +15 -3
- package/src/live/ContactTimeline.ts +28 -7
- package/src/locales/es.ts +4 -0
- package/src/locales/fr.ts +4 -0
- package/src/locales/pt.ts +4 -0
- package/src/utils.ts +19 -0
- package/static/svg/index.svg +1 -1
- package/static/svg/packs/2-temba/star-filled.svg +3 -0
- package/static/svg/work/traced/star-filled.svg +1 -0
- package/static/svg/work/used/star-filled.svg +3 -0
- package/stress-test.js +3 -3
- package/temba-modules.ts +18 -0
- package/web-dev-server.config.mjs +54 -0
- package/web-test-runner.config.mjs +1 -1
- package/xliff/es.xlf +12 -0
- package/xliff/fr.xlf +12 -0
- package/xliff/pt.xlf +12 -0
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
import { css, html, PropertyValues, TemplateResult } from 'lit';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
|
+
import { ContactField, CustomEventType } from '../interfaces';
|
|
4
|
+
import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
|
|
5
|
+
import { Icon } from '../Icons';
|
|
6
|
+
import { designTokens } from '../styles/designTokens';
|
|
7
|
+
import { postJSON } from '../utils';
|
|
8
|
+
|
|
9
|
+
const TYPE_NAMES = {
|
|
10
|
+
text: 'Text',
|
|
11
|
+
numeric: 'Number',
|
|
12
|
+
number: 'Number',
|
|
13
|
+
datetime: 'Date & Time',
|
|
14
|
+
state: 'State',
|
|
15
|
+
ward: 'Ward',
|
|
16
|
+
district: 'District'
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// display name for a field's value type, falling back to the raw type
|
|
20
|
+
// rather than rendering "undefined" for an unmapped type
|
|
21
|
+
const typeName = (field: ContactField): string =>
|
|
22
|
+
TYPE_NAMES[field.value_type] || field.value_type || '';
|
|
23
|
+
|
|
24
|
+
const matches = (field: ContactField, query: string): boolean => {
|
|
25
|
+
if (!query) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
// separate the haystack parts so a query can't match across them
|
|
29
|
+
const search = [field.label, field.key, typeName(field)]
|
|
30
|
+
.join(' ')
|
|
31
|
+
.toLowerCase();
|
|
32
|
+
return search.indexOf(query.toLowerCase()) > -1;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// referenced flow / group / campaign in the detail modal's usages
|
|
36
|
+
interface UsageRef {
|
|
37
|
+
uuid?: string;
|
|
38
|
+
name: string;
|
|
39
|
+
url?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CampaignEventUsage {
|
|
43
|
+
id: number;
|
|
44
|
+
campaign: UsageRef;
|
|
45
|
+
offset_display?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface FieldDetailResponse {
|
|
49
|
+
field: {
|
|
50
|
+
key: string;
|
|
51
|
+
name: string;
|
|
52
|
+
value_type: string;
|
|
53
|
+
featured: boolean;
|
|
54
|
+
agent_access: string;
|
|
55
|
+
};
|
|
56
|
+
usages: {
|
|
57
|
+
flows: UsageRef[];
|
|
58
|
+
groups: UsageRef[];
|
|
59
|
+
campaign_events: CampaignEventUsage[];
|
|
60
|
+
};
|
|
61
|
+
counts: { flows: number; groups: number; campaign_events: number };
|
|
62
|
+
can_edit?: boolean;
|
|
63
|
+
can_delete?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class FieldList extends EndpointMonitorElement {
|
|
67
|
+
// title shown in the page header; a `title` slot overrides it
|
|
68
|
+
@property({ type: String, attribute: 'header-title' })
|
|
69
|
+
headerTitle = '';
|
|
70
|
+
|
|
71
|
+
// GET endpoint for the page's content menu (rapidpro's content-menu view);
|
|
72
|
+
// menu clicks fire temba-selection with the item for the host to dispatch
|
|
73
|
+
@property({ type: String, attribute: 'content-menu-endpoint' })
|
|
74
|
+
contentMenuEndpoint = '';
|
|
75
|
+
|
|
76
|
+
// POST endpoint taking {featured: [keys]} — the ordered featured set
|
|
77
|
+
@property({ type: String, attribute: 'priority-endpoint' })
|
|
78
|
+
priorityEndpoint: string;
|
|
79
|
+
|
|
80
|
+
// base path for the field detail JSON; the field key is appended
|
|
81
|
+
@property({ type: String, attribute: 'detail-endpoint' })
|
|
82
|
+
detailEndpoint = '';
|
|
83
|
+
|
|
84
|
+
@property({ type: Object, attribute: false })
|
|
85
|
+
featuredFields: ContactField[];
|
|
86
|
+
|
|
87
|
+
@property({ type: Object, attribute: false })
|
|
88
|
+
otherFieldKeys: string[] = [];
|
|
89
|
+
|
|
90
|
+
@property({ type: String })
|
|
91
|
+
query = '';
|
|
92
|
+
|
|
93
|
+
@state()
|
|
94
|
+
private searchOpen = false;
|
|
95
|
+
|
|
96
|
+
// the field whose detail modal is open
|
|
97
|
+
@state()
|
|
98
|
+
private detailField: ContactField = null;
|
|
99
|
+
|
|
100
|
+
// the detail modal's usages/permissions - null while the fetch is in flight
|
|
101
|
+
@state()
|
|
102
|
+
private detail: FieldDetailResponse = null;
|
|
103
|
+
|
|
104
|
+
static get styles() {
|
|
105
|
+
return css`
|
|
106
|
+
${designTokens}
|
|
107
|
+
|
|
108
|
+
:host {
|
|
109
|
+
display: flex;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
font-family: var(--font);
|
|
112
|
+
color: var(--text-1);
|
|
113
|
+
font-size: 13.5px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* The header is the same flush surface bar the fill-window lists
|
|
117
|
+
render — full width, the lists' 12px inset, no card chrome —
|
|
118
|
+
so this page and the list pages share one header treatment. */
|
|
119
|
+
.header-panel {
|
|
120
|
+
background: var(--surface);
|
|
121
|
+
padding: 0 12px;
|
|
122
|
+
border-bottom: 1px solid var(--border);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.header-actions {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
gap: 14px;
|
|
129
|
+
color: var(--text-2);
|
|
130
|
+
font-size: 13px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.action {
|
|
134
|
+
display: inline-flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 6px;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
user-select: none;
|
|
139
|
+
height: 26px;
|
|
140
|
+
box-sizing: border-box;
|
|
141
|
+
padding: 0 10px;
|
|
142
|
+
border: 1px solid var(--border-strong);
|
|
143
|
+
border-radius: var(--r-sm);
|
|
144
|
+
background: transparent;
|
|
145
|
+
color: var(--text-2);
|
|
146
|
+
}
|
|
147
|
+
.action:hover {
|
|
148
|
+
background: var(--sunken);
|
|
149
|
+
color: var(--text-1);
|
|
150
|
+
}
|
|
151
|
+
.action temba-icon {
|
|
152
|
+
--icon-color: currentColor;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Inline search bar — same treatment as the content lists, but
|
|
156
|
+
filtering is live so there's no run/commit affordance. */
|
|
157
|
+
.searchbar {
|
|
158
|
+
display: flex;
|
|
159
|
+
align-items: center;
|
|
160
|
+
gap: 6px;
|
|
161
|
+
padding: 6px 12px;
|
|
162
|
+
margin: -6px 0 12px 0;
|
|
163
|
+
background: var(--sunken);
|
|
164
|
+
border-radius: var(--r-sm);
|
|
165
|
+
color: var(--text-3);
|
|
166
|
+
}
|
|
167
|
+
.searchbar input {
|
|
168
|
+
flex: 1 1 auto;
|
|
169
|
+
border: 0;
|
|
170
|
+
background: transparent;
|
|
171
|
+
outline: 0;
|
|
172
|
+
font: inherit;
|
|
173
|
+
color: var(--text-1);
|
|
174
|
+
min-width: 0;
|
|
175
|
+
}
|
|
176
|
+
.searchbar input::placeholder {
|
|
177
|
+
color: var(--text-3);
|
|
178
|
+
}
|
|
179
|
+
.searchbar .search-cancel {
|
|
180
|
+
flex: 0 0 auto;
|
|
181
|
+
margin-left: 5px;
|
|
182
|
+
--icon-color: var(--text-3);
|
|
183
|
+
--icon-color-circle-hover: rgba(15, 22, 36, 0.1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.content {
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
gap: 12px;
|
|
190
|
+
padding: 12px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* Card panel — the same surface treatment as the content lists */
|
|
194
|
+
.panel {
|
|
195
|
+
background: var(--surface);
|
|
196
|
+
border-radius: var(--r);
|
|
197
|
+
overflow: hidden;
|
|
198
|
+
box-shadow: var(--shadow-1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.section-header {
|
|
202
|
+
display: flex;
|
|
203
|
+
align-items: center;
|
|
204
|
+
gap: 8px;
|
|
205
|
+
padding: 10px 12px;
|
|
206
|
+
border-bottom: 1px solid var(--border);
|
|
207
|
+
font-weight: var(--w-semibold, 600);
|
|
208
|
+
color: var(--text-2);
|
|
209
|
+
--icon-color: var(--text-3, #7b8593);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.rows {
|
|
213
|
+
padding: 6px 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
temba-sortable-list {
|
|
217
|
+
display: block;
|
|
218
|
+
width: 100%;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.field {
|
|
222
|
+
display: flex;
|
|
223
|
+
flex-direction: row;
|
|
224
|
+
align-items: center;
|
|
225
|
+
gap: 12px;
|
|
226
|
+
padding: 5px 12px;
|
|
227
|
+
margin: 1px 6px;
|
|
228
|
+
border: 1px solid transparent;
|
|
229
|
+
border-radius: var(--curvature);
|
|
230
|
+
user-select: none;
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* the row outline is the drag affordance, so it only appears when
|
|
235
|
+
the pointer is on the drag handle itself */
|
|
236
|
+
.field:has(.drag-handle:hover) {
|
|
237
|
+
border-color: var(--border, #e4e7ec);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* the drag affordance leads featured rows - always visible, and
|
|
241
|
+
the only place a drag can start from */
|
|
242
|
+
.field .drag-handle {
|
|
243
|
+
flex-shrink: 0;
|
|
244
|
+
cursor: grab;
|
|
245
|
+
--icon-color: var(--text-3, #7b8593);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.field .name {
|
|
249
|
+
min-width: 200px;
|
|
250
|
+
width: 200px;
|
|
251
|
+
white-space: nowrap;
|
|
252
|
+
overflow: hidden;
|
|
253
|
+
text-overflow: ellipsis;
|
|
254
|
+
font-weight: var(--w-medium, 500);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.field .expression {
|
|
258
|
+
flex-grow: 1;
|
|
259
|
+
font-family: var(--font-mono, 'Roboto Mono', monospace);
|
|
260
|
+
font-size: 0.85em;
|
|
261
|
+
color: var(--text-2);
|
|
262
|
+
white-space: nowrap;
|
|
263
|
+
overflow: hidden;
|
|
264
|
+
text-overflow: ellipsis;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.field .type {
|
|
268
|
+
flex-shrink: 0;
|
|
269
|
+
font-size: 0.85em;
|
|
270
|
+
color: var(--text-3, #7b8593);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* the star is the featured toggle - filled and gold when featured,
|
|
274
|
+
hollow and muted when not */
|
|
275
|
+
.star {
|
|
276
|
+
flex-shrink: 0;
|
|
277
|
+
--icon-color: var(--text-3, #7b8593);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.star.featured {
|
|
281
|
+
--icon-color: #f5b60d;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.empty-note {
|
|
285
|
+
padding: 1em 12px;
|
|
286
|
+
color: var(--text-3, #7b8593);
|
|
287
|
+
font-size: 0.9em;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* the detail modal stands in for a field read page - page-like,
|
|
291
|
+
no colored header bar (same shape as the campaign event modal) */
|
|
292
|
+
.detail {
|
|
293
|
+
display: flex;
|
|
294
|
+
flex-direction: column;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.detail-header {
|
|
298
|
+
display: flex;
|
|
299
|
+
align-items: center;
|
|
300
|
+
gap: 12px;
|
|
301
|
+
padding: 1em;
|
|
302
|
+
border-bottom: 1px solid var(--border, #e4e7ec);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.detail-title {
|
|
306
|
+
flex: 1;
|
|
307
|
+
min-width: 0;
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
gap: 10px;
|
|
311
|
+
font-size: 15.5px;
|
|
312
|
+
font-weight: var(--w-semibold, 600);
|
|
313
|
+
color: var(--text-1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.detail-actions {
|
|
317
|
+
flex-shrink: 0;
|
|
318
|
+
display: flex;
|
|
319
|
+
align-items: center;
|
|
320
|
+
gap: 8px;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.detail-actions .star {
|
|
324
|
+
margin-right: 4px;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* the way out - a quiet ✕ at the header's far right, same as the
|
|
328
|
+
campaign event modal */
|
|
329
|
+
.detail-close {
|
|
330
|
+
flex-shrink: 0;
|
|
331
|
+
margin-left: -4px;
|
|
332
|
+
display: inline-flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
justify-content: center;
|
|
335
|
+
width: 28px;
|
|
336
|
+
height: 28px;
|
|
337
|
+
border-radius: var(--r-sm);
|
|
338
|
+
color: var(--text-3, #7b8593);
|
|
339
|
+
cursor: pointer;
|
|
340
|
+
user-select: none;
|
|
341
|
+
}
|
|
342
|
+
.detail-close:hover {
|
|
343
|
+
background: var(--sunken);
|
|
344
|
+
color: var(--text-1);
|
|
345
|
+
}
|
|
346
|
+
.detail-close temba-icon {
|
|
347
|
+
--icon-color: currentColor;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* match the page header's content-menu buttons so the modal's
|
|
351
|
+
actions read like page actions */
|
|
352
|
+
.menu-button {
|
|
353
|
+
display: inline-flex;
|
|
354
|
+
align-items: center;
|
|
355
|
+
gap: 6px;
|
|
356
|
+
box-sizing: border-box;
|
|
357
|
+
height: 26px;
|
|
358
|
+
padding: 0 10px;
|
|
359
|
+
border: 1px solid var(--border-strong, #9ca3af);
|
|
360
|
+
border-radius: var(--r-sm);
|
|
361
|
+
font-size: 12.5px;
|
|
362
|
+
font-weight: var(--w-regular, 400);
|
|
363
|
+
cursor: pointer;
|
|
364
|
+
user-select: none;
|
|
365
|
+
background: var(--surface);
|
|
366
|
+
color: var(--text-1);
|
|
367
|
+
white-space: nowrap;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.menu-button:hover {
|
|
371
|
+
background: var(--sunken);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.menu-button.destructive {
|
|
375
|
+
color: #dc2626;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.menu-button.destructive:hover {
|
|
379
|
+
border-color: #dc2626;
|
|
380
|
+
background: color-mix(in srgb, #dc2626 6%, var(--surface, #fff));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.detail-body {
|
|
384
|
+
display: flex;
|
|
385
|
+
flex-direction: column;
|
|
386
|
+
gap: 1.25em;
|
|
387
|
+
overflow-y: auto;
|
|
388
|
+
max-height: calc(100vh - 300px);
|
|
389
|
+
padding: 1.25em 1.75em;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.detail-meta {
|
|
393
|
+
display: flex;
|
|
394
|
+
align-items: center;
|
|
395
|
+
gap: 12px;
|
|
396
|
+
font-size: 0.9em;
|
|
397
|
+
color: var(--text-3, #7b8593);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.detail-meta .expression {
|
|
401
|
+
font-family: var(--font-mono, 'Roboto Mono', monospace);
|
|
402
|
+
color: var(--text-2);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.detail-section-title {
|
|
406
|
+
margin-top: 0.25em;
|
|
407
|
+
font-size: 0.75em;
|
|
408
|
+
font-weight: 600;
|
|
409
|
+
text-transform: uppercase;
|
|
410
|
+
letter-spacing: 0.05em;
|
|
411
|
+
color: var(--text-3, #7b8593);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.usage-rows {
|
|
415
|
+
display: flex;
|
|
416
|
+
flex-direction: column;
|
|
417
|
+
align-items: flex-start;
|
|
418
|
+
gap: 0.5em;
|
|
419
|
+
margin-top: 0.5em;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.usage-row {
|
|
423
|
+
display: flex;
|
|
424
|
+
align-items: center;
|
|
425
|
+
gap: 0.6em;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.usage-row .usage-detail {
|
|
429
|
+
font-size: 0.85em;
|
|
430
|
+
color: var(--text-3, #7b8593);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.usage-more {
|
|
434
|
+
font-size: 0.85em;
|
|
435
|
+
color: var(--text-3, #7b8593);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.no-usages {
|
|
439
|
+
color: var(--text-3, #7b8593);
|
|
440
|
+
font-size: 0.9em;
|
|
441
|
+
}
|
|
442
|
+
`;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
connectedCallback(): void {
|
|
446
|
+
super.connectedCallback();
|
|
447
|
+
if (this.store?.fieldsEndpoint) {
|
|
448
|
+
this.url = this.store.fieldsEndpoint;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
public willUpdate(changed: PropertyValues): void {
|
|
453
|
+
super.willUpdate(changed);
|
|
454
|
+
if (changed.has('data') || changed.has('query')) {
|
|
455
|
+
this.filterFields();
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
protected updated(changed: PropertyValues): void {
|
|
460
|
+
super.updated(changed);
|
|
461
|
+
if (changed.has('searchOpen') && this.searchOpen) {
|
|
462
|
+
const input = this.shadowRoot.querySelector(
|
|
463
|
+
'.searchbar input'
|
|
464
|
+
) as HTMLInputElement;
|
|
465
|
+
input?.focus();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private filterFields() {
|
|
470
|
+
const filteredKeys = this.store.getFieldKeys().filter((key) => {
|
|
471
|
+
const field = this.store.getContactField(key);
|
|
472
|
+
if (field.featured) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
return matches(field, this.query);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// sort by the label instead of the key
|
|
479
|
+
filteredKeys.sort((a, b) => {
|
|
480
|
+
return this.store
|
|
481
|
+
.getContactField(a)
|
|
482
|
+
.label.localeCompare(this.store.getContactField(b).label);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const featured: ContactField[] = [];
|
|
486
|
+
this.store.getFeaturedFields().forEach((field) => {
|
|
487
|
+
if (matches(field, this.query)) {
|
|
488
|
+
featured.push(field);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
this.otherFieldKeys = filteredKeys;
|
|
493
|
+
this.featuredFields = featured;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// the featured set + order is saved as one atomic list; the server
|
|
497
|
+
// derives show_in_table and descending priorities from it
|
|
498
|
+
private savePriorities() {
|
|
499
|
+
postJSON(this.priorityEndpoint, {
|
|
500
|
+
featured: this.featuredFields.map((field) => field.key)
|
|
501
|
+
})
|
|
502
|
+
.catch((error) => {
|
|
503
|
+
console.warn('Failed to save featured fields', error);
|
|
504
|
+
})
|
|
505
|
+
.finally(() => {
|
|
506
|
+
// reconcile the optimistic featured/order state with server
|
|
507
|
+
// truth on success and failure alike - the refetch recomputes
|
|
508
|
+
// both lists via filterFields
|
|
509
|
+
this.store.refreshFields();
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private isFeatured(key: string): boolean {
|
|
514
|
+
return (this.featuredFields || []).some((field) => field.key === key);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// optimistically append a field to the featured list - the store
|
|
518
|
+
// refresh after the save makes it durable; ordering is a drag affair
|
|
519
|
+
private featureField(key: string) {
|
|
520
|
+
const field = this.store.getContactField(key);
|
|
521
|
+
if (!field || this.isFeatured(key)) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
this.featuredFields = [...this.featuredFields, field];
|
|
525
|
+
this.otherFieldKeys = this.otherFieldKeys.filter((k) => k !== key);
|
|
526
|
+
this.savePriorities();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private unfeatureField(key: string) {
|
|
530
|
+
if (!this.isFeatured(key)) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
this.featuredFields = this.featuredFields.filter(
|
|
534
|
+
(field) => field.key !== key
|
|
535
|
+
);
|
|
536
|
+
const keys = [...this.otherFieldKeys, key];
|
|
537
|
+
keys.sort((a, b) =>
|
|
538
|
+
this.store
|
|
539
|
+
.getContactField(a)
|
|
540
|
+
.label.localeCompare(this.store.getContactField(b).label)
|
|
541
|
+
);
|
|
542
|
+
this.otherFieldKeys = keys;
|
|
543
|
+
this.savePriorities();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private toggleFeatured(key: string) {
|
|
547
|
+
if (this.isFeatured(key)) {
|
|
548
|
+
this.unfeatureField(key);
|
|
549
|
+
} else {
|
|
550
|
+
this.featureField(key);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// dress the drag ghost as a lifted copy of the row: the row itself is
|
|
555
|
+
// transparent over the card surface and offset by its margin, so give
|
|
556
|
+
// the ghost its own surface and zero the margin (border-box keeps the
|
|
557
|
+
// inlined padding from widening it past the measured rect)
|
|
558
|
+
private prepareGhost = (ghost: HTMLElement) => {
|
|
559
|
+
ghost.style.margin = '0';
|
|
560
|
+
ghost.style.boxSizing = 'border-box';
|
|
561
|
+
ghost.style.background = 'var(--surface, #fff)';
|
|
562
|
+
ghost.style.border = '1px solid var(--border, #e4e7ec)';
|
|
563
|
+
ghost.style.borderRadius = 'var(--curvature, 6px)';
|
|
564
|
+
ghost.style.boxShadow = 'var(--shadow-1, 0 2px 6px rgba(0, 0, 0, 0.12))';
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
private handleFeaturedOrderChanged(event: CustomEvent) {
|
|
568
|
+
const [fromIdx, toIdx] = event.detail.swap;
|
|
569
|
+
const featured = [...this.featuredFields];
|
|
570
|
+
const temp = featured[fromIdx];
|
|
571
|
+
featured.splice(fromIdx, 1);
|
|
572
|
+
featured.splice(toIdx, 0, temp);
|
|
573
|
+
this.featuredFields = featured;
|
|
574
|
+
this.savePriorities();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// the row star toggles featured without opening the row's detail modal
|
|
578
|
+
private handleStarClicked(event: MouseEvent, field: ContactField) {
|
|
579
|
+
event.stopPropagation();
|
|
580
|
+
this.toggleFeatured(field.key);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// keyboard activation of the star must not also fire the row's keydown
|
|
584
|
+
// (which would open the detail modal), so swallow the event here
|
|
585
|
+
private handleStarKeydown(event: KeyboardEvent, field: ContactField) {
|
|
586
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
587
|
+
event.stopPropagation();
|
|
588
|
+
}
|
|
589
|
+
this.handleActivationKey(event, () => this.toggleFeatured(field.key));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
private handleSearchInput(event: InputEvent) {
|
|
593
|
+
// keep the visible text verbatim - matches() lowercases the query
|
|
594
|
+
// itself, so no trim/lowercase here that would mutate what's typed
|
|
595
|
+
this.query = (event.target as HTMLInputElement).value || '';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private toggleSearch() {
|
|
599
|
+
this.searchOpen = !this.searchOpen;
|
|
600
|
+
if (!this.searchOpen) {
|
|
601
|
+
this.query = '';
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// in place of a field read page, clicking a row opens its detail
|
|
606
|
+
// modal, which also lazily loads the field's usages and permissions
|
|
607
|
+
private handleFieldClicked(field: ContactField) {
|
|
608
|
+
this.detailField = field;
|
|
609
|
+
this.loadDetail(field);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private async loadDetail(field: ContactField): Promise<void> {
|
|
613
|
+
this.detail = null;
|
|
614
|
+
if (!this.detailEndpoint) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// tolerate a host passing the base path without a trailing slash
|
|
618
|
+
const base = this.detailEndpoint.endsWith('/')
|
|
619
|
+
? this.detailEndpoint
|
|
620
|
+
: `${this.detailEndpoint}/`;
|
|
621
|
+
try {
|
|
622
|
+
const response = await this.store.getUrl(
|
|
623
|
+
`${base}${encodeURIComponent(field.key)}/`,
|
|
624
|
+
{ force: true }
|
|
625
|
+
);
|
|
626
|
+
if (this.detailField !== field) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.detail = response.json;
|
|
630
|
+
} catch {
|
|
631
|
+
// leave the modal usable with just the store's data
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Everything the host must dispatch flows through temba-selection with
|
|
636
|
+
// one of three payload shapes, matching the campaign events contract:
|
|
637
|
+
// a content-menu item ({item, event, ...}) from the embedded page
|
|
638
|
+
// header, a row action ({key, action: 'update'|'delete'}) to open the
|
|
639
|
+
// edit/delete modals, or a usage reference ({uuid, name, url}) to
|
|
640
|
+
// navigate to.
|
|
641
|
+
|
|
642
|
+
// edit / delete close the detail modal before the host opens the edit
|
|
643
|
+
// or delete-confirm modal in its place - modals never stack
|
|
644
|
+
private handleEditClicked() {
|
|
645
|
+
const field = this.detailField;
|
|
646
|
+
this.detailField = null;
|
|
647
|
+
this.fireCustomEvent(CustomEventType.Selection, {
|
|
648
|
+
key: field.key,
|
|
649
|
+
action: 'update'
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private handleDeleteClicked() {
|
|
654
|
+
const field = this.detailField;
|
|
655
|
+
this.detailField = null;
|
|
656
|
+
this.fireCustomEvent(CustomEventType.Selection, {
|
|
657
|
+
key: field.key,
|
|
658
|
+
action: 'delete'
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private handleToggleFeatured() {
|
|
663
|
+
this.toggleFeatured(this.detailField.key);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// navigating to a usage from the detail modal closes it first
|
|
667
|
+
private handleUsageClicked(usage: UsageRef) {
|
|
668
|
+
this.detailField = null;
|
|
669
|
+
this.fireCustomEvent(CustomEventType.Selection, usage);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private handleDetailClosed = () => {
|
|
673
|
+
this.detailField = null;
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// activate a non-button row on Enter/Space (matches native button behavior)
|
|
677
|
+
private handleActivationKey(e: KeyboardEvent, action: () => void) {
|
|
678
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
679
|
+
e.preventDefault();
|
|
680
|
+
action();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
private renderHeader(): TemplateResult {
|
|
685
|
+
return html`
|
|
686
|
+
<div class="header-panel">
|
|
687
|
+
<temba-page-header content-menu-endpoint=${this.contentMenuEndpoint}>
|
|
688
|
+
<slot name="title" slot="title">${this.headerTitle}</slot>
|
|
689
|
+
<div slot="actions" class="header-actions">
|
|
690
|
+
${!this.searchOpen
|
|
691
|
+
? html`
|
|
692
|
+
<span class="action" @click=${() => this.toggleSearch()}>
|
|
693
|
+
<temba-icon name=${Icon.search} size="0.95"></temba-icon>
|
|
694
|
+
Search
|
|
695
|
+
</span>
|
|
696
|
+
`
|
|
697
|
+
: null}
|
|
698
|
+
</div>
|
|
699
|
+
</temba-page-header>
|
|
700
|
+
${this.searchOpen
|
|
701
|
+
? html`
|
|
702
|
+
<div class="searchbar">
|
|
703
|
+
<input
|
|
704
|
+
type="text"
|
|
705
|
+
placeholder="Search fields"
|
|
706
|
+
.value=${this.query}
|
|
707
|
+
@input=${this.handleSearchInput}
|
|
708
|
+
@keydown=${(e: KeyboardEvent) => {
|
|
709
|
+
if (e.key === 'Escape') {
|
|
710
|
+
this.toggleSearch();
|
|
711
|
+
}
|
|
712
|
+
}}
|
|
713
|
+
/>
|
|
714
|
+
<temba-icon
|
|
715
|
+
class="search-cancel"
|
|
716
|
+
name=${Icon.close}
|
|
717
|
+
size="1.1"
|
|
718
|
+
clickable
|
|
719
|
+
title="Cancel search"
|
|
720
|
+
aria-label="Cancel search"
|
|
721
|
+
@click=${() => this.toggleSearch()}
|
|
722
|
+
></temba-icon>
|
|
723
|
+
</div>
|
|
724
|
+
`
|
|
725
|
+
: null}
|
|
726
|
+
</div>
|
|
727
|
+
`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
private renderStar(field: ContactField): TemplateResult {
|
|
731
|
+
const featured = this.isFeatured(field.key);
|
|
732
|
+
return html`
|
|
733
|
+
<temba-icon
|
|
734
|
+
class="star ${featured ? 'featured' : ''}"
|
|
735
|
+
name=${featured ? Icon.featured_filled : Icon.featured}
|
|
736
|
+
size="1.1"
|
|
737
|
+
clickable
|
|
738
|
+
role="button"
|
|
739
|
+
tabindex="0"
|
|
740
|
+
title=${featured ? 'Featured' : 'Not featured'}
|
|
741
|
+
aria-label=${featured
|
|
742
|
+
? `Unfeature ${field.label}`
|
|
743
|
+
: `Feature ${field.label}`}
|
|
744
|
+
@click=${(e: MouseEvent) => this.handleStarClicked(e, field)}
|
|
745
|
+
@keydown=${(e: KeyboardEvent) => this.handleStarKeydown(e, field)}
|
|
746
|
+
></temba-icon>
|
|
747
|
+
`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// sortable rows lead with a drag handle and are draggable from it;
|
|
751
|
+
// every row trails with its featured-toggle star
|
|
752
|
+
private renderField(field: ContactField, sortable = false): TemplateResult {
|
|
753
|
+
return html`
|
|
754
|
+
<div
|
|
755
|
+
class="field ${sortable ? 'sortable' : ''}"
|
|
756
|
+
id="${field.key}"
|
|
757
|
+
tabindex="0"
|
|
758
|
+
@click=${() => this.handleFieldClicked(field)}
|
|
759
|
+
@keydown=${(e: KeyboardEvent) =>
|
|
760
|
+
this.handleActivationKey(e, () => this.handleFieldClicked(field))}
|
|
761
|
+
>
|
|
762
|
+
${sortable
|
|
763
|
+
? html`<temba-icon
|
|
764
|
+
class="drag-handle"
|
|
765
|
+
name=${Icon.drag}
|
|
766
|
+
></temba-icon>`
|
|
767
|
+
: null}
|
|
768
|
+
<div class="name">${field.label}</div>
|
|
769
|
+
<div class="expression">@fields.${field.key}</div>
|
|
770
|
+
<div class="type">${typeName(field)}</div>
|
|
771
|
+
${this.renderStar(field)}
|
|
772
|
+
</div>
|
|
773
|
+
`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private renderFeaturedPanel(): TemplateResult {
|
|
777
|
+
let body: TemplateResult;
|
|
778
|
+
if (this.featuredFields.length === 0) {
|
|
779
|
+
body = html`
|
|
780
|
+
<div class="empty-note">
|
|
781
|
+
${this.query
|
|
782
|
+
? 'No matches'
|
|
783
|
+
: 'Star a field to feature it on contact pages'}
|
|
784
|
+
</div>
|
|
785
|
+
`;
|
|
786
|
+
} else if (this.query) {
|
|
787
|
+
body = html`
|
|
788
|
+
<div class="rows">
|
|
789
|
+
${this.featuredFields.map((field) => this.renderField(field))}
|
|
790
|
+
</div>
|
|
791
|
+
`;
|
|
792
|
+
} else {
|
|
793
|
+
body = html`
|
|
794
|
+
<div class="rows">
|
|
795
|
+
<temba-sortable-list
|
|
796
|
+
id="featured-list"
|
|
797
|
+
dragHandle="drag-handle"
|
|
798
|
+
.prepareGhost=${this.prepareGhost}
|
|
799
|
+
@temba-order-changed=${this.handleFeaturedOrderChanged}
|
|
800
|
+
>
|
|
801
|
+
${this.featuredFields.map((field) => this.renderField(field, true))}
|
|
802
|
+
</temba-sortable-list>
|
|
803
|
+
</div>
|
|
804
|
+
`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return html`
|
|
808
|
+
<div class="panel" id="featured-panel">
|
|
809
|
+
<div class="section-header">
|
|
810
|
+
<temba-icon name=${Icon.featured_filled}></temba-icon>
|
|
811
|
+
<div>Featured</div>
|
|
812
|
+
</div>
|
|
813
|
+
${body}
|
|
814
|
+
</div>
|
|
815
|
+
`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private renderOtherPanel(): TemplateResult {
|
|
819
|
+
return html`
|
|
820
|
+
<div class="panel" id="other-panel">
|
|
821
|
+
<div class="section-header">
|
|
822
|
+
<temba-icon name=${Icon.fields}></temba-icon>
|
|
823
|
+
<div>Other Fields</div>
|
|
824
|
+
</div>
|
|
825
|
+
${this.otherFieldKeys.length === 0
|
|
826
|
+
? html`<div class="empty-note">
|
|
827
|
+
${this.query ? 'No matches' : 'No fields'}
|
|
828
|
+
</div>`
|
|
829
|
+
: html`<div class="rows">
|
|
830
|
+
${this.otherFieldKeys.map((key) =>
|
|
831
|
+
this.renderField(this.store.getContactField(key))
|
|
832
|
+
)}
|
|
833
|
+
</div>`}
|
|
834
|
+
</div>
|
|
835
|
+
`;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
private renderUsageSection(
|
|
839
|
+
title: string,
|
|
840
|
+
type: string,
|
|
841
|
+
items: UsageRef[],
|
|
842
|
+
total: number
|
|
843
|
+
): TemplateResult | null {
|
|
844
|
+
if (!items || items.length === 0) {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
return html`
|
|
848
|
+
<div>
|
|
849
|
+
<div class="detail-section-title">${title}</div>
|
|
850
|
+
<div class="usage-rows">
|
|
851
|
+
${items.map(
|
|
852
|
+
(usage) => html`
|
|
853
|
+
<temba-label
|
|
854
|
+
type=${type}
|
|
855
|
+
clickable
|
|
856
|
+
@click=${() => this.handleUsageClicked(usage)}
|
|
857
|
+
>${usage.name}</temba-label
|
|
858
|
+
>
|
|
859
|
+
`
|
|
860
|
+
)}
|
|
861
|
+
${total > items.length
|
|
862
|
+
? html`<div class="usage-more">
|
|
863
|
+
and ${total - items.length} more
|
|
864
|
+
</div>`
|
|
865
|
+
: null}
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
`;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private renderCampaignEventUsages(): TemplateResult | null {
|
|
872
|
+
const events = this.detail?.usages?.campaign_events || [];
|
|
873
|
+
if (events.length === 0) {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
const total = this.detail.counts?.campaign_events || events.length;
|
|
877
|
+
return html`
|
|
878
|
+
<div>
|
|
879
|
+
<div class="detail-section-title">Campaign Events</div>
|
|
880
|
+
<div class="usage-rows">
|
|
881
|
+
${events.map(
|
|
882
|
+
(event) => html`
|
|
883
|
+
<div class="usage-row">
|
|
884
|
+
<temba-label
|
|
885
|
+
type="campaign"
|
|
886
|
+
clickable
|
|
887
|
+
@click=${() => this.handleUsageClicked(event.campaign)}
|
|
888
|
+
>${event.campaign?.name}</temba-label
|
|
889
|
+
>
|
|
890
|
+
${event.offset_display
|
|
891
|
+
? html`<div class="usage-detail">
|
|
892
|
+
${event.offset_display}
|
|
893
|
+
</div>`
|
|
894
|
+
: null}
|
|
895
|
+
</div>
|
|
896
|
+
`
|
|
897
|
+
)}
|
|
898
|
+
${total > events.length
|
|
899
|
+
? html`<div class="usage-more">
|
|
900
|
+
and ${total - events.length} more
|
|
901
|
+
</div>`
|
|
902
|
+
: null}
|
|
903
|
+
</div>
|
|
904
|
+
</div>
|
|
905
|
+
`;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private renderUsages(): TemplateResult {
|
|
909
|
+
if (!this.detail) {
|
|
910
|
+
return html`<temba-loading units="3" size="8"></temba-loading>`;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const usages = this.detail.usages || {
|
|
914
|
+
flows: [],
|
|
915
|
+
groups: [],
|
|
916
|
+
campaign_events: []
|
|
917
|
+
};
|
|
918
|
+
const counts = this.detail.counts || {
|
|
919
|
+
flows: 0,
|
|
920
|
+
groups: 0,
|
|
921
|
+
campaign_events: 0
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
const hasUsages =
|
|
925
|
+
(usages.flows || []).length +
|
|
926
|
+
(usages.groups || []).length +
|
|
927
|
+
(usages.campaign_events || []).length >
|
|
928
|
+
0;
|
|
929
|
+
|
|
930
|
+
if (!hasUsages) {
|
|
931
|
+
return html`<div class="no-usages">
|
|
932
|
+
Not used by any flows, groups or campaigns.
|
|
933
|
+
</div>`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return html`
|
|
937
|
+
${this.renderUsageSection('Flows', 'flow', usages.flows, counts.flows)}
|
|
938
|
+
${this.renderUsageSection(
|
|
939
|
+
'Groups',
|
|
940
|
+
'group',
|
|
941
|
+
usages.groups,
|
|
942
|
+
counts.groups
|
|
943
|
+
)}
|
|
944
|
+
${this.renderCampaignEventUsages()}
|
|
945
|
+
`;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// modal detail view for a field, standing in for a field read page -
|
|
949
|
+
// the feature toggle, edit and delete actions, and the field's usages
|
|
950
|
+
private renderDetail(): TemplateResult {
|
|
951
|
+
const field = this.detailField;
|
|
952
|
+
const featured = field ? this.isFeatured(field.key) : false;
|
|
953
|
+
|
|
954
|
+
return html`
|
|
955
|
+
<temba-dialog
|
|
956
|
+
size="xlarge"
|
|
957
|
+
variant="flat"
|
|
958
|
+
primaryButtonName=""
|
|
959
|
+
cancelButtonName=""
|
|
960
|
+
hideOnClick
|
|
961
|
+
?open=${!!field}
|
|
962
|
+
@temba-dialog-hidden=${this.handleDetailClosed}
|
|
963
|
+
@keyup=${(e: KeyboardEvent) => {
|
|
964
|
+
if (e.key === 'Escape') {
|
|
965
|
+
this.handleDetailClosed();
|
|
966
|
+
}
|
|
967
|
+
}}
|
|
968
|
+
>
|
|
969
|
+
${field
|
|
970
|
+
? html`
|
|
971
|
+
<div class="detail">
|
|
972
|
+
<div class="detail-header">
|
|
973
|
+
<div class="detail-title">${field.label}</div>
|
|
974
|
+
${this.detail
|
|
975
|
+
? html`<div class="detail-actions">
|
|
976
|
+
${this.detail.can_edit
|
|
977
|
+
? html`<temba-icon
|
|
978
|
+
class="star featured-toggle ${featured
|
|
979
|
+
? 'featured'
|
|
980
|
+
: ''}"
|
|
981
|
+
name=${featured
|
|
982
|
+
? Icon.featured_filled
|
|
983
|
+
: Icon.featured}
|
|
984
|
+
size="1.3"
|
|
985
|
+
clickable
|
|
986
|
+
role="button"
|
|
987
|
+
tabindex="0"
|
|
988
|
+
title=${featured ? 'Featured' : 'Not featured'}
|
|
989
|
+
aria-label=${featured
|
|
990
|
+
? `Unfeature ${field.label}`
|
|
991
|
+
: `Feature ${field.label}`}
|
|
992
|
+
@click=${this.handleToggleFeatured}
|
|
993
|
+
@keydown=${(e: KeyboardEvent) =>
|
|
994
|
+
this.handleActivationKey(e, () =>
|
|
995
|
+
this.handleToggleFeatured()
|
|
996
|
+
)}
|
|
997
|
+
></temba-icon>`
|
|
998
|
+
: null}
|
|
999
|
+
${this.detail.can_edit
|
|
1000
|
+
? html`<button
|
|
1001
|
+
class="menu-button"
|
|
1002
|
+
@click=${this.handleEditClicked}
|
|
1003
|
+
>
|
|
1004
|
+
Edit
|
|
1005
|
+
</button>`
|
|
1006
|
+
: null}
|
|
1007
|
+
${this.detail.can_delete
|
|
1008
|
+
? html`<button
|
|
1009
|
+
class="menu-button destructive"
|
|
1010
|
+
@click=${this.handleDeleteClicked}
|
|
1011
|
+
>
|
|
1012
|
+
Delete
|
|
1013
|
+
</button>`
|
|
1014
|
+
: null}
|
|
1015
|
+
</div>`
|
|
1016
|
+
: null}
|
|
1017
|
+
<div
|
|
1018
|
+
class="detail-close"
|
|
1019
|
+
role="button"
|
|
1020
|
+
tabindex="0"
|
|
1021
|
+
aria-label="Close"
|
|
1022
|
+
@click=${this.handleDetailClosed}
|
|
1023
|
+
@keydown=${(e: KeyboardEvent) =>
|
|
1024
|
+
this.handleActivationKey(e, this.handleDetailClosed)}
|
|
1025
|
+
>
|
|
1026
|
+
<temba-icon name=${Icon.close} size="1.4"></temba-icon>
|
|
1027
|
+
</div>
|
|
1028
|
+
</div>
|
|
1029
|
+
|
|
1030
|
+
<div class="detail-body">
|
|
1031
|
+
<div class="detail-meta">
|
|
1032
|
+
<div class="expression">@fields.${field.key}</div>
|
|
1033
|
+
<div>${typeName(field)}</div>
|
|
1034
|
+
</div>
|
|
1035
|
+
${this.renderUsages()}
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
`
|
|
1039
|
+
: null}
|
|
1040
|
+
</temba-dialog>
|
|
1041
|
+
`;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
public render(): TemplateResult {
|
|
1045
|
+
if (!this.featuredFields) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return html`
|
|
1050
|
+
${this.renderHeader()}
|
|
1051
|
+
<div class="content">
|
|
1052
|
+
${this.renderFeaturedPanel()} ${this.renderOtherPanel()}
|
|
1053
|
+
</div>
|
|
1054
|
+
${this.renderDetail()}
|
|
1055
|
+
`;
|
|
1056
|
+
}
|
|
1057
|
+
}
|