@nyaruka/temba-components 0.158.1 → 0.159.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.158.1",
3
+ "version": "0.159.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",
@@ -14,7 +14,6 @@
14
14
  "build": "rimraf dist && pnpm svg && tsc && rollup -c rollup.components.mjs ",
15
15
  "build-wc": "rollup -c rollup.webchat.mjs",
16
16
  "dev": "pnpm build && cp -R ./dist/* ../temba/rapidpro/node_modules/@nyaruka/temba-components/dist/ && cp -R ./dist/* ../floweditor/node_modules/@nyaruka/temba-components/dist/",
17
- "postversion": "git push --tags && git push origin main",
18
17
  "pre-commit": "pnpm locale:extract && pnpm locale:build && pnpm svg && git add ./src/Icons.ts ./static/svg/index.svg ./xliff ./src/locales && lint-staged",
19
18
  "format:eslint": "eslint --ext .ts . --fix --ignore-path .gitignore --ignore-pattern \"src/locales/\"",
20
19
  "format:prettier": "prettier \"**/*.js\" \"**/*.ts\" \"!src/locales/**\" --config .prettierrc --write --ignore-path .gitignore",
package/src/Icons.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-duplicate-enum-values */
2
2
  // for cache busting we dynamically generate a fingerprint, use pnpm svg to update
3
- export const SVG_FINGERPRINT = '07fb603bff2d16af992a047dd03ab268';
3
+ export const SVG_FINGERPRINT = 'ed78045b1cf74b0b9242822c54badf99';
4
4
 
5
5
  // only icons below are included in the sprite sheet
6
6
  export enum Icon {
@@ -58,6 +58,7 @@ export enum Icon {
58
58
  delete = 'trash-03',
59
59
  delete_small = 'x',
60
60
  down = 'chevron-down',
61
+ down_double = 'chevron-down-double',
61
62
  download = 'download-01',
62
63
  drag = 'dots-grid',
63
64
  edit = 'edit-02',
@@ -135,6 +136,11 @@ export enum Icon {
135
136
  reset = 'flip-backward',
136
137
  resthooks = 'share-07',
137
138
  restore = 'play',
139
+ /** Restoring archived messages — moves them back to the inbox, so
140
+ * the inbox tray reads better here than the generic `restore`
141
+ * (which is `play` because flows/triggers/campaigns use it to mean
142
+ * "reactivate"). */
143
+ restore_messages = 'inbox-01',
138
144
  results_export = 'download-cloud-01',
139
145
  retry = 'refresh-cw-05',
140
146
  revisions = 'clock-rewind',
@@ -189,6 +195,7 @@ export enum Icon {
189
195
  triggers = 'signal-01',
190
196
  updated = 'edit-02',
191
197
  up = 'chevron-up',
198
+ up_double = 'chevron-up-double',
192
199
  upload = 'upload-cloud-01',
193
200
  upload_image = 'camera-01',
194
201
  usages = 'link-04',
@@ -10,6 +10,13 @@ export class Button extends LitElement {
10
10
  display: inline-flex;
11
11
  align-self: stretch;
12
12
  font-family: var(--font);
13
+ /* Match the styleguide's .ds Inter rendering — the stylistic
14
+ sets (ss01/cv11) and the "tnum 0" off-switch — so glyph
15
+ shapes line up with the design-system buttons. */
16
+ font-feature-settings:
17
+ 'ss01',
18
+ 'cv11',
19
+ 'tnum' 0;
13
20
  }
14
21
 
15
22
  /* DS .btn-sm — sizing, type, transition, shape */
@@ -58,34 +65,38 @@ export class Button extends LitElement {
58
65
  font-size: 12px;
59
66
  }
60
67
 
61
- /* DS .btn-primary — solid accent fill with a slightly darker
62
- 1px border so the visible box matches the secondary's
63
- 1px-bordered box. */
68
+ /* DS .btn-primary — solid accent fill with a faint top
69
+ highlight and a ground shadow so it sits a touch above the
70
+ surface. The base 1px transparent border stays in the box
71
+ model so the primary's footprint matches the secondary's. */
64
72
  .primary-button {
65
73
  background: var(--accent-600);
66
- border-color: var(--accent-700);
67
74
  color: #fff;
75
+ box-shadow:
76
+ inset 0 1px 0 rgba(255, 255, 255, 0.18),
77
+ 0 1px 1px rgba(15, 22, 36, 0.1);
68
78
  }
69
79
  .primary-button:hover {
70
80
  background: var(--accent-700);
71
81
  }
72
82
 
73
- /* DS .btn-secondary — surface bg with a 1px gray outline. */
83
+ /* Ghost-style secondary — transparent fill so it blends into
84
+ any surface (the dialog gutter's grey, a white card, etc.),
85
+ no border, with a semi-transparent wash on hover that shows
86
+ up regardless of the underlying colour. */
74
87
  .secondary-button {
75
- background: var(--surface);
76
- border-color: var(--border-strong);
88
+ background: transparent;
77
89
  color: var(--text-1);
78
90
  }
79
91
  .secondary-button:hover {
80
- background: var(--sunken);
92
+ background: rgba(0, 0, 0, 0.05);
81
93
  }
82
94
 
83
- /* affirmative + attention share DS .btn-primary chrome but tint
84
- green; treat them as solid CTAs. */
95
+ /* Flat success fill mirrors the DS .btn-danger chrome (no
96
+ lift), tinted green for go-ahead CTAs. */
85
97
  .attention-button,
86
98
  .affirmative {
87
99
  background: var(--success, #16a34a);
88
- border-color: color-mix(in srgb, var(--success, #16a34a) 80%, black);
89
100
  color: #fff;
90
101
  }
91
102
  .attention-button:hover,
@@ -93,10 +104,9 @@ export class Button extends LitElement {
93
104
  background: color-mix(in srgb, var(--success, #16a34a) 88%, black);
94
105
  }
95
106
 
96
- /* DS .btn-danger */
107
+ /* DS .btn-danger — flat danger fill (no lift). */
97
108
  .destructive-button {
98
109
  background: var(--danger, #d03f3f);
99
- border-color: color-mix(in srgb, var(--danger, #d03f3f) 80%, black);
100
110
  color: #fff;
101
111
  }
102
112
  .destructive-button:hover {
@@ -136,7 +146,7 @@ export class Button extends LitElement {
136
146
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
137
147
  }
138
148
  .secondary-button.active-button {
139
- background: var(--sunken);
149
+ background: rgba(0, 0, 0, 0.08);
140
150
  }
141
151
 
142
152
  /* disabled */
@@ -428,7 +428,7 @@ export class Thumbnail extends RapidElement {
428
428
  alt="Location preview"
429
429
  />`
430
430
  : html`<div
431
- style="padding:1em; background:rgba(0,0,0,.05);border-radius:var(--curvature);"
431
+ style="padding:var(--thumb-icon-padding, 1em); background:rgba(0,0,0,.05);border-radius:var(--curvature);"
432
432
  >
433
433
  <temba-icon
434
434
  size="1.5"
@@ -87,10 +87,10 @@ export const split_by_resthook: NodeConfig = {
87
87
  const existingCases = originalNode.router?.cases || [];
88
88
 
89
89
  const { router, exits } = createSuccessFailureRouter(
90
- '@webhook.json.status',
90
+ '@webhook.status',
91
91
  {
92
- type: 'has_text',
93
- arguments: []
92
+ type: 'has_number_between',
93
+ arguments: ['200', '299']
94
94
  },
95
95
  existingCategories,
96
96
  existingExits,
package/src/interfaces.ts CHANGED
@@ -36,7 +36,8 @@ export enum DateStyle {
36
36
  export enum ScheduledEventType {
37
37
  CampaignEvent = 'campaign_event',
38
38
  ScheduledBroadcast = 'scheduled_broadcast',
39
- ScheduledTrigger = 'scheduled_trigger'
39
+ ScheduledTrigger = 'scheduled_trigger',
40
+ SentBroadcast = 'sent_broadcast'
40
41
  }
41
42
 
42
43
  export enum TicketStatus {
@@ -100,15 +101,53 @@ export interface FlowDetails {
100
101
  };
101
102
  }
102
103
 
104
+ /** A single row in the flow CRUDL list (`flows/flow_list.html`).
105
+ * Distinct from {@link FlowDetails}, which is the per-flow editor
106
+ * payload — this is the lighter list-row shape. */
107
+ export interface Flow {
108
+ uuid: string;
109
+ name: string;
110
+ /** Flow type — drives the leading row icon. */
111
+ type: 'messaging' | 'voice' | 'ivr' | 'background' | 'survey' | string;
112
+ runs: number;
113
+ ongoing: number;
114
+ /** Completion ratio in the range 0–1. */
115
+ completion: number;
116
+ has_issues: boolean;
117
+ status: 'active' | 'archived' | string;
118
+ labels: ObjectReference[];
119
+ /** Recent run counts, oldest to newest — rendered as a sparkline. */
120
+ activity: number[];
121
+ }
122
+
103
123
  export interface Msg {
124
+ /** Numeric id — present on persisted messages (the CRUDL list
125
+ * keys rows off it); absent on outbound drafts. */
126
+ id?: number;
104
127
  text: string;
105
128
  status: string;
106
129
  channel: ObjectReference;
107
130
  quick_replies: string[];
108
131
  urn: string;
132
+ /** The message's contact — populated by the messages CRUDL
133
+ * endpoint. Carries whichever of name/urn the endpoint exposes. */
134
+ contact?: { uuid?: string; name?: string; urn?: string };
109
135
  direction: string;
110
136
  type: string;
137
+ /** Message type as exposed by the messages CRUDL endpoint
138
+ * (`text` / `optin` / …); mirrors `type` for that surface. */
139
+ msg_type?: string;
111
140
  attachments: string[];
141
+ /** Labels applied to the message. */
142
+ labels?: ObjectReference[];
143
+ /** The flow the message was sent from, when there is one. */
144
+ flow?: ObjectReference;
145
+ /** When the message was created. */
146
+ created_on?: string;
147
+ /** Permission- and retention-gated URL to this message's channel
148
+ * log. Present on the CRUDL list when the viewer can read logs and
149
+ * the message is within the retention window; absent otherwise. */
150
+ logs_url?: string;
112
151
  unsendable_reason?:
113
152
  | 'no_route'
114
153
  | 'contact_blocked'
@@ -326,5 +365,10 @@ export enum CustomEventType {
326
365
  RevisionsClosed = 'temba-revisions-closed',
327
366
  RowClick = 'temba-row-click',
328
367
  SelectionChange = 'temba-selection-change',
329
- BulkAction = 'temba-bulk-action'
368
+ BulkAction = 'temba-bulk-action',
369
+ /** A restorable piece of component state changed (page, sort, …).
370
+ * Fired by lists so the host SPA can persist the state into the
371
+ * browser's history entry (typically via `history.replaceState`).
372
+ * Detail: `{ key: string, state: object }`. */
373
+ HistoryChange = 'temba-history-change'
330
374
  }
@@ -0,0 +1,338 @@
1
+ import { css, html, PropertyValues, TemplateResult } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { RapidElement } from '../RapidElement';
4
+ import { CustomEventType } from '../interfaces';
5
+ import { getUrl } from '../utils';
6
+ import { designTokens } from '../styles/designTokens';
7
+ import { ContentMenuItem, ContentMenuItemType } from '../list/ContentMenu';
8
+
9
+ /** Request headers that ask the server for the page's content menu
10
+ * payload (`{ items: ContentMenuItem[] }`) — the rapidpro contract
11
+ * the standalone `temba-content-menu` also speaks. */
12
+ const MENU_HEADERS = {
13
+ 'X-Temba-Content-Menu': '1',
14
+ 'X-Temba-Spa': '1'
15
+ };
16
+
17
+ /**
18
+ * Page header bar — a title (and optional subtitle) on the left and
19
+ * the page's content menu (action buttons + an overflow `⋮`) on the
20
+ * right, styled from the design tokens.
21
+ *
22
+ * Reusable on its own for any page that has a content menu, and
23
+ * embedded by {@link ContentList} as the list's header so a list
24
+ * page and a plain page share one header treatment. The menu is
25
+ * fetched from {@link contentMenuEndpoint}; clicking an item fires
26
+ * `temba-selection` (with the item + click origin) for the host to
27
+ * act on, mirroring `temba-content-menu`.
28
+ *
29
+ * The `actions` slot renders between the title and the content menu
30
+ * — list components slot their search / bulk-action controls there.
31
+ */
32
+ export class PageHeader extends RapidElement {
33
+ static get styles() {
34
+ return css`
35
+ ${designTokens}
36
+
37
+ :host {
38
+ display: block;
39
+ font-family: var(--font);
40
+ /* Match the styleguide's .ds Inter rendering — the stylistic
41
+ sets (ss01/cv11) and the "tnum 0" off-switch — so glyph
42
+ shapes line up with the design-system buttons. */
43
+ font-feature-settings:
44
+ 'ss01',
45
+ 'cv11',
46
+ 'tnum' 0;
47
+ }
48
+
49
+ /* Title on the left, actions + content menu on the right. */
50
+ .header {
51
+ display: flex;
52
+ align-items: flex-start;
53
+ gap: var(--gap);
54
+ padding: 20px 0 16px 0;
55
+ }
56
+ .titles {
57
+ flex: 1 1 auto;
58
+ min-width: 0;
59
+ }
60
+ .title {
61
+ font-size: 15.5px;
62
+ font-weight: var(--w-semibold);
63
+ color: var(--text-1);
64
+ line-height: 1.3;
65
+ }
66
+ .subtitle {
67
+ font-size: 12.5px;
68
+ color: var(--text-3);
69
+ line-height: 1.3;
70
+ margin-top: 1px;
71
+ }
72
+ .actions {
73
+ flex: 0 0 auto;
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 10px;
77
+ color: var(--text-2);
78
+ font-size: 13px;
79
+ }
80
+
81
+ /* Content-menu action buttons — match the design system's
82
+ .btn.btn-sm / .btn-primary / .btn-secondary so the inbox CTA
83
+ reads identically to the styleguide. 28px tall, regular
84
+ weight, 12.5px font, the same transitions and faint top
85
+ highlight + ground-shadow on the primary fill. */
86
+ .menu-button {
87
+ display: inline-flex;
88
+ align-items: center;
89
+ gap: 6px;
90
+ /* border-box so height: 28px includes the 1px border the
91
+ same way .ds * does in the styleguide — otherwise the
92
+ border adds 2px and the button computes 2px taller. */
93
+ box-sizing: border-box;
94
+ height: 28px;
95
+ padding: 0 10px;
96
+ border: 1px solid var(--border-strong);
97
+ border-radius: var(--r-sm);
98
+ font-size: 12.5px;
99
+ font-weight: var(--w-regular);
100
+ letter-spacing: -0.005em;
101
+ cursor: pointer;
102
+ user-select: none;
103
+ background: var(--surface);
104
+ color: var(--text-1);
105
+ white-space: nowrap;
106
+ transition:
107
+ background 120ms,
108
+ border-color 120ms,
109
+ color 120ms,
110
+ box-shadow 120ms;
111
+ }
112
+ .menu-button:hover {
113
+ background: var(--sunken);
114
+ }
115
+ .menu-button.primary {
116
+ background: var(--accent-600);
117
+ border-color: transparent;
118
+ color: white;
119
+ box-shadow:
120
+ inset 0 1px 0 rgba(255, 255, 255, 0.18),
121
+ 0 1px 1px rgba(15, 22, 36, 0.1);
122
+ }
123
+ .menu-button.primary:hover {
124
+ background: var(--accent-700);
125
+ }
126
+ .menu-button[disabled] {
127
+ opacity: 0.45;
128
+ cursor: not-allowed;
129
+ }
130
+
131
+ /* Overflow toggle — the trailing ⋮ that opens the rest of the
132
+ menu items. Plain icon button, matches the list's Search. */
133
+ .menu-toggle {
134
+ display: inline-flex;
135
+ align-items: center;
136
+ cursor: pointer;
137
+ user-select: none;
138
+ padding: 6px;
139
+ border-radius: var(--r-sm);
140
+ color: var(--text-2);
141
+ --icon-color: currentColor;
142
+ }
143
+ .menu-toggle:hover {
144
+ background: var(--sunken);
145
+ color: var(--text-1);
146
+ }
147
+
148
+ /* Overflow dropdown — a column of menu items + dividers. */
149
+ .menu-list {
150
+ min-width: 200px;
151
+ padding: 6px 4px;
152
+ font-size: 13px;
153
+ }
154
+ .menu-item {
155
+ padding: 6px 12px;
156
+ border-radius: var(--r-sm);
157
+ cursor: pointer;
158
+ color: var(--text-1);
159
+ white-space: nowrap;
160
+ }
161
+ .menu-item:hover {
162
+ background: var(--accent-50);
163
+ color: var(--accent-800);
164
+ }
165
+ .menu-divider {
166
+ height: 1px;
167
+ background: var(--border);
168
+ margin: 6px 8px;
169
+ }
170
+ `;
171
+ }
172
+
173
+ /** Header title. A `title` slot overrides it for rich content. */
174
+ @property({ type: String, attribute: 'header-title' })
175
+ headerTitle = '';
176
+
177
+ /** Smaller subtitle under the title. A `subtitle` slot overrides. */
178
+ @property({ type: String })
179
+ subtitle = '';
180
+
181
+ /** GET endpoint for the page's content menu. The response shape is
182
+ * `{ items: ContentMenuItem[] }` (rapidpro's content-menu view). */
183
+ @property({ type: String, attribute: 'content-menu-endpoint' })
184
+ contentMenuEndpoint = '';
185
+
186
+ /** When true, the content menu (buttons + overflow) is not drawn —
187
+ * the host sets this while it's in a selection / bulk-action mode
188
+ * where the page menu isn't relevant. The menu data is kept, so
189
+ * clearing the flag restores the menu without a re-fetch. */
190
+ @property({ type: Boolean, attribute: 'hide-menu' })
191
+ hideMenu = false;
192
+
193
+ /** Buttons split out of the fetched menu (`as_button` items). */
194
+ @state()
195
+ private buttons: ContentMenuItem[] = [];
196
+
197
+ /** Remaining menu items, shown under the overflow `⋮`. */
198
+ @state()
199
+ private items: ContentMenuItem[] = [];
200
+
201
+ private pendingMenu: AbortController = null;
202
+
203
+ public disconnectedCallback(): void {
204
+ if (this.pendingMenu) {
205
+ this.pendingMenu.abort();
206
+ }
207
+ super.disconnectedCallback();
208
+ }
209
+
210
+ protected updated(changes: PropertyValues): void {
211
+ super.updated(changes);
212
+ if (changes.has('contentMenuEndpoint')) {
213
+ this.fetchContentMenu();
214
+ }
215
+ }
216
+
217
+ /** Public API — re-pull the content menu (e.g. after an action
218
+ * changes which items are available). */
219
+ public refresh(): void {
220
+ this.fetchContentMenu();
221
+ }
222
+
223
+ private async fetchContentMenu(): Promise<void> {
224
+ if (!this.contentMenuEndpoint) {
225
+ this.buttons = [];
226
+ this.items = [];
227
+ return;
228
+ }
229
+ if (this.pendingMenu) this.pendingMenu.abort();
230
+ const controller = new AbortController();
231
+ this.pendingMenu = controller;
232
+ try {
233
+ const response = await getUrl(
234
+ this.contentMenuEndpoint,
235
+ controller,
236
+ MENU_HEADERS
237
+ );
238
+ // Staleness guard — if another fetch superseded this one
239
+ // between the request firing and the response arriving, drop
240
+ // the result so the newer fetch wins (mirrors the pattern in
241
+ // ContactList.loadFields).
242
+ if (this.pendingMenu !== controller) return;
243
+ const menu = (response.json?.items as ContentMenuItem[]) || [];
244
+ this.buttons = menu.filter((item) => item.as_button);
245
+ this.items = menu.filter((item) => !item.as_button);
246
+ this.fireCustomEvent(CustomEventType.Loaded, {
247
+ buttons: this.buttons,
248
+ items: this.items
249
+ });
250
+ } catch (err) {
251
+ if ((err as DOMException)?.name !== 'AbortError') {
252
+ // eslint-disable-next-line no-console
253
+ console.error('content menu fetch failed', err);
254
+ }
255
+ } finally {
256
+ if (this.pendingMenu === controller) {
257
+ this.pendingMenu = null;
258
+ }
259
+ }
260
+ }
261
+
262
+ /** Fire `temba-selection` with the clicked item and the click
263
+ * origin — same payload the standalone content menu emits, so the
264
+ * host's existing menu handling keeps working. */
265
+ private handleItemClicked(item: ContentMenuItem, event: MouseEvent): void {
266
+ if (item.disabled) return;
267
+ const el = event.currentTarget as Element;
268
+ const rect = el?.getBoundingClientRect();
269
+ this.fireCustomEvent(CustomEventType.Selection, {
270
+ item,
271
+ event,
272
+ originX: rect ? rect.left + rect.width / 2 : event.clientX,
273
+ originY: rect ? rect.top : event.clientY
274
+ });
275
+ }
276
+
277
+ private renderContentMenu(): TemplateResult {
278
+ if (this.hideMenu) return html``;
279
+ return html`
280
+ ${this.buttons.map(
281
+ (button) => html`
282
+ <div
283
+ class="menu-button ${button.primary ? 'primary' : ''}"
284
+ ?disabled=${button.disabled}
285
+ @click=${(e: MouseEvent) => this.handleItemClicked(button, e)}
286
+ >
287
+ ${button.label}
288
+ </div>
289
+ `
290
+ )}
291
+ ${this.items.length > 0
292
+ ? html`
293
+ <temba-dropdown>
294
+ <div slot="toggle" class="menu-toggle">
295
+ <temba-icon name="menu" size="1.2"></temba-icon>
296
+ </div>
297
+ <div slot="dropdown" class="menu-list">
298
+ ${this.items.map((item) =>
299
+ item.type === ContentMenuItemType.DIVIDER
300
+ ? html`<div class="menu-divider"></div>`
301
+ : html`<div
302
+ class="menu-item"
303
+ @click=${(e: MouseEvent) =>
304
+ this.handleItemClicked(item, e)}
305
+ >
306
+ ${item.label}
307
+ </div>`
308
+ )}
309
+ </div>
310
+ </temba-dropdown>
311
+ `
312
+ : null}
313
+ `;
314
+ }
315
+
316
+ public render(): TemplateResult {
317
+ const hasSubtitle =
318
+ this.subtitle || this.querySelector('[slot="subtitle"]');
319
+ return html`
320
+ <div class="header">
321
+ <div class="titles">
322
+ <div class="title">
323
+ <slot name="title">${this.headerTitle}</slot>
324
+ </div>
325
+ ${hasSubtitle
326
+ ? html`<div class="subtitle">
327
+ <slot name="subtitle">${this.subtitle}</slot>
328
+ </div>`
329
+ : null}
330
+ </div>
331
+ <div class="actions">
332
+ <slot name="actions"></slot>
333
+ ${this.renderContentMenu()}
334
+ </div>
335
+ </div>
336
+ `;
337
+ }
338
+ }