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);
+});