@nyaruka/temba-components 0.159.5 → 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/CHANGELOG.md +8 -0
- package/dist/static/svg/index.svg +1 -1
- package/dist/temba-components.js +66 -18
- package/dist/temba-components.js.map +1 -1
- package/package.json +1 -1
- package/src/Icons.ts +3 -1
- package/src/flow/Editor.ts +201 -1
- package/src/flow/EditorToolbar.ts +24 -0
- package/src/interfaces.ts +4 -0
- package/src/list/ContentList.ts +75 -4
- package/src/live/ContactFields.ts +35 -5
- package/static/svg/index.svg +1 -1
- package/static/svg/work/traced/shuffle-01.svg +1 -0
- package/static/svg/work/traced/switch-horizontal-01.svg +1 -0
- package/static/svg/work/used/shuffle-01.svg +3 -0
- package/static/svg/work/used/switch-horizontal-01.svg +3 -0
package/package.json
CHANGED
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 = '
|
|
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',
|
package/src/flow/Editor.ts
CHANGED
|
@@ -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
|
@@ -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`).
|
package/src/list/ContentList.ts
CHANGED
|
@@ -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;
|
|
@@ -1795,8 +1828,11 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
1795
1828
|
|
|
1796
1829
|
/** Public API — programmatic refresh, mirrors `refreshKey` bump.
|
|
1797
1830
|
* Re-requests the current page (cursor lists included) rather than
|
|
1798
|
-
* 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. */
|
|
1799
1834
|
public refresh(): void {
|
|
1835
|
+
this.labelsByActionKey = {};
|
|
1800
1836
|
this.fetchPage(this.currentUrl || undefined);
|
|
1801
1837
|
}
|
|
1802
1838
|
|
|
@@ -2125,7 +2161,10 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2125
2161
|
}
|
|
2126
2162
|
|
|
2127
2163
|
private renderLabelDropdown(action: ContentListBulkAction): TemplateResult {
|
|
2128
|
-
|
|
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];
|
|
2129
2168
|
return html`
|
|
2130
2169
|
<temba-dropdown
|
|
2131
2170
|
class="label-dropdown"
|
|
@@ -2142,14 +2181,38 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2142
2181
|
<span class="bulk-label">${action.label}</span>
|
|
2143
2182
|
</span>
|
|
2144
2183
|
<div slot="dropdown" class="label-menu">
|
|
2145
|
-
${labels
|
|
2184
|
+
${!labels
|
|
2146
2185
|
? html`<div class="label-menu-empty">Loading…</div>`
|
|
2147
|
-
: labels.
|
|
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…
|
|
2197
|
+
</div>`
|
|
2198
|
+
: null}
|
|
2148
2199
|
</div>
|
|
2149
2200
|
</temba-dropdown>
|
|
2150
2201
|
`;
|
|
2151
2202
|
}
|
|
2152
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
|
+
|
|
2153
2216
|
private renderLabelOption(
|
|
2154
2217
|
label: any,
|
|
2155
2218
|
action: ContentListBulkAction
|
|
@@ -2196,6 +2259,14 @@ export class ContentList<T = any> extends RapidElement {
|
|
|
2196
2259
|
[action.key]: labels
|
|
2197
2260
|
};
|
|
2198
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
|
+
};
|
|
2199
2270
|
// eslint-disable-next-line no-console
|
|
2200
2271
|
console.error('failed to fetch labels', err);
|
|
2201
2272
|
}
|
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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>`;
|