@nyaruka/temba-components 0.170.0 → 0.171.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.
@@ -4,107 +4,157 @@ import { DateTime } from 'luxon';
4
4
  import { html, css, PropertyValues } from 'lit';
5
5
  import { DatePicker } from './DatePicker';
6
6
  import { CustomEventType } from '../interfaces';
7
+ import { Icon } from '../Icons';
8
+ import { designTokens } from '../styles/designTokens';
7
9
 
8
10
  export class RangePicker extends RapidElement {
9
11
  static styles = css`
12
+ ${designTokens}
13
+
14
+ :host {
15
+ display: inline-block;
16
+ font-family: var(--font);
17
+ }
18
+
10
19
  .range-container {
11
20
  display: flex;
12
- gap: 0.5em;
21
+ gap: 8px;
13
22
  align-items: center;
23
+ font-size: 13.5px;
24
+ color: var(--text-1);
14
25
  }
26
+
15
27
  .date-display {
16
28
  cursor: pointer;
17
- padding: 0.2em 0.5em;
18
- margin: 0.6em 0;
19
- border-radius: var(--curvature-widget);
20
- border: 1px solid transparent;
21
- transition: border 0.2s;
29
+ padding: 3px 6px;
30
+ border-radius: var(--r-sm);
31
+ transition:
32
+ background 120ms,
33
+ color 120ms;
22
34
  }
23
35
 
24
36
  .date-display:hover {
25
- border: 1px solid var(--color-widget-border);
26
37
  background: var(--sunken);
27
38
  }
28
39
 
29
- input[type='date'] {
30
- font-size: 1em;
31
- padding: 0.2em 0.5em;
32
- border-radius: var(--curvature-widget);
33
- border: 1px solid var(--color-widget-border);
40
+ .range-separator {
41
+ color: var(--text-3);
34
42
  }
35
43
 
36
44
  .navigation-container {
37
45
  display: flex;
38
46
  align-items: center;
39
- gap: 0.25em;
47
+ gap: 4px;
40
48
  }
41
49
 
50
+ /* Chevron-only step buttons, same chrome as the list pager: bare
51
+ glyph at rest, sunken wash on hover. No border of their own, so
52
+ they don't compete with the period track between them. */
42
53
  .nav-arrow {
43
- background: var(--sunken);
44
- border: 1px solid var(--color-widget-border);
45
-
46
- border-radius: var(--curvature-widget);
47
- padding: 0em 0em;
48
- cursor: pointer;
49
- font-size: 0.6em;
50
- display: flex;
54
+ display: inline-flex;
51
55
  align-items: center;
52
56
  justify-content: center;
53
- width: 23px;
54
- height: 23px;
57
+ width: 24px;
58
+ height: 24px;
59
+ padding: 0;
60
+ border: 0;
61
+ background: transparent;
62
+ border-radius: var(--r-sm);
63
+ color: var(--text-3);
64
+ cursor: pointer;
55
65
  transition:
56
- background 0.2s,
57
- border 0.2s,
58
- opacity 0.2s;
66
+ background 120ms,
67
+ color 120ms,
68
+ opacity 120ms;
69
+ }
70
+
71
+ .nav-arrow temba-icon {
72
+ --icon-color: currentColor;
59
73
  }
60
74
 
61
75
  .nav-arrow:hover:not(:disabled) {
62
- background: var(--accent-50);
63
- border-color: var(--accent);
76
+ background: var(--sunken);
77
+ color: var(--text-1);
64
78
  }
65
79
 
66
80
  .nav-arrow:disabled {
67
- opacity: 0.5;
68
- cursor: not-allowed;
69
- background: var(--sunken);
81
+ opacity: 0.35;
82
+ cursor: default;
70
83
  }
71
84
 
72
85
  .nav-arrow.hidden {
73
86
  visibility: hidden;
74
87
  }
75
88
 
89
+ /* Segmented control — one bordered sunken track holds every
90
+ period and the selected one lifts out as a surface pill. The
91
+ buttons carry no borders themselves, so there are no
92
+ overlapping 1px edges to clip the selection's outline (the old
93
+ -1px margin stacking left the selected button's left border
94
+ painted over by its neighbor). */
76
95
  .button-group {
77
- display: flex;
78
- margin-left: 0em;
96
+ display: inline-flex;
97
+ align-items: center;
98
+ gap: 2px;
99
+ padding: 2px;
100
+ background: var(--sunken);
101
+ border: 1px solid var(--border);
102
+ border-radius: var(--r-sm);
79
103
  }
104
+
80
105
  .range-btn {
81
- background: var(--sunken);
82
- border: 1px solid var(--color-widget-border);
83
- border-radius: 0px;
84
- margin-left: -1px;
85
- padding: 0.2em 0.8em;
106
+ height: 22px;
107
+ padding: 0 10px;
108
+ border: 0;
109
+ background: transparent;
110
+ border-radius: var(--r-xs);
111
+ font-family: inherit;
112
+ font-size: 12.5px;
113
+ font-weight: var(--w-medium);
114
+ line-height: 1;
115
+ color: var(--text-2);
86
116
  cursor: pointer;
87
- font-size: 0.95em;
88
117
  transition:
89
- background 0.2s,
90
- border 0.2s;
118
+ background 120ms,
119
+ color 120ms,
120
+ box-shadow 120ms;
91
121
  }
92
122
 
93
- .button-group .range-btn:first-child {
94
- border-radius: var(--curvature-widget) 0 0 var(--curvature-widget);
123
+ /* The hover wash sits on the sunken track, so --sunken itself
124
+ would be invisible here. Derived from --text-1 rather than a raw
125
+ rgba literal so it follows the palette (the tokens file mixes
126
+ against transparent the same way for --focus-halo). */
127
+ .range-btn:hover:not(.selected) {
128
+ background: color-mix(in srgb, var(--text-1) 6%, transparent);
129
+ color: var(--text-1);
95
130
  }
96
131
 
97
- .button-group .range-btn:last-child {
98
- border-radius: 0 var(--curvature-widget) var(--curvature-widget) 0;
132
+ .range-btn.selected {
133
+ background: var(--surface);
134
+ color: var(--accent-700);
135
+ box-shadow: var(--shadow-1);
136
+ cursor: default;
99
137
  }
100
138
 
101
- .range-btn.selected,
102
- .range-btn:active {
103
- background: var(--accent-50);
104
- border-color: var(--accent);
139
+ .range-btn:focus-visible,
140
+ .nav-arrow:focus-visible {
141
+ outline: none;
142
+ box-shadow: var(--focus-halo);
105
143
  }
106
144
  `;
107
145
 
146
+ // the periods in the segmented control, in display order
147
+ private static RANGES: {
148
+ type: 'W' | 'M' | 'Y' | 'ALL';
149
+ label: string;
150
+ title: string;
151
+ }[] = [
152
+ { type: 'W', label: 'W', title: 'Last week' },
153
+ { type: 'M', label: 'M', title: 'Last month' },
154
+ { type: 'Y', label: 'Y', title: 'Last year' },
155
+ { type: 'ALL', label: 'All', title: 'All time' }
156
+ ];
157
+
108
158
  @property({ type: String, attribute: 'start' })
109
159
  startDate = '';
110
160
 
@@ -465,6 +515,15 @@ export class RangePicker extends RapidElement {
465
515
  } ${amount} ${unit}`;
466
516
  }
467
517
 
518
+ private getNavigationTitle(direction: 'previous' | 'next'): string {
519
+ const prefix = direction === 'previous' ? 'Previous' : 'Next';
520
+ if (this.selectedRange === 'W') return `${prefix} week`;
521
+ if (this.selectedRange === 'M') return `${prefix} month`;
522
+ if (this.selectedRange === 'Y') return `${prefix} year`;
523
+ if (this.selectedRange === '') return this.getNavigationLabel(direction);
524
+ return `${prefix} period`;
525
+ }
526
+
468
527
  willUpdate(changed: PropertyValues) {
469
528
  super.willUpdate(changed);
470
529
 
@@ -543,7 +602,7 @@ export class RangePicker extends RapidElement {
543
602
  >${this.formatDateForDisplay(this.startDate) ||
544
603
  'Start date'}</span
545
604
  >`}
546
- <span> - </span>
605
+ <span class="range-separator">–</span>
547
606
  ${this.editingEnd
548
607
  ? html`<temba-datepicker
549
608
  .value=${this.endDate}
@@ -566,61 +625,34 @@ export class RangePicker extends RapidElement {
566
625
  class="nav-arrow ${this.selectedRange === 'ALL' ? 'hidden' : ''}"
567
626
  ?disabled=${!this.canNavigatePrevious()}
568
627
  @click=${this.navigatePrevious}
569
- title="Previous ${this.selectedRange === 'W'
570
- ? 'week'
571
- : this.selectedRange === 'M'
572
- ? 'month'
573
- : this.selectedRange === 'Y'
574
- ? 'year'
575
- : this.selectedRange === ''
576
- ? this.getNavigationLabel('previous')
577
- : 'period'}"
628
+ title=${this.getNavigationTitle('previous')}
629
+ aria-label=${this.getNavigationTitle('previous')}
578
630
  >
579
-
631
+ <temba-icon name=${Icon.arrow_left}></temba-icon>
580
632
  </button>
581
- <div class="button-group">
582
- <button
583
- class="range-btn ${this.selectedRange === 'W' ? 'selected' : ''}"
584
- @click=${() => this.setRange('W')}
585
- >
586
- W
587
- </button>
588
- <button
589
- class="range-btn ${this.selectedRange === 'M' ? 'selected' : ''}"
590
- @click=${() => this.setRange('M')}
591
- >
592
- M
593
- </button>
594
- <button
595
- class="range-btn ${this.selectedRange === 'Y' ? 'selected' : ''}"
596
- @click=${() => this.setRange('Y')}
597
- >
598
- Y
599
- </button>
600
- <button
601
- class="range-btn ${this.selectedRange === 'ALL'
602
- ? 'selected'
603
- : ''}"
604
- @click=${() => this.setRange('ALL')}
605
- >
606
- All
607
- </button>
633
+ <div class="button-group" role="group" aria-label="Period">
634
+ ${RangePicker.RANGES.map(
635
+ (range) =>
636
+ html`<button
637
+ class="range-btn ${this.selectedRange === range.type
638
+ ? 'selected'
639
+ : ''}"
640
+ title=${range.title}
641
+ aria-pressed=${this.selectedRange === range.type}
642
+ @click=${() => this.setRange(range.type)}
643
+ >
644
+ ${range.label}
645
+ </button>`
646
+ )}
608
647
  </div>
609
648
  <button
610
649
  class="nav-arrow ${this.selectedRange === 'ALL' ? 'hidden' : ''}"
611
650
  ?disabled=${!this.canNavigateNext()}
612
651
  @click=${this.navigateNext}
613
- title="Next ${this.selectedRange === 'W'
614
- ? 'week'
615
- : this.selectedRange === 'M'
616
- ? 'month'
617
- : this.selectedRange === 'Y'
618
- ? 'year'
619
- : this.selectedRange === ''
620
- ? this.getNavigationLabel('next')
621
- : 'period'}"
652
+ title=${this.getNavigationTitle('next')}
653
+ aria-label=${this.getNavigationTitle('next')}
622
654
  >
623
-
655
+ <temba-icon name=${Icon.arrow_right}></temba-icon>
624
656
  </button>
625
657
  </div>
626
658
  </div>
@@ -1615,6 +1615,12 @@ export class ContentList<T = any> extends RapidElement {
1615
1615
  super();
1616
1616
  }
1617
1617
 
1618
+ /** Gives specialized lists a chance to normalize a fetched page before it
1619
+ * becomes visible. The default keeps the server response unchanged. */
1620
+ protected prepareItems(items: T[]): T[] {
1621
+ return items;
1622
+ }
1623
+
1618
1624
  protected willUpdate(changes: PropertyValues): void {
1619
1625
  super.willUpdate(changes);
1620
1626
  if (
@@ -2021,7 +2027,7 @@ export class ContentList<T = any> extends RapidElement {
2021
2027
  // empty results) with an `error` message — surface it over the
2022
2028
  // empty table rather than the plain empty-state copy.
2023
2029
  this.searchError = typeof data.error === 'string' ? data.error : '';
2024
- this.items = data.results || [];
2030
+ this.items = this.prepareItems(data.results || []);
2025
2031
  this.nextCursor = data.next ? this.toRequestUrl(data.next) : '';
2026
2032
  this.prevCursor = data.previous ? this.toRequestUrl(data.previous) : '';
2027
2033
  // Cursor mode is detected from the shape of next/previous,
@@ -2,6 +2,8 @@ import { css, html, TemplateResult } from 'lit';
2
2
  import { ContentList, ContentListColumn } from './ContentList';
3
3
  import { Icon } from '../Icons';
4
4
  import { CustomEventType, Flow, ObjectReference } from '../interfaces';
5
+ import { getStore, StoreAsset, StoreAssetChangedEvent } from '../store/Store';
6
+ import { RealtimeSubscription } from '../live/Realtime';
5
7
 
6
8
  /**
7
9
  * Flow CRUDL list — drop-in replacement for the rapidpro
@@ -11,6 +13,8 @@ import { CustomEventType, Flow, ObjectReference } from '../interfaces';
11
13
  * bar, activity sparkline.
12
14
  */
13
15
  export class FlowList extends ContentList<Flow> {
16
+ private assetWatch: RealtimeSubscription = null;
17
+
14
18
  static get styles() {
15
19
  return css`
16
20
  ${ContentList.styles}
@@ -149,6 +153,84 @@ export class FlowList extends ContentList<Flow> {
149
153
  ];
150
154
  }
151
155
 
156
+ public connectedCallback(): void {
157
+ super.connectedCallback();
158
+ // rows we already have (if any) - each load re-syncs from prepareItems
159
+ this.syncAssetWatch();
160
+ }
161
+
162
+ public disconnectedCallback(): void {
163
+ super.disconnectedCallback();
164
+ if (this.assetWatch) {
165
+ this.assetWatch.unsubscribe();
166
+ this.assetWatch = null;
167
+ }
168
+ }
169
+
170
+ private syncAssetWatch(): void {
171
+ if (!this.isConnected) {
172
+ return;
173
+ }
174
+ const store = getStore();
175
+ if (!store) {
176
+ return;
177
+ }
178
+ if (this.assetWatch) {
179
+ this.assetWatch.unsubscribe();
180
+ }
181
+ this.assetWatch = store.watchAssets(
182
+ (this.items || []).map((flow) => ({ type: 'flow', uuid: flow.uuid })),
183
+ (event: StoreAssetChangedEvent | null) => this.syncFlowNames(event?.asset)
184
+ );
185
+ }
186
+
187
+ private syncFlowNames(changed?: StoreAsset): void {
188
+ const items = this.withCanonicalFlowNames(this.items, changed);
189
+ if (items !== this.items) {
190
+ this.items = items;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * A freshly fetched page is authoritative, so it seeds the store cache
196
+ * rather than being overwritten by it - a cached name can predate this
197
+ * response (e.g. a rename missed while another page was on screen), and
198
+ * writing it back would make the stale name self-reinforcing. Cached names
199
+ * only ever move rows through syncFlowNames, driven by socket changes and
200
+ * the reconnect refresh, which are the paths that are actually newer.
201
+ */
202
+ protected prepareItems(items: Flow[]): Flow[] {
203
+ getStore()?.cacheAssets(
204
+ items
205
+ .filter((item) => !!item.uuid && typeof item.name === 'string')
206
+ .map((item) => ({
207
+ type: 'flow',
208
+ uuid: item.uuid,
209
+ name: item.name
210
+ }))
211
+ );
212
+ Promise.resolve().then(() => this.syncAssetWatch());
213
+ return items;
214
+ }
215
+
216
+ private withCanonicalFlowNames(items: Flow[], changed?: StoreAsset): Flow[] {
217
+ const store = getStore();
218
+ let updated = false;
219
+ const canonical = items.map((item) => {
220
+ const asset = changed
221
+ ? changed.uuid === item.uuid
222
+ ? changed
223
+ : null
224
+ : store?.getAsset('flow', item.uuid);
225
+ if (asset && asset.name !== item.name) {
226
+ updated = true;
227
+ return { ...item, name: asset.name };
228
+ }
229
+ return item;
230
+ });
231
+ return updated ? canonical : items;
232
+ }
233
+
152
234
  protected getRowIcon(item: Flow): string | null {
153
235
  switch (item?.type) {
154
236
  case 'voice':
@@ -115,6 +115,28 @@ export const subscribeToNotifications = (
115
115
  );
116
116
  };
117
117
 
118
+ /**
119
+ * Workspace-wide state changes shared by every component on the page.
120
+ */
121
+ export const subscribeToOrganization = (
122
+ onEvent: (event: any) => void,
123
+ onSubscribed?: () => void
124
+ ): RealtimeSubscription => {
125
+ return subscribeWhenReady((ctx) => `org:${ctx.org}`, onEvent, onSubscribed);
126
+ };
127
+
128
+ /**
129
+ * Realtime events for a flow open in the editor. Needs no page context
130
+ * because the flow UUID uniquely identifies the authorized channel.
131
+ */
132
+ export const subscribeToFlow = (
133
+ flow: string,
134
+ onEvent: (event: any) => void,
135
+ onSubscribed?: () => void
136
+ ): RealtimeSubscription => {
137
+ return subscribeToSocket(`flow:${flow}`, onEvent, onSubscribed);
138
+ };
139
+
118
140
  /**
119
141
  * A contact's history events, or a ticket's detail events when a ticket is
120
142
  * given. Needs no page context so subscribes immediately.
@@ -15,10 +15,60 @@ import { immer } from 'zustand/middleware/immer';
15
15
  import { subscribeWithSelector } from 'zustand/middleware';
16
16
  import { property } from 'lit/decorators.js';
17
17
  import { produce } from 'immer';
18
+ import {
19
+ FlowDependency,
20
+ replaceDependencies,
21
+ resolveDependencyNames
22
+ } from '../flow/dependencies';
18
23
 
19
24
  export const FLOW_SPEC_VERSION = '14.3';
20
25
  const CANVAS_PADDING = 800;
21
26
 
27
+ // how long a revision load will wait on canonical names before rendering the
28
+ // names embedded in the definition, which the editor heals asynchronously
29
+ const DEPENDENCY_RESOLVE_TIMEOUT = 3000;
30
+
31
+ export type DependencyResolver = (
32
+ dependencies: FlowDependency[]
33
+ ) => Promise<FlowDependency[]>;
34
+
35
+ let dependencyResolver: DependencyResolver | null = null;
36
+
37
+ /** Installs the page-level canonical-name resolver and returns the previous
38
+ * resolver so a disconnected store can restore it in tests. */
39
+ export const setDependencyResolver = (
40
+ resolver: DependencyResolver | null
41
+ ): DependencyResolver | null => {
42
+ const previous = dependencyResolver;
43
+ dependencyResolver = resolver;
44
+ return previous;
45
+ };
46
+
47
+ /** The currently installed resolver, so a store can tell whether it is still
48
+ * the owner before restoring the one it replaced. */
49
+ export const getDependencyResolver = (): DependencyResolver | null =>
50
+ dependencyResolver;
51
+
52
+ /** Resolves with null if the given promise hasn't settled in time. */
53
+ const withTimeout = async <T>(
54
+ promise: Promise<T>,
55
+ timeout: number
56
+ ): Promise<T | null> => {
57
+ let timer: any = null;
58
+ try {
59
+ return await Promise.race([
60
+ promise,
61
+ new Promise<null>((resolve) => {
62
+ timer = setTimeout(() => resolve(null), timeout);
63
+ })
64
+ ]);
65
+ } finally {
66
+ if (timer !== null) {
67
+ clearTimeout(timer);
68
+ }
69
+ }
70
+ };
71
+
22
72
  /**
23
73
  * Temporary: Reclassify nodes based on whether they contain terminal actions.
24
74
  * - execute_actions nodes with a terminal action become "terminal"
@@ -105,15 +155,6 @@ export interface InfoResult {
105
155
  node_uuids: string[];
106
156
  }
107
157
 
108
- export interface ObjectRef {
109
- uuid: string;
110
- name: string;
111
- }
112
-
113
- export interface TypedObjectRef extends ObjectRef {
114
- type: string;
115
- }
116
-
117
158
  export interface Language {
118
159
  code: string;
119
160
  name: string;
@@ -133,7 +174,7 @@ export interface FlowIssue {
133
174
 
134
175
  export interface FlowInfo {
135
176
  results: InfoResult[];
136
- dependencies: TypedObjectRef[];
177
+ dependencies: FlowDependency[];
137
178
  counts: { nodes: number; languages: number };
138
179
  locals: string[];
139
180
  issues?: FlowIssue[];
@@ -220,6 +261,7 @@ export interface AppState {
220
261
 
221
262
  setFlowContents: (flow: FlowContents) => void;
222
263
  setFlowInfo: (info: FlowInfo) => void;
264
+ updateDependencyNames: (dependencies: FlowDependency[]) => void;
223
265
  setRevision: (revision: number) => void;
224
266
  setLanguageCode: (languageCode: string) => void;
225
267
  setDirtyDate: (date: Date) => void;
@@ -305,6 +347,31 @@ export const zustand = createStore<AppState>()(
305
347
  throw new Error('Network response was not ok');
306
348
  }
307
349
  const data = (await response.json()) as FlowContents;
350
+ if (dependencyResolver && data.info?.dependencies?.length) {
351
+ try {
352
+ // resolving before the first paint avoids a visible flash of stale
353
+ // names, but a slow endpoint must never hold the editor hostage -
354
+ // past the timeout we render and let the editor's watcher heal it
355
+ const canonical = await withTimeout(
356
+ dependencyResolver(data.info.dependencies),
357
+ DEPENDENCY_RESOLVE_TIMEOUT
358
+ );
359
+ const dependencies = canonical
360
+ ? replaceDependencies(data.info.dependencies, canonical)
361
+ : null;
362
+ if (dependencies) {
363
+ data.info = { ...data.info, dependencies };
364
+ data.definition = resolveDependencyNames(
365
+ data.definition,
366
+ canonical
367
+ );
368
+ }
369
+ } catch (error) {
370
+ // A name lookup shouldn't make an otherwise valid flow unusable.
371
+ // Keep its embedded names and let a later socket/reconnect heal it.
372
+ console.error('failed to resolve flow dependency names', error);
373
+ }
374
+ }
308
375
  reclassifyTerminalNodes(data.definition);
309
376
  reclassifyVoiceWaitNodes(data.definition);
310
377
  const issueMaps = buildIssueMaps(data.info?.issues);
@@ -438,6 +505,26 @@ export const zustand = createStore<AppState>()(
438
505
  });
439
506
  },
440
507
 
508
+ updateDependencyNames: (changed: FlowDependency[]) => {
509
+ set((state: AppState) => {
510
+ if (!state.flowInfo || !state.flowDefinition) {
511
+ return;
512
+ }
513
+ const dependencies = replaceDependencies(
514
+ state.flowInfo.dependencies,
515
+ changed
516
+ );
517
+ if (!dependencies) {
518
+ return;
519
+ }
520
+ state.flowInfo.dependencies = dependencies;
521
+ state.flowDefinition = resolveDependencyNames(
522
+ state.flowDefinition,
523
+ changed
524
+ );
525
+ });
526
+ },
527
+
441
528
  setRevision: (revision: number) => {
442
529
  set((state: AppState) => {
443
530
  state.flowDefinition.revision = revision;