@nyaruka/temba-components 0.162.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.
@@ -20,6 +20,7 @@ import {
20
20
  SPLIT_GROUP_METADATA
21
21
  } from './types';
22
22
  import { CustomEventType } from '../interfaces';
23
+ import { Dialog } from '../layout/Dialog';
23
24
  import { generateUUID } from '../utils';
24
25
  import {
25
26
  formatIssueMessage,
@@ -477,6 +478,11 @@ export class NodeEditor extends RapidElement {
477
478
  @state()
478
479
  private originalFormData: FormData = {};
479
480
 
481
+ // snapshot of getComparableFormData(), refreshed on any non-user mutation
482
+ // (init, async resolve) — the baseline for detecting unsaved edits when
483
+ // dismissing via escape
484
+ private pristineFormData = '';
485
+
480
486
  @state()
481
487
  private errors: { [key: string]: string } = {};
482
488
 
@@ -515,24 +521,112 @@ export class NodeEditor extends RapidElement {
515
521
  private issuesByAction!: Map<string, FlowIssue[]>;
516
522
 
517
523
  private boundHandleEscape = this.handleEscapeKey.bind(this);
524
+ private boundSwallowEscapeUp = this.swallowEscapeUp.bind(this);
518
525
 
519
526
  connectedCallback(): void {
520
527
  super.connectedCallback();
521
528
  this.initializeFormData();
522
529
  document.addEventListener('keydown', this.boundHandleEscape);
530
+ document.addEventListener('keyup', this.boundSwallowEscapeUp, true);
523
531
  }
524
532
 
525
533
  disconnectedCallback(): void {
526
534
  super.disconnectedCallback();
527
535
  document.removeEventListener('keydown', this.boundHandleEscape);
536
+ document.removeEventListener('keyup', this.boundSwallowEscapeUp, true);
528
537
  }
529
538
 
530
539
  private handleEscapeKey(event: KeyboardEvent): void {
531
540
  if (event.key === 'Escape' && this.isOpen) {
541
+ const path = event.composedPath();
542
+
543
+ // an escape aimed at an open select dropdown just closes the dropdown
544
+ if (
545
+ path.some(
546
+ (el) =>
547
+ el instanceof Element &&
548
+ el.tagName === 'TEMBA-SELECT' &&
549
+ (el as any).isOpen?.()
550
+ )
551
+ ) {
552
+ return;
553
+ }
554
+
555
+ if (!this.ownsEscape(path)) {
556
+ return;
557
+ }
558
+
559
+ if (
560
+ this.hasUnsavedChanges() &&
561
+ !window.confirm(Dialog.UNSAVED_CHANGES_MESSAGE)
562
+ ) {
563
+ return;
564
+ }
532
565
  this.handleCancel();
533
566
  }
534
567
  }
535
568
 
569
+ // an escape routed through a dialog other than our own (e.g. a nested
570
+ // dialog or modax opened from a field) belongs to that dialog; an escape
571
+ // with no dialog in its path (canvas or body focus) is ours to handle
572
+ private ownsEscape(path: EventTarget[]): boolean {
573
+ for (const el of path) {
574
+ if (el instanceof Element && el.tagName === 'TEMBA-DIALOG') {
575
+ return el === this.shadowRoot?.querySelector('temba-dialog');
576
+ }
577
+ }
578
+ return true;
579
+ }
580
+
581
+ // temba-dialog handles escape itself on KEYUP reaching its container, which
582
+ // would prompt a second time after the keydown handler above has already
583
+ // asked (or dismiss unprompted if its keyup ever arrived first). While the
584
+ // editor is open, escape within our own dialog is owned entirely by the
585
+ // keydown handler, so stop those keyups before the dialog sees them —
586
+ // but leave escapes belonging to a dialog layered above us alone.
587
+ private swallowEscapeUp(event: KeyboardEvent): void {
588
+ if (
589
+ event.key === 'Escape' &&
590
+ this.isOpen &&
591
+ this.ownsEscape(event.composedPath())
592
+ ) {
593
+ event.stopPropagation();
594
+ }
595
+ }
596
+
597
+ private hasUnsavedChanges(): boolean {
598
+ return this.getComparableFormData() !== this.pristineFormData;
599
+ }
600
+
601
+ // JSON of formData with array-editor scaffolding filtered out: array editors
602
+ // seed a blank row (carrying field defaults, e.g. an attachment type) into
603
+ // form data on first render, which must not count as a user edit
604
+ private getComparableFormData(): string {
605
+ const form = this.getConfig()?.form;
606
+ const comparable = { ...this.formData };
607
+
608
+ if (form) {
609
+ Object.entries(form).forEach(([fieldName, fieldConfig]) => {
610
+ const value = comparable[fieldName];
611
+ if (fieldConfig.type === 'array' && Array.isArray(value)) {
612
+ // same emptiness semantics as ArrayEditor.isEmptyItem
613
+ const isEmpty =
614
+ fieldConfig.isEmptyItem ||
615
+ ((item: any) => {
616
+ const values = Object.values(item || {});
617
+ return (
618
+ values.length === 0 ||
619
+ values.every((v) => v === undefined || v === null || v === '')
620
+ );
621
+ });
622
+ comparable[fieldName] = value.filter((item: any) => !isEmpty(item));
623
+ }
624
+ });
625
+ }
626
+
627
+ return JSON.stringify(comparable);
628
+ }
629
+
536
630
  willUpdate(changedProperties: PropertyValues): void {
537
631
  super.willUpdate(changedProperties);
538
632
  if (
@@ -719,14 +813,24 @@ export class NodeEditor extends RapidElement {
719
813
  this.formData[key] = { ...value };
720
814
  }
721
815
  });
816
+
817
+ // fresh form, so nothing to warn about discarding yet
818
+ this.pristineFormData = this.getComparableFormData();
722
819
  }
723
820
 
724
821
  private async resolveFormData(): Promise<void> {
725
822
  const config = this.getConfig();
726
823
  if (!config?.resolveFormData) return;
727
824
 
825
+ const input = this.formData;
728
826
  try {
729
- const resolved = await config.resolveFormData(this.formData);
827
+ const resolved = await config.resolveFormData(input);
828
+
829
+ // if the user edited while the resolve was in flight, applying the
830
+ // stale result would overwrite their edit — and rebasing the baseline
831
+ // below would then mask the loss as a clean form
832
+ if (this.formData !== input) return;
833
+
730
834
  if (resolved && resolved !== this.formData) {
731
835
  this.formData = resolved;
732
836
  this.processFormDataForEditing();
@@ -749,6 +853,9 @@ export class NodeEditor extends RapidElement {
749
853
  });
750
854
  }
751
855
 
856
+ // resolving isn't a user edit; rebase the unsaved-changes baseline
857
+ this.pristineFormData = this.getComparableFormData();
858
+
752
859
  this.requestUpdate();
753
860
  }
754
861
  } catch (error) {
@@ -2690,6 +2797,7 @@ export class NodeEditor extends RapidElement {
2690
2797
  .open="${this.isOpen}"
2691
2798
  .originX=${this.dialogOrigin?.x ?? null}
2692
2799
  .originY=${this.dialogOrigin?.y ?? null}
2800
+ .checkForChanges=${() => this.hasUnsavedChanges()}
2693
2801
  @temba-button-clicked=${this.handleDialogButtonClick}
2694
2802
  primaryButtonName="Save"
2695
2803
  cancelButtonName="Cancel"
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
- attachments: string[];
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: string[];
304
+ urns: URN[];
228
305
  language?: string;
229
306
  fields: { [key: string]: string };
230
307
  groups: Group[];
@@ -0,0 +1,311 @@
1
+ import { css, html, TemplateResult } from 'lit';
2
+ import { property } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { designTokens } from '../styles/designTokens';
5
+ import { getClasses } from '../utils';
6
+ import { Icon } from '../Icons';
7
+
8
+ /**
9
+ * A collapsible card with a header showing an icon, label and optional
10
+ * count badge. Designed to live inside a temba-card-stack where the header
11
+ * doubles as the drag handle, but works standalone too. The body never
12
+ * scrolls internally — cards grow to their content.
13
+ */
14
+ export class Card extends RapidElement {
15
+ static get styles() {
16
+ return css`
17
+ ${designTokens}
18
+
19
+ :host {
20
+ display: block;
21
+ }
22
+
23
+ /* The chrome lives on an inner frame rather than the host so
24
+ document-level universal rules (e.g. tailwind's preflight
25
+ border-color) can't override it. */
26
+ .frame {
27
+ background: var(--card-bg, var(--surface));
28
+ border: 1px solid var(--card-border, var(--border-strong));
29
+ border-radius: var(--r-sm);
30
+ box-shadow: var(--shadow-2);
31
+ }
32
+
33
+ /* note variant — the sticky-note surface, header included */
34
+ :host([variant='note']) .frame {
35
+ background: var(--surface-note);
36
+ border-color: var(--border-note);
37
+ }
38
+
39
+ .card-header {
40
+ display: flex;
41
+ align-items: center;
42
+ padding: 8px 10px;
43
+ cursor: pointer;
44
+ user-select: none;
45
+ border-radius: var(--r-sm);
46
+ }
47
+
48
+ .card-header temba-icon {
49
+ --icon-color: var(--text-3);
50
+ }
51
+
52
+ .grip {
53
+ margin-right: 0.5em;
54
+ cursor: grab;
55
+ --icon-color: var(--text-4);
56
+ }
57
+
58
+ .card-header:hover .grip {
59
+ --icon-color: var(--text-3);
60
+ }
61
+
62
+ .label {
63
+ display: flex;
64
+ align-items: center;
65
+ flex-grow: 1;
66
+ font-size: 13px;
67
+ font-weight: var(--w-medium);
68
+ color: var(--text-2);
69
+ }
70
+
71
+ .label temba-icon {
72
+ margin-right: 0.5em;
73
+ }
74
+
75
+ .count {
76
+ display: inline-flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ min-width: 16px;
80
+ height: 16px;
81
+ padding: 0 4px;
82
+ margin-right: 0.5em;
83
+ border-radius: 999px;
84
+ background: var(--accent-100);
85
+ color: var(--accent-700);
86
+ font-size: 11px;
87
+ font-weight: var(--w-semibold);
88
+ font-variant-numeric: tabular-nums;
89
+ }
90
+
91
+ .dot {
92
+ height: 0.5em;
93
+ width: 0.5em;
94
+ margin-right: 0.5em;
95
+ background: var(--accent-600);
96
+ border-radius: 99px;
97
+ }
98
+
99
+ .toggle {
100
+ transition: transform 200ms ease;
101
+ }
102
+
103
+ .toggle.collapsed {
104
+ transform: rotate(-90deg);
105
+ }
106
+
107
+ /* Grid trick so collapse animates to natural content height without
108
+ capping how tall an expanded card can grow. */
109
+ .body {
110
+ display: grid;
111
+ grid-template-rows: 1fr;
112
+ transition: grid-template-rows 200ms ease;
113
+ }
114
+
115
+ .body.collapsed {
116
+ grid-template-rows: 0fr;
117
+ }
118
+
119
+ .inner {
120
+ min-height: 0;
121
+ overflow: hidden;
122
+ }
123
+
124
+ /* once fully expanded, let popovers (date pickers etc.) escape */
125
+ .body:not(.collapsed):not(.animating) .inner {
126
+ overflow: visible;
127
+ }
128
+
129
+ .content {
130
+ padding: 0 10px 10px;
131
+ }
132
+
133
+ /* bleed mode: the body content runs edge-to-edge so a panel with its
134
+ own surface (e.g. the notepad) fills the card, clipped to the card
135
+ radius. The footer of such content sits on the card's bottom edge. */
136
+ :host([bleed]) .content {
137
+ padding: 0;
138
+ }
139
+
140
+ :host([bleed]) .body:not(.collapsed):not(.animating) .inner {
141
+ overflow: hidden;
142
+ border-radius: 0 0 var(--r-sm) var(--r-sm);
143
+ }
144
+
145
+ /* plain mode: headerless and non-collapsible — the wrapper (e.g. a
146
+ tab pane) supplies the label — but still a proper card surface
147
+ with padding. Fills its pane and scrolls its content internally,
148
+ since a tab pane is height-bounded. */
149
+ :host([plain]) {
150
+ display: flex;
151
+ flex-direction: column;
152
+ flex-grow: 1;
153
+ min-height: 0;
154
+ margin-top: var(--layout-spacing, 8px);
155
+ margin-bottom: var(--layout-spacing, 8px);
156
+ }
157
+
158
+ :host([plain]) .frame,
159
+ :host([plain]) .body,
160
+ :host([plain]) .inner {
161
+ display: flex;
162
+ flex-direction: column;
163
+ flex-grow: 1;
164
+ min-height: 0;
165
+ overflow: hidden;
166
+ }
167
+
168
+ :host([plain]) .inner {
169
+ border-radius: var(--r-sm);
170
+ }
171
+
172
+ :host([plain]) .content {
173
+ display: flex;
174
+ flex-direction: column;
175
+ flex-grow: 1;
176
+ min-height: 0;
177
+ padding: 10px;
178
+ overflow-y: auto;
179
+ }
180
+
181
+ /* no header row in plain mode, so bleeding content gets a top inset
182
+ directly against the card surface */
183
+ :host([plain][bleed]) .content {
184
+ padding: 10px 0 0 0;
185
+ }
186
+ `;
187
+ }
188
+
189
+ @property({ type: String })
190
+ label = '';
191
+
192
+ @property({ type: String })
193
+ icon = '';
194
+
195
+ @property({ type: Number })
196
+ count = 0;
197
+
198
+ // show a dot instead of the count
199
+ @property({ type: Boolean })
200
+ activity = false;
201
+
202
+ @property({ type: Boolean, reflect: true })
203
+ collapsed = false;
204
+
205
+ // render without chrome (no header, border or collapse) — content only
206
+ @property({ type: Boolean, reflect: true })
207
+ plain = false;
208
+
209
+ // body content runs edge-to-edge instead of getting the inset padding
210
+ @property({ type: Boolean, reflect: true })
211
+ bleed = false;
212
+
213
+ // named surface treatments, e.g. "note" for the sticky-note look
214
+ @property({ type: String, reflect: true })
215
+ variant = '';
216
+
217
+ @property({ type: Boolean })
218
+ dirty = false;
219
+
220
+ private animating = false;
221
+
222
+ private handleHeaderClick() {
223
+ this.collapsed = !this.collapsed;
224
+ this.animating = true;
225
+ this.requestUpdate();
226
+ this.dispatchEvent(
227
+ new CustomEvent('toggle', {
228
+ bubbles: true,
229
+ composed: true,
230
+ detail: { collapsed: this.collapsed, label: this.label }
231
+ })
232
+ );
233
+ }
234
+
235
+ private handleTransitionEnd(event: TransitionEvent) {
236
+ // transitionend bubbles composed out of slotted content — only our own
237
+ // grid collapse animation should clear the clipping state
238
+ if (
239
+ event.target !== event.currentTarget ||
240
+ event.propertyName !== 'grid-template-rows'
241
+ ) {
242
+ return;
243
+ }
244
+ this.animating = false;
245
+ this.requestUpdate();
246
+ }
247
+
248
+ private handleDetailsChanged(event: CustomEvent) {
249
+ if ('dirty' in event.detail) {
250
+ this.dirty = event.detail.dirty;
251
+ }
252
+ if ('count' in event.detail) {
253
+ this.count = event.detail.count;
254
+ }
255
+ }
256
+
257
+ public render(): TemplateResult {
258
+ if (this.plain) {
259
+ return html`
260
+ <div class="frame">
261
+ <div class="body">
262
+ <div class="inner">
263
+ <div class="content">
264
+ <slot
265
+ @temba-details-changed=${this.handleDetailsChanged}
266
+ ></slot>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ `;
272
+ }
273
+
274
+ return html`
275
+ <div class="frame">
276
+ <div class="card-header" @click=${this.handleHeaderClick}>
277
+ <temba-icon name=${Icon.drag} class="grip"></temba-icon>
278
+ <div class="label">
279
+ ${this.icon
280
+ ? html`<temba-icon name=${this.icon}></temba-icon>`
281
+ : null}
282
+ ${this.label}${this.dirty ? ' *' : ''}
283
+ </div>
284
+ <slot name="header-actions"></slot>
285
+ ${this.count > 0
286
+ ? this.activity
287
+ ? html`<div class="dot"></div>`
288
+ : html`<div class="count">${this.count.toLocaleString()}</div>`
289
+ : null}
290
+ <temba-icon
291
+ name=${Icon.arrow_down}
292
+ class="toggle ${this.collapsed ? 'collapsed' : ''}"
293
+ ></temba-icon>
294
+ </div>
295
+ <div
296
+ class="body ${getClasses({
297
+ collapsed: this.collapsed,
298
+ animating: this.animating
299
+ })}"
300
+ @transitionend=${this.handleTransitionEnd}
301
+ >
302
+ <div class="inner">
303
+ <div class="content">
304
+ <slot @temba-details-changed=${this.handleDetailsChanged}></slot>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </div>
309
+ `;
310
+ }
311
+ }