@nyaruka/temba-components 0.159.4 → 0.159.6

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.159.4",
3
+ "version": "0.159.6",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
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 = 'ed78045b1cf74b0b9242822c54badf99';
3
+ export const SVG_FINGERPRINT = '45268f1573f1e352c7d62232863725bb';
4
4
 
5
5
  // only icons below are included in the sprite sheet
6
6
  export enum Icon {
@@ -98,8 +98,10 @@ export enum Icon {
98
98
  issue = 'alert-square',
99
99
  label = 'tag-01',
100
100
  language = 'translate-01',
101
+ language_default = 'switch-horizontal-01',
101
102
  link = 'link-external-01',
102
103
  location = 'marker-pin-01',
104
+ shuffle = 'shuffle-01',
103
105
  log = 'file-02',
104
106
  logout = 'log-out-04',
105
107
  menu = 'menu-01',
@@ -97,7 +97,8 @@ export type ToolbarAction =
97
97
  | { action: 'revisions' }
98
98
  | { action: 'search' }
99
99
  | { action: 'language-change'; isPrimary?: boolean; languageCode?: string }
100
- | { action: 'auto-translate' };
100
+ | { action: 'auto-translate' }
101
+ | { action: 'make-default-language' };
101
102
  const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
102
103
 
103
104
  // How long the pending-changes auto-save countdown runs (in ms).
@@ -1707,6 +1708,191 @@ export class Editor extends RapidElement {
1707
1708
  zustand.getState().setLanguageCode(languageCode);
1708
1709
  }
1709
1710
 
1711
+ private buildMakeDefaultBody(
1712
+ newLanguage: string,
1713
+ currentLanguage: string
1714
+ ): HTMLElement {
1715
+ // Build with textContent rather than innerHTML: language names fall back to
1716
+ // the raw, server-derived language code when Intl/ADDITIONAL_LANGUAGE_NAMES
1717
+ // can't resolve them, so they must never be interpolated into markup.
1718
+ const body = document.createElement('div');
1719
+ body.style.cssText = 'padding: 20px; line-height: 1.5;';
1720
+ const newName = document.createElement('b');
1721
+ newName.textContent = newLanguage;
1722
+ const currentName = document.createElement('b');
1723
+ currentName.textContent = currentLanguage;
1724
+ body.append(
1725
+ 'Make ',
1726
+ newName,
1727
+ ' the default language for this flow? ',
1728
+ currentName,
1729
+ ' will be kept as a translation.'
1730
+ );
1731
+ return body;
1732
+ }
1733
+
1734
+ private showMakeDefaultLanguageConfirmation(): void {
1735
+ if (this.viewingRevision) {
1736
+ return;
1737
+ }
1738
+
1739
+ const languageCode = this.languageCode;
1740
+ const baseLanguage = this.definition?.language;
1741
+ if (!languageCode || !baseLanguage || languageCode === baseLanguage) {
1742
+ return;
1743
+ }
1744
+
1745
+ // don't stack a second confirmation if one is already open
1746
+ if (document.body.querySelector('temba-dialog[data-make-default]')) {
1747
+ return;
1748
+ }
1749
+
1750
+ const newLanguageName = getLanguageName(languageCode);
1751
+ const currentLanguageName = getLanguageName(baseLanguage);
1752
+
1753
+ const dialog = document.createElement('temba-dialog') as Dialog;
1754
+ dialog.setAttribute('data-make-default', '');
1755
+ dialog.header = 'Change Default Language';
1756
+ dialog.primaryButtonName = 'Update';
1757
+ dialog.cancelButtonName = 'Cancel';
1758
+ dialog.submittingName = 'Updating';
1759
+ dialog.appendChild(
1760
+ this.buildMakeDefaultBody(newLanguageName, currentLanguageName)
1761
+ );
1762
+
1763
+ // closes the dialog and removes its node once the close animation finishes.
1764
+ // The dialog never fires temba-dialog-hidden (hideOnClick is not set), so we
1765
+ // must drive removal explicitly rather than relying on that event.
1766
+ const cleanup = () => {
1767
+ // drop the marker first so a fresh confirmation can be opened immediately,
1768
+ // even though the node lingers briefly for the close animation
1769
+ dialog.removeAttribute('data-make-default');
1770
+ dialog.open = false;
1771
+ window.setTimeout(() => dialog.remove(), 500);
1772
+ };
1773
+
1774
+ // created lazily on first error and reused across retries so server-
1775
+ // controlled text is only ever set via textContent, never parsed as HTML.
1776
+ let errorNode: HTMLElement | null = null;
1777
+ const clearError = () => {
1778
+ if (errorNode) {
1779
+ errorNode.remove();
1780
+ errorNode = null;
1781
+ }
1782
+ };
1783
+
1784
+ let submitting = false;
1785
+ dialog.addEventListener('temba-button-clicked', async (event: any) => {
1786
+ // Cancel/Escape route through this same event; tear the dialog down.
1787
+ // Ignore cancel while a swap is in flight — the request can't be recalled,
1788
+ // so closing here would let the change land after an apparent cancel.
1789
+ if (event.detail.button.name !== 'Update') {
1790
+ if (submitting) {
1791
+ return;
1792
+ }
1793
+ cleanup();
1794
+ return;
1795
+ }
1796
+
1797
+ // guard against double-submit: the primary button stays clickable while
1798
+ // the request is in flight, so ignore re-entrant clicks.
1799
+ if (submitting) {
1800
+ return;
1801
+ }
1802
+ submitting = true;
1803
+ dialog.submitting = true;
1804
+ dialog.disabled = true;
1805
+ // Drop the Cancel affordance while the request is in flight. A
1806
+ // `closes:true` button (Cancel, and Escape which clicks it) bypasses
1807
+ // `dialog.disabled`, so leaving it would let the user close the dialog
1808
+ // even though the POST can't be recalled — the swap would land after an
1809
+ // apparent cancel. Removing the name un-renders the button entirely.
1810
+ dialog.cancelButtonName = '';
1811
+ const { error, flushFailed } =
1812
+ await this.changeDefaultLanguage(languageCode);
1813
+ submitting = false;
1814
+ dialog.submitting = false;
1815
+ dialog.disabled = false;
1816
+ dialog.cancelButtonName = 'Cancel';
1817
+
1818
+ if (flushFailed) {
1819
+ // flushSave already surfaced its own "Save Failed" dialog via the
1820
+ // reactive saveError path; close this one rather than stacking a
1821
+ // duplicate message on top of it.
1822
+ cleanup();
1823
+ return;
1824
+ }
1825
+
1826
+ if (error) {
1827
+ // keep the dialog open so the user can retry or cancel
1828
+ if (!errorNode) {
1829
+ errorNode = document.createElement('div');
1830
+ errorNode.style.cssText =
1831
+ 'padding: 0 20px 16px; color: var(--color-error, #e34f4f);';
1832
+ dialog.appendChild(errorNode);
1833
+ }
1834
+ errorNode.textContent = error;
1835
+ return;
1836
+ }
1837
+
1838
+ clearError();
1839
+ cleanup();
1840
+ });
1841
+
1842
+ document.body.appendChild(dialog);
1843
+ dialog.open = true;
1844
+ }
1845
+
1846
+ /**
1847
+ * Asks the backend to promote a fully-translated language to be the flow's
1848
+ * base language, then reloads the swapped definition. Resolves to null on
1849
+ * success or an error message string on failure.
1850
+ */
1851
+ private async changeDefaultLanguage(
1852
+ languageCode: string
1853
+ ): Promise<{ error: string | null; flushFailed: boolean }> {
1854
+ // persist any pending edits so mailroom swaps the latest saved definition
1855
+ await this.flushSave();
1856
+
1857
+ // A successful save clears dirtyDate; saveChanges leaves it in place on
1858
+ // failure. So if it's still set the flush did not land — abort rather than
1859
+ // letting mailroom swap a stale definition and silently drop the latest
1860
+ // edits. dirtyDate alone is the signal: the failed save also sets saveError,
1861
+ // which surfaces the standard "Save Failed" dialog via updated(), so the
1862
+ // caller closes its own dialog instead of duplicating that message.
1863
+ if (this.dirtyDate) {
1864
+ return { error: null, flushFailed: true };
1865
+ }
1866
+
1867
+ try {
1868
+ const response = await getStore().postJSON(
1869
+ `/flow/change_language/${this.flow}/`,
1870
+ { language: languageCode }
1871
+ );
1872
+
1873
+ if (response.status < 200 || response.status >= 300) {
1874
+ return {
1875
+ error: this.extractErrorMessage(response),
1876
+ flushFailed: false
1877
+ };
1878
+ }
1879
+
1880
+ // reload the swapped definition; fetchRevision resets the editing
1881
+ // language to the new base language
1882
+ await getStore().getState().fetchRevision(`/flow/revisions/${this.flow}`);
1883
+
1884
+ // refresh the revisions list if it's currently open
1885
+ this.getRevisionsWindow()?.refresh();
1886
+ return { error: null, flushFailed: false };
1887
+ } catch (error) {
1888
+ console.error('Failed to change default language:', error);
1889
+ return {
1890
+ error: 'Unable to change the default language. Please try again.',
1891
+ flushFailed: false
1892
+ };
1893
+ }
1894
+ }
1895
+
1710
1896
  private getAvailableLanguages(): Array<{ code: string; name: string }> {
1711
1897
  // Use languages from workspace if available
1712
1898
  if (this.workspace?.languages && this.workspace.languages.length > 0) {
@@ -3694,6 +3880,16 @@ export class Editor extends RapidElement {
3694
3880
  const hasPendingTranslations =
3695
3881
  Boolean(activeLanguage) && progress.total > 0;
3696
3882
 
3883
+ // a fully-translated language can be promoted to be the flow's base
3884
+ // language (not while viewing a historical revision). Gate on the raw
3885
+ // counts rather than the rounded percent — e.g. 199/200 rounds to 100%
3886
+ // but is not actually complete, and the server does no completeness check.
3887
+ const canMakeDefault =
3888
+ Boolean(activeLanguage) &&
3889
+ progress.total > 0 &&
3890
+ progress.localized === progress.total &&
3891
+ !this.viewingRevision;
3892
+
3697
3893
  return html`
3698
3894
  <temba-editor-toolbar
3699
3895
  ?message-view=${this.showMessageTable}
@@ -3709,6 +3905,7 @@ export class Editor extends RapidElement {
3709
3905
  ?is-base-language=${isBaseSelected}
3710
3906
  .languagePercent=${percent}
3711
3907
  ?show-localization-tools=${Boolean(activeLanguage)}
3908
+ ?can-make-default=${canMakeDefault}
3712
3909
  ?has-pending-translations=${hasPendingTranslations ||
3713
3910
  this.autoTranslating}
3714
3911
  ?auto-translate-disabled=${this.viewingRevision}
@@ -3751,6 +3948,9 @@ export class Editor extends RapidElement {
3751
3948
  case 'auto-translate':
3752
3949
  this.handleAutoTranslateClick();
3753
3950
  break;
3951
+ case 'make-default-language':
3952
+ this.showMakeDefaultLanguageConfirmation();
3953
+ break;
3754
3954
  case 'language-change':
3755
3955
  if (detail.isPrimary) {
3756
3956
  this.handleLanguageChange(this.definition?.language || '');
@@ -192,6 +192,11 @@ export class EditorToolbar extends RapidElement {
192
192
  color: #1a7f37;
193
193
  }
194
194
 
195
+ .toolbar-btn.make-default {
196
+ width: var(--toolbar-translation-control-height);
197
+ height: var(--toolbar-translation-control-height);
198
+ }
199
+
195
200
  .toolbar-zoom-level {
196
201
  font-size: 12px;
197
202
  min-width: 40px;
@@ -249,6 +254,9 @@ export class EditorToolbar extends RapidElement {
249
254
  @property({ type: Boolean, attribute: 'show-localization-tools' })
250
255
  showLocalizationTools = false;
251
256
 
257
+ @property({ type: Boolean, attribute: 'can-make-default' })
258
+ canMakeDefault = false;
259
+
252
260
  @property({ type: Boolean, attribute: 'has-pending-translations' })
253
261
  hasPendingTranslations = false;
254
262
 
@@ -457,6 +465,7 @@ export class EditorToolbar extends RapidElement {
457
465
  min-width="230"
458
466
  ></temba-options>
459
467
  </div>
468
+ ${this.canMakeDefault ? this.renderMakeDefaultButton() : ''}
460
469
  ${this.showLocalizationTools
461
470
  ? this.renderTranslationTools()
462
471
  : ''}
@@ -567,6 +576,21 @@ export class EditorToolbar extends RapidElement {
567
576
  `;
568
577
  }
569
578
 
579
+ private renderMakeDefaultButton(): TemplateResult {
580
+ return this.renderTip(
581
+ `Make ${this.currentLanguageName} the default`,
582
+ html`
583
+ <button
584
+ class="toolbar-btn language-tool make-default"
585
+ @click=${() => this.fireToolbarAction('make-default-language')}
586
+ aria-label="Make default language"
587
+ >
588
+ <temba-icon name=${Icon.language_default} size="1"></temba-icon>
589
+ </button>
590
+ `
591
+ );
592
+ }
593
+
570
594
  private renderTranslationTools(): TemplateResult {
571
595
  if (!this.hasPendingTranslations && !this.autoTranslating) {
572
596
  return html``;
package/src/interfaces.ts CHANGED
@@ -114,9 +114,9 @@ export interface Flow {
114
114
  /** Completion ratio in the range 0–1. */
115
115
  completion: number;
116
116
  has_issues: boolean;
117
- status: 'active' | 'archived' | string;
118
117
  labels: ObjectReference[];
119
- /** Recent run counts, oldest to newest — rendered as a sparkline. */
118
+ /** Recent daily engagement counts, oldest to newest — rendered as
119
+ * a sparkline. Empty when there's been no recent engagement. */
120
120
  activity: number[];
121
121
  }
122
122
 
@@ -367,6 +367,10 @@ export enum CustomEventType {
367
367
  RowClick = 'temba-row-click',
368
368
  SelectionChange = 'temba-selection-change',
369
369
  BulkAction = 'temba-bulk-action',
370
+ /** The label dropdown's "New Label…" row was clicked — detail
371
+ * carries the action key and the selected row ids so the host can
372
+ * open its create modal seeded with the selection. */
373
+ LabelCreate = 'temba-label-create',
370
374
  /** A restorable piece of component state changed (page, sort, …).
371
375
  * Fired by lists so the host SPA can persist the state into the
372
376
  * browser's history entry (typically via `history.replaceState`).
@@ -73,6 +73,12 @@ export interface ContentListBulkAction {
73
73
  * selected rows. Defaults to `labels` (messages); the contact list
74
74
  * sets it to `groups`. */
75
75
  labelsKey?: string;
76
+ /** When true (and `labelsEndpoint` is set), the dropdown gets a
77
+ * trailing "New Label…" row that fires `temba-label-create` with
78
+ * the selected ids — the host opens its create modal seeded with
79
+ * them. Sent by the server only when the viewer holds the create
80
+ * permission. */
81
+ allowCreate?: boolean;
76
82
  /** When true, the component does not POST the action to
77
83
  * `actionEndpoint` — it only fires the `temba-bulk-action` event
78
84
  * with the selected ids and leaves the work to the host. Used for
@@ -308,6 +314,33 @@ export class ContentList<T = any> extends RapidElement {
308
314
  color: var(--text-3);
309
315
  font-size: 12.5px;
310
316
  }
317
+ /* "New Label…" creation row — separated from the toggle rows
318
+ above it (mirrors the legacy dropdown's add-label row). */
319
+ .lbl-create {
320
+ border-top: 1px solid var(--border-1);
321
+ margin-top: 4px;
322
+ padding: 10px 12px 6px;
323
+ cursor: pointer;
324
+ color: var(--text-2);
325
+ border-radius: var(--r-sm);
326
+ }
327
+ .lbl-create:hover {
328
+ background: var(--accent-50);
329
+ color: var(--text-1);
330
+ }
331
+ /* Blocked while a label-toggle POST is in flight, matching the
332
+ treatment of the label rows above. */
333
+ .lbl-create.blocked,
334
+ .lbl-create.blocked:hover {
335
+ cursor: not-allowed;
336
+ background: transparent;
337
+ color: var(--text-3);
338
+ }
339
+ .lbl-create:first-child {
340
+ border-top: none;
341
+ margin-top: 0;
342
+ padding-top: 6px;
343
+ }
311
344
  .lbl-menu {
312
345
  display: flex;
313
346
  align-items: center;
@@ -918,14 +951,12 @@ export class ContentList<T = any> extends RapidElement {
918
951
  color: var(--accent-700);
919
952
  }
920
953
 
921
- /* Leading entity-type icon — a small icon shared by every row
922
- (contact silhouette, flow type icon, etc.). It rides inside the
954
+ /* Leading entity-type icon — a small icon flagging a row (contact
955
+ silhouette, voice/background flow, etc.). It rides inside the
923
956
  first column's cell rather than in its own column, so the column
924
- header aligns with the icon (the row's leading content) rather
925
- than the value beside it and the alignment reads the same
926
- whether or not the list has a leading icon. Subclasses override
927
- {@link getRowIcon}; when it returns null for every row no space
928
- is reserved (see {@link reservesIcon}). */
957
+ header aligns with the row's leading content. Subclasses
958
+ override {@link getRowIcon}; a row whose icon is null renders
959
+ its value flush at the column edge with no reserved gutter. */
929
960
  .lead-wrap {
930
961
  display: flex;
931
962
  align-items: center;
@@ -934,12 +965,12 @@ export class ContentList<T = any> extends RapidElement {
934
965
  .lead-wrap .cell-inner {
935
966
  min-width: 0;
936
967
  }
937
- /* Reserve the icon's 1em footprint plus a snug 5px gap to the
938
- value whether or not this row has an icon, so values stay
939
- aligned down the column. The fixed box also keeps the column's
940
- intrinsic width stable while <temba-icon> upgrades without it
941
- the column briefly measures narrow and downstream pinned columns
942
- jump, which races with whatever moment we snapshot. */
968
+ /* The icon's 1em footprint plus a snug 5px gap to the value.
969
+ The fixed box keeps the column's intrinsic width stable while
970
+ <temba-icon> upgrades without it the column briefly measures
971
+ narrow and downstream pinned columns jump, which races with
972
+ whatever moment we snapshot. Rows without an icon don't render
973
+ this box at all their value sits flush at the column edge. */
943
974
  .lead-icon {
944
975
  flex: 0 0 auto;
945
976
  display: flex;
@@ -1797,8 +1828,11 @@ export class ContentList<T = any> extends RapidElement {
1797
1828
 
1798
1829
  /** Public API — programmatic refresh, mirrors `refreshKey` bump.
1799
1830
  * Re-requests the current page (cursor lists included) rather than
1800
- * resetting to the first. */
1831
+ * resetting to the first. Also drops the cached label-dropdown
1832
+ * lists — a refresh often follows label/group creation, and the
1833
+ * next dropdown open should see the new entry. */
1801
1834
  public refresh(): void {
1835
+ this.labelsByActionKey = {};
1802
1836
  this.fetchPage(this.currentUrl || undefined);
1803
1837
  }
1804
1838
 
@@ -2127,7 +2161,10 @@ export class ContentList<T = any> extends RapidElement {
2127
2161
  }
2128
2162
 
2129
2163
  private renderLabelDropdown(action: ContentListBulkAction): TemplateResult {
2130
- const labels = this.labelsByActionKey[action.key] || [];
2164
+ // Undefined means the list hasn't been fetched yet (the dropdown
2165
+ // fetches lazily on first open); an empty array is a real
2166
+ // "no labels exist" state.
2167
+ const labels = this.labelsByActionKey[action.key];
2131
2168
  return html`
2132
2169
  <temba-dropdown
2133
2170
  class="label-dropdown"
@@ -2144,14 +2181,38 @@ export class ContentList<T = any> extends RapidElement {
2144
2181
  <span class="bulk-label">${action.label}</span>
2145
2182
  </span>
2146
2183
  <div slot="dropdown" class="label-menu">
2147
- ${labels.length === 0
2184
+ ${!labels
2148
2185
  ? html`<div class="label-menu-empty">Loading&hellip;</div>`
2149
- : labels.map((label) => this.renderLabelOption(label, action))}
2186
+ : labels.length === 0 && !action.allowCreate
2187
+ ? html`<div class="label-menu-empty">No labels</div>`
2188
+ : labels.map((label) => this.renderLabelOption(label, action))}
2189
+ ${labels && action.allowCreate
2190
+ ? html`<div
2191
+ class="lbl-create ${this.pendingLabel !== null
2192
+ ? 'blocked'
2193
+ : ''}"
2194
+ @click=${() => this.handleLabelCreate(action)}
2195
+ >
2196
+ New Label&hellip;
2197
+ </div>`
2198
+ : null}
2150
2199
  </div>
2151
2200
  </temba-dropdown>
2152
2201
  `;
2153
2202
  }
2154
2203
 
2204
+ /** The dropdown's "New Label…" row — fire the event with the
2205
+ * current selection and let the click bubble so the dropdown
2206
+ * closes; the host opens its create modal seeded with the ids.
2207
+ * Blocked while a toggle POST is in flight, like the label rows. */
2208
+ private handleLabelCreate(action: ContentListBulkAction): void {
2209
+ if (this.pendingLabel !== null) return;
2210
+ this.fireCustomEvent(CustomEventType.LabelCreate, {
2211
+ action: action.key,
2212
+ ids: Array.from(this.selectedIds)
2213
+ });
2214
+ }
2215
+
2155
2216
  private renderLabelOption(
2156
2217
  label: any,
2157
2218
  action: ContentListBulkAction
@@ -2198,6 +2259,14 @@ export class ContentList<T = any> extends RapidElement {
2198
2259
  [action.key]: labels
2199
2260
  };
2200
2261
  } catch (err) {
2262
+ // Mark the list as fetched-empty rather than leaving it
2263
+ // undefined — otherwise the dropdown shows "Loading…" until
2264
+ // it's closed and reopened. A later refresh() clears the cache
2265
+ // so a retry still happens.
2266
+ this.labelsByActionKey = {
2267
+ ...this.labelsByActionKey,
2268
+ [action.key]: []
2269
+ };
2201
2270
  // eslint-disable-next-line no-console
2202
2271
  console.error('failed to fetch labels', err);
2203
2272
  }
@@ -2380,11 +2449,14 @@ export class ContentList<T = any> extends RapidElement {
2380
2449
  * cells pin alongside them so identity stays anchored); right-
2381
2450
  * pinned columns contiguous to the last. */
2382
2451
  private computePinLayout(): void {
2383
- // Reserve an empty leading-icon column when any row would carry
2384
- // an icon probe a representative row, then skip the icon
2385
- // per-row if that row's own getRowIcon returns null.
2386
- this.reservesIcon =
2387
- this.items.length > 0 && this.getRowIcon(this.items[0]) !== null;
2452
+ // Whether any row on this page carries a leading icon — probe
2453
+ // every row (a page can lead with icon-less rows, e.g. message
2454
+ // flows). Rows render their icon inline (no gutter on icon-less
2455
+ // rows); this flag just marks the first column's cells as
2456
+ // lead-cells for alignment lookups.
2457
+ this.reservesIcon = this.items.some(
2458
+ (item) => this.getRowIcon(item) !== null
2459
+ );
2388
2460
  this.pinIndexByColumn = new Map();
2389
2461
  this.rightPinIndexByColumn = new Map();
2390
2462
  this.checkPinIndex = -1;
@@ -2728,8 +2800,9 @@ export class ContentList<T = any> extends RapidElement {
2728
2800
  `;
2729
2801
  // The first column carries the row's leading icon (when the list
2730
2802
  // reserves one), so its header aligns with the icon rather than the
2731
- // value. A row with no icon still reserves the space to keep values
2732
- // aligned down the column.
2803
+ // value. A row with no icon renders its value flush at the column
2804
+ // edge the icon is an inline flag on the rows that have one (e.g.
2805
+ // voice flows), not a gutter every row indents around.
2733
2806
  const lead = isLead && this.reservesIcon;
2734
2807
  const icon = lead ? this.getRowIcon(item) : null;
2735
2808
  return html`
@@ -2738,12 +2811,10 @@ export class ContentList<T = any> extends RapidElement {
2738
2811
  ''} ${column.grow ? 'grow' : ''} ${this.columnPinClass(column)}"
2739
2812
  style=${this.columnPinStyle(column)}
2740
2813
  >
2741
- ${lead
2814
+ ${icon
2742
2815
  ? html`<div class="lead-wrap">
2743
2816
  <span class="lead-icon"
2744
- >${icon
2745
- ? html`<temba-icon name=${icon} size="1"></temba-icon>`
2746
- : null}</span
2817
+ ><temba-icon name=${icon} size="1"></temba-icon></span
2747
2818
  >${inner}
2748
2819
  </div>`
2749
2820
  : inner}
@@ -5,10 +5,10 @@ import { Flow, ObjectReference } from '../interfaces';
5
5
 
6
6
  /**
7
7
  * Flow CRUDL list — drop-in replacement for the rapidpro
8
- * `flows/flow_list.html` table. Each row's leading icon reflects
9
- * the flow type (messaging / voice / background / surveyor).
10
- * Columns: name, status, runs, ongoing, completion bar, activity
11
- * sparkline.
8
+ * `flows/flow_list.html` table. Rows of the unusual flow types
9
+ * (voice / background / surveyor) carry a leading type icon;
10
+ * message flows don't. Columns: name, runs, ongoing, completion
11
+ * bar, activity sparkline.
12
12
  */
13
13
  export class FlowList extends ContentList<Flow> {
14
14
  static get styles() {
@@ -92,7 +92,6 @@ export class FlowList extends ContentList<Flow> {
92
92
  width: '280px',
93
93
  pinned: true
94
94
  },
95
- { key: 'status', label: 'Status', width: '110px' },
96
95
  {
97
96
  key: 'runs',
98
97
  label: 'Runs',
@@ -137,7 +136,9 @@ export class FlowList extends ContentList<Flow> {
137
136
  case 'survey':
138
137
  return Icon.flow_surveyor;
139
138
  default:
140
- return Icon.flow_message;
139
+ // message flows are the common case and carry no icon — only
140
+ // the unusual types get flagged, matching the legacy table
141
+ return null;
141
142
  }
142
143
  }
143
144
 
@@ -183,8 +184,6 @@ export class FlowList extends ContentList<Flow> {
183
184
  return this.renderCompletion(item);
184
185
  case 'activity':
185
186
  return this.renderSparkline(item.activity || []);
186
- case 'status':
187
- return this.renderFlowStatus(item);
188
187
  default:
189
188
  return super.renderCell(item, column);
190
189
  }
@@ -235,11 +234,4 @@ export class FlowList extends ContentList<Flow> {
235
234
  </svg>
236
235
  `;
237
236
  }
238
-
239
- private renderFlowStatus(item: Flow): TemplateResult {
240
- const status = (item.status || 'active').toString().toLowerCase();
241
- const kind = status === 'archived' ? 'archived' : 'active';
242
- const label = status === 'archived' ? 'Archived' : 'Active';
243
- return this.renderStatusPill(kind, label);
244
- }
245
237
  }
@@ -94,9 +94,32 @@ export class ContactFields extends ContactStoreElement {
94
94
  @property({ type: Boolean })
95
95
  disabled = false;
96
96
 
97
+ // tracks whether the store's contact field definitions have loaded. these
98
+ // load independently of the contact data this element fetches, so we can't
99
+ // render field rows until they're present (see render). this is reactive so
100
+ // that flipping it re-renders once the definitions arrive.
101
+ @property({ type: Boolean, attribute: false })
102
+ fieldsReady = false;
103
+
97
104
  connectedCallback(): void {
98
105
  super.connectedCallback();
99
106
  this.handleFieldChanged = this.handleFieldChanged.bind(this);
107
+
108
+ // field definitions are usually already loaded by the time this element
109
+ // connects, but during a cold load they may still be in flight
110
+ this.fieldsReady =
111
+ !!this.store &&
112
+ (this.store.ready ||
113
+ (this.store.getKeyedAssets()['fields'] || []).length > 0);
114
+ }
115
+
116
+ protected storeUpdated(event: CustomEvent) {
117
+ super.storeUpdated(event);
118
+ // the store fires a StoreUpdated for the fields endpoint once the field
119
+ // definitions have loaded; that's our signal that it's safe to render
120
+ if (this.store && event.detail.url === this.store.fieldsEndpoint) {
121
+ this.fieldsReady = true;
122
+ }
100
123
  }
101
124
 
102
125
  private isAgent(): boolean {
@@ -148,9 +171,17 @@ export class ContactFields extends ContactStoreElement {
148
171
  }
149
172
 
150
173
  public render(): TemplateResult {
151
- if (this.data) {
152
- const fieldsToShow = Object.entries(this.data.fields).sort(
153
- (a: [string, string], b: [string, string]) => {
174
+ // we need both the contact data and the field definitions (loaded during
175
+ // store init) before we can render. these load independently, so the
176
+ // contact data can arrive first; rendering then would crash on missing
177
+ // field definitions and leave the tab empty (see getContactField below).
178
+ if (this.data && this.fieldsReady) {
179
+ // guard against keys with no matching field definition (mid-load races
180
+ // or fields that have since been deleted) so a single missing
181
+ // definition can't throw and blank out the entire tab
182
+ const fieldsToShow = Object.entries(this.data.fields)
183
+ .filter(([key]: [string, string]) => !!this.store.getContactField(key))
184
+ .sort((a: [string, string], b: [string, string]) => {
154
185
  const [ak] = a;
155
186
  const [bk] = b;
156
187
  const fieldA = this.store.getContactField(ak);
@@ -212,8 +243,7 @@ export class ContactFields extends ContactStoreElement {
212
243
  }
213
244
 
214
245
  return ak.localeCompare(bk);
215
- }
216
- );
246
+ });
217
247
 
218
248
  if (fieldsToShow.length == 0) {
219
249
  return html`<slot name="empty"></slot>`;