@nyaruka/temba-components 0.168.1 → 0.169.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.168.1",
3
+ "version": "0.169.0",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -1,7 +1,20 @@
1
- import { css, html, TemplateResult } from 'lit';
1
+ import { css, html, PropertyValues, TemplateResult } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
3
  import { RapidElement } from '../RapidElement';
4
+ import { Contact, CustomEventType } from '../interfaces';
5
+ import { Events } from '../events/eventRenderers';
6
+ import { getDestinationURN } from '../live/ContactStoreElement';
7
+ import { watchContact } from '../live/ContactWatch';
8
+ import { RealtimeSubscription } from '../live/Realtime';
4
9
 
10
+ /**
11
+ * A contact's name, optionally with the URN that will be used to message
12
+ * them. Given a contact uuid it manages itself: it registers interest in name
13
+ * events with the central contact watcher, which delivers the initial value
14
+ * and any live changes through the same handler - the component never fetches
15
+ * anything. Alternatively the name and urn can be set explicitly for static
16
+ * rendering (e.g. rows of a list that arrived in one fetch).
17
+ */
5
18
  export class ContactName extends RapidElement {
6
19
  @property({ type: String })
7
20
  name: string;
@@ -9,9 +22,25 @@ export class ContactName extends RapidElement {
9
22
  @property({ type: String })
10
23
  urn: string;
11
24
 
25
+ // when set, name and urn are kept live by the central contact watcher
26
+ @property({ type: String })
27
+ contact: string;
28
+
12
29
  @property({ type: Number, attribute: 'icon-size' })
13
30
  size = 20;
14
31
 
32
+ private watch: RealtimeSubscription = null;
33
+ private watchedContact: string = null;
34
+
35
+ // the contact the name and urn on screen came from - only what we put there
36
+ // is ours to clear, and only when we're pointed somewhere else
37
+ private displayedContact: string = null;
38
+
39
+ // the contact behind a refresh notification we've already queued - a name
40
+ // and a urn change arrive as separate deliveries, as does a burst of live
41
+ // events, so the notification is coalesced into a microtask
42
+ private pendingRefresh: Contact = null;
43
+
15
44
  static get styles() {
16
45
  return css`
17
46
  :host {
@@ -34,6 +63,99 @@ export class ContactName extends RapidElement {
34
63
  }
35
64
  `;
36
65
  }
66
+
67
+ public connectedCallback(): void {
68
+ super.connectedCallback();
69
+ this.syncWatch();
70
+ }
71
+
72
+ public disconnectedCallback(): void {
73
+ super.disconnectedCallback();
74
+ this.syncWatch();
75
+ }
76
+
77
+ public willUpdate(changed: PropertyValues): void {
78
+ super.willUpdate(changed);
79
+ if (changed.has('contact')) {
80
+ this.syncWatch();
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Keeps our watch in sync with the contact we're pointed at, clearing the
86
+ * previous contact's values when we switch contacts on the page. Explicitly
87
+ * set name/urn never pass through here since they involve no watch.
88
+ */
89
+ private syncWatch() {
90
+ const target = (this.isConnected && this.contact) || null;
91
+ if (target === this.watchedContact) {
92
+ return;
93
+ }
94
+
95
+ if (this.watch) {
96
+ this.watch.unsubscribe();
97
+ this.watch = null;
98
+ }
99
+
100
+ // only values we put there are ours to clear - a name and urn supplied
101
+ // alongside the contact render until the watcher has something better.
102
+ // Leaving the DOM isn't a switch, and blanking there would flash empty
103
+ // and refetch when we're only being moved - but a swap made while we were
104
+ // detached is, so compare against the contact on screen rather than the
105
+ // one we were last watching
106
+ if (
107
+ this.isConnected &&
108
+ this.displayedContact &&
109
+ this.displayedContact !== target
110
+ ) {
111
+ this.name = null;
112
+ this.urn = null;
113
+ this.displayedContact = null;
114
+ }
115
+ this.watchedContact = target;
116
+
117
+ if (target) {
118
+ this.watch = watchContact(
119
+ target,
120
+ [Events.CONTACT_NAME_CHANGED, Events.CONTACT_URNS_CHANGED],
121
+ (event, contact) => this.handleContactEvent(event, contact)
122
+ );
123
+ }
124
+ }
125
+
126
+ private handleContactEvent(event: any, contact: Contact) {
127
+ if (contact) {
128
+ // the watcher keeps the contact current, so it's authoritative whether
129
+ // this delivery is an event or an initial value. Anon workspaces have
130
+ // no names, show the contact's ref instead
131
+ this.name = contact.name || contact.ref || null;
132
+
133
+ // show the URN that will be used when messaging the contact, falling
134
+ // back to their highest priority URN if none are sendable
135
+ const urn = getDestinationURN(contact) || (contact.urns || [])[0] || null;
136
+ this.urn = urn ? `${urn.scheme}:${urn.display || urn.path}` : null;
137
+ this.displayedContact = this.watchedContact;
138
+ this.fireRefreshed(contact);
139
+ } else if (event && event.type === Events.CONTACT_NAME_CHANGED) {
140
+ // a live event can outrun the initial fetch - show the name we have
141
+ this.name = event.name || null;
142
+ this.displayedContact = this.watchedContact;
143
+ }
144
+ }
145
+
146
+ private fireRefreshed(contact: Contact) {
147
+ if (!this.pendingRefresh) {
148
+ Promise.resolve().then(() => {
149
+ const refreshed = this.pendingRefresh;
150
+ this.pendingRefresh = null;
151
+ if (refreshed) {
152
+ this.fireCustomEvent(CustomEventType.Refreshed, { data: refreshed });
153
+ }
154
+ });
155
+ }
156
+ this.pendingRefresh = contact;
157
+ }
158
+
37
159
  public render(): TemplateResult {
38
160
  const urn = this.urn
39
161
  ? html`<temba-urn size=${this.size} urn=${this.urn}></temba-urn>`
@@ -74,6 +74,12 @@ interface SearchResult {
74
74
  }
75
75
 
76
76
  export class ContactChat extends ContactStoreElement {
77
+ // we run our own firehose subscription below (history rendering, typing,
78
+ // ephemeral state) with its own contact/ticket channel choreography, so we
79
+ // opt out of the central watcher entirely - our data loads and syncs
80
+ // through the store as before
81
+ protected watchTypes: string[] = null;
82
+
77
83
  public static get styles() {
78
84
  return css`
79
85
  ${designTokens}
@@ -1,13 +1,14 @@
1
1
  import { css, html, PropertyValues, TemplateResult } from 'lit';
2
2
  import { property, state } from 'lit/decorators.js';
3
3
  import { Icon } from '../Icons';
4
- import { CustomEventType, Group, URN } from '../interfaces';
4
+ import { Contact, CustomEventType, Group, URN } from '../interfaces';
5
5
  import { getLanguageName } from '../languages';
6
6
  import { capitalize, WebResponse } from '../utils';
7
7
  import { Select } from '../form/select/Select';
8
8
  import { TextInput } from '../form/TextInput';
9
9
  import { ContactFieldEditor } from './ContactFieldEditor';
10
10
  import { ContactStoreElement, getDestinationURN } from './ContactStoreElement';
11
+ import { Events } from '../events/eventRenderers';
11
12
 
12
13
  interface Option {
13
14
  name: string;
@@ -22,6 +23,16 @@ const STATUS_OPTIONS: Option[] = [
22
23
  ];
23
24
 
24
25
  export class ContactDetails extends ContactStoreElement {
26
+ // the contact state we render - live changes to any of these reach us
27
+ // through the central watcher
28
+ protected watchTypes = [
29
+ Events.CONTACT_NAME_CHANGED,
30
+ Events.CONTACT_URNS_CHANGED,
31
+ Events.CONTACT_LANGUAGE_CHANGED,
32
+ Events.CONTACT_STATUS_CHANGED,
33
+ Events.CONTACT_GROUPS_CHANGED
34
+ ];
35
+
25
36
  @property({ type: Boolean })
26
37
  editable = false;
27
38
 
@@ -430,6 +441,26 @@ export class ContactDetails extends ContactStoreElement {
430
441
  }
431
442
  }
432
443
 
444
+ /**
445
+ * A watcher delivery carries server state from before the writes we still
446
+ * have in flight, so keep our own value for anything mid-save - the save
447
+ * itself applies the server's version when it lands.
448
+ */
449
+ protected mergeWatchedContact(contact: Contact, previous: Contact): Contact {
450
+ if (!previous || this.fieldSaveGenerations.size === 0) {
451
+ return contact;
452
+ }
453
+
454
+ const merged = { ...contact };
455
+ this.fieldSaveGenerations.forEach((generation, field) => {
456
+ if (field in previous) {
457
+ const value = previous[field];
458
+ merged[field] = Array.isArray(value) ? [...value] : value;
459
+ }
460
+ });
461
+ return merged;
462
+ }
463
+
433
464
  public willUpdate(changed: PropertyValues): void {
434
465
  if (changed.has('contact') || changed.has('endpoint')) {
435
466
  const url = this.contact ? `${this.endpoint}${this.contact}` : null;
@@ -10,11 +10,16 @@ import { getClasses, postJSON } from '../utils';
10
10
  import { ContactFieldEditor } from './ContactFieldEditor';
11
11
  import { ContactStoreElement } from './ContactStoreElement';
12
12
  import { Checkbox } from '../form/Checkbox';
13
- import { ContactField, CustomEventType } from '../interfaces';
13
+ import { Contact, ContactField, CustomEventType } from '../interfaces';
14
+ import { Events } from '../events/eventRenderers';
14
15
 
15
16
  const MIN_FOR_FILTER = 10;
16
17
 
17
18
  export class ContactFields extends ContactStoreElement {
19
+ // we render field values, so field changes are the only events we need -
20
+ // the central watcher hands us a refetched contact when they happen
21
+ protected watchTypes = [Events.CONTACT_FIELD_CHANGED];
22
+
18
23
  static get styles() {
19
24
  return css`
20
25
  .field {
@@ -154,6 +159,34 @@ export class ContactFields extends ContactStoreElement {
154
159
  );
155
160
  }
156
161
 
162
+ /**
163
+ * A watcher delivery carries server state from before an edit the user is
164
+ * still making, so keep the value we already have for any field whose
165
+ * editor is dirty - rebinding it would drop what they typed.
166
+ */
167
+ protected mergeWatchedContact(contact: Contact, previous: Contact): Contact {
168
+ if (!previous || !previous.fields) {
169
+ return contact;
170
+ }
171
+
172
+ const editors = Array.from(
173
+ this.shadowRoot?.querySelectorAll('temba-contact-field') || []
174
+ ) as ContactFieldEditor[];
175
+ const editing = editors.filter(
176
+ (editor) => editor.dirty && editor.key in previous.fields
177
+ );
178
+
179
+ if (editing.length === 0) {
180
+ return contact;
181
+ }
182
+
183
+ const fields = { ...contact.fields };
184
+ editing.forEach((editor) => {
185
+ fields[editor.key] = previous.fields[editor.key];
186
+ });
187
+ return { ...contact, fields };
188
+ }
189
+
157
190
  public willUpdate(changed: PropertyValues): void {
158
191
  super.willUpdate(changed);
159
192
  if (
@@ -6,6 +6,10 @@ import { ContactNote, CustomEventType } from '../interfaces';
6
6
  import { designTokens } from '../styles/designTokens';
7
7
 
8
8
  export class ContactNotepad extends ContactStoreElement {
9
+ // notes have no contact events - an empty interest list still receives
10
+ // eventless deliveries (initial values, refetches and page-local edits)
11
+ protected watchTypes: string[] = [];
12
+
9
13
  @property({ type: Object, attribute: false })
10
14
  note: ContactNote;
11
15
 
@@ -116,6 +120,10 @@ export class ContactNotepad extends ContactStoreElement {
116
120
  private resizer: ResizeObserver;
117
121
  private lastWidth = 0;
118
122
 
123
+ // the contact our current note was taken off - an edit only belongs to the
124
+ // contact it was typed against, so a switch to another one re-derives
125
+ private noteContact: string = null;
126
+
119
127
  public connectedCallback(): void {
120
128
  super.connectedCallback();
121
129
  // text rewraps when our width changes (browser resize, layout mode
@@ -163,26 +171,53 @@ export class ContactNotepad extends ContactStoreElement {
163
171
  '.notepad'
164
172
  ) as HTMLInputElement;
165
173
  const note = notepad.value;
174
+ // our own write is the one delivery the dirty guard below holds out for,
175
+ // so take the note from it once it lands
166
176
  this.postChanges({ note }).then(() => {
167
- this.markClean();
177
+ this.syncNote();
168
178
  });
169
179
  }
170
180
 
181
+ // takes the newest note off the contact, copied so editing it never writes
182
+ // into the contact data we share with everything else
183
+ private syncNote() {
184
+ this.noteContact = this.data?.uuid || null;
185
+ this.note =
186
+ this.data?.notes?.length > 0
187
+ ? { ...this.data.notes[this.data.notes.length - 1] }
188
+ : null;
189
+ this.fireCustomEvent(CustomEventType.DetailsChanged, {
190
+ count: this.note && this.note.text.length > 0 ? 1 : 0,
191
+ dirty: false
192
+ });
193
+ this.markClean();
194
+
195
+ // lit dirty checks the .value binding against the last string it wrote,
196
+ // not what's actually in the textarea, so re-deriving an unchanged note
197
+ // wouldn't take the user's typing back off - write the note through
198
+ const notepad = this.shadowRoot?.querySelector(
199
+ '.notepad'
200
+ ) as HTMLTextAreaElement;
201
+ if (notepad) {
202
+ notepad.value = this.note ? this.note.text : '';
203
+ }
204
+ }
205
+
171
206
  protected updated(
172
207
  changes: PropertyValueMap<any> | Map<PropertyKey, unknown>
173
208
  ): void {
174
209
  super.updated(changes);
175
210
 
176
- if (changes.has('data')) {
177
- this.note =
178
- this.data?.notes?.length > 0
179
- ? { ...this.data.notes[this.data.notes.length - 1] }
180
- : null;
181
- this.fireCustomEvent(CustomEventType.DetailsChanged, {
182
- count: this.note && this.note.text.length > 0 ? 1 : 0,
183
- dirty: false
184
- });
185
- this.markClean();
211
+ // the central watcher re-delivers the contact on any activity, not just
212
+ // note changes, so a delivery mid-edit would otherwise drop what the user
213
+ // has typed - hold the note until the edit is saved or abandoned. Only
214
+ // for the contact it was typed against though: on a switch the edit goes
215
+ // with the contact we left, so the new one's note lands right away
216
+ if (
217
+ changes.has('data') &&
218
+ (!this.dirty || (this.data?.uuid || null) !== this.noteContact)
219
+ ) {
220
+ this.syncNote();
186
221
  }
187
222
 
188
223
  if (changes.has('note') || changes.has('data')) {
@@ -1,7 +1,14 @@
1
1
  import { PropertyValues } from 'lit';
2
2
  import { property } from 'lit/decorators.js';
3
- import { Contact, Group, URN } from '../interfaces';
3
+ import { Contact, CustomEventType, Group, URN } from '../interfaces';
4
4
  import { EndpointMonitorElement } from '../store/EndpointMonitorElement';
5
+ import {
6
+ CONTACT_STATE_TYPES,
7
+ refreshContact,
8
+ updateContact,
9
+ watchContact
10
+ } from './ContactWatch';
11
+ import { RealtimeSubscription } from './Realtime';
5
12
 
6
13
  /**
7
14
  * Returns the URN that will be used to message the given contact — URNs are
@@ -18,6 +25,15 @@ export class ContactStoreElement extends EndpointMonitorElement {
18
25
  @property({ type: Object, attribute: false })
19
26
  data: Contact;
20
27
 
28
+ // the contact events this component registers interest in with the central
29
+ // watcher - subclasses narrow this to what they actually render. An empty
30
+ // list still receives eventless deliveries (initial values and refetches);
31
+ // null opts out of watching entirely
32
+ protected watchTypes: string[] = CONTACT_STATE_TYPES;
33
+
34
+ private watch: RealtimeSubscription = null;
35
+ private watchedContact: string = null;
36
+
21
37
  // Resolve each URN against a channel while retaining the user's priority
22
38
  // order. Consumers can select the first channel-backed URN for messaging.
23
39
  @property({ type: String })
@@ -70,6 +86,35 @@ export class ContactStoreElement extends EndpointMonitorElement {
70
86
  // make sure contact data is properly prepped
71
87
  this.data = this.prepareData([contact]);
72
88
  this.store.updateCache(`${this.endpoint}${contactId}`, this.data);
89
+
90
+ // sync every watcher on the page with the change immediately, without
91
+ // waiting for it to echo back over the socket
92
+ if (this.data) {
93
+ updateContact(this.data.uuid, this.data);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Refetches the contact. When watched we also go through the central
99
+ * watcher so every watcher on the page gets the fresh contact, not just us,
100
+ * while the store fetch keeps the cache fresh for the components that read
101
+ * it without watching. Two fetches for what is a rare manual operation.
102
+ */
103
+ public refresh(): void {
104
+ if (this.watchedContact) {
105
+ refreshContact(this.watchedContact);
106
+ }
107
+ super.refresh();
108
+ }
109
+
110
+ public connectedCallback(): void {
111
+ super.connectedCallback();
112
+ this.syncWatch();
113
+ }
114
+
115
+ public disconnectedCallback(): void {
116
+ super.disconnectedCallback();
117
+ this.syncWatch();
73
118
  }
74
119
 
75
120
  public willUpdate(changed: PropertyValues): void {
@@ -81,7 +126,64 @@ export class ContactStoreElement extends EndpointMonitorElement {
81
126
  } else {
82
127
  this.url = null;
83
128
  }
129
+ this.syncWatch();
84
130
  }
85
131
  super.willUpdate(changed);
86
132
  }
133
+
134
+ // keeps our registration with the central watcher in sync with the
135
+ // contact we're pointed at
136
+ private syncWatch() {
137
+ const target =
138
+ (this.isConnected && this.watchTypes && this.contact) || null;
139
+ if (target === this.watchedContact) {
140
+ return;
141
+ }
142
+
143
+ if (this.watch) {
144
+ this.watch.unsubscribe();
145
+ this.watch = null;
146
+ }
147
+ this.watchedContact = target;
148
+
149
+ if (target) {
150
+ this.watch = watchContact(target, this.watchTypes, (event, contact) =>
151
+ this.handleWatchedContact(event, contact)
152
+ );
153
+ }
154
+ }
155
+
156
+ /**
157
+ * A delivery from the central watcher - an event we registered interest in
158
+ * or an eventless delivery carrying initial or refetched values. Either
159
+ * way the contact is current, so it simply becomes our data.
160
+ */
161
+ protected handleWatchedContact(event: any, contact: Contact) {
162
+ if (contact && contact.uuid === this.watchedContact) {
163
+ const previous = this.data;
164
+ // fresh identity so change detection sees every delivery, copying the
165
+ // parts prepareData mutates so we never write into the registry's
166
+ // snapshot
167
+ const copy = {
168
+ ...contact,
169
+ groups: (contact.groups || []).map((group) => ({ ...group })),
170
+ fields: { ...contact.fields }
171
+ };
172
+ this.data = this.prepareData(this.mergeWatchedContact(copy, previous));
173
+ this.fireCustomEvent(CustomEventType.Refreshed, {
174
+ data: this.data,
175
+ previous
176
+ });
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Lets subclasses keep values they have a write in flight for. A delivery
182
+ * carries server state that predates our own unsaved edits, so anything
183
+ * mid-save would otherwise flip back to its old value until the write
184
+ * lands. Returns the contact to become our data.
185
+ */
186
+ protected mergeWatchedContact(contact: Contact, _previous: Contact): Contact {
187
+ return contact;
188
+ }
87
189
  }