You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2026/10/16 22:11:53 UTC
(superset) 03/17: fix(dashboard): honour DASHBOARD_AUTO_REFRESH_INTERVALS in the refresh dropdown (#44982)
This is an automated email from the ASF dual-hosted git repository.
villebro pushed a commit to branch 7.0
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 19d846796c9ff877a47a6187ef29ed6cc3de92c3
Author: Konstantin Khlopkov <47...@users.noreply.github.com>
AuthorDate: Mon Oct 12 23:07:05 2026 +0300
fix(dashboard): honour DASHBOARD_AUTO_REFRESH_INTERVALS in the refresh dropdown (#44982)
(cherry picked from commit ea0687222a58fa69edae52e21b79a497ab4b2f23)
---
.../RefreshFrequencySelect.test.tsx | 261 +++++++++++++++++++++
.../RefreshFrequency/RefreshFrequencySelect.tsx | 147 ++++++++++--
.../dashboard/components/RefreshFrequency/index.ts | 3 +
.../components/RefreshIntervalModal.test.tsx | 43 ++++
4 files changed, 437 insertions(+), 17 deletions(-)
diff --git a/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.test.tsx b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.test.tsx
index dbcbc5c63e0..e8493de151d 100644
--- a/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.test.tsx
+++ b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.test.tsx
@@ -16,11 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { t } from '@apache-superset/core/translation';
+import { useState } from 'react';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
+ CUSTOM_REFRESH_FREQUENCY,
+ getRefreshFrequencyOptions,
getRefreshWarningMessage,
+ RefreshFrequencySelect,
+ REFRESH_FREQUENCY_OPTIONS,
validateRefreshFrequency,
} from './RefreshFrequencySelect';
+// No language pack is loaded in tests, so t() is the identity function. Mock
+// it with jest.fn so a test can register a translation and observe it. The
+// default lives in a hoisted function declaration: the factory runs before
+// module-level bindings initialize (transitive imports call t() at load time).
+function mockTranslate(str: string, ...args: unknown[]): string {
+ return str.replace(/%s/g, () => String(args.shift()));
+}
+
+jest.mock('@apache-superset/core/translation', () => ({
+ ...jest.requireActual('@apache-superset/core/translation'),
+ t: jest.fn(mockTranslate),
+}));
+
test('validateRefreshFrequency treats millisecond refreshLimit as seconds', () => {
const errors = validateRefreshFrequency(5, 10000);
@@ -38,3 +58,244 @@ test('getRefreshWarningMessage normalizes refreshLimit', () => {
expect(getRefreshWarningMessage(5, 10, 'warn')).toBe('warn');
expect(getRefreshWarningMessage(15, 10000, 'warn')).toBeNull();
});
+
+test('getRefreshFrequencyOptions honours the configured intervals', () => {
+ const options = getRefreshFrequencyOptions([
+ [0, "Don't refresh"],
+ [600, '10 minutes'],
+ [1800, '30 minutes'],
+ [3600, '1 hour'],
+ ]);
+
+ expect(options).toEqual([
+ { value: 0, label: "Don't refresh" },
+ { value: 600, label: '10 minutes' },
+ { value: 1800, label: '30 minutes' },
+ { value: 3600, label: '1 hour' },
+ { value: CUSTOM_REFRESH_FREQUENCY, label: 'Custom' },
+ ]);
+});
+
+test('getRefreshFrequencyOptions falls back to the built-in list when the config is absent', () => {
+ expect(getRefreshFrequencyOptions(undefined)).toEqual(
+ REFRESH_FREQUENCY_OPTIONS,
+ );
+ expect(getRefreshFrequencyOptions([])).toEqual(REFRESH_FREQUENCY_OPTIONS);
+ expect(getRefreshFrequencyOptions('10 seconds')).toEqual(
+ REFRESH_FREQUENCY_OPTIONS,
+ );
+});
+
+test('getRefreshFrequencyOptions drops malformed entries instead of rendering them', () => {
+ const options = getRefreshFrequencyOptions([
+ [600],
+ ['not-a-number', 'Ten minutes'],
+ [-5, 'Negative'],
+ [1800, ' '],
+ [1800, null],
+ [3600, '1 hour'],
+ ]);
+
+ expect(options).toEqual([
+ { value: 3600, label: '1 hour' },
+ { value: CUSTOM_REFRESH_FREQUENCY, label: 'Custom' },
+ ]);
+});
+
+test('getRefreshFrequencyOptions localizes labels with a translation and passes the rest through', () => {
+ const tMock = jest.mocked(t);
+ tMock.mockImplementation((str: string, ...args: unknown[]) =>
+ str === '10 seconds' ? '10 Sekunden' : mockTranslate(str, ...args),
+ );
+ try {
+ const options = getRefreshFrequencyOptions([
+ [10, '10 seconds'],
+ [600, '10 Minuten'],
+ ]);
+
+ expect(options).toEqual([
+ { value: 10, label: '10 Sekunden' },
+ { value: 600, label: '10 Minuten' },
+ { value: CUSTOM_REFRESH_FREQUENCY, label: 'Custom' },
+ ]);
+ } finally {
+ tMock.mockImplementation(mockTranslate);
+ }
+});
+
+test('getRefreshFrequencyOptions drops a configured Custom entry and appends exactly one', () => {
+ // CUSTOM_REFRESH_FREQUENCY is negative, so the seconds < 0 guard drops it;
+ // the list always ends with the single built-in Custom affordance.
+ const options = getRefreshFrequencyOptions([
+ [600, '10 minutes'],
+ [CUSTOM_REFRESH_FREQUENCY, 'Custom'],
+ ]);
+
+ expect(options).toEqual([
+ { value: 600, label: '10 minutes' },
+ { value: CUSTOM_REFRESH_FREQUENCY, label: 'Custom' },
+ ]);
+});
+
+test('getRefreshFrequencyOptions rejects values that coerce to a real interval', () => {
+ // Number(null), Number('') and Number([]) are all 0, and 0 is the "Don't
+ // refresh" interval, so a loose conversion would smuggle them in as valid.
+ const options = getRefreshFrequencyOptions([
+ [null, 'Nothing'],
+ ['', 'Empty'],
+ [[], 'Array'],
+ [true, 'Boolean'],
+ [{}, 'Object'],
+ [600, '10 minutes'],
+ ]);
+
+ expect(options).toEqual([
+ { value: 600, label: '10 minutes' },
+ { value: CUSTOM_REFRESH_FREQUENCY, label: 'Custom' },
+ ]);
+});
+
+test('getRefreshFrequencyOptions keeps the first of two entries sharing an interval', () => {
+ // Duplicate React keys and two simultaneously-checked radios otherwise.
+ const options = getRefreshFrequencyOptions([
+ [600, 'Ten minutes'],
+ [600, 'Another ten minutes'],
+ ['600', 'A string spelling the same interval'],
+ ]);
+
+ expect(options).toEqual([
+ { value: 600, label: 'Ten minutes' },
+ { value: CUSTOM_REFRESH_FREQUENCY, label: 'Custom' },
+ ]);
+});
+
+const INTERVALS = [
+ [0, "Don't refresh"],
+ [600, '10 minutes'],
+ [3600, '1 hour'],
+];
+
+test('the configured intervals are read from the bootstrapped store when no dashboard is hydrated', () => {
+ // The select also mounts from the Dashboard List / Home pages, where only
+ // the bootstrapped state.common carries the deployment config.
+ render(<RefreshFrequencySelect value={0} onChange={jest.fn()} />, {
+ useRedux: true,
+ initialState: {
+ common: { conf: { DASHBOARD_AUTO_REFRESH_INTERVALS: INTERVALS } },
+ },
+ });
+
+ expect(screen.getByRole('radio', { name: '10 minutes' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('radio', { name: '10 seconds' }),
+ ).not.toBeInTheDocument();
+});
+
+test('the hydrated dashboard config wins over the bootstrapped one', () => {
+ render(<RefreshFrequencySelect value={0} onChange={jest.fn()} />, {
+ useRedux: true,
+ initialState: {
+ dashboardInfo: {
+ common: {
+ conf: {
+ DASHBOARD_AUTO_REFRESH_INTERVALS: [
+ [0, "Don't refresh"],
+ [600, '10 minutes'],
+ ],
+ },
+ },
+ },
+ common: { conf: { DASHBOARD_AUTO_REFRESH_INTERVALS: INTERVALS } },
+ },
+ });
+
+ expect(screen.getByRole('radio', { name: '10 minutes' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('radio', { name: '1 hour' }),
+ ).not.toBeInTheDocument();
+});
+
+// A parent that feeds every emitted value straight back, the way
+// PropertiesModal and RefreshIntervalModal hold the selection in their own
+// state.
+function ControlledSelect({ initialValue }: { initialValue: number }) {
+ const [value, setValue] = useState(initialValue);
+ return <RefreshFrequencySelect value={value} onChange={setValue} />;
+}
+
+test('Custom stays selected when the emitted value collides with a preset', async () => {
+ // Regression: an interval list containing 1 (e.g. [1, '1 second']) made the
+ // first Custom click emit onChange(1), the parent echo that back as value=1,
+ // and the sync effect re-derive the selection from preset membership --
+ // landing on the 1 second preset with a cleared, disabled input.
+ render(<ControlledSelect initialValue={0} />, {
+ useRedux: true,
+ initialState: {
+ common: {
+ conf: {
+ DASHBOARD_AUTO_REFRESH_INTERVALS: [
+ [0, "Don't refresh"],
+ [1, '1 second'],
+ [600, '10 minutes'],
+ ],
+ },
+ },
+ },
+ });
+
+ await userEvent.click(screen.getByRole('radio', { name: /Custom/i }));
+
+ expect(screen.getByRole('radio', { name: '1 second' })).not.toBeChecked();
+ expect(screen.getByRole('radio', { name: /Custom/i })).toBeChecked();
+
+ const customInput = screen.getByRole('spinbutton');
+ expect(customInput).toBeEnabled();
+ expect(customInput).toHaveValue(1);
+});
+
+test('typing a preset-colliding value into the Custom input keeps Custom selected', async () => {
+ render(<ControlledSelect initialValue={0} />, {
+ useRedux: true,
+ initialState: {
+ common: {
+ conf: {
+ DASHBOARD_AUTO_REFRESH_INTERVALS: [
+ [0, "Don't refresh"],
+ [600, '10 minutes'],
+ ],
+ },
+ },
+ },
+ });
+
+ await userEvent.click(screen.getByRole('radio', { name: /Custom/i }));
+ await userEvent.type(screen.getByRole('spinbutton'), '{backspace}600');
+
+ expect(screen.getByRole('radio', { name: /Custom/i })).toBeChecked();
+ expect(screen.getByRole('radio', { name: '10 minutes' })).not.toBeChecked();
+ expect(screen.getByRole('spinbutton')).toHaveValue(600);
+});
+
+test('selecting a preset still snaps selection to that preset', async () => {
+ render(<ControlledSelect initialValue={0} />, {
+ useRedux: true,
+ initialState: {
+ common: {
+ conf: {
+ DASHBOARD_AUTO_REFRESH_INTERVALS: [
+ [0, "Don't refresh"],
+ [1, '1 second'],
+ [600, '10 minutes'],
+ ],
+ },
+ },
+ },
+ });
+
+ await userEvent.click(screen.getByRole('radio', { name: /Custom/i }));
+ await userEvent.click(screen.getByRole('radio', { name: '1 second' }));
+
+ expect(screen.getByRole('radio', { name: '1 second' })).toBeChecked();
+ expect(screen.getByRole('radio', { name: /Custom/i })).not.toBeChecked();
+ expect(screen.getByRole('spinbutton')).toBeDisabled();
+});
diff --git a/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx
index 6f5736d9078..6d9385cf0ff 100644
--- a/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx
+++ b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx
@@ -16,15 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ChangeEvent, useEffect, useState } from 'react';
+import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
+import { useSelector } from 'react-redux';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { Input } from '@superset-ui/core/components';
import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio';
+import { RootState } from 'src/dashboard/types';
// Minimum custom refresh interval in seconds
export const MINIMUM_REFRESH_INTERVAL = 1;
+// Radio value that selects the free-form interval input rather than a preset
+export const CUSTOM_REFRESH_FREQUENCY = -1;
+
+export type RefreshFrequencyOption = { value: number; label: string };
+
const StyledRadioGroup = styled(Radio.Group)`
padding-left: ${({ theme }) => theme.sizeUnit * 2}px;
@@ -50,7 +57,10 @@ const CustomContent = styled.div`
}
`;
-// Standard refresh frequency options used across modals
+// Standard refresh frequency options used when the deployment ships no
+// DASHBOARD_AUTO_REFRESH_INTERVALS value (embedded or partially bootstrapped
+// payloads). The `Custom` entry is a UI affordance, not an interval, so it is
+// kept out of the configured list.
export const REFRESH_FREQUENCY_OPTIONS = [
{ value: 0, label: t("Don't refresh") },
{ value: 10, label: t('10 seconds') },
@@ -62,16 +72,72 @@ export const REFRESH_FREQUENCY_OPTIONS = [
{ value: 21600, label: t('6 hours') },
{ value: 43200, label: t('12 hours') },
{ value: 86400, label: t('24 hours') },
- { value: -1, label: t('Custom') },
+ { value: CUSTOM_REFRESH_FREQUENCY, label: t('Custom') },
];
-const isPresetValue = (frequency: number) =>
- REFRESH_FREQUENCY_OPTIONS.some(
- option => option.value === frequency && option.value !== -1,
+const isPresetValue = (frequency: number, options: RefreshFrequencyOption[]) =>
+ options.some(
+ option =>
+ option.value === frequency && option.value !== CUSTOM_REFRESH_FREQUENCY,
+ );
+
+// `Number(null)`, `Number('')` and `Number([])` all yield 0, and 0 is a real
+// interval, so a bare Number() would smuggle malformed entries in as valid.
+const toIntervalSeconds = (value: unknown): number => {
+ const isNumeric = typeof value === 'number' || typeof value === 'string';
+ return isNumeric && String(value).trim() !== '' ? Number(value) : Number.NaN;
+};
+
+/**
+ * Builds the interval list from DASHBOARD_AUTO_REFRESH_INTERVALS, which reaches
+ * the browser as a list of `[seconds, label]` pairs. Labels run through `t()`
+ * so translated UIs render the English defaults from config.py localized;
+ * `t()` passes strings it has no entry for back unchanged, so operator-authored
+ * non-English labels survive verbatim. Anything that is not a well-formed
+ * non-empty list falls back to the built-in options rather than rendering an
+ * empty selector.
+ */
+export const getRefreshFrequencyOptions = (
+ configuredIntervals?: unknown,
+): RefreshFrequencyOption[] => {
+ const options: RefreshFrequencyOption[] = [];
+ const seen = new Set<number>();
+
+ (Array.isArray(configuredIntervals) ? configuredIntervals : []).forEach(
+ pair => {
+ if (!Array.isArray(pair)) {
+ return;
+ }
+ const [value, label] = pair;
+ const seconds = toIntervalSeconds(value);
+ const isLabelled = typeof label === 'string' && label.trim() !== '';
+ if (!Number.isFinite(seconds) || seconds < 0 || !isLabelled) {
+ return;
+ }
+ // Deployment overrides are unvalidated; a repeated interval would render
+ // duplicate React keys and two simultaneously-checked radios.
+ if (seen.has(seconds)) {
+ return;
+ }
+ seen.add(seconds);
+ options.push({ value: seconds, label: t(label) });
+ },
);
-const getCustomValue = (frequency: number) =>
- !isPresetValue(frequency) && frequency > 0 ? frequency.toString() : '';
+ // Negative intervals never survive the guard above, so a configured list
+ // cannot carry the Custom entry; only the built-in list already has it.
+ return options.length
+ ? [...options, { value: CUSTOM_REFRESH_FREQUENCY, label: t('Custom') }]
+ : REFRESH_FREQUENCY_OPTIONS;
+};
+
+const getCustomValue = (
+ frequency: number,
+ options: RefreshFrequencyOption[],
+) =>
+ !isPresetValue(frequency, options) && frequency > 0
+ ? frequency.toString()
+ : '';
const normalizeRefreshLimitSeconds = (
refreshLimit?: number,
@@ -100,31 +166,75 @@ export const RefreshFrequencySelect = ({
value,
onChange,
}: RefreshFrequencySelectProps) => {
+ const configuredIntervals = useSelector(
+ (state: RootState) =>
+ // The select also mounts from the Dashboard List / Home pages, where no
+ // dashboard has been hydrated yet; the bootstrapped store carries the
+ // same config there.
+ state.dashboardInfo?.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS ??
+ state.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS,
+ );
+ const options = useMemo(
+ () => getRefreshFrequencyOptions(configuredIntervals),
+ [configuredIntervals],
+ );
+ const presets = options.filter(
+ option => option.value !== CUSTOM_REFRESH_FREQUENCY,
+ );
+
// Separate radio selection state from value state
const [radioSelection, setRadioSelection] = useState(() =>
- isPresetValue(value) ? value : -1,
+ isPresetValue(value, options) ? value : CUSTOM_REFRESH_FREQUENCY,
+ );
+
+ const [customValue, setCustomValue] = useState(() =>
+ getCustomValue(value, options),
);
- const [customValue, setCustomValue] = useState(() => getCustomValue(value));
+ // Last value emitted by the Custom controls themselves. When the parent
+ // echoes such a value back, the change originated from Custom even if the
+ // value coincides with a configured preset (e.g. an interval list that
+ // includes 1), so Custom must stay selected instead of snapping to the
+ // colliding preset.
+ const lastCustomEmittedValue = useRef<number | null>(null);
useEffect(() => {
- const selection = isPresetValue(value) ? value : -1;
+ const cameFromCustomControls =
+ lastCustomEmittedValue.current !== null &&
+ lastCustomEmittedValue.current === value;
+ const selection =
+ !cameFromCustomControls && isPresetValue(value, options)
+ ? value
+ : CUSTOM_REFRESH_FREQUENCY;
setRadioSelection(selection);
- setCustomValue(selection === -1 ? getCustomValue(value) : '');
- }, [value]);
+ setCustomValue(
+ selection === CUSTOM_REFRESH_FREQUENCY
+ ? // getCustomValue would blank a value that collides with a preset;
+ // a custom-originated value is the input's own text.
+ cameFromCustomControls
+ ? value.toString()
+ : getCustomValue(value, options)
+ : '',
+ );
+ if (!cameFromCustomControls) {
+ lastCustomEmittedValue.current = null;
+ }
+ }, [value, options]);
const handleRadioChange = (event: RadioChangeEvent) => {
const selectedValue = Number(event.target.value);
setRadioSelection(selectedValue);
- if (selectedValue === -1) {
+ if (selectedValue === CUSTOM_REFRESH_FREQUENCY) {
// Custom selected - use current custom value or minimum
const numValue = parseInt(customValue, 10) || MINIMUM_REFRESH_INTERVAL;
+ lastCustomEmittedValue.current = numValue;
onChange(numValue);
if (!customValue) {
setCustomValue(MINIMUM_REFRESH_INTERVAL.toString());
}
} else {
+ lastCustomEmittedValue.current = null;
onChange(selectedValue);
}
};
@@ -135,19 +245,22 @@ export const RefreshFrequencySelect = ({
const numValue = parseInt(inputValue, 10);
if (numValue >= MINIMUM_REFRESH_INTERVAL) {
+ lastCustomEmittedValue.current = numValue;
onChange(numValue);
}
};
+ const isCustomSelected = radioSelection === CUSTOM_REFRESH_FREQUENCY;
+
return (
<StyledRadioGroup value={radioSelection} onChange={handleRadioChange}>
- {REFRESH_FREQUENCY_OPTIONS.slice(0, -1).map(option => (
+ {presets.map(option => (
<Radio key={option.value} value={option.value}>
{option.label}
</Radio>
))}
- <Radio value={-1}>
+ <Radio value={CUSTOM_REFRESH_FREQUENCY}>
<CustomContent>
{t('Custom')}
<Input
@@ -156,7 +269,7 @@ export const RefreshFrequencySelect = ({
value={customValue}
onChange={handleCustomInputChange}
placeholder={`${MINIMUM_REFRESH_INTERVAL}+`}
- disabled={radioSelection !== -1}
+ disabled={!isCustomSelected}
onClick={e => e.stopPropagation()}
/>
<span>{t('seconds')}</span>
diff --git a/superset-frontend/src/dashboard/components/RefreshFrequency/index.ts b/superset-frontend/src/dashboard/components/RefreshFrequency/index.ts
index 30111615522..22538fc0f9d 100644
--- a/superset-frontend/src/dashboard/components/RefreshFrequency/index.ts
+++ b/superset-frontend/src/dashboard/components/RefreshFrequency/index.ts
@@ -20,6 +20,9 @@
export {
RefreshFrequencySelect,
REFRESH_FREQUENCY_OPTIONS,
+ CUSTOM_REFRESH_FREQUENCY,
+ getRefreshFrequencyOptions,
validateRefreshFrequency,
getRefreshWarningMessage,
} from './RefreshFrequencySelect';
+export type { RefreshFrequencyOption } from './RefreshFrequencySelect';
diff --git a/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx
index 4ddb72f6eee..103f91e2f04 100644
--- a/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx
+++ b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx
@@ -149,3 +149,46 @@ test('cancel resets the selection back to the original frequency and does not sa
expect(screen.getByRole('radio', { name: '1 minute' })).toBeChecked();
expect(screen.getByRole('radio', { name: '1 hour' })).not.toBeChecked();
});
+
+test('the interval list comes from the configured DASHBOARD_AUTO_REFRESH_INTERVALS', async () => {
+ setup(
+ {},
+ {
+ DASHBOARD_AUTO_REFRESH_INTERVALS: [
+ [0, "Don't refresh"],
+ [600, '10 minutes'],
+ [3600, '1 hour'],
+ ],
+ },
+ );
+
+ expect(screen.getByRole('radio', { name: '10 minutes' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('radio', { name: '10 seconds' }),
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('radio', { name: '10 minutes' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(defaultProps.onChange).toHaveBeenCalledWith(600, true);
+});
+
+test('a saved frequency missing from the configured list still renders and survives a save', async () => {
+ // defaultProps.refreshFrequency is 60, which the configuration below omits.
+ setup(
+ {},
+ {
+ DASHBOARD_AUTO_REFRESH_INTERVALS: [
+ [0, "Don't refresh"],
+ [3600, '1 hour'],
+ ],
+ },
+ );
+
+ expect(screen.getByRole('radio', { name: '1 hour' })).toBeInTheDocument();
+ expect(screen.getByRole('spinbutton')).toHaveValue(60);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(defaultProps.onChange).toHaveBeenCalledWith(60, true);
+});