@nyaruka/temba-components 0.163.0 → 0.164.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 +14 -0
- package/dist/temba-components.js +1842 -678
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/display/Chat.ts +3 -2
- package/src/display/Lightbox.ts +57 -109
- package/src/display/Thumbnail.ts +34 -7
- package/src/interfaces.ts +79 -2
- package/src/layout/Card.ts +311 -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 +38 -0
- package/src/layout/PageHeader.ts +5 -6
- package/src/layout/Resizer.ts +20 -0
- package/src/list/CampaignList.ts +144 -0
- package/src/list/ContactList.ts +3 -1
- package/src/list/FlowList.ts +31 -7
- package/src/list/MsgList.ts +18 -12
- package/src/list/SortableList.ts +45 -6
- package/src/list/TriggerList.ts +538 -0
- package/src/live/CampaignEvents.ts +1094 -0
- package/src/live/ContactDetails.ts +3 -4
- 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 +12 -2
- package/src/utils.ts +19 -0
- package/temba-modules.ts +14 -0
- package/web-dev-server.config.mjs +54 -0
package/package.json
CHANGED
package/src/display/Chat.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { RapidElement } from '../RapidElement';
|
|
|
5
5
|
import { CustomEventType } from '../interfaces';
|
|
6
6
|
import { TicketEvent } from '../events';
|
|
7
7
|
import { DEFAULT_AVATAR } from '../webchat/assets';
|
|
8
|
+
import { attachmentAsString } from '../utils';
|
|
8
9
|
|
|
9
10
|
const BATCH_TIME_WINDOW = 60 * 60 * 1000;
|
|
10
11
|
const SCROLL_FETCH_BUFFER = 200; // pixels from top
|
|
@@ -1446,7 +1447,7 @@ export class Chat extends RapidElement {
|
|
|
1446
1447
|
|
|
1447
1448
|
// check if message has location attachment and text is just coordinates
|
|
1448
1449
|
const hasLocationAttachment = message.msg.attachments?.some((att) =>
|
|
1449
|
-
att.startsWith('geo:')
|
|
1450
|
+
attachmentAsString(att).startsWith('geo:')
|
|
1450
1451
|
);
|
|
1451
1452
|
const textIsCoordinates =
|
|
1452
1453
|
hasLocationAttachment &&
|
|
@@ -1510,7 +1511,7 @@ export class Chat extends RapidElement {
|
|
|
1510
1511
|
${(message.msg.attachments || []).map(
|
|
1511
1512
|
(attachment) =>
|
|
1512
1513
|
html`<temba-thumbnail
|
|
1513
|
-
attachment="${attachment}"
|
|
1514
|
+
attachment="${attachmentAsString(attachment)}"
|
|
1514
1515
|
></temba-thumbnail>`
|
|
1515
1516
|
)}
|
|
1516
1517
|
</div>
|
package/src/display/Lightbox.ts
CHANGED
|
@@ -1,59 +1,65 @@
|
|
|
1
1
|
import { css, html, PropertyValueMap } from 'lit';
|
|
2
|
-
import { property } from 'lit/decorators.js';
|
|
2
|
+
import { property, state } from 'lit/decorators.js';
|
|
3
3
|
import { RapidElement } from '../RapidElement';
|
|
4
4
|
import { getClasses } from '../utils';
|
|
5
|
-
import { styleMap } from 'lit-html/directives/style-map.js';
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* A full-screen viewer for an image attachment. `showElement` is handed the
|
|
8
|
+
* element that was clicked (a raw <img> or a temba-thumbnail exposing a `url`),
|
|
9
|
+
* and we present that image centered and contained within the viewport with a
|
|
10
|
+
* short fade / scale-in. Clicking anywhere dismisses it.
|
|
11
11
|
*/
|
|
12
12
|
export class Lightbox extends RapidElement {
|
|
13
13
|
static get styles() {
|
|
14
14
|
return css`
|
|
15
15
|
:host {
|
|
16
|
-
|
|
17
|
-
position: absolute;
|
|
16
|
+
position: fixed;
|
|
18
17
|
top: 0;
|
|
19
18
|
left: 0;
|
|
19
|
+
z-index: 10000;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/* a full-viewport layer that centers the image, catches the dismiss
|
|
23
|
+
click, and lays a light translucent mask over the page behind */
|
|
24
|
+
.backdrop {
|
|
25
|
+
position: fixed;
|
|
26
|
+
inset: 0;
|
|
23
27
|
display: flex;
|
|
24
|
-
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
25
30
|
background: rgba(0, 0, 0, 0.5);
|
|
26
|
-
|
|
27
|
-
height: 100svh;
|
|
28
|
-
width: 100svw;
|
|
31
|
+
opacity: 0;
|
|
29
32
|
pointer-events: none;
|
|
33
|
+
transition: opacity var(--anim) ease;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
.zoom
|
|
36
|
+
.backdrop.zoom {
|
|
33
37
|
opacity: 1;
|
|
34
38
|
pointer-events: auto;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
img {
|
|
42
|
+
/* contain the image within the viewport at any aspect ratio, on
|
|
43
|
+
its own solid backing (so transparent images aren't see-through) */
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
max-width: 90vw;
|
|
46
|
+
max-height: 90vh;
|
|
47
|
+
object-fit: contain;
|
|
48
|
+
background: #fff;
|
|
49
|
+
padding: 6px;
|
|
50
|
+
border-radius: calc(var(--curvature) * 1.5);
|
|
51
|
+
box-shadow: 0 0 24px 6px rgba(0, 0, 0, 0.4);
|
|
52
|
+
transform: scale(0.85);
|
|
53
|
+
opacity: 0;
|
|
54
|
+
transition:
|
|
55
|
+
transform var(--anim) ease,
|
|
56
|
+
opacity var(--anim) ease;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
display:flex;
|
|
49
|
-
align-items:center;
|
|
50
|
-
color:#fff;
|
|
51
|
-
padding:0.5em;
|
|
52
|
-
border-radius:var(--curvature);
|
|
53
|
-
background:rgba(0,0,0,0.5);
|
|
59
|
+
.zoom img {
|
|
60
|
+
transform: scale(1);
|
|
61
|
+
opacity: 1;
|
|
54
62
|
}
|
|
55
|
-
|
|
56
|
-
}
|
|
57
63
|
`;
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -66,77 +72,41 @@ export class Lightbox extends RapidElement {
|
|
|
66
72
|
@property({ type: Boolean })
|
|
67
73
|
zoom = false;
|
|
68
74
|
|
|
69
|
-
@
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
private ele: HTMLElement;
|
|
73
|
-
private left: number;
|
|
74
|
-
private top: number;
|
|
75
|
-
private height: number;
|
|
76
|
-
private width: number;
|
|
77
|
-
private scale = 1;
|
|
78
|
-
private xTrans = 0;
|
|
79
|
-
private yTrans = 0;
|
|
75
|
+
@state()
|
|
76
|
+
private url = '';
|
|
80
77
|
|
|
81
78
|
protected updated(
|
|
82
79
|
changed: PropertyValueMap<any> | Map<PropertyKey, unknown>
|
|
83
80
|
): void {
|
|
81
|
+
// mount first (opacity 0 / scaled down), then flip to zoom on the next
|
|
82
|
+
// tick so the transition to the visible state actually animates
|
|
84
83
|
if (changed.has('show') && this.show) {
|
|
85
84
|
window.setTimeout(() => {
|
|
86
85
|
this.zoom = true;
|
|
87
86
|
}, 0);
|
|
88
87
|
}
|
|
89
88
|
|
|
89
|
+
// once zoomed out, wait for the fade to finish before unmounting the image
|
|
90
90
|
if (changed.has('zoom') && !this.zoom && this.show) {
|
|
91
91
|
window.setTimeout(() => {
|
|
92
|
-
|
|
92
|
+
// unless a re-open during the fade-out already zoomed us back in
|
|
93
|
+
if (!this.zoom) {
|
|
94
|
+
this.show = false;
|
|
95
|
+
}
|
|
93
96
|
}, this.animationTime);
|
|
94
97
|
}
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
public showElement(ele: HTMLElement) {
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
this.
|
|
101
|
-
(this.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
this.top = bounds.top;
|
|
105
|
-
this.width = bounds.width;
|
|
106
|
-
this.height = bounds.height;
|
|
107
|
-
|
|
108
|
-
this.xTrans = 0;
|
|
109
|
-
this.yTrans = 0;
|
|
110
|
-
this.scale = 1;
|
|
111
|
-
|
|
112
|
-
let desiredWidth = this.width;
|
|
113
|
-
let desiredHeight = this.height;
|
|
114
|
-
let desiredScale = this.scale;
|
|
115
|
-
|
|
116
|
-
const maxHeight = window.innerHeight * this.zoomPct;
|
|
117
|
-
const maxWidth = window.innerWidth * this.zoomPct;
|
|
118
|
-
|
|
119
|
-
// if the width fits, constrain by height
|
|
120
|
-
if (this.width * (maxHeight / this.height) < maxWidth) {
|
|
121
|
-
desiredHeight = window.innerHeight * this.zoomPct;
|
|
122
|
-
desiredScale = desiredHeight / this.height;
|
|
123
|
-
desiredWidth = this.width * desiredScale;
|
|
101
|
+
// the clicked element is either a raw <img> or a temba-thumbnail that
|
|
102
|
+
// exposes the attachment url; take whichever gives us an image source
|
|
103
|
+
this.url = (ele as HTMLImageElement).src || (ele as any).url || '';
|
|
104
|
+
if (this.show) {
|
|
105
|
+
// already mounted (e.g. re-opened mid-dismissal) — re-zoom in place
|
|
106
|
+
this.zoom = true;
|
|
124
107
|
} else {
|
|
125
|
-
|
|
126
|
-
desiredScale = desiredWidth / this.width;
|
|
127
|
-
desiredHeight = this.height * desiredScale;
|
|
108
|
+
this.show = true; // updated() flips zoom on the next tick
|
|
128
109
|
}
|
|
129
|
-
|
|
130
|
-
const xGrowth = (desiredWidth - this.width) / 2;
|
|
131
|
-
const xDest = (window.innerWidth - desiredWidth) / 2;
|
|
132
|
-
this.xTrans = xDest - this.left + xGrowth;
|
|
133
|
-
|
|
134
|
-
const yGrowth = (desiredHeight - this.height) / 2;
|
|
135
|
-
const yDest = (window.innerHeight - desiredHeight) / 2;
|
|
136
|
-
this.yTrans = yDest - this.top + yGrowth;
|
|
137
|
-
|
|
138
|
-
this.scale = desiredScale;
|
|
139
|
-
this.show = true;
|
|
140
110
|
}
|
|
141
111
|
|
|
142
112
|
public handleClick() {
|
|
@@ -144,37 +114,15 @@ export class Lightbox extends RapidElement {
|
|
|
144
114
|
}
|
|
145
115
|
|
|
146
116
|
public render() {
|
|
147
|
-
const styles = {
|
|
148
|
-
transition: `transform ${this.animationTime}ms ease, box-shadow ${this.animationTime}ms ease`
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
if (this.show) {
|
|
152
|
-
styles['left'] = this.left + 'px';
|
|
153
|
-
styles['top'] = this.top + 'px';
|
|
154
|
-
styles['width'] = this.width + 'px';
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (this.zoom) {
|
|
158
|
-
styles['transform'] =
|
|
159
|
-
`translate(${this.xTrans}px, ${this.yTrans}px) scale(${this.scale}, ${this.scale})`;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
117
|
return html`
|
|
163
118
|
<div
|
|
164
|
-
class=${getClasses({
|
|
165
|
-
|
|
166
|
-
show: this.show,
|
|
167
|
-
zoom: this.zoom
|
|
168
|
-
})}
|
|
119
|
+
class=${getClasses({ backdrop: true, zoom: this.zoom })}
|
|
120
|
+
style="--anim: ${this.animationTime}ms"
|
|
169
121
|
@click=${this.handleClick}
|
|
170
122
|
>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
></div>
|
|
175
|
-
<div class=${getClasses({ matte: true })} style=${styleMap(styles)}>
|
|
176
|
-
${this.show ? html`${this.ele}` : null}
|
|
177
|
-
</div>
|
|
123
|
+
${this.show && this.url
|
|
124
|
+
? html`<img src=${this.url} alt="attachment" />`
|
|
125
|
+
: null}
|
|
178
126
|
</div>
|
|
179
127
|
`;
|
|
180
128
|
}
|
package/src/display/Thumbnail.ts
CHANGED
|
@@ -182,6 +182,11 @@ export class Thumbnail extends RapidElement {
|
|
|
182
182
|
// audio player state
|
|
183
183
|
private audio: HTMLAudioElement | null = null;
|
|
184
184
|
|
|
185
|
+
// set when an image attachment fails to load, so we fall back to an
|
|
186
|
+
// icon instead of the browser's broken-image glyph
|
|
187
|
+
@state()
|
|
188
|
+
private imageError = false;
|
|
189
|
+
|
|
185
190
|
@state()
|
|
186
191
|
private audioPlaying = false;
|
|
187
192
|
|
|
@@ -264,6 +269,8 @@ export class Thumbnail extends RapidElement {
|
|
|
264
269
|
|
|
265
270
|
// convert our attachment to a url and label
|
|
266
271
|
if (changes.has('attachment')) {
|
|
272
|
+
// a new attachment gets a fresh shot at loading its image
|
|
273
|
+
this.imageError = false;
|
|
267
274
|
if (this.attachment) {
|
|
268
275
|
const splitIndex = this.attachment.indexOf(':');
|
|
269
276
|
if (splitIndex === -1) {
|
|
@@ -337,12 +344,27 @@ export class Thumbnail extends RapidElement {
|
|
|
337
344
|
}
|
|
338
345
|
}
|
|
339
346
|
|
|
340
|
-
public handleThumbnailClicked() {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
347
|
+
public handleThumbnailClicked(e?: Event) {
|
|
348
|
+
// a thumbnail click is a self-contained action (lightbox / open / play);
|
|
349
|
+
// don't let it bubble to a clickable ancestor (e.g. a list row that would
|
|
350
|
+
// otherwise navigate away instead of showing the attachment)
|
|
351
|
+
e?.stopPropagation();
|
|
352
|
+
if (
|
|
353
|
+
this.contentType === ThumbnailContentType.IMAGE &&
|
|
354
|
+
this.preview &&
|
|
355
|
+
!this.imageError
|
|
356
|
+
) {
|
|
357
|
+
const lightbox = document.querySelector('temba-lightbox') as Lightbox;
|
|
358
|
+
if (lightbox) {
|
|
359
|
+
window.setTimeout(() => {
|
|
360
|
+
lightbox.showElement(this);
|
|
361
|
+
}, 100);
|
|
362
|
+
} else {
|
|
363
|
+
// no lightbox mounted on this page — just open the image. This must
|
|
364
|
+
// happen synchronously inside the click's user activation or popup
|
|
365
|
+
// blockers will eat it.
|
|
366
|
+
window.open(this.url, '_blank');
|
|
367
|
+
}
|
|
346
368
|
} else if (this.contentType === ThumbnailContentType.LOCATION) {
|
|
347
369
|
// open location in openstreetmap
|
|
348
370
|
const osmUrl = `https://www.openstreetmap.org/?mlat=${this.latitude}&mlon=${this.longitude}#map=15/${this.latitude}/${this.longitude}`;
|
|
@@ -372,12 +394,17 @@ export class Thumbnail extends RapidElement {
|
|
|
372
394
|
: ''}"
|
|
373
395
|
url=${this.url}
|
|
374
396
|
>
|
|
375
|
-
${this.contentType === ThumbnailContentType.IMAGE &&
|
|
397
|
+
${this.contentType === ThumbnailContentType.IMAGE &&
|
|
398
|
+
this.preview &&
|
|
399
|
+
!this.imageError
|
|
376
400
|
? html`<div class=""><div class="download" @click=${this.handleDownload.bind(
|
|
377
401
|
this
|
|
378
402
|
)}><temba-icon size="1" style="color:#fff;" name="download"></temba-icon></div><img
|
|
379
403
|
class="observe thumb ${this.contentType}"
|
|
380
404
|
src="${this.url}"
|
|
405
|
+
@error=${() => {
|
|
406
|
+
this.imageError = true;
|
|
407
|
+
}}
|
|
381
408
|
></img></div>`
|
|
382
409
|
: this.contentType === ThumbnailContentType.AUDIO
|
|
383
410
|
? html`<div class="audio-player">
|
package/src/interfaces.ts
CHANGED
|
@@ -54,6 +54,25 @@ export interface ScheduledEvent {
|
|
|
54
54
|
message?: string;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// one event definition in a campaign's schedule, offset from a date field
|
|
58
|
+
// on the contact - consumed by the temba-campaign-events component
|
|
59
|
+
export interface CampaignScheduleEvent {
|
|
60
|
+
uuid: string;
|
|
61
|
+
type: 'message' | 'flow';
|
|
62
|
+
status: 'ready' | 'scheduling';
|
|
63
|
+
offset: number;
|
|
64
|
+
unit: string;
|
|
65
|
+
offset_display: string;
|
|
66
|
+
delivery_hour_display?: string;
|
|
67
|
+
relative_to: { key: string; name: string; system?: boolean };
|
|
68
|
+
flow?: ObjectReference;
|
|
69
|
+
message?: string;
|
|
70
|
+
count: number;
|
|
71
|
+
edit_url: string;
|
|
72
|
+
delete_url: string;
|
|
73
|
+
fires_url: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
export interface NamedUser extends User {
|
|
58
77
|
name: string;
|
|
59
78
|
}
|
|
@@ -120,6 +139,59 @@ export interface Flow {
|
|
|
120
139
|
activity: number[];
|
|
121
140
|
}
|
|
122
141
|
|
|
142
|
+
/** A single row in the campaign CRUDL list
|
|
143
|
+
* (`campaigns/campaign_list.html`). */
|
|
144
|
+
export interface Campaign {
|
|
145
|
+
uuid: string;
|
|
146
|
+
name: string;
|
|
147
|
+
/** The contact group the campaign schedules against. */
|
|
148
|
+
group: ObjectReference;
|
|
149
|
+
/** Number of events (messages / flow starts) in the campaign. */
|
|
150
|
+
events: number;
|
|
151
|
+
/** Contacts currently in the campaign's group. */
|
|
152
|
+
contacts: number;
|
|
153
|
+
modified_on: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A single row in the trigger CRUDL list
|
|
157
|
+
* (`triggers/trigger_list.html`): what starts the flow (type +
|
|
158
|
+
* per-type details), any channel / group filters, and the flow it
|
|
159
|
+
* starts. Triggers have no uuid — rows key off the numeric id. */
|
|
160
|
+
export interface Trigger {
|
|
161
|
+
id: number;
|
|
162
|
+
/** Trigger type slug — drives the leading row icon and the
|
|
163
|
+
* details cell (`keyword`, `catch_all`, `schedule`,
|
|
164
|
+
* `inbound_call`, `missed_call`, `new_conversation`, `referral`,
|
|
165
|
+
* `closed_ticket`, `opt_in`, `opt_out`). */
|
|
166
|
+
type: string;
|
|
167
|
+
/** The flow the trigger starts. */
|
|
168
|
+
flow: ObjectReference;
|
|
169
|
+
/** Channel the trigger is limited to, with its type icon. */
|
|
170
|
+
channel?: (ObjectReference & { icon?: string }) | null;
|
|
171
|
+
/** Groups the trigger is limited to. */
|
|
172
|
+
groups?: ObjectReference[];
|
|
173
|
+
/** Groups the trigger excludes. */
|
|
174
|
+
exclude_groups?: ObjectReference[];
|
|
175
|
+
/** Contacts a scheduled trigger starts directly. */
|
|
176
|
+
contacts?: ObjectReference[];
|
|
177
|
+
/** Keyword triggers only. */
|
|
178
|
+
keywords?: string[];
|
|
179
|
+
/** Keyword match type — `F` (starts with) or `O` (matches). */
|
|
180
|
+
match_type?: string | null;
|
|
181
|
+
/** Referral triggers only. */
|
|
182
|
+
referrer_id?: string | null;
|
|
183
|
+
/** Scheduled triggers only — `display` is the server-rendered
|
|
184
|
+
* human schedule ("each week on Monday"); `next_fire` is unset
|
|
185
|
+
* once the schedule is exhausted or paused. */
|
|
186
|
+
schedule?: {
|
|
187
|
+
repeat_period?: string;
|
|
188
|
+
display?: string;
|
|
189
|
+
next_fire?: string | null;
|
|
190
|
+
} | null;
|
|
191
|
+
priority?: number;
|
|
192
|
+
created_on?: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
123
195
|
export interface Msg {
|
|
124
196
|
/** Numeric id — present on persisted messages (the CRUDL list
|
|
125
197
|
* keys rows off it); absent on outbound drafts. */
|
|
@@ -137,7 +209,10 @@ export interface Msg {
|
|
|
137
209
|
/** Message type as exposed by the messages CRUDL endpoint
|
|
138
210
|
* (`text` / `optin` / …); mirrors `type` for that surface. */
|
|
139
211
|
msg_type?: string;
|
|
140
|
-
|
|
212
|
+
/** Attachments as exposed by the messages CRUDL endpoint, which
|
|
213
|
+
* serializes each as a `{content_type, url}` object; some other
|
|
214
|
+
* message surfaces carry them as `contentType:url` strings. */
|
|
215
|
+
attachments: (string | Attachment)[];
|
|
141
216
|
/** Labels applied to the message. */
|
|
142
217
|
labels?: ObjectReference[];
|
|
143
218
|
/** The flow the message was sent from, when there is one. */
|
|
@@ -192,6 +267,8 @@ export interface ContactGroup {
|
|
|
192
267
|
export interface URN {
|
|
193
268
|
scheme: string;
|
|
194
269
|
path: string;
|
|
270
|
+
display?: string | null;
|
|
271
|
+
channel?: ObjectReference | null;
|
|
195
272
|
}
|
|
196
273
|
|
|
197
274
|
export interface Group {
|
|
@@ -224,7 +301,7 @@ export interface Contact {
|
|
|
224
301
|
uuid: string;
|
|
225
302
|
stopped: boolean;
|
|
226
303
|
blocked: boolean;
|
|
227
|
-
urns:
|
|
304
|
+
urns: URN[];
|
|
228
305
|
language?: string;
|
|
229
306
|
fields: { [key: string]: string };
|
|
230
307
|
groups: Group[];
|