@nyaruka/temba-components 0.121.7 → 0.123.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/.github/copilot-instructions.md +163 -0
- package/.github/workflows/build.yml +3 -3
- package/.github/workflows/cla.yml +6 -6
- package/.github/workflows/copilot-setup-steps.yml +86 -0
- package/CHANGELOG.md +41 -0
- package/demo/index.html +61 -12
- package/dist/locales/es.js +1 -0
- package/dist/locales/es.js.map +1 -1
- package/dist/locales/fr.js +1 -0
- package/dist/locales/fr.js.map +1 -1
- package/dist/locales/pt.js +1 -0
- package/dist/locales/pt.js.map +1 -1
- package/dist/temba-components.js +555 -465
- package/dist/temba-components.js.map +1 -1
- package/out-tsc/src/chart/TembaChart.js +377 -0
- package/out-tsc/src/chart/TembaChart.js.map +1 -0
- package/out-tsc/src/list/RunList.js +13 -8
- package/out-tsc/src/list/RunList.js.map +1 -1
- package/out-tsc/src/locales/es.js +1 -0
- package/out-tsc/src/locales/es.js.map +1 -1
- package/out-tsc/src/locales/fr.js +1 -0
- package/out-tsc/src/locales/fr.js.map +1 -1
- package/out-tsc/src/locales/pt.js +1 -0
- package/out-tsc/src/locales/pt.js.map +1 -1
- package/out-tsc/src/options/Options.js +37 -13
- package/out-tsc/src/options/Options.js.map +1 -1
- package/out-tsc/src/select/Select.js +28 -5
- package/out-tsc/src/select/Select.js.map +1 -1
- package/out-tsc/src/store/AppState.js +3 -3
- package/out-tsc/src/store/AppState.js.map +1 -1
- package/out-tsc/src/utils/index.js +6 -1
- package/out-tsc/src/utils/index.js.map +1 -1
- package/out-tsc/src/vectoricon/VectorIcon.js +2 -1
- package/out-tsc/src/vectoricon/VectorIcon.js.map +1 -1
- package/out-tsc/temba-modules.js +2 -2
- package/out-tsc/temba-modules.js.map +1 -1
- package/out-tsc/test/temba-appstate-language.test.js +176 -0
- package/out-tsc/test/temba-appstate-language.test.js.map +1 -0
- package/out-tsc/test/temba-chart.test.js +171 -0
- package/out-tsc/test/temba-chart.test.js.map +1 -0
- package/out-tsc/test/temba-dropdown.test.js +317 -0
- package/out-tsc/test/temba-dropdown.test.js.map +1 -0
- package/out-tsc/test/temba-run-list.test.js +588 -0
- package/out-tsc/test/temba-run-list.test.js.map +1 -0
- package/out-tsc/test/temba-select.test.js +16 -0
- package/out-tsc/test/temba-select.test.js.map +1 -1
- package/out-tsc/test/temba-toast.test.js +299 -0
- package/out-tsc/test/temba-toast.test.js.map +1 -0
- package/out-tsc/test/temba-utils-index.test.js +1178 -0
- package/out-tsc/test/temba-utils-index.test.js.map +1 -0
- package/out-tsc/test/temba-webchat.test.js +816 -0
- package/out-tsc/test/temba-webchat.test.js.map +1 -0
- package/out-tsc/test/utils.test.js +3 -1
- package/out-tsc/test/utils.test.js.map +1 -1
- package/package.json +8 -8
- package/screenshots/truth/alert/error.png +0 -0
- package/screenshots/truth/alert/info.png +0 -0
- package/screenshots/truth/alert/warning.png +0 -0
- package/screenshots/truth/checkbox/checkbox-label-background-hover.png +0 -0
- package/screenshots/truth/checkbox/checked.png +0 -0
- package/screenshots/truth/checkbox/default.png +0 -0
- package/screenshots/truth/colorpicker/default.png +0 -0
- package/screenshots/truth/colorpicker/focused.png +0 -0
- package/screenshots/truth/colorpicker/initialized.png +0 -0
- package/screenshots/truth/colorpicker/selected.png +0 -0
- package/screenshots/truth/compose/attachments-tab.png +0 -0
- package/screenshots/truth/compose/attachments-with-files-focused.png +0 -0
- package/screenshots/truth/compose/attachments-with-files.png +0 -0
- package/screenshots/truth/compose/intial-text.png +0 -0
- package/screenshots/truth/compose/no-counter.png +0 -0
- package/screenshots/truth/compose/wraps-text-and-spaces.png +0 -0
- package/screenshots/truth/compose/wraps-text-and-url.png +0 -0
- package/screenshots/truth/compose/wraps-text-no-spaces.png +0 -0
- package/screenshots/truth/contacts/badges.png +0 -0
- package/screenshots/truth/contacts/chat-failure.png +0 -0
- package/screenshots/truth/contacts/chat-for-active-contact.png +0 -0
- package/screenshots/truth/contacts/chat-for-archived-contact.png +0 -0
- package/screenshots/truth/contacts/chat-for-blocked-contact.png +0 -0
- package/screenshots/truth/contacts/chat-for-stopped-contact.png +0 -0
- package/screenshots/truth/contacts/chat-sends-attachments-only.png +0 -0
- package/screenshots/truth/contacts/chat-sends-text-and-attachments.png +0 -0
- package/screenshots/truth/contacts/chat-sends-text-only.png +0 -0
- package/screenshots/truth/content-menu/button-no-items.png +0 -0
- package/screenshots/truth/content-menu/items-and-buttons.png +0 -0
- package/screenshots/truth/counter/summary.png +0 -0
- package/screenshots/truth/counter/text.png +0 -0
- package/screenshots/truth/counter/unicode-variables.png +0 -0
- package/screenshots/truth/counter/unicode.png +0 -0
- package/screenshots/truth/counter/variable.png +0 -0
- package/screenshots/truth/date/date-inline.png +0 -0
- package/screenshots/truth/date/date.png +0 -0
- package/screenshots/truth/date/datetime.png +0 -0
- package/screenshots/truth/date/duration.png +0 -0
- package/screenshots/truth/date/timedate.png +0 -0
- package/screenshots/truth/datepicker/date-truncated-time.png +0 -0
- package/screenshots/truth/datepicker/date.png +0 -0
- package/screenshots/truth/datepicker/initial-timezone.png +0 -0
- package/screenshots/truth/datepicker/updated-keyboard-date.png +0 -0
- package/screenshots/truth/dialog/focused.png +0 -0
- package/screenshots/truth/dropdown/after-blur.png +0 -0
- package/screenshots/truth/dropdown/bottom-edge-collision.png +0 -0
- package/screenshots/truth/dropdown/custom-arrow-size.png +0 -0
- package/screenshots/truth/dropdown/default.png +0 -0
- package/screenshots/truth/dropdown/narrow-toggle.png +0 -0
- package/screenshots/truth/dropdown/no-mask.png +0 -0
- package/screenshots/truth/dropdown/opened.png +0 -0
- package/screenshots/truth/dropdown/positioned.png +0 -0
- package/screenshots/truth/dropdown/right-edge-collision.png +0 -0
- package/screenshots/truth/dropdown/with-mask.png +0 -0
- package/screenshots/truth/label/custom.png +0 -0
- package/screenshots/truth/label/danger.png +0 -0
- package/screenshots/truth/label/dark.png +0 -0
- package/screenshots/truth/label/default-icon.png +0 -0
- package/screenshots/truth/label/no-icon.png +0 -0
- package/screenshots/truth/label/primary.png +0 -0
- package/screenshots/truth/label/secondary.png +0 -0
- package/screenshots/truth/label/shadow.png +0 -0
- package/screenshots/truth/label/tertiary.png +0 -0
- package/screenshots/truth/lightbox/img-zoomed.png +0 -0
- package/screenshots/truth/list/fields-dragging.png +0 -0
- package/screenshots/truth/list/fields-filtered.png +0 -0
- package/screenshots/truth/list/fields-hovered.png +0 -0
- package/screenshots/truth/list/fields.png +0 -0
- package/screenshots/truth/list/items-selected.png +0 -0
- package/screenshots/truth/list/items-updated.png +0 -0
- package/screenshots/truth/list/items.png +0 -0
- package/screenshots/truth/list/sortable-dragging.png +0 -0
- package/screenshots/truth/list/sortable-dropped.png +0 -0
- package/screenshots/truth/list/sortable.png +0 -0
- package/screenshots/truth/menu/menu-focused-with items.png +0 -0
- package/screenshots/truth/menu/menu-refresh-1.png +0 -0
- package/screenshots/truth/menu/menu-refresh-2.png +0 -0
- package/screenshots/truth/menu/menu-root.png +0 -0
- package/screenshots/truth/menu/menu-submenu.png +0 -0
- package/screenshots/truth/menu/menu-tasks-nextup.png +0 -0
- package/screenshots/truth/menu/menu-tasks.png +0 -0
- package/screenshots/truth/modax/form.png +0 -0
- package/screenshots/truth/modax/simple.png +0 -0
- package/screenshots/truth/omnibox/selected.png +0 -0
- package/screenshots/truth/options/block.png +0 -0
- package/screenshots/truth/run-list/basic.png +0 -0
- package/screenshots/truth/select/disabled-multi-selection.png +0 -0
- package/screenshots/truth/select/disabled-selection.png +0 -0
- package/screenshots/truth/select/disabled.png +0 -0
- package/screenshots/truth/select/embedded.png +0 -0
- package/screenshots/truth/select/empty-options.png +0 -0
- package/screenshots/truth/select/expression-selected.png +0 -0
- package/screenshots/truth/select/expressions.png +0 -0
- package/screenshots/truth/select/functions.png +0 -0
- package/screenshots/truth/select/local-options.png +0 -0
- package/screenshots/truth/select/multi-with-endpoint.png +0 -0
- package/screenshots/truth/select/multiple-initial-values.png +0 -0
- package/screenshots/truth/select/remote-options.png +0 -0
- package/screenshots/truth/select/search-enabled.png +0 -0
- package/screenshots/truth/select/search-multi-no-matches.png +0 -0
- package/screenshots/truth/select/search-selected-focus.png +0 -0
- package/screenshots/truth/select/search-selected.png +0 -0
- package/screenshots/truth/select/search-with-selected.png +0 -0
- package/screenshots/truth/select/searching.png +0 -0
- package/screenshots/truth/select/selected-multi-maxitems-reached.png +0 -0
- package/screenshots/truth/select/selected-multi.png +0 -0
- package/screenshots/truth/select/selected-single.png +0 -0
- package/screenshots/truth/select/selection-clearable.png +0 -0
- package/screenshots/truth/select/static-initial-value.png +0 -0
- package/screenshots/truth/select/static-initial-via-selected.png +0 -0
- package/screenshots/truth/select/truncated-selection.png +0 -0
- package/screenshots/truth/select/with-placeholder.png +0 -0
- package/screenshots/truth/select/without-placeholder.png +0 -0
- package/screenshots/truth/slider/custom-min-custom-max-valid-value.png +0 -0
- package/screenshots/truth/slider/custom-min-default-max-no-value.png +0 -0
- package/screenshots/truth/slider/default-min-custom-max-no-value.png +0 -0
- package/screenshots/truth/slider/default-min-default-max-invalid-value.png +0 -0
- package/screenshots/truth/slider/default-min-default-max-valid-value.png +0 -0
- package/screenshots/truth/slider/update-slider-on-value-change.png +0 -0
- package/screenshots/truth/templates/default.png +0 -0
- package/screenshots/truth/templates/unapproved.png +0 -0
- package/screenshots/truth/textinput/input-disabled.png +0 -0
- package/screenshots/truth/textinput/input-focused.png +0 -0
- package/screenshots/truth/textinput/input-form.png +0 -0
- package/screenshots/truth/textinput/input-inserted.png +0 -0
- package/screenshots/truth/textinput/input-placeholder.png +0 -0
- package/screenshots/truth/textinput/input-updated.png +0 -0
- package/screenshots/truth/textinput/input.png +0 -0
- package/screenshots/truth/textinput/textarea-focused.png +0 -0
- package/screenshots/truth/textinput/textarea.png +0 -0
- package/screenshots/truth/tip/bottom.png +0 -0
- package/screenshots/truth/tip/left.png +0 -0
- package/screenshots/truth/tip/right.png +0 -0
- package/screenshots/truth/tip/top.png +0 -0
- package/screenshots/truth/webchat/closed-widget.png +0 -0
- package/screenshots/truth/webchat/connected-state.png +0 -0
- package/screenshots/truth/webchat/connecting-state.png +0 -0
- package/screenshots/truth/webchat/disconnected-state.png +0 -0
- package/screenshots/truth/webchat/opened-widget.png +0 -0
- package/src/chart/TembaChart.ts +399 -0
- package/src/list/RunList.ts +11 -8
- package/src/locales/es.ts +1 -0
- package/src/locales/fr.ts +1 -0
- package/src/locales/pt.ts +1 -0
- package/src/options/Options.ts +39 -13
- package/src/select/Select.ts +32 -5
- package/src/store/AppState.ts +3 -3
- package/src/utils/index.ts +17 -5
- package/src/vectoricon/VectorIcon.ts +2 -1
- package/temba-modules.ts +2 -2
- package/test/temba-appstate-language.test.ts +218 -0
- package/test/temba-chart.test.ts +215 -0
- package/test/temba-dropdown.test.ts +444 -0
- package/test/temba-run-list.test.ts +774 -0
- package/test/temba-select.test.ts +27 -0
- package/test/temba-toast.test.ts +386 -0
- package/test/temba-utils-index.test.ts +1547 -0
- package/test/temba-webchat.test.ts +1095 -0
- package/test/utils.test.ts +4 -2
- package/test-assets/list/flow-results.json +17 -0
- package/test-assets/list/runs.json +126 -0
- package/test-assets/style.css +23 -0
- package/web-test-runner.config.mjs +33 -7
- package/xliff/es.xlf +3 -0
- package/xliff/fr.xlf +3 -0
- package/xliff/pt.xlf +3 -0
- package/out-tsc/src/outboxmonitor/OutboxMonitor.js +0 -136
- package/out-tsc/src/outboxmonitor/OutboxMonitor.js.map +0 -1
- package/src/outboxmonitor/OutboxMonitor.ts +0 -148
package/src/select/Select.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
|
2
2
|
import { TemplateResult, html, css, CSSResult, CSSResultArray } from 'lit';
|
|
3
|
-
import { property } from 'lit/decorators.js';
|
|
3
|
+
import { property, state } from 'lit/decorators.js';
|
|
4
4
|
import {
|
|
5
5
|
getUrl,
|
|
6
6
|
getClasses,
|
|
@@ -410,6 +410,9 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
410
410
|
@property({ type: Boolean })
|
|
411
411
|
disabled = false;
|
|
412
412
|
|
|
413
|
+
@state()
|
|
414
|
+
attemptedOpen = false;
|
|
415
|
+
|
|
413
416
|
@property({ attribute: false })
|
|
414
417
|
selectedIndex = -1;
|
|
415
418
|
|
|
@@ -863,6 +866,7 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
863
866
|
}
|
|
864
867
|
|
|
865
868
|
this.visibleOptions = [];
|
|
869
|
+
this.attemptedOpen = false;
|
|
866
870
|
this.input = '';
|
|
867
871
|
this.next = null;
|
|
868
872
|
this.complete = true;
|
|
@@ -955,7 +959,9 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
955
959
|
}
|
|
956
960
|
|
|
957
961
|
public isOpen(): boolean {
|
|
958
|
-
return
|
|
962
|
+
return (
|
|
963
|
+
this.visibleOptions.length > 0 || (this.attemptedOpen && this.focused)
|
|
964
|
+
);
|
|
959
965
|
}
|
|
960
966
|
|
|
961
967
|
public setOptions(options: any[]): void {
|
|
@@ -1212,6 +1218,7 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
1212
1218
|
|
|
1213
1219
|
private handleBlur() {
|
|
1214
1220
|
this.focused = false;
|
|
1221
|
+
this.attemptedOpen = false;
|
|
1215
1222
|
if (this.visibleOptions.length > 0) {
|
|
1216
1223
|
this.input = '';
|
|
1217
1224
|
this.next = null;
|
|
@@ -1285,8 +1292,10 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
1285
1292
|
) {
|
|
1286
1293
|
if (
|
|
1287
1294
|
this.visibleOptions.length === 0 &&
|
|
1288
|
-
this.completionOptions.length === 0
|
|
1295
|
+
this.completionOptions.length === 0 &&
|
|
1296
|
+
!this.input
|
|
1289
1297
|
) {
|
|
1298
|
+
this.attemptedOpen = true;
|
|
1290
1299
|
this.requestUpdate('input');
|
|
1291
1300
|
return;
|
|
1292
1301
|
}
|
|
@@ -1322,6 +1331,7 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
1322
1331
|
|
|
1323
1332
|
private handleCancel() {
|
|
1324
1333
|
this.visibleOptions = [];
|
|
1334
|
+
this.attemptedOpen = false;
|
|
1325
1335
|
}
|
|
1326
1336
|
|
|
1327
1337
|
private handleCursorChanged(event: CustomEvent) {
|
|
@@ -1340,10 +1350,15 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
1340
1350
|
return;
|
|
1341
1351
|
}
|
|
1342
1352
|
|
|
1343
|
-
if (
|
|
1353
|
+
// Check if dropdown is currently open (either with options or showing "No options")
|
|
1354
|
+
if (this.isOpen()) {
|
|
1344
1355
|
this.visibleOptions = [];
|
|
1356
|
+
this.attemptedOpen = false;
|
|
1345
1357
|
} else {
|
|
1358
|
+
this.attemptedOpen = true;
|
|
1346
1359
|
this.requestUpdate('input');
|
|
1360
|
+
// Also trigger an immediate update to show empty dropdown
|
|
1361
|
+
this.requestUpdate();
|
|
1347
1362
|
}
|
|
1348
1363
|
}
|
|
1349
1364
|
}
|
|
@@ -1461,6 +1476,17 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
1461
1476
|
this.requestUpdate('values', oldValues);
|
|
1462
1477
|
}
|
|
1463
1478
|
|
|
1479
|
+
private shouldShowEmptyMessage(): boolean {
|
|
1480
|
+
return (
|
|
1481
|
+
this.attemptedOpen &&
|
|
1482
|
+
this.focused &&
|
|
1483
|
+
this.visibleOptions.length === 0 &&
|
|
1484
|
+
!this.input &&
|
|
1485
|
+
this.staticOptions.length === 0 &&
|
|
1486
|
+
!this.endpoint
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1464
1490
|
public render(): TemplateResult {
|
|
1465
1491
|
const placeholder = this.values.length === 0 ? this.placeholder : '';
|
|
1466
1492
|
const placeholderDiv = html`
|
|
@@ -1620,7 +1646,8 @@ export class Select<T extends SelectOption> extends FormElement {
|
|
|
1620
1646
|
.getName=${this.getNameInternal}
|
|
1621
1647
|
?static-width=${this.optionWidth}
|
|
1622
1648
|
?anchor-right=${this.anchorRight}
|
|
1623
|
-
?visible=${this.visibleOptions.length > 0}
|
|
1649
|
+
?visible=${this.visibleOptions.length > 0 || this.shouldShowEmptyMessage()}
|
|
1650
|
+
?showEmptyMessage=${this.shouldShowEmptyMessage()}
|
|
1624
1651
|
></temba-options>
|
|
1625
1652
|
|
|
1626
1653
|
<temba-options
|
package/src/store/AppState.ts
CHANGED
|
@@ -157,11 +157,11 @@ export const zustand = createStore<AppState>()(
|
|
|
157
157
|
setFlowContents: (flow: FlowContents) => {
|
|
158
158
|
set((state: AppState) => {
|
|
159
159
|
const flowLang = flow.definition.language;
|
|
160
|
-
const languageCode = state.languageCode || flowLang;
|
|
161
160
|
state.flowDefinition = flow.definition;
|
|
162
161
|
state.flowInfo = flow.info;
|
|
163
|
-
|
|
164
|
-
state.languageCode =
|
|
162
|
+
// Reset to the flow's default language when loading a new flow
|
|
163
|
+
state.languageCode = flowLang;
|
|
164
|
+
state.isTranslating = false;
|
|
165
165
|
});
|
|
166
166
|
},
|
|
167
167
|
|
package/src/utils/index.ts
CHANGED
|
@@ -494,7 +494,7 @@ export const getScrollParent = (node: any): any => {
|
|
|
494
494
|
return null;
|
|
495
495
|
};
|
|
496
496
|
|
|
497
|
-
export const isElementVisible = (el: any, holder
|
|
497
|
+
export const isElementVisible = (el: any, holder?: any) => {
|
|
498
498
|
holder = holder || document.body;
|
|
499
499
|
const { top, bottom } = el.getBoundingClientRect();
|
|
500
500
|
const holderRect = holder.getBoundingClientRect();
|
|
@@ -575,7 +575,7 @@ export const timeSince = (
|
|
|
575
575
|
}
|
|
576
576
|
};
|
|
577
577
|
|
|
578
|
-
export const isDate = (value:
|
|
578
|
+
export const isDate = (value: any): boolean => {
|
|
579
579
|
if (toString.call(value) === '[object Date]') {
|
|
580
580
|
return true;
|
|
581
581
|
}
|
|
@@ -635,6 +635,10 @@ export const truncate = (input: string, max: number): string => {
|
|
|
635
635
|
};
|
|
636
636
|
|
|
637
637
|
export const oxford = (items: any[], joiner = 'and'): any => {
|
|
638
|
+
if (items.length === 0) {
|
|
639
|
+
return '';
|
|
640
|
+
}
|
|
641
|
+
|
|
638
642
|
if (items.length === 1) {
|
|
639
643
|
return items[0];
|
|
640
644
|
}
|
|
@@ -657,7 +661,9 @@ export const oxford = (items: any[], joiner = 'and'): any => {
|
|
|
657
661
|
});
|
|
658
662
|
}
|
|
659
663
|
|
|
660
|
-
|
|
664
|
+
const allButLast = items.slice(0, -1);
|
|
665
|
+
const last = items[items.length - 1];
|
|
666
|
+
return allButLast.join(', ') + ', ' + joiner + ' ' + last;
|
|
661
667
|
};
|
|
662
668
|
|
|
663
669
|
export const oxfordFn = (
|
|
@@ -713,13 +719,19 @@ export enum COOKIE_KEYS {
|
|
|
713
719
|
TICKET_SHOW_DETAILS = 'tickets.show-details'
|
|
714
720
|
}
|
|
715
721
|
|
|
716
|
-
export const capitalize = (
|
|
722
|
+
export const capitalize = (
|
|
723
|
+
[first, ...rest]: string[] | string,
|
|
724
|
+
locale = navigator.language
|
|
725
|
+
) =>
|
|
717
726
|
first === undefined ? '' : first.toLocaleUpperCase(locale) + rest.join('');
|
|
718
727
|
|
|
719
728
|
export const formatFileType = (type: string): string => {
|
|
720
729
|
return type.split('/')[1];
|
|
721
730
|
};
|
|
722
|
-
export const formatFileSize = (
|
|
731
|
+
export const formatFileSize = (
|
|
732
|
+
bytes: number,
|
|
733
|
+
decimalPoint?: number
|
|
734
|
+
): string => {
|
|
723
735
|
if (bytes == 0) return '0 KB';
|
|
724
736
|
const k = 1024,
|
|
725
737
|
dm = decimalPoint || 2,
|
|
@@ -127,9 +127,10 @@ export class VectorIcon extends LitElement {
|
|
|
127
127
|
|
|
128
128
|
.spin-forever {
|
|
129
129
|
animation-name: spin;
|
|
130
|
-
animation-duration: 2000ms;
|
|
130
|
+
animation-duration: var(--test-animation-duration, 2000ms);
|
|
131
131
|
animation-iteration-count: infinite;
|
|
132
132
|
animation-timing-function: linear;
|
|
133
|
+
animation-play-state: var(--test-animation-play-state, running);
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
@keyframes spin {
|
package/temba-modules.ts
CHANGED
|
@@ -58,13 +58,13 @@ import { MediaPicker } from './src/mediapicker/MediaPicker';
|
|
|
58
58
|
import { Editor } from './src/flow/Editor';
|
|
59
59
|
import { EditorNode } from './src/flow/EditorNode';
|
|
60
60
|
import { ContactNotepad } from './src/contacts/ContactNotepad';
|
|
61
|
-
import { OutboxMonitor } from './src/outboxmonitor/OutboxMonitor';
|
|
62
61
|
import { ProgressBar } from './src/progress/ProgressBar';
|
|
63
62
|
import { StartProgress } from './src/progress/StartProgress';
|
|
64
63
|
import { ShortcutList } from './src/list/ShortcutList';
|
|
65
64
|
import { PopupSelect } from './src/select/PopupSelect';
|
|
66
65
|
import { UserSelect } from './src/select/UserSelect';
|
|
67
66
|
import { WorkspaceSelect } from './src/select/WorkspaceSelect';
|
|
67
|
+
import { TembaChart } from './src/chart/TembaChart';
|
|
68
68
|
|
|
69
69
|
export function addCustomElement(name: string, comp: any) {
|
|
70
70
|
if (!window.customElements.get(name)) {
|
|
@@ -133,10 +133,10 @@ addCustomElement('temba-media-picker', MediaPicker);
|
|
|
133
133
|
addCustomElement('temba-flow-editor', Editor);
|
|
134
134
|
addCustomElement('temba-flow-node', EditorNode);
|
|
135
135
|
addCustomElement('temba-contact-notepad', ContactNotepad);
|
|
136
|
-
addCustomElement('temba-outbox-monitor', OutboxMonitor);
|
|
137
136
|
addCustomElement('temba-progress', ProgressBar);
|
|
138
137
|
addCustomElement('temba-start-progress', StartProgress);
|
|
139
138
|
addCustomElement('temba-shortcuts', ShortcutList);
|
|
140
139
|
addCustomElement('temba-popup-select', PopupSelect);
|
|
141
140
|
addCustomElement('temba-user-select', UserSelect);
|
|
142
141
|
addCustomElement('temba-workspace-select', WorkspaceSelect);
|
|
142
|
+
addCustomElement('temba-chart', TembaChart);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { assert } from '@open-wc/testing';
|
|
2
|
+
import { zustand } from '../src/store/AppState';
|
|
3
|
+
|
|
4
|
+
describe('AppState Language Reset', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// Reset the store state before each test
|
|
7
|
+
const state = zustand.getState();
|
|
8
|
+
zustand.setState({
|
|
9
|
+
...state,
|
|
10
|
+
languageCode: '',
|
|
11
|
+
isTranslating: false,
|
|
12
|
+
flowDefinition: null,
|
|
13
|
+
flowInfo: null
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should reset language when loading a new flow', () => {
|
|
18
|
+
const state = zustand.getState();
|
|
19
|
+
|
|
20
|
+
// First, load an initial flow to establish state
|
|
21
|
+
const initialFlowContents = {
|
|
22
|
+
definition: {
|
|
23
|
+
language: 'en',
|
|
24
|
+
localization: {},
|
|
25
|
+
name: 'Initial Flow',
|
|
26
|
+
nodes: [],
|
|
27
|
+
uuid: 'initial-uuid',
|
|
28
|
+
type: 'messaging' as const,
|
|
29
|
+
revision: 1,
|
|
30
|
+
spec_version: '14.3',
|
|
31
|
+
_ui: {
|
|
32
|
+
nodes: {},
|
|
33
|
+
languages: []
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
info: {
|
|
37
|
+
results: [],
|
|
38
|
+
dependencies: [],
|
|
39
|
+
counts: { nodes: 0, languages: 1 },
|
|
40
|
+
locals: []
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
state.setFlowContents(initialFlowContents);
|
|
45
|
+
|
|
46
|
+
// Simulate having a previous flow with localization
|
|
47
|
+
state.setLanguageCode('es'); // User selected Spanish for localization
|
|
48
|
+
assert.equal(zustand.getState().languageCode, 'es');
|
|
49
|
+
assert.equal(zustand.getState().isTranslating, true); // Now translating from English to Spanish
|
|
50
|
+
|
|
51
|
+
// Simulate loading a new flow with English as default
|
|
52
|
+
const mockFlowContents = {
|
|
53
|
+
definition: {
|
|
54
|
+
language: 'en',
|
|
55
|
+
localization: {},
|
|
56
|
+
name: 'Test Flow',
|
|
57
|
+
nodes: [],
|
|
58
|
+
uuid: 'test-uuid',
|
|
59
|
+
type: 'messaging' as const,
|
|
60
|
+
revision: 1,
|
|
61
|
+
spec_version: '14.3',
|
|
62
|
+
_ui: {
|
|
63
|
+
nodes: {},
|
|
64
|
+
languages: []
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
info: {
|
|
68
|
+
results: [],
|
|
69
|
+
dependencies: [],
|
|
70
|
+
counts: { nodes: 0, languages: 1 },
|
|
71
|
+
locals: []
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
state.setFlowContents(mockFlowContents);
|
|
76
|
+
|
|
77
|
+
// The language should reset to the flow's default language
|
|
78
|
+
assert.equal(
|
|
79
|
+
zustand.getState().languageCode,
|
|
80
|
+
'en',
|
|
81
|
+
'Language should reset to flow default'
|
|
82
|
+
);
|
|
83
|
+
assert.equal(
|
|
84
|
+
zustand.getState().isTranslating,
|
|
85
|
+
false,
|
|
86
|
+
'Should not be in translation mode'
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should set isTranslating correctly when languages differ', () => {
|
|
91
|
+
const state = zustand.getState();
|
|
92
|
+
|
|
93
|
+
// Load a flow with Spanish as default
|
|
94
|
+
const mockFlowContents = {
|
|
95
|
+
definition: {
|
|
96
|
+
language: 'es',
|
|
97
|
+
localization: {},
|
|
98
|
+
name: 'Test Flow',
|
|
99
|
+
nodes: [],
|
|
100
|
+
uuid: 'test-uuid',
|
|
101
|
+
type: 'messaging' as const,
|
|
102
|
+
revision: 1,
|
|
103
|
+
spec_version: '14.3',
|
|
104
|
+
_ui: {
|
|
105
|
+
nodes: {},
|
|
106
|
+
languages: []
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
info: {
|
|
110
|
+
results: [],
|
|
111
|
+
dependencies: [],
|
|
112
|
+
counts: { nodes: 0, languages: 1 },
|
|
113
|
+
locals: []
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
state.setFlowContents(mockFlowContents);
|
|
118
|
+
|
|
119
|
+
// Should be in Spanish and not translating
|
|
120
|
+
assert.equal(zustand.getState().languageCode, 'es');
|
|
121
|
+
assert.equal(zustand.getState().isTranslating, false);
|
|
122
|
+
|
|
123
|
+
// Now switch to English for localization
|
|
124
|
+
state.setLanguageCode('en');
|
|
125
|
+
assert.equal(zustand.getState().languageCode, 'en');
|
|
126
|
+
assert.equal(
|
|
127
|
+
zustand.getState().isTranslating,
|
|
128
|
+
true,
|
|
129
|
+
'Should be translating when language differs from flow default'
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should preserve flow default language when no previous language is set', () => {
|
|
134
|
+
const state = zustand.getState();
|
|
135
|
+
|
|
136
|
+
// Load a flow with French as default, no previous language set
|
|
137
|
+
const mockFlowContents = {
|
|
138
|
+
definition: {
|
|
139
|
+
language: 'fr',
|
|
140
|
+
localization: {},
|
|
141
|
+
name: 'Test Flow',
|
|
142
|
+
nodes: [],
|
|
143
|
+
uuid: 'test-uuid',
|
|
144
|
+
type: 'messaging' as const,
|
|
145
|
+
revision: 1,
|
|
146
|
+
spec_version: '14.3',
|
|
147
|
+
_ui: {
|
|
148
|
+
nodes: {},
|
|
149
|
+
languages: []
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
info: {
|
|
153
|
+
results: [],
|
|
154
|
+
dependencies: [],
|
|
155
|
+
counts: { nodes: 0, languages: 1 },
|
|
156
|
+
locals: []
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
state.setFlowContents(mockFlowContents);
|
|
161
|
+
|
|
162
|
+
// Should use the flow's default language
|
|
163
|
+
assert.equal(
|
|
164
|
+
zustand.getState().languageCode,
|
|
165
|
+
'fr',
|
|
166
|
+
'Should use flow default language'
|
|
167
|
+
);
|
|
168
|
+
assert.equal(
|
|
169
|
+
zustand.getState().isTranslating,
|
|
170
|
+
false,
|
|
171
|
+
'Should not be translating'
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should handle language switching after flow is loaded', () => {
|
|
176
|
+
const state = zustand.getState();
|
|
177
|
+
|
|
178
|
+
// Load a flow with English as default
|
|
179
|
+
const mockFlowContents = {
|
|
180
|
+
definition: {
|
|
181
|
+
language: 'en',
|
|
182
|
+
localization: {},
|
|
183
|
+
name: 'Test Flow',
|
|
184
|
+
nodes: [],
|
|
185
|
+
uuid: 'test-uuid',
|
|
186
|
+
type: 'messaging' as const,
|
|
187
|
+
revision: 1,
|
|
188
|
+
spec_version: '14.3',
|
|
189
|
+
_ui: {
|
|
190
|
+
nodes: {},
|
|
191
|
+
languages: []
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
info: {
|
|
195
|
+
results: [],
|
|
196
|
+
dependencies: [],
|
|
197
|
+
counts: { nodes: 0, languages: 1 },
|
|
198
|
+
locals: []
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
state.setFlowContents(mockFlowContents);
|
|
203
|
+
|
|
204
|
+
// Should start in English, not translating
|
|
205
|
+
assert.equal(zustand.getState().languageCode, 'en');
|
|
206
|
+
assert.equal(zustand.getState().isTranslating, false);
|
|
207
|
+
|
|
208
|
+
// Switch to Spanish for localization
|
|
209
|
+
state.setLanguageCode('es');
|
|
210
|
+
assert.equal(zustand.getState().languageCode, 'es');
|
|
211
|
+
assert.equal(zustand.getState().isTranslating, true);
|
|
212
|
+
|
|
213
|
+
// Switch back to English
|
|
214
|
+
state.setLanguageCode('en');
|
|
215
|
+
assert.equal(zustand.getState().languageCode, 'en');
|
|
216
|
+
assert.equal(zustand.getState().isTranslating, false);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { expect } from '@open-wc/testing';
|
|
2
|
+
import {
|
|
3
|
+
RapidChartData,
|
|
4
|
+
TembaChart,
|
|
5
|
+
formatDurationFromSeconds
|
|
6
|
+
} from '../src/chart/TembaChart';
|
|
7
|
+
import { getComponent } from './utils.test';
|
|
8
|
+
|
|
9
|
+
const sampleData: RapidChartData = {
|
|
10
|
+
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
|
|
11
|
+
datasets: [
|
|
12
|
+
{
|
|
13
|
+
label: 'General',
|
|
14
|
+
data: [65, 59, 80, 81, 56, 55, 40]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
label: 'Support',
|
|
18
|
+
data: [28, 48, 40, 19, 86, 27, 90]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
label: 'VIPs',
|
|
22
|
+
data: [58, 28, 10, 1, 0, 19, 20]
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const TAG = 'temba-chart';
|
|
28
|
+
const getChart = async (attrs: any = {}) => {
|
|
29
|
+
const picker = (await getComponent(TAG, attrs, '', 400)) as TembaChart;
|
|
30
|
+
return picker;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('temba-chart', () => {
|
|
34
|
+
it('calculates others', async () => {
|
|
35
|
+
const chart: TembaChart = await getChart();
|
|
36
|
+
|
|
37
|
+
chart.data = sampleData;
|
|
38
|
+
await chart.updateComplete;
|
|
39
|
+
|
|
40
|
+
// if we haven't set any splits, everything should be summed as "All"
|
|
41
|
+
expect(chart.datasets[0].data).to.deep.equal([
|
|
42
|
+
151, 135, 130, 101, 142, 101, 150
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// add a split
|
|
46
|
+
chart.splits = ['General'];
|
|
47
|
+
await chart.updateComplete;
|
|
48
|
+
|
|
49
|
+
// now we should get an "Others" dataset
|
|
50
|
+
expect(chart.datasets[1].data).to.deep.equal([86, 76, 50, 20, 86, 46, 110]);
|
|
51
|
+
|
|
52
|
+
// add another split
|
|
53
|
+
chart.splits = ['General', 'Support'];
|
|
54
|
+
await chart.updateComplete;
|
|
55
|
+
|
|
56
|
+
// now others should be everything but general and support
|
|
57
|
+
expect(chart.datasets[2].data).to.deep.equal([58, 28, 10, 1, 0, 19, 20]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('supports duration formatting', async () => {
|
|
61
|
+
const chart: TembaChart = await getChart();
|
|
62
|
+
|
|
63
|
+
// Test that formatDuration property exists and defaults to false
|
|
64
|
+
expect(chart.formatDuration).to.equal(false);
|
|
65
|
+
|
|
66
|
+
// Test that we can set formatDuration to true
|
|
67
|
+
chart.formatDuration = true;
|
|
68
|
+
expect(chart.formatDuration).to.equal(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('formats duration values correctly', async () => {
|
|
72
|
+
const chart: TembaChart = await getChart();
|
|
73
|
+
|
|
74
|
+
// Access the formatDurationFromSeconds function through the chart's module
|
|
75
|
+
// We need to test the duration formatting logic
|
|
76
|
+
const durationData: RapidChartData = {
|
|
77
|
+
labels: ['Day 1', 'Day 2', 'Day 3'],
|
|
78
|
+
datasets: [
|
|
79
|
+
{
|
|
80
|
+
label: 'Process Time',
|
|
81
|
+
data: [68787, 958000, 3661] // seconds that should be formatted as durations
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
chart.formatDuration = true;
|
|
87
|
+
chart.data = durationData;
|
|
88
|
+
await chart.updateComplete;
|
|
89
|
+
|
|
90
|
+
// Wait for the chart to be created after data is set
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
92
|
+
|
|
93
|
+
// Test that the chart was created and has the duration formatting enabled
|
|
94
|
+
expect(chart.formatDuration).to.equal(true);
|
|
95
|
+
expect(chart.chart).to.exist;
|
|
96
|
+
|
|
97
|
+
// Test that the chart configuration includes the duration formatting
|
|
98
|
+
const chartConfig = chart.chart.options;
|
|
99
|
+
expect(chartConfig.scales.y.ticks).to.exist;
|
|
100
|
+
expect(chartConfig.scales.y.ticks.callback).to.be.a('function');
|
|
101
|
+
|
|
102
|
+
// Test the tick callback function formatting
|
|
103
|
+
const tickCallback = chartConfig.scales.y.ticks.callback;
|
|
104
|
+
expect(tickCallback.call({}, 68787, 0, [])).to.equal('19h 6m');
|
|
105
|
+
expect(tickCallback.call({}, 958000, 1, [])).to.equal('11d 2h');
|
|
106
|
+
expect(tickCallback.call({}, 3661, 2, [])).to.equal('1h 1m');
|
|
107
|
+
expect(tickCallback.call({}, 120, 3, [])).to.equal('2m');
|
|
108
|
+
expect(tickCallback.call({}, 0, 4, [])).to.equal('0s');
|
|
109
|
+
|
|
110
|
+
// Test tooltip formatting
|
|
111
|
+
expect(chartConfig.plugins.tooltip.callbacks.label).to.be.a('function');
|
|
112
|
+
const tooltipCallback = chartConfig.plugins.tooltip.callbacks.label;
|
|
113
|
+
|
|
114
|
+
const mockContext = {
|
|
115
|
+
dataset: { label: 'Process Time' },
|
|
116
|
+
parsed: { y: 68787 }
|
|
117
|
+
};
|
|
118
|
+
expect(tooltipCallback.call({}, mockContext)).to.equal(
|
|
119
|
+
'Process Time: 19h 6m'
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('formats various duration edge cases correctly', async () => {
|
|
124
|
+
const chart: TembaChart = await getChart();
|
|
125
|
+
chart.formatDuration = true;
|
|
126
|
+
chart.data = sampleData;
|
|
127
|
+
await chart.updateComplete;
|
|
128
|
+
|
|
129
|
+
// Wait for the chart to be created after data is set
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
131
|
+
|
|
132
|
+
expect(chart.chart).to.exist;
|
|
133
|
+
const tickCallback = chart.chart.options.scales.y.ticks.callback;
|
|
134
|
+
|
|
135
|
+
// Test edge cases for duration formatting
|
|
136
|
+
expect(tickCallback.call({}, 0, 0, [])).to.equal('0s');
|
|
137
|
+
expect(tickCallback.call({}, 1, 1, [])).to.equal('1s');
|
|
138
|
+
expect(tickCallback.call({}, 59, 2, [])).to.equal('59s');
|
|
139
|
+
expect(tickCallback.call({}, 60, 3, [])).to.equal('1m');
|
|
140
|
+
expect(tickCallback.call({}, 61, 4, [])).to.equal('1m 1s');
|
|
141
|
+
expect(tickCallback.call({}, 3600, 5, [])).to.equal('1h');
|
|
142
|
+
expect(tickCallback.call({}, 3661, 6, [])).to.equal('1h 1m');
|
|
143
|
+
expect(tickCallback.call({}, 86400, 7, [])).to.equal('1d');
|
|
144
|
+
expect(tickCallback.call({}, 90061, 8, [])).to.equal('1d 1h'); // 1 day, 1 hour, 1 minute, 1 second - should show only first two units
|
|
145
|
+
expect(tickCallback.call({}, 604800, 9, [])).to.equal('7d'); // 1 week in seconds
|
|
146
|
+
expect(tickCallback.call({}, 1209600, 10, [])).to.equal('14d'); // 2 weeks in seconds
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('respects formatDuration property state', async () => {
|
|
150
|
+
const chart: TembaChart = await getChart();
|
|
151
|
+
|
|
152
|
+
// Test default state
|
|
153
|
+
expect(chart.formatDuration).to.equal(false);
|
|
154
|
+
|
|
155
|
+
chart.data = sampleData;
|
|
156
|
+
await chart.updateComplete;
|
|
157
|
+
|
|
158
|
+
// Test that formatDuration property can be toggled
|
|
159
|
+
chart.formatDuration = true;
|
|
160
|
+
expect(chart.formatDuration).to.equal(true);
|
|
161
|
+
|
|
162
|
+
chart.formatDuration = false;
|
|
163
|
+
expect(chart.formatDuration).to.equal(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('formatDurationFromSeconds', () => {
|
|
168
|
+
it('formats zero correctly', () => {
|
|
169
|
+
expect(formatDurationFromSeconds(0)).to.equal('0s');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('formats seconds only', () => {
|
|
173
|
+
expect(formatDurationFromSeconds(1)).to.equal('1s');
|
|
174
|
+
expect(formatDurationFromSeconds(30)).to.equal('30s');
|
|
175
|
+
expect(formatDurationFromSeconds(59)).to.equal('59s');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('formats minutes and seconds', () => {
|
|
179
|
+
expect(formatDurationFromSeconds(60)).to.equal('1m');
|
|
180
|
+
expect(formatDurationFromSeconds(61)).to.equal('1m 1s');
|
|
181
|
+
expect(formatDurationFromSeconds(90)).to.equal('1m 30s');
|
|
182
|
+
expect(formatDurationFromSeconds(120)).to.equal('2m');
|
|
183
|
+
expect(formatDurationFromSeconds(3599)).to.equal('59m 59s');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('formats hours and minutes', () => {
|
|
187
|
+
expect(formatDurationFromSeconds(3600)).to.equal('1h');
|
|
188
|
+
expect(formatDurationFromSeconds(3661)).to.equal('1h 1m');
|
|
189
|
+
expect(formatDurationFromSeconds(7200)).to.equal('2h');
|
|
190
|
+
expect(formatDurationFromSeconds(68787)).to.equal('19h 6m');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('formats days and hours', () => {
|
|
194
|
+
expect(formatDurationFromSeconds(86400)).to.equal('1d');
|
|
195
|
+
expect(formatDurationFromSeconds(90000)).to.equal('1d 1h');
|
|
196
|
+
expect(formatDurationFromSeconds(958000)).to.equal('11d 2h');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('shows only two most significant units', () => {
|
|
200
|
+
// 1 day, 1 hour, 1 minute, 1 second - should show only "1d 1h"
|
|
201
|
+
expect(formatDurationFromSeconds(90061)).to.equal('1d 1h');
|
|
202
|
+
|
|
203
|
+
// 2 hours, 30 minutes, 45 seconds - should show only "2h 30m"
|
|
204
|
+
expect(formatDurationFromSeconds(9045)).to.equal('2h 30m');
|
|
205
|
+
|
|
206
|
+
// 5 minutes, 30 seconds - should show "5m 30s"
|
|
207
|
+
expect(formatDurationFromSeconds(330)).to.equal('5m 30s');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('handles large durations', () => {
|
|
211
|
+
expect(formatDurationFromSeconds(604800)).to.equal('7d'); // 1 week
|
|
212
|
+
expect(formatDurationFromSeconds(1209600)).to.equal('14d'); // 2 weeks
|
|
213
|
+
expect(formatDurationFromSeconds(2678400)).to.equal('31d'); // ~1 month
|
|
214
|
+
});
|
|
215
|
+
});
|